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}

${highlight(app.title, tokens)}

${app.sub ? `

${highlight(app.sub, tokens)}

` : ''}

${highlight(app.cls, tokens)}