Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .github/workflows/wiki-sync.yml
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"
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ tools/*
!tools/check-zero-deps.mjs
!tools/check-docs.mjs
!tools/build-bundle.mjs
!tools/sync-wiki.mjs

# --- Build configuration a consumer never runs ----------------------------
tsconfig.json
Expand Down
264 changes: 264 additions & 0 deletions tools/sync-wiki.mjs
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",
]);
Comment on lines +8 to +12

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Derive excluded pages from the MkDocs configuration

When a page is later added to mkdocs.yml's exclude_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 when mkdocs.yml changes, the generator should consume that configuration or otherwise keep the exclusion rules in one canonical source.

Useful? React with 👍 / 👎.

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();
Loading