From bf7abebc1a08b7c3e967761e1836c4833702acf9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 17:46:05 +0000 Subject: [PATCH 1/5] Fix the outline's double escaping, the search index's dead anchors and the fences the gates skipped Bugs found by reading the site's scripts and theme against the built pages: - build-site: the "On this page" outline escaped the heading text a second time, so "Build & Test" read "Build & Test" and a heading with quotes or angle brackets spelt out its entities. The text out of the rendered heading is HTML already and is written back as it is. - pages.mjs: heading anchors in the search index were a plain slug, not VitePress's - a heading starting with a digit, one carrying an arrow or a long dash, and the second heading of one name on a page all opened the page at the top. 93 of 737 heading links were dead. slugify is now the copy of @mdit-vue/shared's, with markdown-it-anchor's -1 suffix, and test/search.test.mjs pins the three shapes. - build-site: the 404 page suggested /docs/
.html for a section's index page, which the build never writes; the suggestion keeps its `index` now. - list-runnable, check-examples, check-api-names: a fence with an info string (```abap edit on the front door, ```abap [ABAP] in the quickstart, ```abap [lcl_help]) was invisible to all three, so the most-copied example on the site was never compiled and the runnable count read 63 against check:playground's 64. The regexes accept the info string; check:examples now compiles 70 classes, runnable lists 64. - SiteNav.vue: the Playground item bound an href that was declared nowhere, and leave( ) sent every non-docs item to the catalogue - a press on Playground opened Samples in the VitePress build. It has its ref, is lifted like the other two, and leave( ) branches on the site. - playground.js: a failed load of the embed loader was cached for the life of the page; the promise is forgotten on error so the next press retries. - playground.mjs: the Run-button rules did not see CLASS-METHODS as a declaration (a class with one unimplemented got a button) and did not know popover_display as output. - build-site: a Shiki line with a second class (highlighted, diff) fell out of the rebuilt listing; and a build with disagreeing release numbers now fails instead of printing "Version null" in every bar. - README: the API gates judge against main, not the release the site names, as AGENTS.md has said since the pin was cut; the Run-button count is the measured one. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RBw7XcLuTa94iCUYZEtuh5 --- AGENTS.md | 2 +- README.md | 13 +++++----- docs/.vitepress/playground.mjs | 4 ++-- docs/.vitepress/theme/SiteNav.vue | 13 ++++++---- docs/.vitepress/theme/playground.js | 8 ++++++- docs/.vitepress/theme/search-engine.js | 2 +- scripts/build-site.mjs | 17 ++++++++++--- scripts/check-api-names.mjs | 2 +- scripts/check-examples.mjs | 4 ++-- scripts/lib/pages.mjs | 33 ++++++++++++++++++++++---- scripts/list-runnable.mjs | 2 +- test/search.test.mjs | 8 +++++++ 12 files changed, 80 insertions(+), 28 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 730a67a4b..4e6a4290d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -823,7 +823,7 @@ serve it, and open each example in it, checking that the status line reaches `running` and that the app frame contains something: ```sh -npm run runnable -- --json > /tmp/runnable.json # 63 examples, ABAP included +npm run runnable -- --json > /tmp/runnable.json # 64 examples, ABAP included git clone https://github.com/abap2UI5/playground && cd playground npm ci && npm run build # the first build is a few minutes RUNNABLE_JSON=/tmp/runnable.json npm test -- docs-examples diff --git a/README.md b/README.md index 9b34a9cf8..199703874 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,9 @@ own 404 (`check:cross-site`), the fenced ABAP examples compile and the views they build name real UI5 API (`check:examples`), those examples are written in the same house style as the sample corpora — chain layout and class shell — (`check:conventions`), every `client->` name and -`cs_*` constant the prose and snippets mention still exists in the release the -site names (`check:api-names`), the generated client API reference still -matches the interface at that release (`check:api-reference`), the sample +`cs_*` constant the prose and snippets mention still exists in the framework +on `main` (`check:api-names`), the generated client API reference still +matches the interface there (`check:api-reference`), the sample links still match the sample repositories (`check:samples`), every complete app example carries a playground Run button or its declared reason not to (`check:playground`), the release @@ -83,7 +83,7 @@ than on the site. The client API is published for the same reader as one JSON document, [client-api.json](https://abap2ui5.github.io/docs/api/client-api.json) — -generated from `z2ui5_if_client` at the pinned release by +generated from `z2ui5_if_client` on the framework's `main` by `npm run generate:api`, committed, and held fresh by `check:api-reference`. The human-readable half is the [Client API page](https://abap2ui5.github.io/docs/resources/api.html). @@ -103,8 +103,9 @@ Which blocks get a button is decided in `docs/.vitepress/playground.mjs`, and the rule is narrow on purpose — a button on an example that cannot run is worse than no button. It has to be a complete class implementing `z2ui5_if_app` that displays something and needs nothing the browser has not got: no table of its -own, no CDS entity, no add-on repository, no on-premise SAP class. **38 of the -261 ABAP blocks here** clear that today. Every rule was written from an example +own, no CDS entity, no add-on repository, no on-premise SAP class. **64 of the +83 complete app classes here** clear that today (`npm run check:playground` +prints the current count). Every rule was written from an example watched failing in a real playground; `test/playground.test.mjs` keeps one fixture per shape, and [AGENTS.md](AGENTS.md) says how to redo the measurement. diff --git a/docs/.vitepress/playground.mjs b/docs/.vitepress/playground.mjs index 40003c921..a93e00072 100644 --- a/docs/.vitepress/playground.mjs +++ b/docs/.vitepress/playground.mjs @@ -72,7 +72,7 @@ const DECLARED_NAME = /^\s*(?:CLASS|INTERFACE)\s+([a-zA-Z_]\w*)\s+(?:DEFINITION| const MAX_NAME = 30; /** Something has to arrive on screen. */ -const DISPLAYS_SOMETHING = /->\s*(?:view|popup|message_box|message_toast|nest_view)[a-z_]*_display\s*\(/i; +const DISPLAYS_SOMETHING = /->\s*(?:view|popup|popover|message_box|message_toast|nest_view)[a-z_]*_display\s*\(/i; /* The database in the page holds the framework's own tables and what open-abap * ships. A business table is not among them, and `SELECT` from one is the @@ -206,7 +206,7 @@ function tablesUsed(code) { function unimplementedMethods(code) { const definition = code.split(CLASS_IMPLEMENTATION)[0]; const declared = []; - for (const m of definition.matchAll(/^\s*METHODS:?\s+([^.]+)\./gim)) { + for (const m of definition.matchAll(/^\s*(?:CLASS-)?METHODS:?\s+([^.]+)\./gim)) { for (const one of m[1].split(',')) { const name = /^\s*([a-z_]\w*)/i.exec(one)?.[1]; /* A redefinition or an interface method is implemented under another diff --git a/docs/.vitepress/theme/SiteNav.vue b/docs/.vitepress/theme/SiteNav.vue index 33df9ec4e..16c2fc700 100644 --- a/docs/.vitepress/theme/SiteNav.vue +++ b/docs/.vitepress/theme/SiteNav.vue @@ -51,6 +51,10 @@ const onHome = computed(() => page.value.relativePath === "index.md"); * section. */ const samplesHref = ref(SAMPLES); const docsHref = ref(DOCS); +/* And the Playground's, which was bound in the markup and declared nowhere: + * the item rendered without an href, and leave( ) then sent it to the + * catalogue - the one branch it had for anything that was not the manual. */ +const playgroundHref = ref(PLAYGROUND); /* Documentation restores WHEREVER IN THE MANUAL you were, which is why it * passes a scope: the link is written at the manual's first page, and a stored @@ -65,6 +69,7 @@ const docsHref = ref(DOCS); const lift = () => { samplesHref.value = lastVisited("samples", SAMPLES); docsHref.value = lastVisited("docs", DOCS, HOME); + playgroundHref.value = lastVisited("playground", PLAYGROUND); }; /* The lift that cannot be missed: on the click itself, on the element, because * a ref set in the handler reaches the DOM a tick too late. @@ -85,11 +90,9 @@ const leave = (e) => { * in theme/index.js is throttled, and a click that lands inside its window * would otherwise store an offset from before the last scroll. */ rememberScroll(); - if (el.dataset.site) { - el.href = el.dataset.site === "docs" - ? lastVisited("docs", DOCS, HOME) - : lastVisited("samples", SAMPLES); - } + if (el.dataset.site === "docs") el.href = lastVisited("docs", DOCS, HOME); + else if (el.dataset.site === "samples") el.href = lastVisited("samples", SAMPLES); + else if (el.dataset.site === "playground") el.href = lastVisited("playground", PLAYGROUND); handOff(el.href); }; diff --git a/docs/.vitepress/theme/playground.js b/docs/.vitepress/theme/playground.js index 278036c5b..761a152ee 100644 --- a/docs/.vitepress/theme/playground.js +++ b/docs/.vitepress/theme/playground.js @@ -51,7 +51,13 @@ function loader() { window.abap2ui5Embed ? resolve(window.abap2ui5Embed) : reject(new Error('The playground loader did not install itself.'))); - script.addEventListener('error', () => reject(new Error(`${LOADER} could not be loaded.`))); + script.addEventListener('error', () => { + /* Forgotten, not kept: a load that failed once (offline for a moment) + * must not answer every later press on the page with the same error. */ + loading = undefined; + script.remove(); + reject(new Error(`${LOADER} could not be loaded.`)); + }); document.head.append(script); }); return loading; diff --git a/docs/.vitepress/theme/search-engine.js b/docs/.vitepress/theme/search-engine.js index f4725d194..951f3d348 100644 --- a/docs/.vitepress/theme/search-engine.js +++ b/docs/.vitepress/theme/search-engine.js @@ -342,7 +342,7 @@ export function grouped(hits, { perGroup = 8 } = {}) { return groups.map(({ label, hits: rows, total }) => ({ label, hits: rows, total })); } -/** The index, fetched once. Callers await this on the first keystroke, never +/** The index, fetched once. Callers await this when the box is opened, never * at load: a reader who does not search pays nothing for the box. */ let pending = null; export function loadIndex(url = INDEX_URL, { fetchFn = globalThis.fetch } = {}) { diff --git a/scripts/build-site.mjs b/scripts/build-site.mjs index e96753ce1..dc56e292a 100644 --- a/scripts/build-site.mjs +++ b/scripts/build-site.mjs @@ -267,6 +267,7 @@ const MENU_SCRIPT = (() => { * finding a string in somebody else's markup and failing loudly when it is not * there. */ const RELEASE = declaredRelease(ROOT); +if (!RELEASE) throw new Error('the three places that name the release disagree (npm run check:version says which) - not building a bar that reads "Version null"'); const withRelease = (bar) => { const at = '
'; if (!bar.includes(at)) throw new Error("the borrowed bar has no socials group to put the release before"); @@ -390,6 +391,10 @@ const LEVELS = (() => { function outlineFor(html) { const rows = [...html.matchAll(/]*>([\s\S]*?) ({ level: Number(m[1]), id: m[2], text: m[3].replace(/<[^>]*>/g, '').trim() })) .filter((r) => r.level >= LEVELS[0] && r.level <= LEVELS[1]); if (rows.length < 2) return ''; @@ -408,7 +413,7 @@ function outlineFor(html) { do the same (tools/sample-pages.mjs over there). */ return ``; } @@ -958,7 +963,10 @@ const tabled = (html) => html.replace( const abapify = (html) => html.replace( /(
[\s\S]*?)([\s\S]*?)(<\/code>)/g, (all, head, code, tail) => { - const lines = [...code.matchAll(/([\s\S]*?)<\/span>\s*(?=|$)/g)] + /* `line` and whatever Shiki put beside it (`line highlighted`, `line diff + add`): a line with a second class is still a line, and matched on the + bare class alone it fell out of the listing. */ + const lines = [...code.matchAll(/([\s\S]*?)<\/span>\s*(?=|$)/g)] .map((m) => unescape(m[1].replace(/<[^>]*>/g, ''))); if (!lines.length) return all; const source = lines.join('\n'); @@ -1219,7 +1227,10 @@ for (const page of pages) { /* Every page as [path, name], for the suggestions at the foot of the 404 - the one place that wants the whole list inside one page. `<` is escaped, so no chapter title can end the script block early. */ -const nearby = JSON.stringify(pages.map((f) => [f.replace(/(?:\/index)?\.md$/, ''), names.get(f)])) +/* `cookbook/index`, not `cookbook`: the script below appends `.html`, and the + build writes cookbook/index.html and never cookbook.html - so a suggestion + for a section's front page was itself a 404. */ +const nearby = JSON.stringify(pages.map((f) => [f.replace(/\.md$/, ''), names.get(f)])) .replace(/ text.replace(/```(\w*)\n([\s\S]*?)```/g, (all, lang, body) => ( +const abapOnly = (text) => text.replace(/```(\w*)[^\n]*\n([\s\S]*?)```/g, (all, lang, body) => ( /^(abap)?$/i.test(lang) ? all.replace(body, body) : ' '.repeat(all.length) )); diff --git a/scripts/check-examples.mjs b/scripts/check-examples.mjs index eba71c334..822fbd45c 100644 --- a/scripts/check-examples.mjs +++ b/scripts/check-examples.mjs @@ -146,7 +146,7 @@ function legacyFragments() { if (SHOWS_THE_OLD_API.has(page)) continue; const md = readFileSync(file, 'utf8'); if (md.includes('This page still shows the previous view builder')) continue; - for (const m of md.matchAll(/```abap\n([\s\S]*?)```/g)) { + for (const m of md.matchAll(/^```abap\b[^\n]*\n([\s\S]*?)^```/gm)) { const code = m[1]; /* The other fluent API in this documentation. `z2ui5_cl_ajson` chains * the same way and its verbs are its own, so a fence building JSON is @@ -190,7 +190,7 @@ function examples() { for (const file of walk(DOCS).filter((f) => f.endsWith('.md')).sort()) { const md = readFileSync(file, 'utf8'); const pending = md.includes('This page still shows the previous view builder'); - for (const m of md.matchAll(/```abap\n([\s\S]*?)```/g)) { + for (const m of md.matchAll(/^```abap\b[^\n]*\n([\s\S]*?)^```/gm)) { const code = m[1]; if (!/CLASS\s+\S+\s+DEFINITION/i.test(code)) continue; if (!/CLASS\s+\S+\s+IMPLEMENTATION/i.test(code)) continue; diff --git a/scripts/lib/pages.mjs b/scripts/lib/pages.mjs index 142fd4aa4..24a7eb4f5 100644 --- a/scripts/lib/pages.mjs +++ b/scripts/lib/pages.mjs @@ -183,9 +183,11 @@ export const title = (body, fallback) => /** Every `##`/`###` heading on a page, flattened — what a reader is actually * looking for when they type two words into a search box. Anchors are - * VitePress's own slug: lower case, non-word runs to a dash. */ + * VitePress's own slug (slugify below), made unique the way markdown-it-anchor + * does it - the second `## EML` on a page is `eml-1`. */ export function headings(body) { const out = []; + const seen = new Map(); let inFence = false; for (const line of stripFrontmatter(body).split('\n')) { if (line.startsWith('```')) { inFence = !inFence; continue; } @@ -194,14 +196,35 @@ export function headings(body) { if (!m) continue; const text = m[2].replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/[*`]/g, '').trim(); if (!text) continue; - out.push({ - text, - anchor: text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-|-$/g, ''), - }); + const slug = slugify(text); + const n = seen.get(slug) ?? 0; + seen.set(slug, n + 1); + out.push({ text, anchor: n === 0 ? slug : `${slug}-${n}` }); } return out; } +/** The id VitePress gives a heading - `@mdit-vue/shared`'s slugify, copied + * rather than imported so this file stays runnable without VitePress's tree + * (the playground's build calls into it). What it does that a plain + * "non-word runs to a dash" did not: a heading starting with a digit gets an + * underscore in front (`## 3. First Launch` is `_3-first-launch`), and a + * character outside its fixed list - an arrow, a dash of the long kind - + * stays in the id. 93 of the index's 737 heading links were dead before + * this matched the real ids. */ +const rControl = /[\u0000-\u001f]/g; +const rSpecial = /[\s~`!@#$%^&*()\-_+=[\]{}|\\;:"'\u201c\u201d\u2018\u2019<>,.?/]+/g; +const rCombining = /[\u0300-\u036F]/g; +export const slugify = (str) => str + .normalize('NFKD') + .replace(rCombining, '') + .replace(rControl, '') + .replace(rSpecial, '-') + .replace(/-{2,}/g, '-') + .replace(/^-+|-+$/g, '') + .replace(/^(\d)/, '_$1') + .toLowerCase(); + /* Words that are in every page of every documentation and therefore identify * none of it. Kept short on purpose: this is a size measure, not a language * model, and a word wrongly dropped is a search that finds nothing. */ diff --git a/scripts/list-runnable.mjs b/scripts/list-runnable.mjs index 9340d4b5e..19d18019b 100644 --- a/scripts/list-runnable.mjs +++ b/scripts/list-runnable.mjs @@ -53,7 +53,7 @@ function pages(dir, found = []) { const examples = []; for (const file of pages(DOCS)) { const md = fs.readFileSync(file, 'utf8'); - for (const fence of md.matchAll(/```abap\n([\s\S]*?)```/g)) { + for (const fence of md.matchAll(/^```abap\b[^\n]*\n([\s\S]*?)^```/gm)) { const code = fence[1]; if (!isRunnable(code)) continue; examples.push({ diff --git a/test/search.test.mjs b/test/search.test.mjs index 1fdd6fc18..3fe01e40c 100644 --- a/test/search.test.mjs +++ b/test/search.test.mjs @@ -121,6 +121,14 @@ test('a heading anchor is the one VitePress generates', () => { /* A fenced block is code, not an outline: a comment starting with ## in an * ABAP example is not a section of the page. */ assert.equal(headings('```abap\n## not a heading\n```\n').length, 0); + /* The three shapes a plain "non-word runs to a dash" got wrong, against the + * ids VitePress writes into the built pages: a heading that starts with a + * digit gets an underscore (get_started/quickstart.html), a character + * outside slugify's list stays (cookbook/view/xml_templating.html), and the + * second heading of one name on a page counts up (cookbook/eml_cds_sql/eml.html). */ + assert.deepEqual(headings('## 3. First Launch\n').map((h) => h.anchor), ['_3-first-launch']); + assert.deepEqual(headings('## `template:repeat` — Loops\n').map((h) => h.anchor), ['template-repeat-—-loops']); + assert.deepEqual(headings('## EML\n\ntext\n\n## EML\n').map((h) => h.anchor), ['eml', 'eml-1']); }); /* ------------------------------------------------------------ the matcher */ From 90ab190772dcfd9075b00be5a8d7a0bcf264c945 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 17:47:13 +0000 Subject: [PATCH 2/5] The page title takes its slug first: a heading under an H1 of the same name is -1 `## EML` under `# EML` is `eml-1` in the built page, because markdown-it-anchor counts every heading. headings( ) now counts the title too and still indexes only the second and third level; the test says so with the real shape. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RBw7XcLuTa94iCUYZEtuh5 --- scripts/lib/pages.mjs | 6 +++++- test/search.test.mjs | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/lib/pages.mjs b/scripts/lib/pages.mjs index 24a7eb4f5..ad036cbf7 100644 --- a/scripts/lib/pages.mjs +++ b/scripts/lib/pages.mjs @@ -192,13 +192,17 @@ export function headings(body) { for (const line of stripFrontmatter(body).split('\n')) { if (line.startsWith('```')) { inFence = !inFence; continue; } if (inFence) continue; - const m = /^(#{2,3})\s+(.+?)\s*$/.exec(line); + /* Every heading counts towards the de-duplication - the title of the + page takes its slug first, so a `## EML` under `# EML` is `eml-1` - + and only the second and third level are indexed. */ + const m = /^(#{1,3})\s+(.+?)\s*$/.exec(line); if (!m) continue; const text = m[2].replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/[*`]/g, '').trim(); if (!text) continue; const slug = slugify(text); const n = seen.get(slug) ?? 0; seen.set(slug, n + 1); + if (m[1].length === 1) continue; out.push({ text, anchor: n === 0 ? slug : `${slug}-${n}` }); } return out; diff --git a/test/search.test.mjs b/test/search.test.mjs index 3fe01e40c..fd90232c1 100644 --- a/test/search.test.mjs +++ b/test/search.test.mjs @@ -128,7 +128,7 @@ test('a heading anchor is the one VitePress generates', () => { * second heading of one name on a page counts up (cookbook/eml_cds_sql/eml.html). */ assert.deepEqual(headings('## 3. First Launch\n').map((h) => h.anchor), ['_3-first-launch']); assert.deepEqual(headings('## `template:repeat` — Loops\n').map((h) => h.anchor), ['template-repeat-—-loops']); - assert.deepEqual(headings('## EML\n\ntext\n\n## EML\n').map((h) => h.anchor), ['eml', 'eml-1']); + assert.deepEqual(headings('# EML\n\n## EML\n\ntext\n\n## EML\n').map((h) => h.anchor), ['eml-1', 'eml-2']); }); /* ------------------------------------------------------------ the matcher */ From 8a6f46bc57f2a1ab2a0540a0b767adea2231d33a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 18:04:28 +0000 Subject: [PATCH 3/5] Print in light whatever the screen showed, a focus ring that exists, and the repo's own words brought up to date - docs.css: the print block set a white page and kept the dark palette - the site switches schemes by attribute, which a browser's print emulation does not touch - so a reader who chose dark printed light grey on white. The tokens go back to light under @media print. The same block hid a `.run-panel` nothing writes; it hides the Run button, the running frame, its bar and the fold button now. The outline's two media queries both matched at exactly 1100px. - style.css (the VitePress second opinion): the focus-ring selector list ended in a comma and ran into the line-counter rule, so nothing focused had a ring and everything focused had `counter-reset: line`. - config.mjs: apple-touch-icon pointed at the .ico; the PNG the static build writes exists. - build-site: the drawer's checkbox carries an accessible name once its label's text is hidden at desk width. - index.md: rel="noopener" on the project's own links, as build-site says. - README, SECURITY, CONTRIBUTING, package.json: fifteen gates, not eight; the README's list now names all fifteen; the first two sections of the bar are this site, not the middle two; the site is written by scripts/build-site.mjs, not VitePress; and SECURITY says what the build fetches and runs from the sibling deployment, which a security reader should know. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RBw7XcLuTa94iCUYZEtuh5 --- CONTRIBUTING.md | 4 ++-- README.md | 10 +++++++--- SECURITY.md | 16 ++++++++++------ docs/.vitepress/config.mjs | 2 +- docs/.vitepress/theme/style.css | 5 ++++- docs/index.md | 4 ++-- package.json | 2 +- scripts/build-site.mjs | 4 ++-- scripts/site-css/docs.css | 14 +++++++++++--- 9 files changed, 40 insertions(+), 21 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 84aef0a76..3e9aad876 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,8 +10,8 @@ npm run docs:dev # the site at localhost, with hot reload, while writing npm run check # everything CI runs on a pull request ``` -`npm run check` is `check.yml`: the test suite, then the VitePress build, then -the gates that hold the pages to what the other repositories actually ship — +`npm run check` is `check.yml`: the test suite, then the site build (and the +VitePress build as a second opinion), then the gates that hold the pages to what the other repositories actually ship — the version the site names, the examples it prints, the Run buttons the playground can honour, the API names and the sample links. A page that quotes another repository is checked against that repository, not against memory, diff --git a/README.md b/README.md index 199703874..5fcf90826 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ npm run check # what CI runs, all fifteen steps A documentation repository has no compiler for its prose, but fifteen things in it are decidable, and `npm run check` decides all fifteen before a merge — the -prose builds (`docs:build`), the four bars are still made of the same palette, +site builds (`build`, the pages as they are published, and `docs:build`, the +VitePress second opinion), the four bars are still made of the same palette, type and radii as the playground's (`check:design`), every link into a neighbouring site on this origin — the playground, the catalogue — still leads there rather than to this site's @@ -36,7 +37,10 @@ matches the interface there (`check:api-reference`), the sample links still match the sample repositories (`check:samples`), every complete app example carries a playground Run button or its declared reason not to (`check:playground`), the release -number in the nav bar still matches the framework (`check:version`), and the +number in the nav bar still matches the framework (`check:version`), every +image is the format and the size a page can afford (`check:images`), a page +that is wrapped stays wrapped (`check:line-length`), every prose word is one +a dictionary or the project's own list knows (`check:vocabulary`), and the catalogue parser still parses (`test`). `.github/workflows/check.yml` runs the same list in the same order, so a green `npm run check` locally is a green pull request — and `deploy.yml` runs it @@ -53,7 +57,7 @@ changing anything beyond prose. The bar is the mark, then the four sections — **Home**, **Documentation**, **Samples**, **Playground** — then one search box, then the project's links. -The two middle sections are this site; Samples and Playground are the two +The first two sections are this site; Samples and Playground are the two neighbouring deployments on the same origin, and all four bars across them are kept identical by hand. diff --git a/SECURITY.md b/SECURITY.md index 8b4780e0e..b6eb91df4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -19,8 +19,8 @@ deployed by merging it. ## What this site is, from a security point of view -- **There is no server.** The site is static files built by VitePress and - served from GitHub Pages. No backend, no SAP system, no account, no session, +- **There is no server.** The site is static files, written by + `scripts/build-site.mjs` and served from GitHub Pages. No backend, no SAP system, no account, no session, and nothing a reader types is sent anywhere. - **The one thing that executes is the Run button.** A fenced example that carries one links into @@ -34,15 +34,19 @@ deployed by merging it. checked against, `abap2UI5` and its sibling repositories at a pinned release. A wrong statement here is usually wrong there first — but report it here if that is where you found it. -- **Dependencies are pinned**, and the generators fetch only from - `raw.githubusercontent.com` at that pin. +- **Dependencies are pinned.** What the build fetches beyond them is the + organisation's own: the framework's interface and the sample catalogues from + `raw.githubusercontent.com`, and the bar, two stylesheets, the search box and + the ABAP highlighter from the playground's published site + (`abap2ui5.github.io/playground`), which the build runs to colour the + examples - so a compromise of that deployment would reach this build. ## Out of scope - A page that is out of date, incomplete, or wrong about the framework. That is a documentation bug and belongs in - [an issue](https://github.com/abap2UI5/docs/issues) — it is what the eight - checks in [AGENTS.md](AGENTS.md) exist to catch, so a report that names one + [an issue](https://github.com/abap2UI5/docs/issues) — it is what the fifteen + gates in [AGENTS.md](AGENTS.md) exist to catch, so a report that names one they missed is genuinely useful. - Anything a reader does to their own browser tab with code they wrote themselves. diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index b2f1e4358..96c4e1451 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -50,7 +50,7 @@ export default defineConfig({ ["link", { rel: "shortcut icon", href: "/docs/favicon.ico" }], [ "link", - { rel: "apple-touch-icon", sizes: "180x180", href: "/docs/favicon.ico" }, + { rel: "apple-touch-icon", sizes: "180x180", href: "/docs/apple-touch-icon.png" }, ], // THE TYPE. One file, from this origin, and preloaded because it is what // the first line of every page is set in — a `@font-face` is only diff --git a/docs/.vitepress/theme/style.css b/docs/.vitepress/theme/style.css index ca062fed7..9cdcff3b9 100644 --- a/docs/.vitepress/theme/style.css +++ b/docs/.vitepress/theme/style.css @@ -2366,7 +2366,10 @@ .Layout a:focus-visible, .Layout button:focus-visible, .Layout summary:focus-visible, -.Layout input:focus-visible, +.Layout input:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} /* ---- a number beside every line, and an address for it ---- * diff --git a/docs/index.md b/docs/index.md index 7ebf14e2a..fa8f59a66 100644 --- a/docs/index.md +++ b/docs/index.md @@ -350,7 +350,7 @@ ENDCLASS. → *The [Tutorial](/tutorials/walkthrough/) builds this app in twelve steps, through to transport and unit tests.* diff --git a/package.json b/package.json index 55e11c647..438ee679a 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "abap2ui5-docs", "private": true, "type": "module", - "description": "Documentation site for abap2UI5 (VitePress).", + "description": "Documentation site for abap2UI5.", "license": "MIT", "repository": { "type": "git", diff --git a/scripts/build-site.mjs b/scripts/build-site.mjs index dc56e292a..dc8dcd536 100644 --- a/scripts/build-site.mjs +++ b/scripts/build-site.mjs @@ -739,7 +739,7 @@ const lastTouched = (() => { })(); const chapter = ({ body, page, route }) => `
- + ${sidebarFor(route)}
@@ -1322,7 +1322,7 @@ fs.writeFileSync(path.join(OUT, 'docs', '404.html'), shell({ bar: BAR_DOCS, inline: [NOT_FOUND_SCRIPT], main: `
- + ${sidebarFor('/404')}
diff --git a/scripts/site-css/docs.css b/scripts/site-css/docs.css index 7e1d39a7f..84ea615a1 100644 --- a/scripts/site-css/docs.css +++ b/scripts/site-css/docs.css @@ -171,7 +171,7 @@ /* Below 1100 the outline goes, exactly as it does on a sample page. The menu stays: it is a drawer at this width already, and a manual with no way into its other chapters is not one. */ -@media (max-width: 1100px) { +@media (max-width: 1099.98px) { .manual { grid-template-columns: minmax(0, 1fr); } .manual .outline { display: none; } } @@ -1096,9 +1096,17 @@ html { scrollbar-gutter: stable; } * be broken across two sheets if it fits on one, and a heading may not be * the last thing on a sheet. */ @media print { - .bar, .sidebar, .outline, .prev-next, .skip, .side-button, .side-scrim, .run-panel { display: none !important; } + .bar, .sidebar, .outline, .prev-next, .skip, .side-button, .side-scrim, + .a2ui5-play-run, .a2ui5-play-bar, .a2ui5-play-fold, .abap2ui5-demo, .doc-foot .edit { display: none !important; } html { scroll-padding-top: 0; } - body { background: #fff; } + /* The palette back to light, whatever the reader chose on screen: the site + switches schemes by attribute (data-theme), which a browser's print + emulation does not touch - so a dark page printed #e6e7ea on white. */ + :root, :root[data-theme="dark"] { + --bg: #fff; --bg-sunken: #f4f5f7; --fg: #1c1d21; --fg-dim: #6a6d76; --line: #d9dbe0; + color-scheme: light; + } + body { background: #fff; color: var(--fg); } .manual, .doc-body, .home { display: block; max-width: none; margin: 0; padding: 0; } .vp-doc { max-width: none; } a { color: inherit; } From d8b247943be7566aac85e46b1fd1ce7108dd1559 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 18:12:02 +0000 Subject: [PATCH 4/5] The pages stop teaching the raw JavaScript form of follow_up_action( ), and a dozen sentences match the framework again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read against z2ui5_if_client on main and the framework's source: - Frontend, Deprecations, WebSocket: `follow_up_action( )` no longer runs a raw JavaScript expression - the client class says so in as many words ("there is no code form: a raw JavaScript val is not run") - yet the Frontend page carried a hundred lines on how to, the deprecations table named "a raw expression" as the replacement for three removed things, and the WebSocket page opened its socket with one. The section says what replaced the form (control_global, control_by_id, hash_back, a custom control in z2ui5_ccc), the table rows point there, and the WebSocket page names the frontend's own cc/Websocket, which its sample uses. - Frontend: `ICON_POOL` is on the control_global whitelist; the method is `addCustomCurrencies` and it merges rather than adding one code; `setNextStep` is a WizardStep's method, so the example addresses the step. - Troubleshooting: the type-coercion section said `abap_bool` arrives as "X" and a CheckBox needs a formatter - the Formatter and Binding pages, and the framework, say the boolean types travel as JSON true/false and the trap is a flag typed `c LENGTH 1`. The event-name section pointed at `cs_event-*` for the reader's own event names, which are frontend actions. - Popup: `NEW z2ui5_cl_sample_confirm( mv_question = … )` on a class with no constructor; the attribute is set after NEW, as the other pages do. - Walkthrough step 3 and the cheat sheet said the check_* methods are exclusive; `check_on_init( )` implies `check_on_navigated( )`, which is why init goes first. Step 7 said one step was left with three to go. - Value Help: an `i_multiselect` / `ls_res-table` sentence left over from a removed built-in popup, and old typed-builder names for the suggestion aggregation. Demo Output named `_cc_plain_xml`, a method of the frozen builder, for what the code does with core:HTML. - Themes: the Belize rows and the user exit example - removed in UI5 1.136, as the View page already says. Insight 22: the framework's CSRF gate is a check, not a token. Insight 12: `custom_js` exists only in the frozen package. Renaming: the exit lookup's literal is `Z2UI5_IF_UI5_EXIT`, with a fallback beside it - not the two the page named. - i18n: a text symbol goes on a single-quoted literal, as the sample has it. Barcode: SAPUI5, not "the UI5 framework". About: ten runnable steps. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RBw7XcLuTa94iCUYZEtuh5 --- docs/advanced/extensibility/user_exits.md | 2 +- .../12-where-your-own-javascript-goes.md | 10 +- .../insights/22-who-may-start-which-app.md | 2 +- docs/advanced/renaming.md | 5 +- docs/configuration/setup/theme.md | 2 - docs/cookbook/cheat_sheet.md | 2 +- .../device_capabilities/barcode_scanning.md | 2 +- docs/cookbook/event_navigation/frontend.md | 127 +++--------------- docs/cookbook/expert_more/demo_output.md | 2 +- docs/cookbook/expert_more/value_help.md | 5 +- docs/cookbook/expert_more/websocket.md | 4 +- docs/cookbook/popup_popover/popup.md | 4 +- .../translation_messages/translation_i18n.md | 2 +- .../troubleshooting/common_failures.md | 8 +- docs/get_started/about.md | 2 +- docs/resources/deprecations.md | 25 ++-- docs/tutorials/walkthrough/step-3.md | 5 +- docs/tutorials/walkthrough/step-7.md | 4 +- 18 files changed, 62 insertions(+), 151 deletions(-) diff --git a/docs/advanced/extensibility/user_exits.md b/docs/advanced/extensibility/user_exits.md index 19ead5ce7..9c48d25df 100644 --- a/docs/advanced/extensibility/user_exits.md +++ b/docs/advanced/extensibility/user_exits.md @@ -23,7 +23,7 @@ CLASS zcl_a2ui5_user_exit IMPLEMENTATION. METHOD z2ui5_if_ui5_exit~set_config_http_get. - cs_config-theme = `sap_belize`. + cs_config-theme = `sap_horizon_dark`. ENDMETHOD. diff --git a/docs/advanced/insights/12-where-your-own-javascript-goes.md b/docs/advanced/insights/12-where-your-own-javascript-goes.md index 074e0a8fc..560083064 100644 --- a/docs/advanced/insights/12-where-your-own-javascript-goes.md +++ b/docs/advanced/insights/12-where-your-own-javascript-goes.md @@ -42,10 +42,12 @@ load. Write it as a backtick literal rather than a string template: a template has to escape every brace, and one missed escape is a parser error on the whole statement instead of a wrong string. Ask how we know. -**Everything else is a system decision, not an app decision.** Extra JavaScript -for the initial page is `custom_js` in the HTTP GET configuration, set in the -[user exit](/advanced/extensibility/user_exits) through `z2ui5_if_ui5_exit` — -one place, reviewable, and the same for every app in the system. +**Everything else is a system decision, not an app decision.** Frontend code +of your own is a custom control in the customer frontend BSP (`z2ui5_ccc`), +and what the initial page loads is the bootstrap the +[user exit](/advanced/extensibility/user_exits) sets through +`z2ui5_if_ui5_exit` — one place, reviewable, and the same for every app in +the system. None of them lets an app change the framework, and none requires the framework to change for an app. No plugin registry to learn, and no pull request to wait diff --git a/docs/advanced/insights/22-who-may-start-which-app.md b/docs/advanced/insights/22-who-may-start-which-app.md index a250de523..c10ce50ac 100644 --- a/docs/advanced/insights/22-who-may-start-which-app.md +++ b/docs/advanced/insights/22-who-may-start-which-app.md @@ -52,7 +52,7 @@ with the refusal branch and what it should say, is on [Authorization](/configuration/authorization). What the framework itself brings to the table is the part a web application -needs and a report never did. A CSRF token on every POST, on by default. A +needs and a report never did. A CSRF check on every POST, on by default. A Content Security Policy on the first page, restricting what the browser will load, tightened in the user exit when an old UI5 release no longer has to be served. Error details off in production, as diff --git a/docs/advanced/renaming.md b/docs/advanced/renaming.md index 3f2e3c9c6..357bf6594 100644 --- a/docs/advanced/renaming.md +++ b/docs/advanced/renaming.md @@ -220,9 +220,10 @@ in `CATCH cx_root`, so nothing is reported: the installation just runs with the default configuration and your [user exit](/advanced/extensibility/user_exits) is never called — no custom theme, no bootstrap configuration, no CSP override. -::: warning Patch the two literals after renaming +::: warning Patch the literals after renaming In your renamed branch, change the literals in `get_user_exit_class( )` to your -own namespace (`` `ZMYUI5_IF_EXIT` `` and `` `ZMYUI5_CL_UI5_USER_EXIT` ``). Only +own namespace (`` `ZMYUI5_IF_UI5_EXIT` ``, `` `ZMYUI5_CL_UI5_USER_EXIT` `` and +the `` `ZMYUI5_IF_EXIT` `` fallback beside them). Only apps that use a user exit are affected — everything else in the renamed installation works without a change. ::: diff --git a/docs/configuration/setup/theme.md b/docs/configuration/setup/theme.md index 012166fe3..4697d80dc 100644 --- a/docs/configuration/setup/theme.md +++ b/docs/configuration/setup/theme.md @@ -29,8 +29,6 @@ The themes shipped with current UI5 versions are: | `sap_fiori_3_dark` | Quartz Dark | | `sap_fiori_3_hcb` | Quartz high-contrast black | | `sap_fiori_3_hcw` | Quartz high-contrast white | -| `sap_belize` | Belize (older, blue) | -| `sap_belize_plus` | Belize Plus (older, gray background) | See the official [list of available themes](https://sapui5.hana.ondemand.com/#/topic/4cfe7eff3001447a9d4b0abeaba95166) for the most up-to-date catalog. diff --git a/docs/cookbook/cheat_sheet.md b/docs/cookbook/cheat_sheet.md index 8c7459b1c..772b3f0eb 100644 --- a/docs/cookbook/cheat_sheet.md +++ b/docs/cookbook/cheat_sheet.md @@ -8,7 +8,7 @@ A one-page recap of the rules that decide whether an abap2UI5 app works or misbe | Rule | Why it matters | |---|---| | Implement `z2ui5_if_app` and put everything in the single `main` method | It is the only entry point the framework calls — on the initial load *and* on every user interaction → [Life Cycle](/cookbook/event_navigation/life_cycle) | -| Dispatch with one `IF` / `ELSEIF` chain over `check_on_init( )`, `check_on_navigated( )` and `check_on_event( )` | Each check answers for its own phase only; separate `IF` blocks let two branches run in the same roundtrip → [Life Cycle](/cookbook/event_navigation/life_cycle) | +| Dispatch with one `IF` / `ELSEIF` chain over `check_on_init( )`, `check_on_navigated( )` and `check_on_event( )` | `check_on_init( )` implies `check_on_navigated( )`, so init goes first; separate `IF` blocks let two branches run in the same roundtrip → [Life Cycle](/cookbook/event_navigation/life_cycle) | | Always call `view_display( )` in the `check_on_navigated( )` branch | After a called app returns via `nav_app_leave( )`, the browser still shows *its* view — without a re-display the user is left on a stale or blank screen → [Navigation](/cookbook/event_navigation/navigation/inner_app) | | Declare every attribute you bind in the `PUBLIC SECTION` | Binding works via dynamic `ASSIGN` and cannot reach `PROTECTED` / `PRIVATE`; the roundtrip fails with `BINDING_ERROR` → [Binding](/cookbook/model/binding) | | Keep state in attributes, not in local variables | Between two events the app instance is serialized into a draft on the SERVER and read back — attributes survive at any visibility; locals, `DATA(...)` declarations, open cursors and locks do not → [Statefulness](/cookbook/expert_more/statefulness) | diff --git a/docs/cookbook/device_capabilities/barcode_scanning.md b/docs/cookbook/device_capabilities/barcode_scanning.md index ccfaf7f5c..339f8d862 100644 --- a/docs/cookbook/device_capabilities/barcode_scanning.md +++ b/docs/cookbook/device_capabilities/barcode_scanning.md @@ -49,7 +49,7 @@ ENDMETHOD. ``` ::: tip **UI5 Versions** -This feature works only with the UI5 framework, not with OpenUI5. +This feature needs SAPUI5 - OpenUI5 does not ship the barcode scanner. ::: ## Focus Handling diff --git a/docs/cookbook/event_navigation/frontend.md b/docs/cookbook/event_navigation/frontend.md index 45e8d9587..c4699d077 100644 --- a/docs/cookbook/event_navigation/frontend.md +++ b/docs/cookbook/event_navigation/frontend.md @@ -150,11 +150,11 @@ The control-call constants — `control_by_id`, `control_global`, `binding_call` | Event | `t_arg` (positional) | | ---------------- | ------------------------------------------------------------------------------------ | | `control_by_id` | `id`, `method`, `params…` — call a method on a control resolved by id | -| `control_global` | `object`, `method`, `params…` — `MESSAGE_TOAST`, `MESSAGE_BOX`, `BUSY_INDICATOR`, `THEMING`, `POPUP`, `INVISIBLE_MESSAGE`, `FORMATTING` | +| `control_global` | `object`, `method`, `params…` — `MESSAGE_TOAST`, `MESSAGE_BOX`, `BUSY_INDICATOR`, `THEMING`, `POPUP`, `INVISIBLE_MESSAGE`, `FORMATTING`, `ICON_POOL` | | `binding_call` | `id`, `aggregation`, `method`, `params…` — e.g. `filter` (path, operator, value1, value2) or `sort` (path, descending, group) on the aggregation's binding | | `bind_element` | `index`, `_bind( table )` — element-bind a whole view slot to a table row, see below | -For `control_by_id`, any public control method is callable as long as it is not on the framework's **denylist**: methods that would break abap2UI5's own invariants (destroying views, re-rendering, detaching the framework's handlers, …) are blocked, ordinary setters and toggles (`setVisible`, `toggleBy`, `enablePostButton`, …) simply work. A small set of methods is additionally special-cased for typed arguments. `control_global` and `binding_call` remain strict whitelists — only the listed global objects and the binding methods `filter` / `sort` are callable. Three of those objects are less obvious than the rest: `POPUP-setWithinArea` confines every popup to one control instead of to the window (UI5 ≥ 1.89; an empty argument releases it again), `INVISIBLE_MESSAGE-announce` reads a text out to a screen reader without rendering it (UI5 ≥ 1.78; `t_arg` = text, mode), and `FORMATTING-setCustomCurrencies` / `-addCustomCurrency` register currency codes the standard `sap.ui.model.type.Currency` does not know (UI5 ≥ 1.120) — `set…` REPLACES the whole registration, `add…` adds one code. +For `control_by_id`, any public control method is callable as long as it is not on the framework's **denylist**: methods that would break abap2UI5's own invariants (destroying views, re-rendering, detaching the framework's handlers, …) are blocked, ordinary setters and toggles (`setVisible`, `toggleBy`, `enablePostButton`, …) simply work. A small set of methods is additionally special-cased for typed arguments. `control_global` and `binding_call` remain strict whitelists — only the listed global objects and the binding methods `filter` / `sort` are callable. Three of those objects are less obvious than the rest: `POPUP-setWithinArea` confines every popup to one control instead of to the window (UI5 ≥ 1.89; an empty argument releases it again), `INVISIBLE_MESSAGE-announce` reads a text out to a screen reader without rendering it (UI5 ≥ 1.78; `t_arg` = text, mode), and `FORMATTING-setCustomCurrencies` / `-addCustomCurrencies` register currency codes the standard `sap.ui.model.type.Currency` does not know (UI5 ≥ 1.120) — `set…` REPLACES the whole registration, `add…` MERGES codes into it. ```abap " toggle a MessagePopover open, anchored to the pressing button, no roundtrip @@ -182,10 +182,11 @@ the subject of [Message](/cookbook/translation_messages/message). The same events also work as a **statement** in your `main` method, with the identical `t_arg` — then the browser runs them after the response arrives, once your backend work is done: ```abap -" after backend processing, advance a wizard step +" after backend processing, let the wizard advance: setNextStep is the +" STEP's method, so the current step is the control addressed client->follow_up_action( val = client->cs_event-control_by_id - t_arg = VALUE #( ( `wiz` ) ( `setNextStep` ) ( `STEP2` ) ) ). + t_arg = VALUE #( ( `STEP1` ) ( `setNextStep` ) ( `STEP2` ) ) ). ``` @@ -227,112 +228,18 @@ The view used to be the second entry of `t_arg` (`id`, `view`, `method`, …). I ## Raw JavaScript -The second way to call `follow_up_action( )`: pass a raw JavaScript expression as -`val` (without `t_arg`). It runs as-is in the browser. - -```abap -client->follow_up_action( `myFunction()` ). -``` - -`follow_up_action( )` decides which way applies from the content of `val`: a -plain event name (only `A-Z`, `a-z`, `0-9`, `_`) becomes a frontend event call, -anything containing JavaScript syntax runs verbatim. - -::: warning Not Recommended -This is still available, but its use is **strongly discouraged**. Injecting -arbitrary JavaScript from the backend into the frontend introduces serious -security risks. Only use it if you fully understand the consequences and have no -alternative. -::: - - -### Why It Is a Security Risk - -Custom JS works by sending a JavaScript string from the ABAP backend to the frontend, where it is injected into the DOM as an HTML `| - - )->ele( `Page` - )->tag( `Button` - )->a( n = `text` v = `call custom JS` - )->a( n = `press` v = client->_event( `CUSTOM_JS` ) ). - - client->view_display( view->stringify( ) ). - - ENDIF. - - IF client->get( )-event = `CUSTOM_JS`. - client->follow_up_action( `myFunction()` ). - ENDIF. - -ENDMETHOD. -``` - -::: danger Never Inject Untrusted Input -If you must use this, ensure the JavaScript content is **entirely static and hardcoded**. Never concatenate user input, database values, translatable texts, or any other dynamic data into the script string — doing so turns the feature into a direct XSS vulnerability. -::: - -### Embedding JavaScript Directly in an XML View - -::: warning Also Not Recommended -The same security considerations apply: any `