From f9665a3a90c21da5638c5fa9a3157ef42cf36fce Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 15:59:50 +0000 Subject: [PATCH 1/3] Commit the catalogue as data, for the reader who runs nothing The same catalogue exists three times for three readers - SAMPLES.md as a page for a person, web/apps.json behind the page in web/, the overview app inside the system - and none of them serves a program with a question. "Which sample shows a SmartTable, and what does my system need for it?" asked against a raw checkout had no better answer than a regex over SAMPLES.md's prose rows, and web/apps.json is deliberately not committed, so before a deploy it does not exist at all. catalogue.json is that answer: 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, plus a head block saying what this repository is (step 3 of 3, the stack integrations) and how a sample is started. One committed file, one raw.githubusercontent.com fetch from main, no generator run on the reader's side - which is exactly why it is committed while web/apps.json stays a build output. It introduces no new source of truth. generate-catalogue.mjs reads the same scan as SAMPLES.md (scripts/lib/scan-samples.mjs) and the same packages.json-plus-README merge as the page - that merge moved out of generate-web-index.mjs into scripts/lib/read-packages.mjs so both generators share one copy instead of drifting apart the way two copies of the sample scan once did; web/apps.json is byte-identical before and after. No timestamp in the output either: the content is a pure function of the tree, so the freshness check can compare bytes. A committed generated file goes stale the first time somebody adds a sample and does not rerun the generator, so the gate follows the samples-md pattern: npm run check:catalogue runs the generator with --check, sits in npm run check, and gets its own check-catalogue workflow - a check only npm run check runs cannot make a pull request red. AGENTS.md section 6 documents the artefact next to SAMPLES.md, and the add-a-sample checklist in section 7 gains the regeneration step. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WLKJKRzJ6gYAM6i9Bjyt45 --- .github/workflows/check-catalogue.yaml | 32 + AGENTS.md | 18 +- README.md | 1 + catalogue.json | 826 +++++++++++++++++++++++++ package.json | 4 +- scripts/generate-catalogue.mjs | 127 ++++ scripts/generate-web-index.mjs | 131 +--- scripts/lib/read-packages.mjs | 149 +++++ 8 files changed, 1162 insertions(+), 126 deletions(-) create mode 100644 .github/workflows/check-catalogue.yaml create mode 100644 catalogue.json create mode 100644 scripts/generate-catalogue.mjs create mode 100644 scripts/lib/read-packages.mjs 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/AGENTS.md b/AGENTS.md index f53ce7f..40cc43e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,7 +95,7 @@ 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 @@ -295,6 +295,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,7 +320,8 @@ 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/` diff --git a/README.md b/README.md index 3bfee3f..43b18fc 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,7 @@ they take seconds. | `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-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..ce3ccb0 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,13 @@ "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", 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-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; +} From a8f3b4bf4051ad1506bc24bf536088dc81bcf68e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 16:02:31 +0000 Subject: [PATCH 2/3] Route the reader to their one package, and gate the routing Nine packages, nine unrelated technologies, and most readers need exactly one - but the front page made them derive which one from a table organised by technology name. A reader who does not yet know that "Smart Controls" is the name for columns-from-metadata scrolls past their answer. So the README opens with a decision table phrased from the reader's goal: "Put a screen in front of a RAP business object" -> src/03, "Expose your app in the Fiori Launchpad" -> src/09 - one row per package with what it needs (release floor, service, ICF node), each row taken from the package README's What you need section. The package table below keeps the exact release strings; the decision table routes, it does not repeat. The table is prose kept by hand - a reader's goal is nothing a generator can write - and a hand-kept index next to a machine one is exactly the drift check-overview exists for. It gains a fifth direction: the decision table routes to every package of .github/packages.json exactly once, so a package added without a row (nobody routed to it) and a row pointing at a directory that is gone (routed to nothing) both fail while they can still be fixed. The rows deliberately start with the goal, not with the directory link, so the two README-table parsers (check-overview direction 3 and the web index) keep reading only the package table. Verified both ways: npm run check is green, and removing a row makes check-overview name the missing package. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WLKJKRzJ6gYAM6i9Bjyt45 --- AGENTS.md | 5 +++-- README.md | 23 ++++++++++++++++++++++- scripts/check-overview.mjs | 33 +++++++++++++++++++++++++++++---- 3 files changed, 54 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 40cc43e..6886aaf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,8 +100,9 @@ npm run check # abaplint + abap2UI5-linter + overview + keywords + abapdo 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 diff --git a/README.md b/README.md index 43b18fc..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,7 +255,7 @@ 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` | 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) { From af3e67d624ca22d309117d4d4f9df27c45bf6860 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 17:10:36 +0000 Subject: [PATCH 3/3] Photograph every view the harness can reach, and let the rest be cards The page in web/ tells a reader what every sample needs and what it costs, but not what any of them looks like - and unlike the sibling pages there is no playground link to click through to, because a system is exactly what every sample here requires. abap2UI5/samples just proved the answer: the linter's screenshotFiles keeps the render gate's headless harness standing long enough to photograph each main view - statically, seeded with mock data, no Gateway, RAP or APC anywhere - so a thumbnail is the gate's own view of the class, not a staged picture. Ported here with the same three decisions. Generated at deploy, never committed: deploy-web writes web/thumbs/ fresh on every run, exactly like apps.json, so no sample pull request carries a binary diff. Skip-and-report: a view the harness cannot render loses only its picture, and the removes itself so the card is complete without it. Fail only on zero: a run that photographs nothing is a harness problem, and even then the deploy publishes (continue-on-error), since a page without pictures beats no page. Measured before wiring CI, because this corpus builds against a system the harness does not have: 19 of 32 app views render, across eight of the ten packages. The 13 skips are three stable categories - sap.ui.comp is SAPUI5-only and absent from the harness's OpenUI5 runtime (7 of the 9 Smart Controls samples), z2ui5.cc custom controls do not load headless (the WebSocket, MIME-audio and Smart Multi Input samples), and the mock model seeds an empty ObjectStatus state on three RAP samples. AGENTS.md section 8 carries those numbers, so the picture-less Smart Controls, AMC/APC and MIME cards read as expected rather than broken. One divergence from the sibling, said in the CSS: their cards float the shot beside the text, these cards are narrow grid columns and flex columns - where a float is just another item - so the thumbnail is a banner across the top, cropped from the top by object-fit so nothing is squashed. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WLKJKRzJ6gYAM6i9Bjyt45 --- .github/workflows/deploy-web.yaml | 45 ++++++++++--- .gitignore | 4 ++ AGENTS.md | 26 +++++++- package.json | 3 +- scripts/generate-screenshots.mjs | 102 ++++++++++++++++++++++++++++++ web/README.md | 21 +++++- web/stack.css | 19 ++++++ web/stack.js | 11 ++++ 8 files changed, 217 insertions(+), 14 deletions(-) create mode 100644 scripts/generate-screenshots.mjs 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 6886aaf..5726863 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -329,8 +329,9 @@ gone. **** — 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 @@ -342,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/package.json b/package.json index ce3ccb0..305d7f1 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "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/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/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}

${highlight(app.title, tokens)}

${app.sub ? `

${highlight(app.sub, tokens)}

` : ''}

${highlight(app.cls, tokens)}