diff --git a/package.json b/package.json
index 7ae9fd5b..86777b6c 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,8 @@
"check:examples": "node scripts/check-examples.mjs",
"link:samples": "node scripts/link-samples.mjs",
"check:samples": "node scripts/link-samples.mjs --check",
- "check": "npm run check:version && npm run docs:build && npm run check:examples && npm run check:samples",
+ "test": "node --test test/*.test.mjs",
+ "check": "npm run test && npm run check:version && npm run docs:build && npm run check:examples && npm run check:samples",
"llms": "node scripts/generate-llms.mjs",
"check:version": "node scripts/check-version.mjs"
},
diff --git a/scripts/lib/catalogue.mjs b/scripts/lib/catalogue.mjs
new file mode 100644
index 00000000..86e7a523
--- /dev/null
+++ b/scripts/lib/catalogue.mjs
@@ -0,0 +1,55 @@
+/*
+ * catalogue — the one parser for a SAMPLES.md row.
+ *
+ * Extracted from link-samples.mjs so it can be tested without running it: the
+ * script resolves a samples checkout and rewrites pages at import time, and a
+ * parser that has silently stopped matching TWICE deserves a test of its own
+ * (test/catalogue.test.mjs).
+ *
+ * The row shape is maintained in abap2UI5/samples, /samples-controls and
+ * /samples-stack - three repositories, none of them this one - and read here
+ * and in abap2UI5/ai-mcp. It is a contract between five programs.
+ */
+
+/* One catalogue row, as generate-samples-md.js writes it:
+ *
+ * | **Basics I** — Hello World
hello world minimal | [`Z2UI5_CL_SMP_APP_493`](src/01/z2ui5_cl_smp_app_493.clas.abap) |
+ *
+ * The bold half is only there when the row carries a header of its own (the
+ * generator drops one that would just repeat its section heading), so both
+ * shapes have to parse.
+ *
+ * What follows the title is a run of `
` blocks, and they are matched as a
+ * GROUP rather than counted or typed: the small type in `` (keywords,
+ * then the `@docs` links this script maintains) and, since the samples
+ * repository gave every app a `" @summary`, a block of NORMAL type carrying
+ * that sentence:
+ *
+ * | **Basics I** — Hello World
The smallest app that runs.
hello world minimal | [`Z2UI5_CL_SMP_APP_493`](...) |
+ *
+ * The narrower pattern that expected `
` blocks ONLY matched no rows
+ * at all the day the sentence arrived - every page's samples block then read
+ * "not in the sample catalogue", which is a wrong answer rather than a broken
+ * run. That is why this matches loosely: the row shape is maintained in
+ * another repository (three of them now), and a block this script does not
+ * know about must cost it nothing. */
+const ROW = /^\|\s*(?:\*\*(?[^*]+)\*\*\s*(?:—|--)\s*)?(?[^|<]*?)\s*(?(?:
(?:<[a-z]+>[^<]*<\/[a-z]+>|[^<]*))*)\s*\|\s*\[`(?[A-Z0-9_]+)`\]\((?[^)]+)\)\s*\|/;
+
+/** class name (lower case) -> { label, path } for every app in the catalogue. */
+export function parseCatalogue(text) {
+ const byClass = new Map();
+ let section = '';
+ for (const line of text.split('\n')) {
+ const head = /^#{2,3}\s+(.+?)\s*$/.exec(line);
+ if (head) { section = head[1].replace(/[*`]/g, '').replace(/\s+—.*$/, '').trim(); continue; }
+ const m = ROW.exec(line);
+ if (!m) continue;
+ const g = m.groups;
+ // the row's own header plus its short text; a row without a header of its
+ // own is titled by the section it sits under, or it would read as an
+ // orphaned fragment out here
+ const label = [g.title?.trim(), (g.sub || '').trim()].filter(Boolean).join(' — ') || section;
+ byClass.set(g.cls.toLowerCase(), { label, path: g.path, section });
+ }
+ return byClass;
+}
diff --git a/scripts/link-samples.mjs b/scripts/link-samples.mjs
index 6206a9e6..e69d1ace 100644
--- a/scripts/link-samples.mjs
+++ b/scripts/link-samples.mjs
@@ -47,6 +47,7 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
+import { parseCatalogue } from './lib/catalogue.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const DOCS = path.join(ROOT, 'docs');
@@ -75,49 +76,6 @@ function resolveSamples() {
return null;
}
-/* One catalogue row, as generate-samples-md.js writes it:
- *
- * | **Basics I** — Hello World
hello world minimal | [`Z2UI5_CL_SMP_APP_493`](src/01/z2ui5_cl_smp_app_493.clas.abap) |
- *
- * The bold half is only there when the row carries a header of its own (the
- * generator drops one that would just repeat its section heading), so both
- * shapes have to parse.
- *
- * What follows the title is a run of `
` blocks, and they are matched as a
- * GROUP rather than counted or typed: the small type in `` (keywords,
- * then the `@docs` links this script maintains) and, since the samples
- * repository gave every app a `" @summary`, a block of NORMAL type carrying
- * that sentence:
- *
- * | **Basics I** — Hello World
The smallest app that runs.
hello world minimal | [`Z2UI5_CL_SMP_APP_493`](...) |
- *
- * The narrower pattern that expected `
` blocks ONLY matched no rows
- * at all the day the sentence arrived - every page's samples block then read
- * "not in the sample catalogue", which is a wrong answer rather than a broken
- * run. That is why this matches loosely: the row shape is maintained in
- * another repository (three of them now), and a block this script does not
- * know about must cost it nothing. */
-const ROW = /^\|\s*(?:\*\*(?[^*]+)\*\*\s*(?:—|--)\s*)?(?[^|<]*?)\s*(?(?:
(?:[^<]*<\/sub>|[^<]*))*)\s*\|\s*\[`(?[A-Z0-9_]+)`\]\((?[^)]+)\)\s*\|/;
-
-/** class name (lower case) -> { label, path } for every app in the catalogue. */
-function parseCatalogue(text) {
- const byClass = new Map();
- let section = '';
- for (const line of text.split('\n')) {
- const head = /^#{2,3}\s+(.+?)\s*$/.exec(line);
- if (head) { section = head[1].replace(/[*`]/g, '').replace(/\s+—.*$/, '').trim(); continue; }
- const m = ROW.exec(line);
- if (!m) continue;
- const g = m.groups;
- // the row's own header plus its short text; a row without a header of its
- // own is titled by the section it sits under, or it would read as an
- // orphaned fragment out here
- const label = [g.title?.trim(), (g.sub || '').trim()].filter(Boolean).join(' — ') || section;
- byClass.set(g.cls.toLowerCase(), { label, path: g.path, section });
- }
- return byClass;
-}
-
/* ------------------------------------------------------------------- pages */
/** The `samples:` key of a page's frontmatter. Block list or inline array —
diff --git a/test/catalogue.test.mjs b/test/catalogue.test.mjs
new file mode 100644
index 00000000..69448dfc
--- /dev/null
+++ b/test/catalogue.test.mjs
@@ -0,0 +1,67 @@
+/*
+ * The catalogue parser, pinned by a row of every shape the three sample
+ * repositories generate.
+ *
+ * This exists because the parser has now silently stopped matching TWICE, and
+ * both times the symptom was a WRONG ANSWER rather than a red run: every page's
+ * declaration came back as "`z2ui5_cl_smp_app_493` is not in the sample
+ * catalogue — renamed, deleted, or a typo", which is exactly the sentence this
+ * check exists to say truthfully. First when the catalogue grew the `@docs`
+ * links under the keywords, then when it grew the `" @summary` sentence in
+ * normal type above them.
+ *
+ * The row shape is maintained in abap2UI5/samples, /samples-controls and
+ * /samples-stack - three repositories, none of them this one - so the fixture
+ * below is a CONTRACT, not a sample of today's output. A block nobody here has
+ * seen before must cost this parser nothing.
+ *
+ * node --test test/
+ */
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { parseCatalogue } from '../scripts/lib/catalogue.mjs';
+
+const ROWS = [
+ '## Basics',
+ '',
+ '| Sample | Class |',
+ '|---|---|',
+ // title + sub only (the oldest shape)
+ '| **Basics I** — Hello World | [`Z2UI5_CL_SMP_APP_493`](src/01/z2ui5_cl_smp_app_493.clas.abap) |',
+ // + keywords
+ '| **Basics II** — Data Binding
binding input button | [`Z2UI5_CL_SMP_APP_494`](src/01/z2ui5_cl_smp_app_494.clas.abap) |',
+ // + summary, + keywords, + docs (today's shape, all four blocks)
+ '| **Popup** — Value Help
The value help, both halves.
f4 search help
docs: [cookbook/expert_more/value_help](https://abap2ui5.github.io/docs/cookbook/expert_more/value_help) | [`Z2UI5_CL_SMP_APP_009`](src/01/z2ui5_cl_smp_app_009.clas.abap) |',
+ // no header of its own - titled by its section (samples-controls rows, too)
+ '| App state
Turns the running app into a URL.
bookmark restore url | [`Z2UI5_CL_SMP_APP_321`](src/00/97/z2ui5_cl_smp_app_321.clas.abap) |',
+ // a block this parser has never seen: it must not stop the row parsing
+ '| **Future** — Something
a block from a later generator
terms | [`Z2UI5_CL_SMP_APP_999`](src/01/z2ui5_cl_smp_app_999.clas.abap) |',
+].join('\n');
+
+test('every row shape the three catalogues generate parses into a pointer', () => {
+ const byClass = parseCatalogue(ROWS);
+ assert.deepEqual(
+ [...byClass.keys()].sort(),
+ ['z2ui5_cl_smp_app_009', 'z2ui5_cl_smp_app_321', 'z2ui5_cl_smp_app_493',
+ 'z2ui5_cl_smp_app_494', 'z2ui5_cl_smp_app_999'],
+ );
+
+ assert.equal(byClass.get('z2ui5_cl_smp_app_493').label, 'Basics I — Hello World');
+ assert.equal(byClass.get('z2ui5_cl_smp_app_493').path, 'src/01/z2ui5_cl_smp_app_493.clas.abap');
+
+ // the summary and the docs links are metadata, not part of the label
+ assert.equal(byClass.get('z2ui5_cl_smp_app_009').label, 'Popup — Value Help');
+
+ /* A row without a header of its own is titled by its section - the
+ * generators drop a header that would only repeat the heading above it. */
+ assert.equal(byClass.get('z2ui5_cl_smp_app_321').label, 'App state');
+ assert.equal(byClass.get('z2ui5_cl_smp_app_321').section, 'Basics');
+});
+
+test('a page link survives a block this parser has never seen', () => {
+ // the whole point: the shape is maintained elsewhere, so an unknown block
+ // must degrade to "ignored", never to "this sample does not exist"
+ const byClass = parseCatalogue(ROWS);
+ assert.ok(byClass.has('z2ui5_cl_smp_app_999'), 'the row with an unknown block was dropped');
+ assert.equal(byClass.get('z2ui5_cl_smp_app_999').path, 'src/01/z2ui5_cl_smp_app_999.clas.abap');
+});