mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-25 16:42:16 +03:00
docs(readme): reconcile v3.8.50 metrics and contributors (#11356)
Merged via consolidated batch validation (worktree `.claude/worktrees/batch-0824d`). Reconciles README/diagram claims against the live release branch with explicit, non-conflated denominators (merged-PR ranking vs GitHub Contributors REST vs normalized Git census) and adds a repository-local SVG validator. Static gates green; own SVG-validator + render-pipeline tests (tests/unit/docs-validate-svg.test.ts) passed in the combined-batch run, docs:check-all clean per the PR's own evidence. Thanks!
This commit is contained in:
committed by
GitHub
parent
fdcd15e6a9
commit
b010d8bf86
@@ -18,11 +18,13 @@
|
||||
* gate on it.
|
||||
*/
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { ensureSvgAccessibility, validateSvgFile } from "./validate-svg.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(__dirname, "..", "..");
|
||||
const srcDir = resolve(repoRoot, "docs", "diagrams");
|
||||
@@ -75,6 +77,31 @@ for (const src of sources) {
|
||||
if (result.status !== 0) {
|
||||
console.error(` [FAIL] ${src} (exit ${result.status})`);
|
||||
failures += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const source = readFileSync(input, "utf8");
|
||||
const title = source.match(/^%%\s*svg-title:\s*(.+)$/im)?.[1]?.trim();
|
||||
const description = source.match(/^%%\s*svg-description:\s*(.+)$/im)?.[1]?.trim();
|
||||
if (title && description) {
|
||||
const svg = readFileSync(output, "utf8");
|
||||
writeFileSync(
|
||||
output,
|
||||
ensureSvgAccessibility(svg, {
|
||||
title,
|
||||
description,
|
||||
idBase: src.replace(/\.mmd$/, ""),
|
||||
})
|
||||
);
|
||||
} else if (title || description) {
|
||||
console.warn(` [WARN] ${src}: svg-title and svg-description must be provided together`);
|
||||
}
|
||||
|
||||
const validation = validateSvgFile(output);
|
||||
for (const warning of validation.warnings) console.warn(` [WARN] ${src}: ${warning}`);
|
||||
if (validation.errors.length > 0) {
|
||||
for (const error of validation.errors) console.error(` [FAIL] ${src}: ${error}`);
|
||||
failures += 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
167
scripts/docs/validate-svg.mjs
Normal file
167
scripts/docs/validate-svg.mjs
Normal file
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { XMLParser, XMLValidator } from "fast-xml-parser";
|
||||
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: "@_",
|
||||
preserveOrder: true,
|
||||
});
|
||||
|
||||
function collectIds(value, ids) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) collectIds(entry, ids);
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== "object") return;
|
||||
|
||||
const attributes = value[":@"];
|
||||
if (attributes && typeof attributes === "object" && typeof attributes["@_id"] === "string") {
|
||||
ids.push(attributes["@_id"]);
|
||||
}
|
||||
for (const entry of Object.values(value)) collectIds(entry, ids);
|
||||
}
|
||||
|
||||
function escapeXml(value) {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function replaceRootAttribute(openingTag, name, value) {
|
||||
const attribute = new RegExp(`\\s${name}=(?:"[^"]*"|'[^']*')`, "i");
|
||||
const withoutExisting = openingTag.replace(attribute, "");
|
||||
return withoutExisting.replace(/>$/, ` ${name}="${escapeXml(value)}">`);
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
export function ensureSvgAccessibility(svg, { title, description, idBase }) {
|
||||
const xmlResult = XMLValidator.validate(svg);
|
||||
if (xmlResult !== true) throw new Error(`invalid XML: ${xmlResult.err.msg}`);
|
||||
|
||||
const titleId = `${idBase}-title`;
|
||||
const descriptionId = `${idBase}-desc`;
|
||||
const priorTitle = new RegExp(
|
||||
`<title\\b[^>]*\\bid=["']${escapeRegExp(titleId)}["'][^>]*>[\\s\\S]*?<\\/title>`,
|
||||
"i"
|
||||
);
|
||||
const priorDescription = new RegExp(
|
||||
`<desc\\b[^>]*\\bid=["']${escapeRegExp(descriptionId)}["'][^>]*>[\\s\\S]*?<\\/desc>`,
|
||||
"i"
|
||||
);
|
||||
const withoutPriorAccessibleName = svg.replace(priorTitle, "").replace(priorDescription, "");
|
||||
const match = withoutPriorAccessibleName.match(/<svg\b[^>]*>/i);
|
||||
if (!match) throw new Error("document root is not an SVG element");
|
||||
|
||||
let openingTag = replaceRootAttribute(match[0], "role", "img");
|
||||
openingTag = replaceRootAttribute(openingTag, "aria-labelledby", `${titleId} ${descriptionId}`);
|
||||
const accessibleName =
|
||||
`<title id="${escapeXml(titleId)}">${escapeXml(title)}</title>` +
|
||||
`<desc id="${escapeXml(descriptionId)}">${escapeXml(description)}</desc>`;
|
||||
|
||||
return withoutPriorAccessibleName.replace(match[0], `${openingTag}${accessibleName}`);
|
||||
}
|
||||
|
||||
export function validateSvgText(svg) {
|
||||
const xmlResult = XMLValidator.validate(svg);
|
||||
if (xmlResult !== true) {
|
||||
return { errors: [`invalid XML: ${xmlResult.err.msg}`], warnings: [] };
|
||||
}
|
||||
|
||||
const document = parser.parse(svg);
|
||||
const ids = [];
|
||||
collectIds(document, ids);
|
||||
const duplicates = [...new Set(ids.filter((id, index) => ids.indexOf(id) !== index))].sort();
|
||||
|
||||
const openingTag = svg.match(/<svg\b[^>]*>/i)?.[0] ?? "";
|
||||
const warnings = [];
|
||||
if (!/\srole=["']img["']/i.test(openingTag)) warnings.push('root role is not "img"');
|
||||
const hasAccessibleName =
|
||||
/\saria-(?:label|labelledby)=["'][^"']+["']/i.test(openingTag) ||
|
||||
/<title\b[^>]*>[^<]+<\/title>/i.test(svg);
|
||||
if (!hasAccessibleName) {
|
||||
warnings.push("missing accessible name (title, aria-label, or aria-labelledby)");
|
||||
}
|
||||
if (!/<desc\b[^>]*>[^<]+<\/desc>/i.test(svg)) warnings.push("missing desc element");
|
||||
if (/<foreignObject\b/i.test(svg)) warnings.push("foreignObject present (Mermaid output)");
|
||||
if (/\s(?:width|height)=["'][^"']+["']/i.test(openingTag)) {
|
||||
warnings.push("fixed root width or height present (Mermaid output)");
|
||||
}
|
||||
|
||||
return {
|
||||
errors: duplicates.length > 0 ? [`duplicate IDs: ${duplicates.join(", ")}`] : [],
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateSvgFile(file) {
|
||||
return validateSvgText(readFileSync(file, "utf8"));
|
||||
}
|
||||
|
||||
function isDirectExecution() {
|
||||
if (!process.argv[1]) return false;
|
||||
return fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
|
||||
}
|
||||
|
||||
if (isDirectExecution()) {
|
||||
const args = process.argv.slice(2);
|
||||
let fixAccessibility = false;
|
||||
let title;
|
||||
let description;
|
||||
const files = [];
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--fix-a11y") {
|
||||
fixAccessibility = true;
|
||||
} else if (arg === "--title") {
|
||||
title = args[++index];
|
||||
} else if (arg === "--description") {
|
||||
description = args[++index];
|
||||
} else {
|
||||
files.push(arg);
|
||||
}
|
||||
}
|
||||
if (files.length === 0) {
|
||||
console.error(
|
||||
"Usage: node scripts/docs/validate-svg.mjs [--fix-a11y --title TEXT --description TEXT] <file.svg> [...]"
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (fixAccessibility && (!title || !description)) {
|
||||
console.error("--fix-a11y requires both --title and --description");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
for (const file of files) {
|
||||
if (fixAccessibility) {
|
||||
const idBase = path.basename(file, path.extname(file));
|
||||
const updated = ensureSvgAccessibility(readFileSync(file, "utf8"), {
|
||||
title,
|
||||
description,
|
||||
idBase,
|
||||
});
|
||||
writeFileSync(file, updated);
|
||||
}
|
||||
const result = validateSvgFile(file);
|
||||
for (const warning of result.warnings) console.warn(`WARN ${file}: ${warning}`);
|
||||
if (result.errors.length === 0) {
|
||||
console.log(`PASS ${file}`);
|
||||
continue;
|
||||
}
|
||||
failures += 1;
|
||||
for (const error of result.errors) console.error(`FAIL ${file}: ${error}`);
|
||||
}
|
||||
if (failures > 0) process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user