diff --git a/.github/workflows/check-catalogue.yaml b/.github/workflows/check-catalogue.yaml
new file mode 100644
index 0000000..83789a1
--- /dev/null
+++ b/.github/workflows/check-catalogue.yaml
@@ -0,0 +1,32 @@
+name: check-catalogue
+
+# catalogue.json is the catalogue as data - the same facts SAMPLES.md carries
+# as a page, committed as one JSON file so a program (an agent, an editor, a
+# tool asking "which sample shows X with RAP") can answer from a single fetch
+# of `main` without running anything. It is generated from the classes and the
+# package index, and a committed generated file is a file that goes stale the
+# first time somebody adds a sample and does not rerun the generator. This is
+# what notices.
+
+on:
+ pull_request:
+ push:
+ branches: [main]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: check-catalogue-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ check-catalogue:
+ 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/generate-catalogue.mjs --check
diff --git a/.github/workflows/deploy-web.yaml b/.github/workflows/deploy-web.yaml
index 7f1a23a..8f8865c 100644
--- a/.github/workflows/deploy-web.yaml
+++ b/.github/workflows/deploy-web.yaml
@@ -5,12 +5,16 @@ name: deploy-web
# https://abap2ui5.github.io/samples-stack/ — every sample of this repository,
# searchable by the technology it plays with and by the release your system
# runs, with what it needs from that system on every card. It is static: three
-# hand-written files plus web/apps.json, which this workflow generates from the
-# tree on every deploy, so the page is never staler than the samples it
-# describes and a sample pull request carries no diff of derived data.
+# hand-written files plus two generated-at-deploy pieces, web/apps.json and
+# the web/thumbs/ thumbnails, which this workflow writes from the tree on
+# every deploy, so the page is never staler than the samples it describes and
+# a sample pull request carries no diff of derived data.
#
-# No dependencies are installed — the generator is plain node over the same
-# scan SAMPLES.md is built from.
+# The catalogue needs no dependencies — the generator is plain node over the
+# same scan SAMPLES.md is built from. The thumbnails do: they are the
+# abap2UI5-linter's render harness photographing each view (the same
+# devDependencies and chromium the check-abap2UI5 workflow drives), which is
+# why npm ci and the browser install sit below.
#
# This is the ONLY way the site is published: Settings -> Pages -> Source must
# be "GitHub Actions". A red "pages build and deployment" run on main is
@@ -30,6 +34,7 @@ on:
- 'README.md'
- '.github/packages.json'
- 'scripts/generate-web-index.mjs'
+ - 'scripts/generate-screenshots.mjs'
- 'scripts/lib/scan-samples.mjs'
- '.github/workflows/deploy-web.yaml'
@@ -46,21 +51,41 @@ concurrency:
jobs:
build:
runs-on: ubuntu-latest
- timeout-minutes: 10
+ timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
+ cache: 'npm'
- name: The catalogue behind the page
run: node scripts/generate-web-index.mjs
- # The gate this pipeline needs now that there is nothing here that can
- # fail to build: a page whose data file is missing deploys exactly like a
- # working one — it just says "the catalogue could not be loaded" to every
- # visitor. Assert the five files and a non-trivial index before uploading.
+ # A thumbnail per sample, photographed by the linter's render harness —
+ # the render gate's own view of each class, not a staged picture, and
+ # the closest thing to "seeing it run" a corpus without a playground
+ # link can offer. Best effort by design, in both directions: the script
+ # skips a view the headless harness cannot render (sap.ui.comp,
+ # z2ui5.cc custom controls — its card simply shows no picture, the page
+ # treats a missing file as exactly that), and a wholesale failure here —
+ # a browser download flaking, the runtime breaking — must not stop the
+ # samples themselves from publishing. continue-on-error is that second
+ # half; the step still fails visibly in the run when it happens.
+ - run: npm ci
+ - name: Install chromium for the render harness
+ run: npx playwright install chromium --with-deps
+ - name: One thumbnail per sample
+ continue-on-error: true
+ run: node scripts/generate-screenshots.mjs
+
+ # The gate this pipeline needs: a page whose data file is missing
+ # deploys exactly like a working one — it just says "the catalogue could
+ # not be loaded" to every visitor. Assert the five files and a
+ # non-trivial index before uploading. The thumbnails are deliberately
+ # NOT asserted — a card without its picture is complete (the
+ # removes itself), which is what lets the step above be best effort.
- name: Check the artefact is complete
run: |
cd web
diff --git a/.gitignore b/.gitignore
index 949adba..06b9e86 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,7 @@ output
# derived: the catalogue behind the page in web/, written by
# scripts/generate-web-index.mjs on every deploy (AGENTS.md section 8)
web/apps.json
+
+# derived: one thumbnail per sample, photographed by
+# scripts/generate-screenshots.mjs on every deploy (AGENTS.md section 8)
+web/thumbs/
diff --git a/AGENTS.md b/AGENTS.md
index f53ce7f..5726863 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -95,13 +95,14 @@ What that costs you when you edit:
```sh
npm ci
-npm run check # abaplint + abap2UI5-linter + overview + keywords + abapdoc + SAMPLES.md + app-rules
+npm run check # abaplint + abap2UI5-linter + overview + keywords + abapdoc + SAMPLES.md + catalogue.json + app-rules
```
Individually: `npm run lint` (abaplint), `npm run check:abap2ui5` (the app
class and the view it builds, including a headless render of every view),
-`npm run check:overview` (the four consistency directions between the overview
-app, the tree, `packages.json` and the README table).
+`npm run check:overview` (the five consistency directions between the overview
+app, the tree, `packages.json` and the two README tables — the package table
+and the *Which package do I need?* decision table).
`npm run fmt:chains` applies the house chain layout. It rewrites whitespace
between chain segments only — but it needs the ABAP to be *balanced* to know
@@ -295,6 +296,19 @@ gone.
makes that a configuration change over there rather than a second parser.
Changing the shape is not a cosmetic decision — a row that stops matching is
a row that silently is not there.
+- **[`catalogue.json`](catalogue.json) is the catalogue as data** — generated
+ by `npm run catalogue`, checked by `npm run check:catalogue`, **not** edited
+ by hand. SAMPLES.md is the reading copy for a person; this is the same
+ catalogue for a program: one entry per sample with class, path, package,
+ technology, `@summary`, `@keywords`, and the package's *Runs on* and *Plays
+ together with* facts repeated on the entry — so "which sample shows X with
+ RAP, and what does my system need for it" is answered from one committed
+ file, one `raw.githubusercontent.com` fetch away, without running anything.
+ It introduces no source of truth: everything in it comes out of the same
+ scan behind SAMPLES.md (`scripts/lib/scan-samples.mjs`) and the same package
+ merge behind the page (`scripts/lib/read-packages.mjs`). Committed, unlike
+ `web/apps.json`, because its reader runs no generator — which is exactly why
+ the freshness check exists.
## 7. When you add a sample
@@ -307,15 +321,17 @@ gone.
4. Give it a `" @keywords` line as its first line (§6) — what somebody would
type who does not know your sample exists.
5. Say what it needs in the package README if it needs anything new.
-6. `npm run samples:md` and commit `SAMPLES.md` with it (§6).
+6. `npm run samples:md` and `npm run catalogue`, and commit `SAMPLES.md` and
+ `catalogue.json` with it (§6).
7. `npm run check`.
## 8. The page in `web/`
**** — the catalogue as a searchable
page, for somebody who has installed nothing yet and is asking whether their
-system can run any of it. Four files: `index.html`, `stack.css`, `stack.js` and
-the generated `web/apps.json`. [`web/README.md`](web/README.md) has the detail;
+system can run any of it. Three hand-written files — `index.html`, `stack.css`,
+`stack.js` — plus two generated-at-deploy pieces, `web/apps.json` and the
+`web/thumbs/` thumbnails. [`web/README.md`](web/README.md) has the detail;
what matters here:
- **It introduces no new source of truth.** Every fact on it is read out of
@@ -327,6 +343,27 @@ what matters here:
- **`web/apps.json` is generated and not committed** — `npm run web:index`
writes it, `deploy-web` writes it again on every deploy. A committed copy
would put a diff of derived data on every sample pull request.
+- **`web/thumbs/` follows the same rule: one thumbnail per sample, generated
+ at deploy, never committed.** `scripts/generate-screenshots.mjs` (`npm run
+ screenshots`) photographs each app's main view with the abap2UI5-linter's
+ render harness — the view statically, seeded with mock data, no Gateway,
+ RAP or APC anywhere — so what a card shows is what the render gate checks.
+ It is a deploy step, not a gate, and it is in no check aggregate; it is the
+ one script here that needs the devDependencies and a playwright chromium.
+ A view the harness cannot render is reported and skipped, and the page
+ treats the missing file as "no picture" (the `` removes itself), so a
+ card without a thumbnail is normal, not broken. Measured over the corpus
+ (2026-08): **19 of 32 app views render.** The 13 skips are three stable
+ categories — `sap.ui.comp` controls (SAPUI5-only, absent from the
+ harness's OpenUI5 runtime: 7 of the 9 Smart Controls samples), `z2ui5.cc`
+ custom controls that do not load headless (the WebSocket, MIME-audio and
+ Smart Multi Input samples), and three RAP samples whose `ObjectStatus`
+ gets an empty `state` from the mock model. So the Smart Controls, AMC/APC
+ and MIME cards are mostly or wholly picture-less, and that is expected —
+ a change to the harness, not to those classes, is what would fix it. Only
+ a run that photographs *nothing* fails, because that is a harness problem;
+ even then the deploy publishes (`continue-on-error`), since a page without
+ pictures beats no page.
- **`npm run check:web` is the gate** (its own workflow, and part of `npm run
check`): it runs the generator with `--check`, which fails on a package with
no README row, a row whose cells no longer parse, or an app in a directory
diff --git a/README.md b/README.md
index 3bfee3f..a5ce081 100644
--- a/README.md
+++ b/README.md
@@ -51,6 +51,27 @@ for and try it out — the others can wait until you need them.
> your system runs, and every card says what the sample needs from that system
> before you install anything. ([`web/`](web/README.md))
+## Which package do I need?
+
+Most readers need exactly one. The nine areas are unrelated technologies, none
+depends on another — so find the row that says what you came to do, take that
+package, and skip the rest:
+
+| You want to … | Package | It needs |
+|---|---|---|
+| Bind a view straight to an OData V2 service you already run | [`src/01`](src/01/README.md) — OData | an activated OData V2 service |
+| Get columns, filters and value help for free from OData metadata — SmartTable, SmartField, SmartFilterBar | [`src/02`](src/02/README.md) — Smart Controls | SAPUI5 + an activated Gateway service |
+| Put a screen in front of a RAP business object, in plain ABAP with EML | [`src/03`](src/03/README.md) — RAP | ≥ 1909; the business object ships with the package |
+| Add draft handling on top — edit, resume, discard, activate | [`src/04`](src/04/README.md) — RAP with Draft | ≥ 1909; the draft business object ships with the package |
+| React to what a business object announces, while it happens | [`src/05`](src/05/README.md) — Business Events | ≥ 2021, the release that carries RAP business events |
+| Keep session state and an ABAP `ENQUEUE` lock alive between two clicks | [`src/06`](src/06/README.md) — Stateful Sessions / Locks | on-premise; the lock table ships with the package |
+| Push messages from ABAP into every open browser tab, without JavaScript | [`src/07`](src/07/README.md) — AMC/APC | on-premise; activate one ICF node in `SICF` |
+| Play or serve a file the MIME repository already holds | [`src/08`](src/08/README.md) — MIME Play Audio | on-premise; activate one ICF service in `SICF` |
+| Expose your app in the Fiori Launchpad — tile, startup parameters, cross-app navigation | [`src/09`](src/09/README.md) — Launchpad | a launchpad with a tile pointing at abap2UI5 |
+
+Each package README opens with a **What you need** section that turns the last
+column into concrete steps; the table below adds the exact release floors.
+
## What is in here
| Package | Topic | Plays together with | Runs on |
@@ -234,8 +255,9 @@ they take seconds.
|---|---|
| `abap-standard` | `abaplint ./abaplint.jsonc` — syntax `v757`, the on-premise release |
| `check-abap2UI5` | [`abap2ui5lint`](https://github.com/abap2UI5/linter) — the app class and the view it produces, together; also writes the two badges above |
-| `check-overview` | the two hand-kept indexes: every sample is listed in the overview app, and the package table matches `.github/packages.json` |
+| `check-overview` | the hand-kept indexes: every sample is listed in the overview app, the package table matches `.github/packages.json`, and the *Which package do I need?* table routes to every package |
| `check-samples-md` | [`SAMPLES.md`](SAMPLES.md) still is what the generator would write — and every app that exists is in an entry |
+| `check-catalogue` | [`catalogue.json`](catalogue.json) still is what the generator would write — the same catalogue as data, committed for tooling that fetches one file instead of scanning the tree |
| `check-keywords` | every app carries `@keywords` and `@summary`, and the overview's detail line still is the class's `@summary` |
| `check-abapdoc` | every `"!` block documents the declaration below it, rather than attaching to nothing |
| `check-app-rules` | the shared abaplint rule block still matches its source in [abap2UI5](https://github.com/abap2UI5/abap2UI5) |
diff --git a/catalogue.json b/catalogue.json
new file mode 100644
index 0000000..0420747
--- /dev/null
+++ b/catalogue.json
@@ -0,0 +1,826 @@
+{
+ "comment": "Generated by scripts/generate-catalogue.mjs — run `npm run catalogue`. Do not edit by hand (AGENTS.md section 6).",
+ "repo": "abap2UI5/samples-stack",
+ "role": "Step 3 of 3 in the abap2UI5 sample family: samples (the abap2UI5 basics) -> samples-controls (the UI5 control set) -> samples-stack (abap2UI5 together with the rest of the stack). Every sample here needs something from the system beyond an abap2UI5 installation — an OData service, a RAP business object, an APC channel, a launchpad — and its entry says what.",
+ "start": "Install abap2UI5, pull this repository — or the one-package branch the entry names — with abapGit, do the setup its package README asks for, then open ?app_start=.",
+ "overviewApp": "Z2UI5_CL_SMPS_APP_000",
+ "packages": [
+ {
+ "package": "src",
+ "technology": "Overview",
+ "topic": "the catalogue of this repository, inside your system",
+ "needs": "nothing beyond abap2UI5 — it ships on every branch and resolves every sample at runtime",
+ "runsOn": "Cloud + Standard ≥ 7.40 SP08",
+ "cloud": true,
+ "release": "7.40 SP08",
+ "note": "",
+ "branch": "main",
+ "readme": "README.md"
+ },
+ {
+ "package": "src/01",
+ "technology": "OData",
+ "topic": "bind a table to an OData V2 model",
+ "needs": "an activated OData V2 service",
+ "runsOn": "Cloud + Standard ≥ 7.40 SP08",
+ "cloud": true,
+ "release": "7.40 SP08",
+ "note": "",
+ "branch": "01-odata",
+ "readme": "src/01/README.md"
+ },
+ {
+ "package": "src/02",
+ "technology": "Smart Controls",
+ "topic": "`sap.ui.comp` driven by OData metadata",
+ "needs": "SAPUI5 + an activated Gateway service",
+ "runsOn": "Cloud + Standard ≥ 7.40 SP08",
+ "cloud": true,
+ "release": "7.40 SP08",
+ "note": "",
+ "branch": "02-smart-controls",
+ "readme": "src/02/README.md"
+ },
+ {
+ "package": "src/03",
+ "technology": "RAP",
+ "topic": "consume a business object with EML",
+ "needs": "ABAP Platform >= 1909; the BO ships with this repo",
+ "runsOn": "Cloud + Standard ≥ 7.54 (1909)",
+ "cloud": true,
+ "release": "7.54 (1909)",
+ "note": "",
+ "branch": "03-rap",
+ "readme": "src/03/README.md"
+ },
+ {
+ "package": "src/04",
+ "technology": "RAP with Draft",
+ "topic": "use draft handling",
+ "needs": "ABAP Platform >= 1909; the BO ships with this repo",
+ "runsOn": "Cloud + Standard ≥ 7.54 (1909)",
+ "cloud": true,
+ "release": "7.54 (1909)",
+ "note": "",
+ "branch": "04-rap-draft",
+ "readme": "src/04/README.md"
+ },
+ {
+ "package": "src/05",
+ "technology": "Business Events",
+ "topic": "react to RAP events, log them, show them",
+ "needs": "ABAP Platform >= 1909; the BO ships with this repo",
+ "runsOn": "Cloud + Standard ≥ 7.56 (2021)",
+ "cloud": true,
+ "release": "7.56 (2021)",
+ "note": "RAP business events are younger than EML: the ABAP parses at 7.54, the feature itself is there from 2021 on. If RAISE ENTITY EVENT does not activate on your system, this package is out of reach.",
+ "branch": "05-business-events",
+ "readme": "src/05/README.md"
+ },
+ {
+ "package": "src/06",
+ "technology": "Stateful Sessions / Locks",
+ "topic": "sticky session, `ENQUEUE`",
+ "needs": "ABAP Standard (on-premise), the table `Z2UI5_T_SMPS_01`",
+ "runsOn": "Standard only, ≥ 7.40 SP08",
+ "cloud": false,
+ "release": "7.40 SP08",
+ "note": "",
+ "branch": "06-stateful-locks",
+ "readme": "src/06/README.md"
+ },
+ {
+ "package": "src/07",
+ "technology": "AMC/APC",
+ "topic": "a news feed over WebSocket",
+ "needs": "on-premise APC/AMC, the ICF node `Z2UI5_APC_SMP_2`",
+ "runsOn": "Standard only, ≥ 7.50",
+ "cloud": false,
+ "release": "7.50",
+ "note": "",
+ "branch": "07-amc-apc",
+ "readme": "src/07/README.md"
+ },
+ {
+ "package": "src/08",
+ "technology": "MIME Play Audio",
+ "topic": "play a sound from the MIME repository",
+ "needs": "the ICF service `/SAP/PUBLIC/BC/ABAP/mime_demo`",
+ "runsOn": "Standard only, ≥ 7.50",
+ "cloud": false,
+ "release": "7.50",
+ "note": "",
+ "branch": "08-mime",
+ "readme": "src/08/README.md"
+ },
+ {
+ "package": "src/09",
+ "technology": "Launchpad",
+ "topic": "startup parameters, shell title, cross-app navigation",
+ "needs": "a Fiori Launchpad with a tile pointing at abap2UI5",
+ "runsOn": "Cloud + Standard ≥ 7.40 SP08",
+ "cloud": true,
+ "release": "7.40 SP08",
+ "note": "",
+ "branch": "09-launchpad",
+ "readme": "src/09/README.md"
+ }
+ ],
+ "samples": [
+ {
+ "class": "Z2UI5_CL_SMPS_APP_000",
+ "path": "src/z2ui5_cl_smps_app_000.clas.abap",
+ "package": "src",
+ "technology": "Overview",
+ "title": "Overview — All Samples in This Repository",
+ "summary": "every sample in this repository, grouped by what it needs from the system",
+ "keywords": [
+ "overview",
+ "launchpad",
+ "index",
+ "start",
+ "nav_app_call",
+ "popover",
+ "all",
+ "samples"
+ ],
+ "runsOn": "Cloud + Standard ≥ 7.40 SP08",
+ "cloud": true,
+ "needs": "nothing beyond abap2UI5 — it ships on every branch and resolves every sample at runtime",
+ "branch": "main",
+ "setup": "README.md"
+ },
+ {
+ "class": "Z2UI5_CL_SMPS_APP_315",
+ "path": "src/01/z2ui5_cl_smps_app_315.clas.abap",
+ "package": "src/01",
+ "technology": "OData",
+ "title": "Two Models in One View",
+ "summary": "one table bound to each, column headers from the metadata",
+ "keywords": [
+ "odata",
+ "model",
+ "service",
+ "entityset",
+ "switch_default_model_path",
+ "external",
+ "binding"
+ ],
+ "runsOn": "Cloud + Standard ≥ 7.40 SP08",
+ "cloud": true,
+ "needs": "an activated OData V2 service",
+ "branch": "01-odata",
+ "setup": "src/01/README.md"
+ },
+ {
+ "class": "Z2UI5_CL_SMPS_APP_313",
+ "path": "src/02/z2ui5_cl_smps_app_313.clas.abap",
+ "package": "src/02",
+ "technology": "Smart Controls",
+ "title": "Smart Table and Variants",
+ "summary": "with variant management - UI_PRODUCTLIST",
+ "keywords": [
+ "smarttable",
+ "smartfilterbar",
+ "variant",
+ "management",
+ "annotations",
+ "controlconfiguration",
+ "odata"
+ ],
+ "runsOn": "Cloud + Standard ≥ 7.40 SP08",
+ "cloud": true,
+ "needs": "SAPUI5 + an activated Gateway service",
+ "branch": "02-smart-controls",
+ "setup": "src/02/README.md"
+ },
+ {
+ "class": "Z2UI5_CL_SMPS_APP_314",
+ "path": "src/02/z2ui5_cl_smps_app_314.clas.abap",
+ "package": "src/02",
+ "technology": "Smart Controls",
+ "title": "Switch Default Model",
+ "summary": "device, HTTP and OData model side by side - GWSAMPLE_BASIC",
+ "keywords": [
+ "switch_default_model_path",
+ "odata",
+ "model",
+ "default",
+ "binding",
+ "smart",
+ "controls"
+ ],
+ "runsOn": "Cloud + Standard ≥ 7.40 SP08",
+ "cloud": true,
+ "needs": "SAPUI5 + an activated Gateway service",
+ "branch": "02-smart-controls",
+ "setup": "src/02/README.md"
+ },
+ {
+ "class": "Z2UI5_CL_SMPS_APP_319",
+ "path": "src/02/z2ui5_cl_smps_app_319.clas.abap",
+ "package": "src/02",
+ "technology": "Smart Controls",
+ "title": "Smart Multi Input",
+ "summary": "UI conditions mapped 1:1 onto an ABAP range table",
+ "keywords": [
+ "smartmultiinput",
+ "multi",
+ "input",
+ "tokens",
+ "smart",
+ "controls",
+ "odata"
+ ],
+ "runsOn": "Cloud + Standard ≥ 7.40 SP08",
+ "cloud": true,
+ "needs": "SAPUI5 + an activated Gateway service",
+ "branch": "02-smart-controls",
+ "setup": "src/02/README.md"
+ },
+ {
+ "class": "Z2UI5_CL_SMPS_APP_475",
+ "path": "src/02/z2ui5_cl_smps_app_475.clas.abap",
+ "package": "src/02",
+ "technology": "Smart Controls",
+ "title": "SmartField in a SmartForm",
+ "summary": "needs the GWSAMPLE_BASIC OData service",
+ "keywords": [
+ "smartform",
+ "smartfield",
+ "group",
+ "groupelement",
+ "columnlayout",
+ "annotations"
+ ],
+ "runsOn": "Cloud + Standard ≥ 7.40 SP08",
+ "cloud": true,
+ "needs": "SAPUI5 + an activated Gateway service",
+ "branch": "02-smart-controls",
+ "setup": "src/02/README.md"
+ },
+ {
+ "class": "Z2UI5_CL_SMPS_APP_476",
+ "path": "src/02/z2ui5_cl_smps_app_476.clas.abap",
+ "package": "src/02",
+ "technology": "Smart Controls",
+ "title": "SmartForm, editable toggle",
+ "summary": "needs the GWSAMPLE_BASIC OData service",
+ "keywords": [
+ "smartform",
+ "smartfield",
+ "editable",
+ "toggle",
+ "edit",
+ "mode"
+ ],
+ "runsOn": "Cloud + Standard ≥ 7.40 SP08",
+ "cloud": true,
+ "needs": "SAPUI5 + an activated Gateway service",
+ "branch": "02-smart-controls",
+ "setup": "src/02/README.md"
+ },
+ {
+ "class": "Z2UI5_CL_SMPS_APP_477",
+ "path": "src/02/z2ui5_cl_smps_app_477.clas.abap",
+ "package": "src/02",
+ "technology": "Smart Controls",
+ "title": "SmartFilterBar and SmartTable",
+ "summary": "needs the GWSAMPLE_BASIC OData service",
+ "keywords": [
+ "smartfilterbar",
+ "smarttable",
+ "filter",
+ "search",
+ "annotations",
+ "controlconfiguration"
+ ],
+ "runsOn": "Cloud + Standard ≥ 7.40 SP08",
+ "cloud": true,
+ "needs": "SAPUI5 + an activated Gateway service",
+ "branch": "02-smart-controls",
+ "setup": "src/02/README.md"
+ },
+ {
+ "class": "Z2UI5_CL_SMPS_APP_478",
+ "path": "src/02/z2ui5_cl_smps_app_478.clas.abap",
+ "package": "src/02",
+ "technology": "Smart Controls",
+ "title": "Page Variant Management",
+ "summary": "needs the GWSAMPLE_BASIC OData service",
+ "keywords": [
+ "smartvariantmanagement",
+ "page",
+ "variant",
+ "save",
+ "smarttable",
+ "smartfilterbar",
+ "filter"
+ ],
+ "runsOn": "Cloud + Standard ≥ 7.40 SP08",
+ "cloud": true,
+ "needs": "SAPUI5 + an activated Gateway service",
+ "branch": "02-smart-controls",
+ "setup": "src/02/README.md"
+ },
+ {
+ "class": "Z2UI5_CL_SMPS_APP_479",
+ "path": "src/02/z2ui5_cl_smps_app_479.clas.abap",
+ "package": "src/02",
+ "technology": "Smart Controls",
+ "title": "SmartChart with NavPopover",
+ "summary": "an analytical service - you supply the path",
+ "keywords": [
+ "smartchart",
+ "navpopover",
+ "semanticobjectcontroller",
+ "chart",
+ "semantic",
+ "object",
+ "navigation"
+ ],
+ "runsOn": "Cloud + Standard ≥ 7.40 SP08",
+ "cloud": true,
+ "needs": "SAPUI5 + an activated Gateway service",
+ "branch": "02-smart-controls",
+ "setup": "src/02/README.md"
+ },
+ {
+ "class": "Z2UI5_CL_SMPS_APP_493",
+ "path": "src/02/z2ui5_cl_smps_app_493.clas.abap",
+ "package": "src/02",
+ "technology": "Smart Controls",
+ "title": "classic FilterBar variants",
+ "summary": "no service needed - the data is ABAP",
+ "keywords": [
+ "filterbar",
+ "filtergroupitem",
+ "smartvariantmanagement",
+ "classic",
+ "filter",
+ "variant"
+ ],
+ "runsOn": "Cloud + Standard ≥ 7.40 SP08",
+ "cloud": true,
+ "needs": "SAPUI5 + an activated Gateway service",
+ "branch": "02-smart-controls",
+ "setup": "src/02/README.md"
+ },
+ {
+ "class": "Z2UI5_CL_SMPS_APP_001",
+ "path": "src/03/z2ui5_cl_smps_app_001.clas.abap",
+ "package": "src/03",
+ "technology": "RAP",
+ "title": "Read a Travel",
+ "summary": "reads one instance by its key - a missing key comes back in FAILED, not as an exception",
+ "keywords": [
+ "eml",
+ "rap",
+ "read",
+ "travel",
+ "select",
+ "entity",
+ "behavior"
+ ],
+ "runsOn": "Cloud + Standard ≥ 7.54 (1909)",
+ "cloud": true,
+ "needs": "ABAP Platform >= 1909; the BO ships with this repo",
+ "branch": "03-rap",
+ "setup": "src/03/README.md"
+ },
+ {
+ "class": "Z2UI5_CL_SMPS_APP_002",
+ "path": "src/03/z2ui5_cl_smps_app_002.clas.abap",
+ "package": "src/03",
+ "technology": "RAP",
+ "title": "Create a Travel",
+ "summary": "MODIFY ... CREATE, key from MAPPED",
+ "keywords": [
+ "eml",
+ "rap",
+ "create",
+ "travel",
+ "insert",
+ "commit",
+ "datepicker"
+ ],
+ "runsOn": "Cloud + Standard ≥ 7.54 (1909)",
+ "cloud": true,
+ "needs": "ABAP Platform >= 1909; the BO ships with this repo",
+ "branch": "03-rap",
+ "setup": "src/03/README.md"
+ },
+ {
+ "class": "Z2UI5_CL_SMPS_APP_003",
+ "path": "src/03/z2ui5_cl_smps_app_003.clas.abap",
+ "package": "src/03",
+ "technology": "RAP",
+ "title": "Update a Travel",
+ "summary": "changes single fields of one instance - UPDATE FIELDS names what may be touched",
+ "keywords": [
+ "eml",
+ "rap",
+ "update",
+ "travel",
+ "modify",
+ "commit",
+ "table"
+ ],
+ "runsOn": "Cloud + Standard ≥ 7.54 (1909)",
+ "cloud": true,
+ "needs": "ABAP Platform >= 1909; the BO ships with this repo",
+ "branch": "03-rap",
+ "setup": "src/03/README.md"
+ },
+ {
+ "class": "Z2UI5_CL_SMPS_APP_004",
+ "path": "src/03/z2ui5_cl_smps_app_004.clas.abap",
+ "package": "src/03",
+ "technology": "RAP",
+ "title": "Delete a Travel",
+ "summary": "deletes one instance - MODIFY ... DELETE FROM",
+ "keywords": [
+ "eml",
+ "rap",
+ "delete",
+ "travel",
+ "remove",
+ "commit",
+ "table"
+ ],
+ "runsOn": "Cloud + Standard ≥ 7.54 (1909)",
+ "cloud": true,
+ "needs": "ABAP Platform >= 1909; the BO ships with this repo",
+ "branch": "03-rap",
+ "setup": "src/03/README.md"
+ },
+ {
+ "class": "Z2UI5_CL_SMPS_APP_005",
+ "path": "src/03/z2ui5_cl_smps_app_005.clas.abap",
+ "package": "src/03",
+ "technology": "RAP",
+ "title": "Manage Travels, the Complete App",
+ "summary": "01-04 plus EXECUTE and COMMIT ENTITIES RESPONSE OF",
+ "keywords": [
+ "eml",
+ "rap",
+ "crud",
+ "travel",
+ "manage",
+ "popup",
+ "objectstatus"
+ ],
+ "runsOn": "Cloud + Standard ≥ 7.54 (1909)",
+ "cloud": true,
+ "needs": "ABAP Platform >= 1909; the BO ships with this repo",
+ "branch": "03-rap",
+ "setup": "src/03/README.md"
+ },
+ {
+ "class": "Z2UI5_CL_SMPS_APP_006",
+ "path": "src/04/z2ui5_cl_smps_app_006.clas.abap",
+ "package": "src/04",
+ "technology": "RAP with Draft",
+ "title": "Which Travels Have One",
+ "summary": "READ ... %is_draft = mk-on",
+ "keywords": [
+ "eml",
+ "rap",
+ "draft",
+ "list",
+ "objectstatus",
+ "which",
+ "travels"
+ ],
+ "runsOn": "Cloud + Standard ≥ 7.54 (1909)",
+ "cloud": true,
+ "needs": "ABAP Platform >= 1909; the BO ships with this repo",
+ "branch": "04-rap-draft",
+ "setup": "src/04/README.md"
+ },
+ {
+ "class": "Z2UI5_CL_SMPS_APP_007",
+ "path": "src/04/z2ui5_cl_smps_app_007.clas.abap",
+ "package": "src/04",
+ "technology": "RAP with Draft",
+ "title": "Enter Draft Mode",
+ "summary": "Edit copies the active instance into a new draft, Resume picks up an existing one",
+ "keywords": [
+ "eml",
+ "rap",
+ "draft",
+ "edit",
+ "enter",
+ "lock",
+ "mode"
+ ],
+ "runsOn": "Cloud + Standard ≥ 7.54 (1909)",
+ "cloud": true,
+ "needs": "ABAP Platform >= 1909; the BO ships with this repo",
+ "branch": "04-rap-draft",
+ "setup": "src/04/README.md"
+ },
+ {
+ "class": "Z2UI5_CL_SMPS_APP_008",
+ "path": "src/04/z2ui5_cl_smps_app_008.clas.abap",
+ "package": "src/04",
+ "technology": "RAP with Draft",
+ "title": "Change and Save a Draft",
+ "summary": "UPDATE ... %is_draft = mk-on",
+ "keywords": [
+ "eml",
+ "rap",
+ "draft",
+ "change",
+ "save",
+ "modify"
+ ],
+ "runsOn": "Cloud + Standard ≥ 7.54 (1909)",
+ "cloud": true,
+ "needs": "ABAP Platform >= 1909; the BO ships with this repo",
+ "branch": "04-rap-draft",
+ "setup": "src/04/README.md"
+ },
+ {
+ "class": "Z2UI5_CL_SMPS_APP_009",
+ "path": "src/04/z2ui5_cl_smps_app_009.clas.abap",
+ "package": "src/04",
+ "technology": "RAP with Draft",
+ "title": "Leave Draft Mode",
+ "summary": "EXECUTE Activate / Discard",
+ "keywords": [
+ "eml",
+ "rap",
+ "draft",
+ "discard",
+ "resume",
+ "leave",
+ "mode"
+ ],
+ "runsOn": "Cloud + Standard ≥ 7.54 (1909)",
+ "cloud": true,
+ "needs": "ABAP Platform >= 1909; the BO ships with this repo",
+ "branch": "04-rap-draft",
+ "setup": "src/04/README.md"
+ },
+ {
+ "class": "Z2UI5_CL_SMPS_APP_010",
+ "path": "src/04/z2ui5_cl_smps_app_010.clas.abap",
+ "package": "src/04",
+ "technology": "RAP with Draft",
+ "title": "Complete Draft Handling",
+ "summary": "a whole app, not a snippet - the complete draft lifecycle in one screen",
+ "keywords": [
+ "eml",
+ "rap",
+ "draft",
+ "handling",
+ "crud",
+ "popup",
+ "complete"
+ ],
+ "runsOn": "Cloud + Standard ≥ 7.54 (1909)",
+ "cloud": true,
+ "needs": "ABAP Platform >= 1909; the BO ships with this repo",
+ "branch": "04-rap-draft",
+ "setup": "src/04/README.md"
+ },
+ {
+ "class": "Z2UI5_CL_SMPS_APP_011",
+ "path": "src/05/z2ui5_cl_smps_app_011.clas.abap",
+ "package": "src/05",
+ "technology": "Business Events",
+ "title": "Ticket App",
+ "summary": "every create and update raises an entity event",
+ "keywords": [
+ "rap",
+ "business",
+ "events",
+ "ticket",
+ "raise",
+ "publish"
+ ],
+ "runsOn": "Cloud + Standard ≥ 7.56 (2021)",
+ "cloud": true,
+ "needs": "ABAP Platform >= 1909; the BO ships with this repo",
+ "branch": "05-business-events",
+ "setup": "src/05/README.md"
+ },
+ {
+ "class": "Z2UI5_CL_SMPS_APP_012",
+ "path": "src/05/z2ui5_cl_smps_app_012.clas.abap",
+ "package": "src/05",
+ "technology": "Business Events",
+ "title": "Event Log App",
+ "summary": "what the handler wrote, newest first",
+ "keywords": [
+ "rap",
+ "business",
+ "events",
+ "log",
+ "consumer",
+ "subscribe"
+ ],
+ "runsOn": "Cloud + Standard ≥ 7.56 (2021)",
+ "cloud": true,
+ "needs": "ABAP Platform >= 1909; the BO ships with this repo",
+ "branch": "05-business-events",
+ "setup": "src/05/README.md"
+ },
+ {
+ "class": "Z2UI5_CL_SMPS_APP_485",
+ "path": "src/06/z2ui5_cl_smps_app_485.clas.abap",
+ "package": "src/06",
+ "technology": "Stateful Sessions / Locks",
+ "title": "Stateful Sessions — Locks",
+ "summary": "ENQUEUE_E_TABLE and ENQUEUE_READ, end and restart the session",
+ "keywords": [
+ "stateful",
+ "session",
+ "lock",
+ "enqueue",
+ "dequeue",
+ "set_session_stateful"
+ ],
+ "runsOn": "Standard only, ≥ 7.40 SP08",
+ "cloud": false,
+ "needs": "ABAP Standard (on-premise), the table `Z2UI5_T_SMPS_01`",
+ "branch": "06-stateful-locks",
+ "setup": "src/06/README.md"
+ },
+ {
+ "class": "Z2UI5_CL_SMPS_APP_486",
+ "path": "src/06/z2ui5_cl_smps_app_486.clas.abap",
+ "package": "src/06",
+ "technology": "Stateful Sessions / Locks",
+ "title": "Stateful Sessions — Basics",
+ "summary": "counts up while the session is stateful, starts over once it is not",
+ "keywords": [
+ "stateful",
+ "session",
+ "basics",
+ "state",
+ "roundtrip",
+ "set_session_stateful"
+ ],
+ "runsOn": "Standard only, ≥ 7.40 SP08",
+ "cloud": false,
+ "needs": "ABAP Standard (on-premise), the table `Z2UI5_T_SMPS_01`",
+ "branch": "06-stateful-locks",
+ "setup": "src/06/README.md"
+ },
+ {
+ "class": "Z2UI5_CL_SMPS_APP_490",
+ "path": "src/06/z2ui5_cl_smps_app_490.clas.abap",
+ "package": "src/06",
+ "technology": "Stateful Sessions / Locks",
+ "title": "Stateful Sessions — Navigation and Locks",
+ "summary": "every Next Lock View takes the next VARKEY, going back releases it",
+ "keywords": [
+ "stateful",
+ "session",
+ "lock",
+ "navigation",
+ "nav_app_call",
+ "check_on_navigated"
+ ],
+ "runsOn": "Standard only, ≥ 7.40 SP08",
+ "cloud": false,
+ "needs": "ABAP Standard (on-premise), the table `Z2UI5_T_SMPS_01`",
+ "branch": "06-stateful-locks",
+ "setup": "src/06/README.md"
+ },
+ {
+ "class": "Z2UI5_CL_SMPS_APP_489",
+ "path": "src/07/z2ui5_cl_smps_app_489.clas.abap",
+ "package": "src/07",
+ "technology": "AMC/APC",
+ "title": "Websocket — News Feed",
+ "summary": "connect, publish, list the active connections - no JavaScript",
+ "keywords": [
+ "websocket",
+ "apc",
+ "amc",
+ "push",
+ "channel",
+ "feedlistitem",
+ "news",
+ "popover"
+ ],
+ "runsOn": "Standard only, ≥ 7.50",
+ "cloud": false,
+ "needs": "on-premise APC/AMC, the ICF node `Z2UI5_APC_SMP_2`",
+ "branch": "07-amc-apc",
+ "setup": "src/07/README.md"
+ },
+ {
+ "class": "Z2UI5_CL_SMPS_APP_487",
+ "path": "src/08/z2ui5_cl_smps_app_487.clas.abap",
+ "package": "src/08",
+ "technology": "MIME Play Audio",
+ "title": "MIME — Audio and Play Sound",
+ "summary": "a success and an error tone, addressed by their ICF path",
+ "keywords": [
+ "mime",
+ "audio",
+ "sound",
+ "play_audio",
+ "wav",
+ "follow_up_action"
+ ],
+ "runsOn": "Standard only, ≥ 7.50",
+ "cloud": false,
+ "needs": "the ICF service `/SAP/PUBLIC/BC/ABAP/mime_demo`",
+ "branch": "08-mime",
+ "setup": "src/08/README.md"
+ },
+ {
+ "class": "Z2UI5_CL_SMPS_APP_481",
+ "path": "src/09/z2ui5_cl_smps_app_481.clas.abap",
+ "package": "src/09",
+ "technology": "Launchpad",
+ "title": "Read Startup Parameters",
+ "summary": "what the tile passed in - client->get( )-t_comp_params",
+ "keywords": [
+ "launchpad",
+ "fiori",
+ "flp",
+ "startup",
+ "parameters",
+ "intent"
+ ],
+ "runsOn": "Cloud + Standard ≥ 7.40 SP08",
+ "cloud": true,
+ "needs": "a Fiori Launchpad with a tile pointing at abap2UI5",
+ "branch": "09-launchpad",
+ "setup": "src/09/README.md"
+ },
+ {
+ "class": "Z2UI5_CL_SMPS_APP_482",
+ "path": "src/09/z2ui5_cl_smps_app_482.clas.abap",
+ "package": "src/09",
+ "technology": "Launchpad",
+ "title": "Set Shell Title",
+ "summary": "follow_up_action( cs_event-set_title_launchpad )",
+ "keywords": [
+ "launchpad",
+ "fiori",
+ "flp",
+ "shell",
+ "title",
+ "follow_up_action"
+ ],
+ "runsOn": "Cloud + Standard ≥ 7.40 SP08",
+ "cloud": true,
+ "needs": "a Fiori Launchpad with a tile pointing at abap2UI5",
+ "branch": "09-launchpad",
+ "setup": "src/09/README.md"
+ },
+ {
+ "class": "Z2UI5_CL_SMPS_APP_483",
+ "path": "src/09/z2ui5_cl_smps_app_483.clas.abap",
+ "package": "src/09",
+ "technology": "Launchpad",
+ "title": "Cross-App Navigation Sender",
+ "summary": "hands two values over to another tile",
+ "keywords": [
+ "launchpad",
+ "fiori",
+ "flp",
+ "cross",
+ "app",
+ "navigation",
+ "sender",
+ "intent"
+ ],
+ "runsOn": "Cloud + Standard ≥ 7.40 SP08",
+ "cloud": true,
+ "needs": "a Fiori Launchpad with a tile pointing at abap2UI5",
+ "branch": "09-launchpad",
+ "setup": "src/09/README.md"
+ },
+ {
+ "class": "Z2UI5_CL_SMPS_APP_484",
+ "path": "src/09/z2ui5_cl_smps_app_484.clas.abap",
+ "package": "src/09",
+ "technology": "Launchpad",
+ "title": "Cross-App Navigation Receiver",
+ "summary": "reads them back out of its startup parameters",
+ "keywords": [
+ "launchpad",
+ "fiori",
+ "flp",
+ "cross",
+ "app",
+ "navigation",
+ "receiver",
+ "intent"
+ ],
+ "runsOn": "Cloud + Standard ≥ 7.40 SP08",
+ "cloud": true,
+ "needs": "a Fiori Launchpad with a tile pointing at abap2UI5",
+ "branch": "09-launchpad",
+ "setup": "src/09/README.md"
+ }
+ ]
+}
diff --git a/package.json b/package.json
index 1d74f88..305d7f1 100644
--- a/package.json
+++ b/package.json
@@ -9,17 +9,20 @@
"check:abap2ui5": "abap2ui5lint",
"fmt:chains": "abap2ui5lint --fix",
"check:overview": "node scripts/check-overview.mjs",
- "check": "npm run check:pin && 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": "npm run check:pin && 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:catalogue && 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",
+ "catalogue": "node scripts/generate-catalogue.mjs",
+ "check:catalogue": "node scripts/generate-catalogue.mjs --check",
"check:app-rules": "node scripts/check-app-rules.mjs",
"check:pin": "node scripts/check-framework-pin.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"
+ "check:web": "node scripts/generate-web-index.mjs --check",
+ "screenshots": "node scripts/generate-screenshots.mjs"
},
"repository": {
"type": "git",
diff --git a/scripts/check-overview.mjs b/scripts/check-overview.mjs
index 445a4c3..ea30b09 100644
--- a/scripts/check-overview.mjs
+++ b/scripts/check-overview.mjs
@@ -7,19 +7,27 @@
// documentation). The price is that the compiler no longer notices a renamed
// or a newly added sample - this check is what notices instead.
//
-// Four directions:
+// Five directions:
// 1. every sample class in the tree is listed in the overview (always)
// 2. every class the overview names exists in the tree (full tree only)
// 3. every package of .github/packages.json is in the README
// table with the release it declares (full tree only)
// 4. every class the overview references STATICALLY survives on
// every generated package branch (full tree only)
+// 5. the README's "Which package do I need?" table routes to
+// every package exactly once (full tree only)
//
// (3) is the second index this repository keeps by hand: packages.json drives
// the generated per-package branches and the release each one is checked at,
// the README table tells the reader the same thing in prose. They drift apart
// silently, so they are compared here.
//
+// (5) is the third: the decision table phrases each package from the reader's
+// goal, which no generator can write, so it is prose kept by hand. A package
+// added without a row is a package nobody is routed to, and a row pointing at
+// a directory that is gone routes to nothing - both are the same silent drift
+// as (3), so they are gated the same way.
+//
// (4) is the rule the class documentation states and nothing enforced: the
// overview ships on every branch, but a branch carries only its own package
// plus whatever it names in "shared", so a static reference into any other
@@ -132,9 +140,8 @@ if (complete) {
}
}
- const rows = readFileSync('README.md', 'utf8')
- .split('\n')
- .filter((line) => line.startsWith('| [`src/'));
+ const readme = readFileSync('README.md', 'utf8');
+ const rows = readme.split('\n').filter((line) => line.startsWith('| [`src/'));
for (const entry of packages) {
const row = rows.find((line) => line.startsWith(`| [\`src/${entry.dir}\`]`));
@@ -147,6 +154,24 @@ if (complete) {
);
}
}
+
+ // the decision table - "You want to ... -> package". Its rows start with the
+ // reader's goal, not with the directory, so they are found by the link they
+ // carry rather than by the row shape the package table above is found by
+ const aid = readme.split(/\n## /).find((section) => section.startsWith('Which package do I need'));
+ if (aid === undefined) {
+ errors.push('README.md has no "Which package do I need?" section');
+ } else {
+ for (const entry of packages) {
+ const links = (aid.match(new RegExp(`\\[\`src/${entry.dir}\`\\]`, 'g')) ?? []).length;
+ if (links !== 1) {
+ errors.push(
+ `the "Which package do I need?" table must route to src/${entry.dir} exactly once, ` +
+ `but points at it ${links} time(s)`,
+ );
+ }
+ }
+ }
}
if (errors.length > 0) {
diff --git a/scripts/generate-catalogue.mjs b/scripts/generate-catalogue.mjs
new file mode 100644
index 0000000..d8d4244
--- /dev/null
+++ b/scripts/generate-catalogue.mjs
@@ -0,0 +1,127 @@
+#!/usr/bin/env node
+/*
+ * generate-catalogue — the catalogue as one committed JSON file, for programs.
+ *
+ * The same catalogue exists three times for three readers. SAMPLES.md is the
+ * page for a person browsing GitHub, web/apps.json feeds the page in web/, and
+ * the overview app carries it into the system. What none of them served is a
+ * program with a question — "which sample shows a SmartTable, and what does my
+ * system need for it?" — asked against nothing but a raw checkout or a single
+ * `raw.githubusercontent.com` fetch. SAMPLES.md answers it only through a
+ * regex over prose, and web/apps.json is deliberately not committed, so before
+ * a deploy it does not exist at all. This file is that answer: every fact the
+ * repository already keeps about a sample, as data, committed.
+ *
+ * IT INTRODUCES NO NEW SOURCE OF TRUTH — the same rule as the page in web/.
+ * Everything here is read out of what the repository already keeps:
+ *
+ * scripts/lib/scan-samples.mjs which classes are apps, their title from
+ * DESCRIPT, `@summary`, `@keywords` — the
+ * same scan behind SAMPLES.md and the page
+ * scripts/lib/read-packages.mjs the packages: `.github/packages.json`
+ * merged with the root README's table, which
+ * is where "runs on" and "plays together
+ * with" already live
+ *
+ * node scripts/generate-catalogue.mjs write catalogue.json
+ * node scripts/generate-catalogue.mjs --check fail if it differs
+ * (this is what CI runs)
+ *
+ * COMMITTED, unlike web/apps.json — deliberately, and the difference is the
+ * reader. The page is always deployed from a fresh generator run, so a
+ * committed copy there would only be a diff of derived data on every pull
+ * request. This file exists precisely for the reader who runs nothing: an
+ * agent or an editor fetching one URL from `main`. A committed generated file
+ * is a file that goes stale the first time somebody adds a sample and does not
+ * rerun the generator — `npm run check:catalogue` is what notices, exactly as
+ * `check:samples-md` does for the page. No timestamp in the output for the
+ * same reason: the content is a pure function of the tree, so the check can
+ * compare bytes.
+ */
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+import { scanSamples, sampleTitle } from './lib/scan-samples.mjs';
+import { packages } from './lib/read-packages.mjs';
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const OUT = path.join(ROOT, 'catalogue.json');
+const CHECK = process.argv.includes('--check');
+
+const die = (message) => {
+ console.error(`generate-catalogue: ${message}`);
+ process.exit(1);
+};
+
+/* -------------------------------------------------------------------- build */
+
+const allPackages = packages(ROOT);
+const byDir = new Map(allPackages.map((p) => [p.dir, p]));
+const apps = scanSamples(ROOT).filter((s) => s.isApp);
+
+const samples = apps.map((s) => {
+ const pkg = byDir.get(s.pkg);
+ /* Same guard as the page: an app in a subpackage would silently vanish. */
+ if (!pkg) die(`${s.cls} lives in src/${s.pkg}, which is no package of .github/packages.json`);
+
+ const { title, sub } = sampleTitle(s, s.section);
+ return {
+ class: s.cls.toUpperCase(),
+ path: s.rel,
+ package: s.pkg === '.' ? 'src' : `src/${s.pkg}`,
+ technology: pkg.title,
+ title: sub ? `${title} — ${sub}` : title,
+ summary: s.summary,
+ keywords: s.keywords ? s.keywords.split(/\s+/) : [],
+ /* what this sample asks of the system — the package's facts, repeated on
+ * the entry so one entry answers the whole question */
+ runsOn: pkg.runsOn,
+ cloud: pkg.cloud,
+ needs: pkg.needs,
+ branch: pkg.branch,
+ setup: pkg.readme,
+ };
+});
+
+const catalogue = {
+ comment: 'Generated by scripts/generate-catalogue.mjs — run `npm run catalogue`. Do not edit by hand (AGENTS.md section 6).',
+ repo: 'abap2UI5/samples-stack',
+ role: 'Step 3 of 3 in the abap2UI5 sample family: samples (the abap2UI5 basics) -> samples-controls (the UI5 control set) -> samples-stack (abap2UI5 together with the rest of the stack). Every sample here needs something from the system beyond an abap2UI5 installation — an OData service, a RAP business object, an APC channel, a launchpad — and its entry says what.',
+ start: 'Install abap2UI5, pull this repository — or the one-package branch the entry names — with abapGit, do the setup its package README asks for, then open ?app_start=.',
+ overviewApp: 'Z2UI5_CL_SMPS_APP_000',
+ packages: allPackages.map((p) => ({
+ package: p.dir === '.' ? 'src' : `src/${p.dir}`,
+ technology: p.title,
+ topic: p.topic,
+ needs: p.needs,
+ runsOn: p.runsOn,
+ cloud: p.cloud,
+ release: p.release,
+ note: p.note,
+ branch: p.branch,
+ readme: p.readme,
+ })),
+ samples,
+};
+
+const page = `${JSON.stringify(catalogue, null, 2)}\n`;
+
+/* The same two silent failures the page gates: an entry with nothing to search
+ * for is an entry no question ever matches. check-keywords gates it too;
+ * repeated here because this file is what an agent actually queries. */
+const mute = samples.filter((s) => !s.summary || !s.keywords.length).map((s) => s.class);
+if (mute.length) die(`${mute.join(', ')} — no @summary or no @keywords, so no question ever finds them`);
+if (!samples.length) die('no apps found under src/ — the scan came back empty');
+
+if (CHECK) {
+ const have = fs.existsSync(OUT) ? fs.readFileSync(OUT, 'utf8') : '';
+ if (have !== page) {
+ console.error('catalogue.json is out of date — run `npm run catalogue` and commit the result.');
+ console.error('It is generated from the classes and the package index; editing it by hand is how it starts lying.');
+ process.exit(1);
+ }
+ console.log(`catalogue: up to date — ${samples.length} sample(s) in ${allPackages.length} package(s)`);
+} else {
+ fs.writeFileSync(OUT, page);
+ console.log(`catalogue: wrote ${samples.length} sample(s) in ${allPackages.length} package(s) to ${path.relative(ROOT, OUT)}`);
+}
diff --git a/scripts/generate-screenshots.mjs b/scripts/generate-screenshots.mjs
new file mode 100644
index 0000000..2de4a1d
--- /dev/null
+++ b/scripts/generate-screenshots.mjs
@@ -0,0 +1,102 @@
+#!/usr/bin/env node
+/*
+ * generate-screenshots - a thumbnail per sample for the page in web/.
+ *
+ * The page describes every sample with a title, a sentence and what it needs
+ * from the system; what a sample LOOKS like was invisible, and unlike on the
+ * sibling pages there is no playground link to click through to - a system is
+ * what every sample here needs. The abap2UI5-linter can answer that without a
+ * system: its render gate reconstructs the view from the
+ * z2ui5_cl_ui5_view_builder calls, seeds it with a model derived from the
+ * class's own TYPES/DATA and renders it in a headless browser - and
+ * `screenshotFiles` is that same harness kept standing long enough to
+ * photograph it. So a thumbnail is the render gate's view of the sample, not
+ * a staged picture: the view statically, with mock data, no Gateway, RAP or
+ * APC anywhere. What it shows is what the gate checks.
+ *
+ * GENERATED AT DEPLOY, NEVER COMMITTED - the same decision as web/apps.json,
+ * for the same reason: the deploy-web workflow writes web/thumbs/ fresh on
+ * every deploy, so the pictures are never staler than the classes, and a
+ * sample pull request carries no binary diff. The page treats a missing
+ * picture as "no picture" (the removes itself), so this script is
+ * allowed to skip what it cannot photograph. Measured over the whole corpus
+ * (2026-08): 19 of 32 app views render; the three skip reasons are stable and
+ * documented in AGENTS.md §8 - `sap.ui.comp` is SAPUI5-only and not in the
+ * harness's OpenUI5 runtime, `z2ui5.cc` custom controls do not load headless,
+ * and the mock model seeds an empty ObjectStatus state. Each skipped card
+ * simply has no thumbnail. Only when NOTHING could be photographed does the
+ * run fail, because that is not a sample problem but a harness one (no
+ * browser, broken runtime), and a deploy that silently dropped every picture
+ * would look like a design change.
+ *
+ * Unlike the other scripts here this one needs the devDependencies - the
+ * linter and @abap2ui5/render-runtime, the same pair `npm run check:abap2ui5`
+ * already uses - plus the playwright chromium the render gate drives.
+ *
+ * node scripts/generate-screenshots.mjs write web/thumbs/
+ * node scripts/generate-screenshots.mjs --limit 5 a quick local smoke
+ * node scripts/generate-screenshots.mjs --out DIR write elsewhere
+ */
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+import { screenshotFiles } from '@abap2ui5/linter';
+import { scanSamples } from './lib/scan-samples.mjs';
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const argOut = process.argv.indexOf('--out');
+const OUT = argOut === -1
+ ? path.join(ROOT, 'web', 'thumbs')
+ : path.resolve(process.argv[argOut + 1]);
+const argLimit = process.argv.indexOf('--limit');
+const LIMIT = argLimit === -1 ? Infinity : Number(process.argv[argLimit + 1]);
+
+/* The card thumbnail's viewport. 4:3 at a laptop-ish width, viewport only
+ * (not the full page): the first screen is what a reader recognises a sample
+ * by, and a full-page shot of a long table would shrink to an unreadable
+ * strip. The CSS crops from the top, so nothing is distorted. */
+const SIZE = { width: 800, height: 600 };
+
+/* One browser session per chunk. screenshotFiles renders every file it is
+ * given in one session, so bigger chunks amortise the browser start - but a
+ * whole corpus in one call holds every PNG in memory at once, and one crash
+ * would take all pictures with it. */
+const CHUNK = 25;
+
+/* Every app of the repository, the same scan the page itself is built from -
+ * helpers (behavior pools, demo data, the APC protocol class) have no card
+ * and get no picture. */
+const apps = scanSamples(ROOT).filter((s) => s.isApp).slice(0, LIMIT);
+fs.mkdirSync(OUT, { recursive: true });
+
+let written = 0;
+const skipped = [];
+for (let i = 0; i < apps.length; i += CHUNK) {
+ const chunk = apps.slice(i, i + CHUNK);
+ const byFile = new Map(chunk.map((a) => [a.file, a]));
+ const shots = await screenshotFiles([...byFile.keys()], { ...SIZE, fullPage: false });
+ for (const shot of shots) {
+ /* A class can build several documents - the main view first, then nested
+ * views and popup fragments. The thumbnail is the main view; index 0 is
+ * what the app opens with. */
+ if (shot.index !== 0) continue;
+ const app = byFile.get(shot.file);
+ if (!shot.png || shot.errors.length) {
+ skipped.push(`${app.cls}: ${shot.errors[0] || 'no picture'}`);
+ continue;
+ }
+ /* Named by the class in lower case, which is how the page derives the
+ * URL from the catalogue entry (stack.js). */
+ fs.writeFileSync(path.join(OUT, `${app.cls}.png`), shot.png);
+ written++;
+ }
+}
+
+for (const line of skipped) console.warn(`no thumbnail for ${line}`);
+console.log(`${path.relative(ROOT, OUT)}: ${written} of ${apps.length} samples photographed`
+ + (skipped.length ? `, ${skipped.length} skipped (their cards show no picture)` : ''));
+
+if (written === 0) {
+ console.error('nothing could be photographed - that is a harness problem (browser, render runtime), not a sample one');
+ process.exit(1);
+}
diff --git a/scripts/generate-web-index.mjs b/scripts/generate-web-index.mjs
index 30109e2..062c143 100644
--- a/scripts/generate-web-index.mjs
+++ b/scripts/generate-web-index.mjs
@@ -36,14 +36,10 @@
* same scan behind SAMPLES.md and
* check-keywords, so the page and the
* catalogue cannot disagree
- * .github/packages.json the package index: directory, branch name,
- * title, the release the package needs
- * README.md, the package table what the package plays together with, and
- * the one line describing it. That column is
- * written for a reader and there is nowhere
- * better to keep it; check-overview.mjs
- * already gates that every package has a row
- * carrying the release packages.json declares
+ * scripts/lib/read-packages.mjs the packages: `.github/packages.json`
+ * merged with the root README's table — the
+ * same merge behind catalogue.json, for the
+ * same no-second-copy reason as the scan
* the class's ABAP-Doc header the long description, where a class has one
*
* node scripts/generate-web-index.mjs write web/apps.json
@@ -61,6 +57,7 @@ import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { scanSamples, sampleTitle } from './lib/scan-samples.mjs';
+import { packages, OVERVIEW_PKG } from './lib/read-packages.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const CHECK = process.argv.includes('--check');
@@ -77,125 +74,11 @@ const REF = 'main';
const SOURCE = `https://github.com/${REPO}/blob/${REF}/`;
const TREE = `https://github.com/${REPO}/tree/`;
-/* The overview app sits in `src/` itself and is in no package, because it
- * ships on EVERY generated branch (AGENTS.md section 3). It is still an app a
- * reader starts, so it gets a group of its own rather than being dropped from
- * the page.
- *
- * The release is not invented: the branch build lints the overview at its
- * branch's own syntax version, and the lowest of those is v740sp08 — so
- * 7.40 SP08 is measured, exactly like the numbers in packages.json. Cloud
- * likewise: the overview resolves every sample by name at runtime and calls no
- * on-premise API, which is what puts it on the cloud-capable branches. */
-const OVERVIEW_PKG = {
- dir: '.',
- branch: REF,
- title: 'Overview',
- topic: 'the catalogue of this repository, inside your system',
- needs: 'nothing beyond abap2UI5 — it ships on every branch and resolves every sample at runtime',
- runsOn: 'Cloud + Standard ≥ 7.40 SP08',
- readme: 'README.md',
-};
-
const die = (message) => {
console.error(`generate-web-index: ${message}`);
process.exit(1);
};
-/* ------------------------------------------------------------- the packages */
-
-/**
- * The `What is in here` table of the root README, by package directory.
- *
- * | [`src/01`](src/01) | **[OData](…)** — bind a table … | an activated … | Cloud + … |
- *
- * `topic` is the half of the second cell behind the em dash, `needs` the third
- * cell — the "Plays together with" column, which is the answer to "what do I
- * have to have before this sample does anything".
- */
-function readmeTable() {
- const rows = fs.readFileSync(path.join(ROOT, 'README.md'), 'utf8')
- .split('\n')
- .filter((line) => line.startsWith('| [`src/'));
-
- const table = new Map();
- let previous = '';
- for (const line of rows) {
- const cells = line.split('|').slice(1, -1).map((c) => c.trim());
- const dir = (cells[0].match(/src\/(\S+?)`/) || [])[1];
- if (!dir) die(`cannot read the package directory out of README row:\n ${line}`);
-
- const topic = (cells[1].split('—')[1] || '').trim();
- if (!topic) die(`the README row for src/${dir} has no "— what it is" half in its Topic cell`);
-
- /* `as above` is how the table says "the same as the row before" — a
- * sentence for a reader, and nothing a card can show on its own. */
- const needs = /^as above$/i.test(cells[2]) ? previous : cells[2];
- if (!needs) die(`the README row for src/${dir} has an empty "Plays together with" cell`);
- previous = needs;
-
- table.set(dir, { topic, needs, runsOn: cells[3] });
- }
- return table;
-}
-
-/**
- * The release floor of a `runsOn` string, as a number that sorts.
- *
- * "Cloud + Standard ≥ 7.40 SP08" -> 740.08, "7.40 SP08"
- * "Standard only, ≥ 7.54 (1909)" -> 754, "7.54 (1909)"
- *
- * SP as hundredths, so 7.40 SP08 sorts below 7.50 and above a bare 7.40 — the
- * order the facet needs, and the only arithmetic on a release number anywhere.
- */
-function release(runsOn) {
- const m = runsOn.match(/≥\s*(\d)\.(\d\d)(?:\s*SP(\d+))?/);
- if (!m) die(`cannot read a release out of "${runsOn}" — expected "≥ 7.40 SP08" or "≥ 7.54"`);
- const platform = (runsOn.match(/\((\d{4})\)/) || [])[1] || '';
- return {
- num: Number(`${m[1]}${m[2]}`) + (m[3] ? Number(m[3]) / 100 : 0),
- label: `${m[1]}.${m[2]}${m[3] ? ` SP${m[3]}` : ''}${platform ? ` (${platform})` : ''}`,
- };
-}
-
-function packages() {
- const declared = JSON.parse(fs.readFileSync(path.join(ROOT, '.github', 'packages.json'), 'utf8'));
- const table = readmeTable();
-
- const build = (entry, prose) => {
- const { num, label } = release(entry.runsOn);
- return {
- dir: entry.dir,
- branch: entry.branch,
- title: entry.title,
- topic: prose.topic,
- needs: prose.needs,
- runsOn: entry.runsOn,
- /* "Cloud + Standard ≥ x" vs "Standard only, ≥ x" — the one fact that
- * decides whether a BTP tenant can see the sample at all. */
- cloud: /cloud/i.test(entry.runsOn),
- release: label,
- releaseNum: num,
- note: entry.note || '',
- readme: entry.readme || `src/${entry.dir}/README.md`,
- count: 0,
- };
- };
-
- const out = [build(OVERVIEW_PKG, OVERVIEW_PKG)];
- for (const entry of declared) {
- const prose = table.get(entry.dir);
- /* check-overview.mjs fails on this too, and from the other side. Repeated
- * here because this generator cannot describe a package it cannot read. */
- if (!prose) die(`src/${entry.dir} is in .github/packages.json but has no row in the README table`);
- if (prose.runsOn !== entry.runsOn) {
- die(`src/${entry.dir}: README says "${prose.runsOn}", packages.json says "${entry.runsOn}"`);
- }
- out.push(build(entry, prose));
- }
- return out;
-}
-
/* ------------------------------------------------------- the long description */
/**
@@ -263,7 +146,9 @@ function abapDoc(source) {
/* -------------------------------------------------------------------- build */
-const byDir = new Map(packages().map((p) => [p.dir, p]));
+/* `count` is the page's own derived state — apps per group, for the chips —
+ * so it is added here rather than carried by the shared package merge. */
+const byDir = new Map(packages(ROOT).map((p) => [p.dir, { ...p, count: 0 }]));
const all = scanSamples(ROOT);
const apps = [];
diff --git a/scripts/lib/read-packages.mjs b/scripts/lib/read-packages.mjs
new file mode 100644
index 0000000..3142f61
--- /dev/null
+++ b/scripts/lib/read-packages.mjs
@@ -0,0 +1,149 @@
+/*
+ * read-packages — the package index with its prose, merged and verified.
+ *
+ * Two generators describe the packages: `generate-web-index.mjs` builds the
+ * data behind the page in web/, `generate-catalogue.mjs` writes the committed
+ * catalogue.json. Both need the same merge of the same two sources, and two
+ * copies of a merge drift exactly the way two copies of the sample scan once
+ * did (see scan-samples.mjs) — so it lives here once.
+ *
+ * The two sources, and no fact is restated here:
+ *
+ * .github/packages.json the package index: directory, branch name,
+ * title, the release the package needs. It
+ * drives the generated one-package branches,
+ * so what it declares is what is checked.
+ * README.md, the package table what the package plays together with, and
+ * the one line describing it. That column is
+ * written for a reader and there is nowhere
+ * better to keep it; check-overview.mjs
+ * already gates that every package has a row
+ * carrying the release packages.json declares.
+ */
+import fs from 'fs';
+import path from 'path';
+
+/* The overview app sits in `src/` itself and is in no package, because it
+ * ships on EVERY generated branch (AGENTS.md section 3). It is still an app a
+ * reader starts, so it gets a group of its own rather than being dropped.
+ *
+ * The release is not invented: the branch build lints the overview at its
+ * branch's own syntax version, and the lowest of those is v740sp08 — so
+ * 7.40 SP08 is measured, exactly like the numbers in packages.json. Cloud
+ * likewise: the overview resolves every sample by name at runtime and calls no
+ * on-premise API, which is what puts it on the cloud-capable branches. */
+export const OVERVIEW_PKG = {
+ dir: '.',
+ branch: 'main',
+ title: 'Overview',
+ topic: 'the catalogue of this repository, inside your system',
+ needs: 'nothing beyond abap2UI5 — it ships on every branch and resolves every sample at runtime',
+ runsOn: 'Cloud + Standard ≥ 7.40 SP08',
+ readme: 'README.md',
+};
+
+const die = (message) => {
+ console.error(`read-packages: ${message}`);
+ process.exit(1);
+};
+
+/**
+ * The `What is in here` table of the root README, by package directory.
+ *
+ * | [`src/01`](src/01) | **[OData](…)** — bind a table … | an activated … | Cloud + … |
+ *
+ * `topic` is the half of the second cell behind the em dash, `needs` the third
+ * cell — the "Plays together with" column, which is the answer to "what do I
+ * have to have before this sample does anything".
+ */
+export function readmeTable(root) {
+ const rows = fs.readFileSync(path.join(root, 'README.md'), 'utf8')
+ .split('\n')
+ .filter((line) => line.startsWith('| [`src/'));
+
+ const table = new Map();
+ let previous = '';
+ for (const line of rows) {
+ const cells = line.split('|').slice(1, -1).map((c) => c.trim());
+ const dir = (cells[0].match(/src\/(\S+?)`/) || [])[1];
+ if (!dir) die(`cannot read the package directory out of README row:\n ${line}`);
+
+ const topic = (cells[1].split('—')[1] || '').trim();
+ if (!topic) die(`the README row for src/${dir} has no "— what it is" half in its Topic cell`);
+
+ /* `as above` is how the table says "the same as the row before" — a
+ * sentence for a reader, and nothing a card can show on its own. */
+ const needs = /^as above$/i.test(cells[2]) ? previous : cells[2];
+ if (!needs) die(`the README row for src/${dir} has an empty "Plays together with" cell`);
+ previous = needs;
+
+ table.set(dir, { topic, needs, runsOn: cells[3] });
+ }
+ return table;
+}
+
+/**
+ * The release floor of a `runsOn` string, as a number that sorts.
+ *
+ * "Cloud + Standard ≥ 7.40 SP08" -> 740.08, "7.40 SP08"
+ * "Standard only, ≥ 7.54 (1909)" -> 754, "7.54 (1909)"
+ *
+ * SP as hundredths, so 7.40 SP08 sorts below 7.50 and above a bare 7.40 — the
+ * order the page's facet needs, and the only arithmetic on a release number
+ * anywhere.
+ */
+export function release(runsOn) {
+ const m = runsOn.match(/≥\s*(\d)\.(\d\d)(?:\s*SP(\d+))?/);
+ if (!m) die(`cannot read a release out of "${runsOn}" — expected "≥ 7.40 SP08" or "≥ 7.54"`);
+ const platform = (runsOn.match(/\((\d{4})\)/) || [])[1] || '';
+ return {
+ num: Number(`${m[1]}${m[2]}`) + (m[3] ? Number(m[3]) / 100 : 0),
+ label: `${m[1]}.${m[2]}${m[3] ? ` SP${m[3]}` : ''}${platform ? ` (${platform})` : ''}`,
+ };
+}
+
+/**
+ * Every package with its prose, the overview group first — facts only, in
+ * `.github/packages.json` order. A consumer that keeps its own derived state
+ * (the page counts its apps per group) adds it on its side.
+ *
+ * @returns {{dir: string, branch: string, title: string, topic: string,
+ * needs: string, runsOn: string, cloud: boolean, release: string,
+ * releaseNum: number, note: string, readme: string}[]}
+ */
+export function packages(root) {
+ const declared = JSON.parse(fs.readFileSync(path.join(root, '.github', 'packages.json'), 'utf8'));
+ const table = readmeTable(root);
+
+ const build = (entry, prose) => {
+ const { num, label } = release(entry.runsOn);
+ return {
+ dir: entry.dir,
+ branch: entry.branch,
+ title: entry.title,
+ topic: prose.topic,
+ needs: prose.needs,
+ runsOn: entry.runsOn,
+ /* "Cloud + Standard ≥ x" vs "Standard only, ≥ x" — the one fact that
+ * decides whether a BTP tenant can see the sample at all. */
+ cloud: /cloud/i.test(entry.runsOn),
+ release: label,
+ releaseNum: num,
+ note: entry.note || '',
+ readme: entry.readme || `src/${entry.dir}/README.md`,
+ };
+ };
+
+ const out = [build(OVERVIEW_PKG, OVERVIEW_PKG)];
+ for (const entry of declared) {
+ const prose = table.get(entry.dir);
+ /* check-overview.mjs fails on this too, and from the other side. Repeated
+ * here because a generator cannot describe a package it cannot read. */
+ if (!prose) die(`src/${entry.dir} is in .github/packages.json but has no row in the README table`);
+ if (prose.runsOn !== entry.runsOn) {
+ die(`src/${entry.dir}: README says "${prose.runsOn}", packages.json says "${entry.runsOn}"`);
+ }
+ out.push(build(entry, prose));
+ }
+ return out;
+}
diff --git a/web/README.md b/web/README.md
index 0e42f69..b1acb74 100644
--- a/web/README.md
+++ b/web/README.md
@@ -12,6 +12,7 @@ web/stack.css one stylesheet, light and dark off one set of custom properties
web/stack.js filtering and drawing — plain ES2020, no dependencies
web/favicon.ico the abap2UI5 logo in the tab (see below)
web/apps.json generated, NOT committed (see below)
+web/thumbs/ generated, NOT committed — one thumbnail per sample (see below)
```
## What it answers
@@ -131,14 +132,32 @@ holds what a pull request can break without touching this folder: a package
with no README row, a row whose cells no longer parse, an app in a directory
that is no package of `.github/packages.json`.
+## `thumbs/` is not committed either
+
+One thumbnail per sample, photographed by `npm run screenshots`
+(`scripts/generate-screenshots.mjs`): the abap2UI5-linter's render harness —
+the same headless reconstruction `npm run check:abap2ui5`'s render gate
+clears — renders each class's main view with mock data and no system behind
+it, and the deploy writes the pictures fresh on every run. It needs the
+devDependencies and a playwright chromium, which is why `deploy-web` runs
+`npm ci` where the catalogue alone would not need it.
+
+Not every card has a picture, by design: a view the harness cannot render is
+reported and skipped — the `sap.ui.comp` smart controls (SAPUI5-only, not in
+the harness's OpenUI5 runtime) and the `z2ui5.cc` custom controls, mostly —
+and the `` removes itself when its file is missing, so a card without a
+thumbnail is complete, not broken. AGENTS.md §8 carries the measured count.
+
## Running it locally
Nothing to build:
```bash
npm run web:index
+npm run screenshots # optional: writes web/thumbs/
python3 -m http.server 8099 --directory web # any static server will do
```
-`file://` does not work — the page `fetch`es `apps.json`. Every outgoing link is
+`file://` does not work — the page `fetch`es `apps.json`. The thumbnails are
+optional because the page is complete without them. Every outgoing link is
absolute (GitHub), so they work from a local server exactly as in production.
diff --git a/web/stack.css b/web/stack.css
index ea94c1a..6c49e13 100644
--- a/web/stack.css
+++ b/web/stack.css
@@ -246,6 +246,25 @@ main { max-width: var(--wide); margin: 0 auto; padding: 0 var(--gutter); }
border-radius: var(--radius);
}
+/* The thumbnail: the render gate's photograph of the first screen. The
+ sibling pages float it beside the text; these cards are narrow grid columns
+ (and flex columns, where a float is just another item), so here it is a
+ banner across the top instead. Shot at 800x600 and cropped from the top by
+ object-fit, so nothing is squashed. Always on a white ground - the
+ screenshots are taken in the light theme, and a transparent edge on a dark
+ card would read as a rendering mistake. An that fails to load has
+ removed itself (stack.js), so there is no broken-picture state to style. */
+.card .shot {
+ width: 100%;
+ aspect-ratio: 16 / 10;
+ object-fit: cover;
+ object-position: top;
+ margin: 0 0 .6rem;
+ border: 1px solid var(--line);
+ border-radius: calc(var(--radius) - 4px);
+ background: #fff;
+}
+
.card h2 {
margin: 0;
font-size: 1.05rem;
diff --git a/web/stack.js b/web/stack.js
index a62aeb9..43d78a5 100644
--- a/web/stack.js
+++ b/web/stack.js
@@ -201,8 +201,19 @@ function card(app, tokens) {
`≥ ${esc(p.release)}`,
];
+ /* The render gate's photograph of the sample's first screen, written by
+ * scripts/generate-screenshots.mjs into thumbs/ on every deploy - generated
+ * like apps.json, never committed. Not every sample has one (a view the
+ * headless harness cannot render is skipped there - sap.ui.comp and the
+ * z2ui5.cc custom controls, mostly), and a local checkout has none until
+ * the script has run, so a picture that does not load removes itself: the
+ * card is complete without it. */
+ const shot = ``;
+
return `
+ ${shot}