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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
55 changes: 55 additions & 0 deletions scripts/lib/catalogue.mjs
Original file line number Diff line number Diff line change
@@ -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<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>(?:<[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;
}
44 changes: 1 addition & 43 deletions scripts/link-samples.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 —
Expand Down
67 changes: 67 additions & 0 deletions test/catalogue.test.mjs
Original file line number Diff line number Diff line change
@@ -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');
});