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*)?(?<sub>[^|<]*?)\s*(?<small>(?:<br>(?:<[a-z]+>[^<]*<\/[a-z]+>|[^<]*))*)\s*\|\s*\[`(?<cls>[A-Z0-9_]+)`\]\((?<path>[^)]+)\)\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<br><sub>hello world minimal</sub> | [`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 `<br>` blocks, and they are matched as a - * GROUP rather than counted or typed: the small type in `<sub>` (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<br>The smallest app that runs.<br><sub>hello world minimal</sub> | [`Z2UI5_CL_SMP_APP_493`](...) | - * - * The narrower pattern that expected `<br><sub>` 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*(?:\*\*(?<title>[^*]+)\*\*\s*(?:—|--)\s*)?(?<sub>[^|<]*?)\s*(?<small>(?:<br>(?:<sub>[^<]*<\/sub>|[^<]*))*)\s*\|\s*\[`(?<cls>[A-Z0-9_]+)`\]\((?<path>[^)]+)\)\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<br><sub>binding input button</sub> | [`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<br>The value help, both halves.<br><sub>f4 search help</sub><br><sub>docs: [cookbook/expert_more/value_help](https://abap2ui5.github.io/docs/cookbook/expert_more/value_help)</sub> | [`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<br>Turns the running app into a URL.<br><sub>bookmark restore url</sub> | [`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<br><span>a block from a later generator</span><br><sub>terms</sub> | [`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'); +});