Repository navigation
docs: automate MkDocs to GitHub Wiki synchronization #14
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| name: Synchronize documentation Wiki | ||
|
|
||
| on: | ||
| push: | ||
| branches: | ||
| - main | ||
| paths: | ||
| - docs/** | ||
| - mkdocs.yml | ||
| - tools/sync-wiki.mjs | ||
| - .github/workflows/wiki-sync.yml | ||
| workflow_dispatch: | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| concurrency: | ||
| group: wiki-sync-${{ github.workflow }}-${{ github.ref }} | ||
| cancel-in-progress: true | ||
|
|
||
| jobs: | ||
| sync: | ||
| name: Synchronize GitHub Wiki | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: write | ||
|
|
||
| steps: | ||
| - name: Check out the repository | ||
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | ||
| with: | ||
| persist-credentials: false | ||
|
|
||
| - name: Set up Node.js | ||
| uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 | ||
| with: | ||
| node-version: '24.x' | ||
|
|
||
| - name: Prepare Wiki content from docs/ | ||
| run: node tools/sync-wiki.mjs --output "$RUNNER_TEMP/wiki-content" | ||
|
|
||
| - name: Clone and update the Wiki repository | ||
| env: | ||
| WIKI_TOKEN: ${{ github.token }} | ||
| WIKI_REPOSITORY: ${{ github.repository }}.wiki.git | ||
| run: | | ||
| set -euo pipefail | ||
| auth_header="$(printf 'x-access-token:%s' "$WIKI_TOKEN" | base64 -w0)" | ||
| git -c "http.extraheader=AUTHORIZATION: basic ${auth_header}" clone --depth 1 "https://github.com/${WIKI_REPOSITORY}" "$RUNNER_TEMP/wiki-repository" | ||
| wiki_branch="$(git -C "$RUNNER_TEMP/wiki-repository" symbolic-ref --short HEAD)" | ||
| node tools/sync-wiki.mjs --merge "$RUNNER_TEMP/wiki-repository" --content "$RUNNER_TEMP/wiki-content" | ||
| git -C "$RUNNER_TEMP/wiki-repository" diff --check | ||
| if git -C "$RUNNER_TEMP/wiki-repository" diff --quiet; then | ||
| echo "Wiki is already synchronized." | ||
| exit 0 | ||
| fi | ||
| git -C "$RUNNER_TEMP/wiki-repository" config user.name "Sythos" | ||
| git -C "$RUNNER_TEMP/wiki-repository" config user.email "sythos@users.noreply.github.com" | ||
| git -C "$RUNNER_TEMP/wiki-repository" add --all | ||
| git -C "$RUNNER_TEMP/wiki-repository" commit -m "docs: synchronize MkDocs documentation to Wiki" | ||
| git -C "$RUNNER_TEMP/wiki-repository" -c "http.extraheader=AUTHORIZATION: basic ${auth_header}" push origin "$wiki_branch" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,264 @@ | ||
| import { cp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; | ||
| import path from "node:path"; | ||
| import process from "node:process"; | ||
| import { fileURLToPath } from "node:url"; | ||
|
|
||
| const REPOSITORY_URL = "https://github.com/Sythos/JS_Barcode_Universal"; | ||
| const PAGES_URL = "https://sythos.github.io/JS_Barcode_Universal/"; | ||
| const EXCLUDED_DOCUMENTS = new Set([ | ||
| "COLOR_PIPELINE_NOTES.md", | ||
| "DOCS_ARCHITECTURE.md", | ||
| "JABCODE_NOTES.md", | ||
| ]); | ||
| const MANIFEST_NAME = ".sythos-wiki-sync.json"; | ||
|
|
||
| const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); | ||
| const docsRoot = path.join(repositoryRoot, "docs"); | ||
|
|
||
| function readArgument(name) { | ||
| const argumentIndex = process.argv.indexOf(name); | ||
| return argumentIndex >= 0 ? process.argv[argumentIndex + 1] : undefined; | ||
| } | ||
|
|
||
| function requiredArgument(name) { | ||
| const value = readArgument(name); | ||
| if (!value) { | ||
| throw new Error(`Missing required argument: ${name}`); | ||
| } | ||
| return path.resolve(value); | ||
| } | ||
|
|
||
| function toPosix(value) { | ||
| return value.split(path.sep).join("/"); | ||
| } | ||
|
|
||
| async function listMarkdownFiles(directory, relativeDirectory = "") { | ||
| const entries = await readdir(directory, { withFileTypes: true }); | ||
| const files = []; | ||
|
|
||
| for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { | ||
| const relativePath = path.join(relativeDirectory, entry.name); | ||
| const absolutePath = path.join(directory, entry.name); | ||
|
|
||
| if (entry.isDirectory()) { | ||
| files.push(...(await listMarkdownFiles(absolutePath, relativePath))); | ||
| continue; | ||
| } | ||
|
|
||
| if (entry.isFile() && entry.name.endsWith(".md") && !EXCLUDED_DOCUMENTS.has(entry.name)) { | ||
| files.push(toPosix(relativePath)); | ||
| } | ||
| } | ||
|
|
||
| return files; | ||
| } | ||
|
|
||
| function resolveDocumentPath(sourceRelativePath, linkPath) { | ||
| const sourceDirectory = path.posix.dirname(sourceRelativePath); | ||
| return path.posix.normalize(path.posix.join(sourceDirectory, linkPath)); | ||
| } | ||
|
|
||
| function documentUrl(documentPath) { | ||
| const normalized = documentPath.replace(/\.md$/i, ""); | ||
| if (normalized === "index") { | ||
| return PAGES_URL; | ||
| } | ||
| return new URL(`${normalized.replace(/\\/g, "/")}/`, PAGES_URL).toString(); | ||
| } | ||
|
|
||
| function assetUrl(sourceRelativePath, linkPath) { | ||
| const resolved = resolveDocumentPath(sourceRelativePath, linkPath); | ||
| return new URL(resolved.replace(/\\/g, "/"), PAGES_URL).toString(); | ||
| } | ||
|
|
||
| function rewriteLink(sourceRelativePath, rawTarget) { | ||
| const match = rawTarget.match(/^([^?#]*)([?#].*)?$/); | ||
| if (!match) { | ||
| return rawTarget; | ||
| } | ||
|
|
||
| const [, linkPath, suffix = ""] = match; | ||
| if ( | ||
| !linkPath || | ||
| linkPath.startsWith("#") || | ||
| linkPath.startsWith("/") || | ||
| linkPath.startsWith("//") || | ||
| /^(?:[a-z][a-z\d+.-]*:)/i.test(linkPath) | ||
| ) { | ||
| return rawTarget; | ||
| } | ||
|
|
||
| if (linkPath.toLowerCase().endsWith(".md")) { | ||
| return `${documentUrl(resolveDocumentPath(sourceRelativePath, linkPath))}${suffix}`; | ||
| } | ||
|
|
||
| if (/\.(?:png|jpe?g|gif|svg|webp|avif|ico|txt|json|pdf)$/i.test(linkPath)) { | ||
| return `${assetUrl(sourceRelativePath, linkPath)}${suffix}`; | ||
| } | ||
|
|
||
| return rawTarget; | ||
| } | ||
|
|
||
| function rewriteMarkdownLinks(sourceRelativePath, markdown) { | ||
| const inlineLinks = /(\[[^\]]*\]\()([^\s)]+)([^)]*\))/g; | ||
| const referenceLinks = /^(\s*\[[^\]]+\]:\s*)(\S+)(.*)$/gm; | ||
| const rewrite = (_match, prefix, target, suffix) => | ||
| `${prefix}${rewriteLink(sourceRelativePath, target)}${suffix}`; | ||
|
|
||
| return markdown | ||
| .replace(inlineLinks, rewrite) | ||
| .replace(referenceLinks, rewrite); | ||
| } | ||
|
|
||
| function titleFromPath(relativePath) { | ||
| const name = path.posix.basename(relativePath, ".md"); | ||
| if (name === "index") { | ||
| return "Home"; | ||
| } | ||
| return name | ||
| .replace(/[-_]+/g, " ") | ||
| .replace(/\b\w/g, (character) => character.toUpperCase()); | ||
| } | ||
|
|
||
| function pageUrl(relativePath) { | ||
| const route = relativePath === "index.md" ? "" : relativePath.replace(/\.md$/i, ""); | ||
| return new URL(route ? `${route}/` : "", PAGES_URL).toString(); | ||
| } | ||
|
|
||
| function renderSidebar(markdownFiles) { | ||
| const groups = new Map(); | ||
| const rootFiles = []; | ||
|
|
||
| for (const relativePath of markdownFiles) { | ||
| if (relativePath === "index.md") { | ||
| continue; | ||
| } | ||
|
|
||
| const [firstSegment] = relativePath.split("/"); | ||
| if (!relativePath.includes("/")) { | ||
| rootFiles.push(relativePath); | ||
| continue; | ||
| } | ||
|
|
||
| if (!groups.has(firstSegment)) { | ||
| groups.set(firstSegment, []); | ||
| } | ||
| groups.get(firstSegment).push(relativePath); | ||
| } | ||
|
|
||
| const groupTitles = { | ||
| api: "API reference", | ||
| examples: "Recipes", | ||
| formats: "Barcode formats", | ||
| guides: "Platform guides", | ||
| }; | ||
| const lines = [ | ||
| "# JS Barcode Universal", | ||
| "", | ||
| `- [Home](${PAGES_URL})`, | ||
| `- [GitHub repository](${REPOSITORY_URL})`, | ||
| "", | ||
| ]; | ||
|
|
||
| for (const [group, files] of groups) { | ||
| lines.push(`## ${groupTitles[group] ?? titleFromPath(`${group}.md`)}`); | ||
| for (const relativePath of files.sort()) { | ||
| lines.push(`- [${titleFromPath(relativePath)}](${pageUrl(relativePath)})`); | ||
| } | ||
| lines.push(""); | ||
| } | ||
|
|
||
| for (const relativePath of rootFiles.sort()) { | ||
| lines.push(`- [${titleFromPath(relativePath)}](${pageUrl(relativePath)})`); | ||
| } | ||
|
|
||
| lines.push( | ||
| "", | ||
| "---", | ||
| "", | ||
| `This sidebar is generated from the canonical [MkDocs documentation](${PAGES_URL}).`, | ||
| "", | ||
| ); | ||
| return `${lines.join("\n").replace(/\n+$/u, "")}\n`; | ||
| } | ||
|
|
||
| async function main() { | ||
| const mergeDirectoryArgument = readArgument("--merge"); | ||
| if (mergeDirectoryArgument) { | ||
| const contentDirectory = requiredArgument("--content"); | ||
| await mergeWiki(path.resolve(mergeDirectoryArgument), contentDirectory); | ||
| return; | ||
| } | ||
|
|
||
| const outputDirectory = requiredArgument("--output"); | ||
| const relativeOutput = path.relative(repositoryRoot, outputDirectory); | ||
| if (!relativeOutput || (!relativeOutput.startsWith("..") && !path.isAbsolute(relativeOutput))) { | ||
| throw new Error("The Wiki output directory must be outside the repository checkout."); | ||
| } | ||
|
|
||
| const markdownFiles = await listMarkdownFiles(docsRoot); | ||
| await rm(outputDirectory, { recursive: true, force: true }); | ||
| await mkdir(outputDirectory, { recursive: true }); | ||
|
|
||
| for (const relativePath of markdownFiles) { | ||
| const sourcePath = path.join(docsRoot, relativePath); | ||
| const wikiPath = relativePath === "index.md" ? "Home.md" : relativePath; | ||
| const destinationPath = path.join(outputDirectory, wikiPath); | ||
| const source = await readFile(sourcePath, "utf8"); | ||
| let content = rewriteMarkdownLinks(relativePath, source); | ||
|
|
||
| if (relativePath === "index.md") { | ||
| content += | ||
| `\n\n---\n\n> This Wiki page is generated from the canonical [MkDocs documentation](${PAGES_URL}). ` + | ||
| "Changes made in `docs/` are synchronized here automatically.\n"; | ||
| } | ||
|
|
||
| await mkdir(path.dirname(destinationPath), { recursive: true }); | ||
| await writeFile(destinationPath, content, "utf8"); | ||
| } | ||
|
|
||
| await writeFile(path.join(outputDirectory, "_Sidebar.md"), renderSidebar(markdownFiles), "utf8"); | ||
| const generatedFiles = [ | ||
| ...markdownFiles.map((relativePath) => (relativePath === "index.md" ? "Home.md" : relativePath)), | ||
| "_Sidebar.md", | ||
| ].sort(); | ||
| await writeFile( | ||
| path.join(outputDirectory, MANIFEST_NAME), | ||
| `${JSON.stringify({ version: 1, source: "docs/", files: generatedFiles }, null, 2)}\n`, | ||
| "utf8", | ||
| ); | ||
| console.log(`Prepared ${markdownFiles.length} documentation pages plus _Sidebar.md.`); | ||
| } | ||
|
|
||
| async function mergeWiki(wikiDirectory, contentDirectory) { | ||
| const manifestPath = path.join(contentDirectory, MANIFEST_NAME); | ||
| const contentManifest = JSON.parse(await readFile(manifestPath, "utf8")); | ||
| const existingManifestPath = path.join(wikiDirectory, MANIFEST_NAME); | ||
| let existingManifest = { files: [] }; | ||
|
|
||
| try { | ||
| existingManifest = JSON.parse(await readFile(existingManifestPath, "utf8")); | ||
| } catch (error) { | ||
| if (error.code !== "ENOENT") { | ||
| throw error; | ||
| } | ||
| } | ||
|
|
||
| const generatedFiles = new Set(contentManifest.files); | ||
| for (const relativePath of existingManifest.files ?? []) { | ||
| if (!generatedFiles.has(relativePath)) { | ||
| await rm(path.join(wikiDirectory, relativePath), { force: true }); | ||
| } | ||
| } | ||
|
|
||
| for (const relativePath of contentManifest.files) { | ||
| const destinationPath = path.join(wikiDirectory, relativePath); | ||
| await mkdir(path.dirname(destinationPath), { recursive: true }); | ||
| await cp(path.join(contentDirectory, relativePath), destinationPath, { force: true }); | ||
| } | ||
|
|
||
| await cp(manifestPath, existingManifestPath, { force: true }); | ||
| console.log(`Merged ${contentManifest.files.length} generated files into the Wiki checkout.`); | ||
| } | ||
|
|
||
| await main(); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a page is later added to
mkdocs.yml'sexclude_docs, this fixed basename list will still copy it into the public Wiki, so drafts explicitly removed from the published MkDocs site can be exposed on the next synchronization; removing an exclusion has the inverse problem. Because the workflow deliberately runs whenmkdocs.ymlchanges, the generator should consume that configuration or otherwise keep the exclusion rules in one canonical source.Useful? React with 👍 / 👎.