Skip to content
Merged
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
38 changes: 38 additions & 0 deletions .github/workflows/check-family-nav.yaml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
164 changes: 164 additions & 0 deletions scripts/check-family-nav.mjs
Original file line number Diff line number Diff line change
@@ -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 <a ... aria-current="page"> points at. */
function currentPage(block) {
const marked = [...block.matchAll(/<a\b[^>]*>/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(`<b>${page.verb}</b> <span>${page.subtitle}</span>`)) {
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('<footer>'));
for (const page of PAGES.filter((p) => p.key !== SELF)) {
if (!footer.includes(page.url)) fail(`the footer does not link ${page.url}`);
}

if (html.includes(RETIRED)) {
fail(`${PAGE_HTML} links ${RETIRED}, which is a 404 - the catalogue is served from the root of that site`);
}

/* ---------------------------------------------------------------- report */

if (problems.length) {
console.error(`check:family-nav - ${problems.length} problem${problems.length === 1 ? '' : 's'} in ${SELF}:\n`);
for (const p of problems) console.error(` * ${p}\n`);
console.error('The bar and the strip are shared with abap2UI5/samples, /samples-controls');
console.error('and /samples-stack. Change them in all three repositories or in none.');
process.exit(1);
}

console.log(`check:family-nav - the shared blocks in ${SELF} are intact`);
34 changes: 34 additions & 0 deletions web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,40 @@ keeps every package at or below it. The counts are in the labels, so what an
older system costs is visible before the click. Filters live in the URL, so a
search is linkable.

## The bar at the top is shared, and so is the strip at the bottom

Three repositories publish three pages that answer three different questions,
and until now only one of them said so. Two blocks fix that, and both are
**identical in all three repositories**:

| | |
|---|---|
| `<nav class="family">` | above the masthead: *Learn · Controls · Stack*, the current one marked with `aria-current`, and the playground and the documentation set apart on the right as the tools they are |
| `<section class="three">` | before the footer: one card per page with the question it answers, because the end of a page is where a reader who is done with it arrives |

They carry verbs rather than repository names — `samples-controls` tells a
newcomer nothing, *Controls / every UI5 control, searchable* tells them
everything — and the repository name lives in the `title` attribute and the
footer instead. There is no numbering: *step 3 of 3* used to be on the
samples-stack page and claimed an order that does not hold, since Controls is
a reference you come back to rather than a step you finish.

Three repositories cannot share a file at run time without one page fetching
something from another host, which is exactly what these pages avoid, so the
blocks are **copied**. That is already the practice here — the design tokens
in this stylesheet are a declared copy — and `npm run check:family-nav` is what
keeps the copies honest: it fails when a subtitle is reworded on one page only,
when the *you are here* marker is left on whichever page was copied from, when
a sibling drops out of the footer, or when anything links
`…/samples-controls/search/` again, which has been a 404 since that catalogue
moved to the root of its site.

The styles sit at the end of the stylesheet between the same markers and read
three tokens the page sets in `:root` — `--family-width`, `--family-gutter` and
`--family-bleed`. Those three are the *only* thing the copies are allowed to
differ in, because the three pages are built around containers of different
widths.

## There is no playground link

The other two pages open a class in the
Expand Down
Loading