mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-06-02 06:24:16 +02:00
feat(docs): In-app documentation browser with Jekyll site and Docusaurus sync (#5445)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/check-doc-coverage.js
|
||||
// Checks that each user-facing feature module has corresponding documentation.
|
||||
// Exit 0 = full coverage, Exit 1 = gaps found.
|
||||
//
|
||||
// Usage: node scripts/check-doc-coverage.js [repo-root]
|
||||
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { forEachDocPage } = require("./lib/frontmatter");
|
||||
|
||||
const REPO_ROOT = path.resolve(process.argv[2] || ".");
|
||||
const DOCS_DIR = path.join(REPO_ROOT, "docs");
|
||||
|
||||
// Map of feature module directory names to expected doc page slugs.
|
||||
// Modules not listed here are considered internal (no user-facing docs required).
|
||||
const MODULE_TO_DOCS = {
|
||||
"feature/connections": { pages: ["connections"], section: "user" },
|
||||
"feature/discovery": { pages: ["discovery"], section: "user" },
|
||||
"feature/docs": { pages: [], section: "user", internal: true },
|
||||
"feature/firmware": { pages: ["firmware"], section: "user" },
|
||||
"feature/intro": { pages: ["onboarding"], section: "user" },
|
||||
"feature/map": { pages: ["map-and-waypoints"], section: "user" },
|
||||
"feature/messaging": { pages: ["messages-and-channels"], section: "user" },
|
||||
"feature/node": { pages: ["nodes", "node-metrics"], section: "user" },
|
||||
"feature/settings": { pages: ["settings-radio-user", "settings-module-admin"], section: "user" },
|
||||
"feature/telemetry": { pages: ["telemetry-and-sensors"], section: "user" },
|
||||
};
|
||||
|
||||
// Collect existing doc pages
|
||||
const existingPages = new Set();
|
||||
forEachDocPage(DOCS_DIR, (_filePath, slug, section) => {
|
||||
existingPages.add(`${section}/${slug}`);
|
||||
});
|
||||
|
||||
console.log(`Checking doc coverage for ${Object.keys(MODULE_TO_DOCS).length} feature modules...`);
|
||||
console.log(`Found ${existingPages.size} doc pages.`);
|
||||
console.log("");
|
||||
|
||||
let gaps = 0;
|
||||
|
||||
for (const [module, config] of Object.entries(MODULE_TO_DOCS)) {
|
||||
if (config.internal) continue;
|
||||
|
||||
const moduleDir = path.join(REPO_ROOT, module);
|
||||
if (!fs.existsSync(moduleDir)) continue;
|
||||
|
||||
for (const page of config.pages) {
|
||||
const key = `${config.section}/${page}`;
|
||||
if (!existingPages.has(key)) {
|
||||
console.log(` ✗ ${module} → missing ${key}.md`);
|
||||
gaps++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for doc pages that reference non-existent modules (orphans)
|
||||
const documentedModules = new Set();
|
||||
for (const config of Object.values(MODULE_TO_DOCS)) {
|
||||
for (const page of config.pages) {
|
||||
documentedModules.add(`${config.section}/${page}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Report coverage summary
|
||||
const coveredModules = Object.entries(MODULE_TO_DOCS)
|
||||
.filter(([, c]) => !c.internal)
|
||||
.filter(([m]) => fs.existsSync(path.join(REPO_ROOT, m)));
|
||||
const totalExpected = coveredModules.reduce((sum, [, c]) => sum + c.pages.length, 0);
|
||||
const covered = totalExpected - gaps;
|
||||
const pct = totalExpected > 0 ? Math.round((covered / totalExpected) * 100) : 100;
|
||||
|
||||
console.log("");
|
||||
console.log(`Coverage: ${covered}/${totalExpected} required pages present (${pct}%)`);
|
||||
|
||||
if (gaps > 0) {
|
||||
console.log(`\n${gaps} documentation gap(s) found.`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log("All feature modules have documentation coverage.");
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/check-doc-freshness.js
|
||||
// Reports doc pages whose last_updated frontmatter is older than a threshold.
|
||||
// Exit 0 = all fresh, Exit 1 = stale pages found (advisory).
|
||||
//
|
||||
// Usage: node scripts/check-doc-freshness.js [docs-dir] [--max-age-days=180]
|
||||
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { parseFrontmatter, forEachDocPage } = require("./lib/frontmatter");
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const positional = args.filter(a => !a.startsWith("--"));
|
||||
const DOCS_DIR = path.resolve(positional[0] || "docs");
|
||||
|
||||
const maxAgeArg = args.find(a => a.startsWith("--max-age-days="));
|
||||
const MAX_AGE_DAYS = maxAgeArg ? parseInt(maxAgeArg.split("=")[1], 10) : 180;
|
||||
|
||||
const now = new Date();
|
||||
let staleCount = 0;
|
||||
let totalCount = 0;
|
||||
|
||||
console.log(`Checking doc freshness (max age: ${MAX_AGE_DAYS} days)...`);
|
||||
console.log("");
|
||||
|
||||
forEachDocPage(DOCS_DIR, (filePath, slug, section) => {
|
||||
totalCount++;
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const { fields } = parseFrontmatter(content);
|
||||
|
||||
if (!fields.last_updated) {
|
||||
console.log(` ⚠ ${section}/${slug}.md — missing last_updated field`);
|
||||
staleCount++;
|
||||
return;
|
||||
}
|
||||
|
||||
const lastUpdated = new Date(fields.last_updated);
|
||||
const ageDays = Math.floor((now - lastUpdated) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (ageDays > MAX_AGE_DAYS) {
|
||||
console.log(` ⚠ ${section}/${slug}.md — ${ageDays} days old (last: ${fields.last_updated})`);
|
||||
staleCount++;
|
||||
}
|
||||
});
|
||||
|
||||
console.log("");
|
||||
if (staleCount > 0) {
|
||||
console.log(`${staleCount}/${totalCount} page(s) need review (older than ${MAX_AGE_DAYS} days or missing date).`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(`All ${totalCount} pages are fresh (updated within ${MAX_AGE_DAYS} days).`);
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const FM_RE = /^---\n([\s\S]*?)\n---\n/;
|
||||
|
||||
/**
|
||||
* Parse YAML-ish frontmatter from a markdown string.
|
||||
* Returns { fields: { key: string }, body: string, raw: string }.
|
||||
* `fields` maps lowercase keys to their raw string values (no YAML arrays).
|
||||
*/
|
||||
function parseFrontmatter(content) {
|
||||
const match = content.match(FM_RE);
|
||||
if (!match) return { fields: {}, body: content, raw: "" };
|
||||
|
||||
const raw = match[1];
|
||||
const body = content.slice(match[0].length);
|
||||
const fields = {};
|
||||
|
||||
for (const line of raw.split("\n")) {
|
||||
const kv = line.match(/^(\w[\w_-]*):\s*(.*)/);
|
||||
if (kv) fields[kv[1]] = kv[2].trim();
|
||||
}
|
||||
|
||||
return { fields, body, raw };
|
||||
}
|
||||
|
||||
/** Discover all .md page slugs under docs/{section}/ */
|
||||
function discoverSlugs(docsDir, section) {
|
||||
const dir = path.join(docsDir, section);
|
||||
if (!fs.existsSync(dir)) return new Set();
|
||||
return new Set(
|
||||
fs.readdirSync(dir)
|
||||
.filter(f => f.endsWith(".md"))
|
||||
.map(f => f.replace(/\.md$/, "")),
|
||||
);
|
||||
}
|
||||
|
||||
/** Iterate all doc pages, calling fn(filePath, slug, section) */
|
||||
function forEachDocPage(docsDir, fn) {
|
||||
for (const section of ["user", "developer"]) {
|
||||
const dir = path.join(docsDir, section);
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
for (const file of fs.readdirSync(dir).filter(f => f.endsWith(".md")).sort()) {
|
||||
fn(path.join(dir, file), file.replace(/\.md$/, ""), section);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { parseFrontmatter, discoverSlugs, forEachDocPage };
|
||||
Executable
+331
@@ -0,0 +1,331 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/sync-android-docs.js
|
||||
// Transforms Android in-app docs for publishing on the meshtastic.org Docusaurus site.
|
||||
//
|
||||
// Usage: node scripts/sync-android-docs.js <android-repo-path> [--convert-webp] [--dry-run]
|
||||
//
|
||||
// <android-repo-path> Path to a clone of meshtastic/Meshtastic-Android (or omit to
|
||||
// auto-detect from this script's location in the repo).
|
||||
// --convert-webp Convert PNG/JPG/JPEG/GIF images to WebP via cwebp and rewrite
|
||||
// all image references in Markdown to use .webp. Requires cwebp on PATH.
|
||||
// --dry-run Print what would be written without actually writing files.
|
||||
//
|
||||
// Output structure (relative to CWD, typically the meshtastic/meshtastic repo root):
|
||||
// docs/software/android/user/*.md
|
||||
// docs/software/android/developer/*.md
|
||||
// docs/software/android/index.md
|
||||
// static/img/android/docs/*.webp (or .png/.svg if not converting)
|
||||
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { execSync } = require("child_process");
|
||||
const { discoverSlugs } = require("./lib/frontmatter");
|
||||
|
||||
// ── Configuration ────────────────────────────────────────────────────────────
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const CONVERT_WEBP = args.includes("--convert-webp");
|
||||
const DRY_RUN = args.includes("--dry-run");
|
||||
const positionalArgs = args.filter(a => !a.startsWith("--"));
|
||||
|
||||
const WEBP_CONVERTIBLE = new Set([".png", ".jpg", ".jpeg", ".gif"]);
|
||||
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"]);
|
||||
|
||||
// Resolve source: explicit path argument, or auto-detect from script location
|
||||
const ANDROID_REPO_ROOT = positionalArgs.length > 0
|
||||
? path.resolve(positionalArgs[0])
|
||||
: path.resolve(__dirname, "..");
|
||||
const SRC_DOCS_DIR = path.join(ANDROID_REPO_ROOT, "docs");
|
||||
const SRC_SCREENSHOTS_DIR = path.join(SRC_DOCS_DIR, "assets", "screenshots");
|
||||
|
||||
if (!fs.existsSync(SRC_DOCS_DIR)) {
|
||||
console.error(`Error: docs directory not found at ${SRC_DOCS_DIR}`);
|
||||
console.error("Usage: node sync-android-docs.js <android-repo-path> [--convert-webp] [--dry-run]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Output directories (relative to CWD, which should be the meshtastic/meshtastic repo)
|
||||
const DEST_DOCS_DIR = path.join("docs", "software", "android");
|
||||
const DEST_IMAGES_DIR = path.join("static", "img", "android", "docs");
|
||||
|
||||
// Derive sibling page slugs from the filesystem (no manual sync needed)
|
||||
const KNOWN_USER_SLUGS = discoverSlugs(SRC_DOCS_DIR, "user");
|
||||
const KNOWN_DEV_SLUGS = discoverSlugs(SRC_DOCS_DIR, "developer");
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function ensureDir(dir) {
|
||||
if (!DRY_RUN) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function writeFile(filePath, content) {
|
||||
if (DRY_RUN) {
|
||||
console.log(`[dry-run] Would write: ${filePath} (${Buffer.byteLength(content)} bytes)`);
|
||||
} else {
|
||||
ensureDir(path.dirname(filePath));
|
||||
fs.writeFileSync(filePath, content, "utf-8");
|
||||
console.log(`Wrote: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function copyFile(src, dest) {
|
||||
if (DRY_RUN) {
|
||||
console.log(`[dry-run] Would copy: ${src} → ${dest}`);
|
||||
} else {
|
||||
ensureDir(path.dirname(dest));
|
||||
fs.copyFileSync(src, dest);
|
||||
console.log(`Copied: ${dest}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite image references in markdown to point to /img/android/docs/<file>.
|
||||
* When --convert-webp, convertible extensions become .webp.
|
||||
*/
|
||||
function rewriteImagePaths(content) {
|
||||
function destBasename(imgPath) {
|
||||
const base = path.basename(imgPath);
|
||||
const ext = path.extname(base).toLowerCase();
|
||||
if (CONVERT_WEBP && WEBP_CONVERTIBLE.has(ext)) {
|
||||
return base.slice(0, -ext.length) + ".webp";
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
return content
|
||||
.replace(
|
||||
/!\[([^\]]*)\]\((?!https?:\/\/)(?!\/img\/)([^)]+)\)/g,
|
||||
(match, alt, imgPath) => {
|
||||
const ext = path.extname(path.basename(imgPath)).toLowerCase();
|
||||
if (!IMAGE_EXTENSIONS.has(ext)) return match;
|
||||
return `})`;
|
||||
},
|
||||
)
|
||||
.replace(
|
||||
/<img\s+([^>]*?)src=["'](?!https?:\/\/)(?!\/img\/)([^"']+)["']([^>]*)>/gi,
|
||||
(match, before, imgPath, after) => {
|
||||
const ext = path.extname(path.basename(imgPath)).toLowerCase();
|
||||
if (!IMAGE_EXTENSIONS.has(ext)) return match;
|
||||
return `<img ${before}src="/img/android/docs/${destBasename(imgPath)}"${after}>`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite relative markdown links between sibling pages.
|
||||
* e.g., `[text](connections)` → `[text](connections.md)`
|
||||
* e.g., `[text](../developer/testing)` → `[text](../developer/testing.md)`
|
||||
*/
|
||||
function rewriteSiblingLinks(content, section) {
|
||||
const slugs = section === "user" ? KNOWN_USER_SLUGS : KNOWN_DEV_SLUGS;
|
||||
|
||||
// Match [text](link) where link is NOT an absolute URL, NOT an anchor, NOT already .md
|
||||
return content.replace(
|
||||
/\[([^\]]*)\]\((?!https?:\/\/)(?!#)([^)]+)\)/g,
|
||||
(match, text, link) => {
|
||||
// Skip if already has .md extension or is an image
|
||||
if (link.endsWith(".md") || IMAGE_EXTENSIONS.has(path.extname(link).toLowerCase())) {
|
||||
return match;
|
||||
}
|
||||
|
||||
// Check for cross-section links like ../developer/testing
|
||||
const crossMatch = link.match(/^\.\.\/(\w+)\/(.+)/);
|
||||
if (crossMatch) {
|
||||
const [, targetSection, slug] = crossMatch;
|
||||
const targetSlugs = targetSection === "user" ? KNOWN_USER_SLUGS : KNOWN_DEV_SLUGS;
|
||||
if (targetSlugs.has(slug)) {
|
||||
return `[${text}](../${targetSection}/${slug}.md)`;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for sibling links
|
||||
const bare = link.replace(/^\.\//, "");
|
||||
if (slugs.has(bare)) {
|
||||
return `[${text}](${bare}.md)`;
|
||||
}
|
||||
|
||||
return match;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform Jekyll/kramdown frontmatter to Docusaurus-compatible format.
|
||||
* Strips `parent`, `aliases`, and remaps `nav_order` to `sidebar_position`.
|
||||
*/
|
||||
function transformFrontmatter(content, section) {
|
||||
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n/);
|
||||
if (!fmMatch) return content;
|
||||
|
||||
const fmBlock = fmMatch[1];
|
||||
const body = content.slice(fmMatch[0].length);
|
||||
|
||||
const lines = fmBlock.split("\n");
|
||||
const newLines = [];
|
||||
let sidebarPosition = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Skip Jekyll-specific fields
|
||||
if (trimmed.startsWith("parent:")) continue;
|
||||
if (trimmed.startsWith("aliases:")) continue;
|
||||
if (trimmed.startsWith("- ")) continue; // alias list items
|
||||
|
||||
// Remap nav_order → sidebar_position
|
||||
const navMatch = trimmed.match(/^nav_order:\s*(\d+)/);
|
||||
if (navMatch) {
|
||||
sidebarPosition = navMatch[1];
|
||||
newLines.push(`sidebar_position: ${sidebarPosition}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed) newLines.push(line);
|
||||
}
|
||||
|
||||
// Add parent reference for Docusaurus category
|
||||
const parentTitle = section === "user" ? "User Guide" : "Developer Guide";
|
||||
newLines.push(`parent: ${parentTitle}`);
|
||||
|
||||
return `---\n${newLines.join("\n")}\n---\n${body}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Jekyll-style callouts to Docusaurus admonitions.
|
||||
* > **Tip — text** → :::tip\ntext\n:::
|
||||
*/
|
||||
function convertCallouts(content) {
|
||||
// Match blockquotes starting with **Tip/Note/Warning —
|
||||
return content.replace(
|
||||
/^(> \*\*(Tip|Note|Warning)\s*[—–-]\s*)([^*]*)\*\*\s*([\s\S]*?)(?=\n(?!>)|$)/gm,
|
||||
(match, prefix, type, title, body) => {
|
||||
const admonitionType = type.toLowerCase();
|
||||
const cleanBody = body.replace(/^>\s?/gm, "").trim();
|
||||
const fullContent = title.trim() ? `${title.trim()} ${cleanBody}` : cleanBody;
|
||||
return `:::${admonitionType}\n${fullContent}\n:::`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function processMarkdown(srcPath, destPath, section) {
|
||||
let content = fs.readFileSync(srcPath, "utf-8");
|
||||
content = transformFrontmatter(content, section);
|
||||
content = rewriteImagePaths(content);
|
||||
content = rewriteSiblingLinks(content, section);
|
||||
content = convertCallouts(content);
|
||||
writeFile(destPath, content);
|
||||
}
|
||||
|
||||
function processImages() {
|
||||
if (!fs.existsSync(SRC_SCREENSHOTS_DIR)) {
|
||||
console.log("No screenshots directory found, skipping image sync.");
|
||||
return;
|
||||
}
|
||||
|
||||
const images = fs.readdirSync(SRC_SCREENSHOTS_DIR)
|
||||
.filter(f => IMAGE_EXTENSIONS.has(path.extname(f).toLowerCase()));
|
||||
|
||||
for (const img of images) {
|
||||
const srcPath = path.join(SRC_SCREENSHOTS_DIR, img);
|
||||
const ext = path.extname(img).toLowerCase();
|
||||
|
||||
if (CONVERT_WEBP && WEBP_CONVERTIBLE.has(ext)) {
|
||||
const destName = img.slice(0, -ext.length) + ".webp";
|
||||
const destPath = path.join(DEST_IMAGES_DIR, destName);
|
||||
|
||||
if (DRY_RUN) {
|
||||
console.log(`[dry-run] Would convert: ${srcPath} → ${destPath}`);
|
||||
} else {
|
||||
ensureDir(path.dirname(destPath));
|
||||
try {
|
||||
execSync(`cwebp -q 80 "${srcPath}" -o "${destPath}"`, { stdio: "pipe" });
|
||||
console.log(`Converted: ${destPath}`);
|
||||
} catch (err) {
|
||||
console.error(`Failed to convert ${img}: ${err.message}`);
|
||||
// Fall back to copying the original
|
||||
copyFile(srcPath, path.join(DEST_IMAGES_DIR, img));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
copyFile(srcPath, path.join(DEST_IMAGES_DIR, img));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createIndexPage() {
|
||||
const content = `---
|
||||
title: Android App
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# Meshtastic Android & Desktop App
|
||||
|
||||
Documentation for the [Meshtastic Android](https://github.com/meshtastic/Meshtastic-Android) application, also available as a Desktop (JVM) app for Linux, macOS, and Windows.
|
||||
|
||||
## Guides
|
||||
|
||||
- **[User Guide](user/)** — Setup, messaging, nodes, maps, settings, and more
|
||||
- **[Developer Guide](developer/)** — Architecture, KMP conventions, testing, and contributing
|
||||
`;
|
||||
writeFile(path.join(DEST_DOCS_DIR, "index.md"), content);
|
||||
}
|
||||
|
||||
function createCategoryFiles() {
|
||||
const userCategory = `label: User Guide
|
||||
position: 1
|
||||
`;
|
||||
const devCategory = `label: Developer Guide
|
||||
position: 2
|
||||
`;
|
||||
writeFile(path.join(DEST_DOCS_DIR, "user", "_category_.yml"), userCategory);
|
||||
writeFile(path.join(DEST_DOCS_DIR, "developer", "_category_.yml"), devCategory);
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log(`Source: ${SRC_DOCS_DIR}`);
|
||||
console.log(`Destination: ${DEST_DOCS_DIR}`);
|
||||
console.log(`WebP conversion: ${CONVERT_WEBP ? "enabled" : "disabled"}`);
|
||||
console.log(`Dry run: ${DRY_RUN}`);
|
||||
console.log("");
|
||||
|
||||
// Process user guide
|
||||
const userDir = path.join(SRC_DOCS_DIR, "user");
|
||||
if (fs.existsSync(userDir)) {
|
||||
for (const file of fs.readdirSync(userDir).filter(f => f.endsWith(".md"))) {
|
||||
processMarkdown(
|
||||
path.join(userDir, file),
|
||||
path.join(DEST_DOCS_DIR, "user", file),
|
||||
"user",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Process developer guide
|
||||
const devDir = path.join(SRC_DOCS_DIR, "developer");
|
||||
if (fs.existsSync(devDir)) {
|
||||
for (const file of fs.readdirSync(devDir).filter(f => f.endsWith(".md"))) {
|
||||
processMarkdown(
|
||||
path.join(devDir, file),
|
||||
path.join(DEST_DOCS_DIR, "developer", file),
|
||||
"developer",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create index and category files
|
||||
createIndexPage();
|
||||
createCategoryFiles();
|
||||
|
||||
// Process images
|
||||
processImages();
|
||||
|
||||
console.log("\nSync complete.");
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/validate-doc-links.js
|
||||
// Validates internal cross-references and image paths in in-app documentation.
|
||||
// Exit 0 = all valid, Exit 1 = broken links found.
|
||||
//
|
||||
// Usage: node scripts/validate-doc-links.js [docs-dir]
|
||||
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { discoverSlugs, forEachDocPage } = require("./lib/frontmatter");
|
||||
|
||||
const DOCS_DIR = path.resolve(process.argv[2] || "docs");
|
||||
const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"]);
|
||||
|
||||
// Collect known page slugs from both sections
|
||||
const knownPages = new Set([
|
||||
...discoverSlugs(DOCS_DIR, "user"),
|
||||
...discoverSlugs(DOCS_DIR, "developer"),
|
||||
]);
|
||||
|
||||
console.log(`Validating links across ${knownPages.size} doc pages in ${DOCS_DIR}...`);
|
||||
|
||||
let errors = 0;
|
||||
|
||||
forEachDocPage(DOCS_DIR, (filePath, slug, section) => {
|
||||
const lines = fs.readFileSync(filePath, "utf-8").split("\n");
|
||||
|
||||
lines.forEach((line, idx) => {
|
||||
const lineNum = idx + 1;
|
||||
|
||||
// Check markdown links (non-image)
|
||||
let match;
|
||||
const linkRe = /(?<!!)\[([^\]]*)\]\(([^)]+)\)/g;
|
||||
while ((match = linkRe.exec(line)) !== null) {
|
||||
const target = match[2];
|
||||
|
||||
if (/^(https?:|mailto:|#)/.test(target)) continue;
|
||||
|
||||
const ext = path.extname(target).toLowerCase();
|
||||
if (IMAGE_EXTS.has(ext)) continue;
|
||||
|
||||
let targetSlug = target
|
||||
.replace(/\.md$/, "")
|
||||
.replace(/^\.\//, "")
|
||||
.replace(/#.*$/, "");
|
||||
|
||||
const crossMatch = targetSlug.match(/^\.\.\/\w+\/(.+)/);
|
||||
if (crossMatch) targetSlug = crossMatch[1];
|
||||
targetSlug = targetSlug.split("/").pop();
|
||||
|
||||
if (targetSlug && !knownPages.has(targetSlug)) {
|
||||
console.log(` ERROR: ${section}/${slug}.md:${lineNum} — broken link to '${targetSlug}'`);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
// Check image references
|
||||
let imgMatch;
|
||||
const imgRe = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
||||
while ((imgMatch = imgRe.exec(line)) !== null) {
|
||||
const imgPath = imgMatch[2];
|
||||
if (/^https?:/.test(imgPath)) continue;
|
||||
|
||||
const resolved = imgPath.startsWith("/")
|
||||
? path.join(DOCS_DIR, imgPath)
|
||||
: path.resolve(path.dirname(filePath), imgPath);
|
||||
if (!fs.existsSync(resolved)) {
|
||||
console.log(` ERROR: ${section}/${slug}.md:${lineNum} — missing image '${imgPath}'`);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (errors > 0) {
|
||||
console.log(`\nFAILED: ${errors} broken link(s) found.`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log("PASSED: All internal links and images are valid.");
|
||||
process.exit(0);
|
||||
}
|
||||
Reference in New Issue
Block a user