diff --git a/.github/workflows/check-family-nav.yaml b/.github/workflows/check-family-nav.yaml new file mode 100644 index 0000000..766b904 --- /dev/null +++ b/.github/workflows/check-family-nav.yaml @@ -0,0 +1,38 @@ +name: check-family-nav + +# The bar at the top of the page and the card strip at the end of it are three +# copies of the same markup, one per sample repository - abap2UI5/samples, +# abap2UI5/samples-controls and abap2UI5/samples-stack. Copies drift, and the +# way they drift is quiet: a subtitle reworded on one page only, a "you are +# here" marker left on whichever page was copied from, a sibling dropped from +# the footer, a link to an address that has since become a 404. None of that +# breaks a build, and all of it is visible to every reader. +# +# The check is offline on purpose. Diffing the three against each other needs +# the network, and then this repository goes red because github.com is having a +# morning. It checks the canonical wording it carries itself instead - the same +# strings in all three copies, so rewording one means editing three files. That +# is the point, not a cost. + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: check-family-nav-${{ github.ref }} + cancel-in-progress: true + +jobs: + check-family-nav: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + - run: node scripts/check-family-nav.mjs diff --git a/package.json b/package.json index 671da5d..b6579cf 100644 --- a/package.json +++ b/package.json @@ -9,13 +9,14 @@ "check:abap2ui5": "abap2ui5lint", "fmt:chains": "abap2ui5lint --fix", "check:overview": "node scripts/check-overview.mjs", - "check": "npm run lint && npm run check:abap2ui5 && npm run check:overview && npm run check:keywords && npm run check:abapdoc && npm run check:samples-md && npm run check:app-rules && npm run check:prose && npm run check:web", + "check": "npm run lint && npm run check:abap2ui5 && npm run check:overview && npm run check:keywords && npm run check:abapdoc && npm run check:samples-md && npm run check:app-rules && npm run check:prose && npm run check:web && npm run check:family-nav", "check:keywords": "node scripts/check-keywords.mjs", "check:abapdoc": "node scripts/check-abapdoc.mjs", "samples:md": "node scripts/generate-samples-md.mjs", "check:samples-md": "node scripts/generate-samples-md.mjs --check", "check:app-rules": "node scripts/check-app-rules.mjs", "check:prose": "node scripts/check-prose-names.mjs", + "check:family-nav": "node scripts/check-family-nav.mjs", "web:index": "node scripts/generate-web-index.mjs", "check:web": "node scripts/generate-web-index.mjs --check" }, diff --git a/scripts/check-family-nav.mjs b/scripts/check-family-nav.mjs new file mode 100644 index 0000000..83a2eba --- /dev/null +++ b/scripts/check-family-nav.mjs @@ -0,0 +1,164 @@ +/* + * The family blocks are three copies of the same markup, one per sample + * repository, and copies drift. This is what stops them. + * + * It does NOT diff the blocks byte for byte against the other two - a check + * that needs the network to say whether this repository is correct fails for + * reasons that have nothing to do with the change under review. It checks the + * things a wrong copy actually gets wrong: a link that goes somewhere else, a + * verb or a subtitle reworded on one page only, the "you are here" marker left + * on whichever page was copied from, a sibling missing from the footer. + * + * The canonical wording lives below and is identical in all three copies, so + * rewording a subtitle means editing three files - which is the point. + * + * Only these three lines differ between the copies. + */ +const SELF = 'samples-stack'; +const PAGE_HTML = 'web/index.html'; +const PAGE_CSS = 'web/stack.css'; + +import { readFileSync } from 'node:fs'; + +/* --------------------------------------------------------------- canon */ + +const BASE = 'https://abap2ui5.github.io'; + +const PAGES = [ + { + key: 'samples', + url: `${BASE}/samples/`, + repo: 'abap2UI5/samples', + verb: 'Learn', + subtitle: 'start here, one idea at a time', + question: 'Where do I start?', + }, + { + key: 'samples-controls', + url: `${BASE}/samples-controls/`, + repo: 'abap2UI5/samples-controls', + verb: 'Controls', + subtitle: 'every UI5 control, searchable', + question: 'Which control does what?', + }, + { + key: 'samples-stack', + url: `${BASE}/samples-stack/`, + repo: 'abap2UI5/samples-stack', + verb: 'Stack', + subtitle: 'OData, RAP and your system', + question: 'Will my system run it?', + }, +]; + +const TOOLS = [`${BASE}/playground/`, `${BASE}/docs/`]; + +/* The catalogue moved to the root of its Pages site when the in-browser demo + * was dropped, so this address is a 404 and stayed in one footer for a while. + * Nothing may link to it again. */ +const RETIRED = `${BASE}/samples-controls/search/`; + +/* ------------------------------------------------------------- helpers */ + +const problems = []; +const fail = (msg) => problems.push(msg); + +/** The text between two markers, or null. */ +function between(text, name) { + const start = text.indexOf(`${name}:start`); + const end = text.indexOf(`${name}:end`); + if (start < 0 || end < 0 || end < start) return null; + return text.slice(start, end); +} + +/** Every href in source order. */ +const hrefs = (block) => [...block.matchAll(/href="([^"]+)"/g)].map((m) => m[1]); + +/** Which of the three pages an points at. */ +function currentPage(block) { + const marked = [...block.matchAll(/]*>/g)] + .map((m) => m[0]) + .filter((tag) => tag.includes('aria-current="page"')); + if (marked.length !== 1) return { count: marked.length, key: null }; + const href = /href="([^"]+)"/.exec(marked[0])?.[1]; + return { count: 1, key: PAGES.find((p) => p.url === href)?.key ?? href }; +} + +/* --------------------------------------------------------------- checks */ + +const html = readFileSync(PAGE_HTML, 'utf8'); +const css = readFileSync(PAGE_CSS, 'utf8'); + +if (!between(css, 'family-nav')) { + fail(`${PAGE_CSS}: the family-nav:start / family-nav:end styles are missing`); +} + +const nav = between(html, 'family-nav'); +const three = between(html, 'three-pages'); + +if (!nav) fail(`${PAGE_HTML}: no family-nav:start / family-nav:end block`); +if (!three) fail(`${PAGE_HTML}: no three-pages:start / three-pages:end block`); + +if (nav) { + const want = [...PAGES.map((p) => p.url), ...TOOLS]; + const got = hrefs(nav).filter((h) => h.startsWith(BASE)); + if (got.join(' ') !== want.join(' ')) { + fail(`the bar links to\n ${got.join('\n ')}\n but must link, in this order, to\n ${want.join('\n ')}`); + } + + for (const page of PAGES) { + if (!nav.includes(`${page.verb} ${page.subtitle}`)) { + fail(`the bar does not carry "${page.verb}" with its subtitle "${page.subtitle}" - reword it in all three repositories or in none`); + } + if (!nav.includes(`title="${page.repo}"`)) { + fail(`the bar does not name the repository ${page.repo} in a title attribute`); + } + } + + const here = currentPage(nav); + if (here.count !== 1) fail(`the bar carries ${here.count} aria-current="page" links; it must carry exactly one`); + else if (here.key !== SELF) fail(`the bar marks "${here.key}" as the current page, but this repository is ${SELF}`); +} + +if (three) { + const want = PAGES.map((p) => p.url); + const got = hrefs(three).filter((h) => h.startsWith(BASE)); + if (got.join(' ') !== want.join(' ')) { + fail(`the three-pages strip links to\n ${got.join('\n ')}\n but must link, in this order, to\n ${want.join('\n ')}`); + } + + for (const page of PAGES) { + for (const [what, text] of [['verb', page.verb], ['repository', page.repo], ['question', page.question]]) { + if (!three.includes(text)) { + fail(`the three-pages strip is missing the ${what} "${text}" - reword it in all three repositories or in none`); + } + } + } + + const here = currentPage(three); + if (here.count !== 1) fail(`the three-pages strip carries ${here.count} aria-current="page" cards; it must carry exactly one`); + else if (here.key !== SELF) fail(`the three-pages strip marks "${here.key}" as the current page, but this repository is ${SELF}`); +} + +/* The footer is not a shared block - each repository links its own catalogue - + * but both siblings have to be reachable from it. */ +const footer = html.slice(html.indexOf('