From f6e84b98314db6d36f224f95533314248d0f4502 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 14:22:29 +0000 Subject: [PATCH] A page that is wrapped has to stay wrapped Twice in two days, a paragraph edited in the browser came back as one long line - 140 characters, then 106, both in the article's first paragraph. Nothing was red: no gate reads the shape of a line. The cost is not the rendering, which is identical, but the diff: a paragraph on one line is one changed line instead of three, so every later correction to it reads as a rewrite of the whole thing. The manual is not written to one column and should not be. 39 pages are wrapped, 59 are one line per paragraph, 64 are in between - a site-wide column would be a reformat of two thirds of the pages rather than a gate. So the rule is the drift, not the column: a page whose prose already sits inside 80 characters may not acquire a line outside it. 66 pages are held; the other 97 are left alone and start being held the day somebody wraps them. What is not prose, each for a reason: fenced code, frontmatter, tables (they wrap at the cell), HTML, headings and image alt text (one line by construction), the caption under an image when it repeats that alt text word for word (the two are meant to be comparable at a glance), a line that is only long because of one unbreakable URL, and the generated samples:/api: blocks - wrapping one of those by hand is a change the next regeneration undoes. `npm run fix:line-length` rewraps a drifted paragraph, and the gate found 13 already: on 22, 24 and 36, the linter and vscode pages, url_handling, hello_world, deprecations and logo. A rewrap is whitespace only, and it is verified as such rather than asserted - the words are identical and so is the rendered article, which is what caught the two bugs in the rewrapper: it tore the punctuation off a code span (`x` , not `x`,) and it swallowed a ::: container into the paragraph, which would have broken the callout on hello_world. Fourteenth gate, so it is named in the check script, both workflows and the four documents that count them - test/gates.test.mjs holds all three lists and the count against each other. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0182AiPodwfGRKPNZG9D5epe --- .github/workflows/check.yml | 11 + .github/workflows/deploy.yml | 3 + AGENTS.md | 14 +- CLAUDE.md | 2 +- CONTRIBUTING.md | 2 +- README.md | 8 +- .../insights/22-who-may-start-which-app.md | 4 +- .../insights/24-abap-unit-for-a-screen.md | 6 +- .../insights/36-written-for-agents.md | 5 +- docs/advanced/linter.md | 4 +- docs/advanced/vscode.md | 7 +- .../browser_interaction/url_handling.md | 3 +- docs/get_started/hello_world.md | 6 +- docs/resources/deprecations.md | 17 +- docs/resources/logo.md | 7 +- package.json | 4 +- scripts/check-line-length.mjs | 86 ++++++++ scripts/lib/line-length.mjs | 203 ++++++++++++++++++ test/gates.test.mjs | 12 +- test/line-length.test.mjs | 106 +++++++++ 20 files changed, 466 insertions(+), 44 deletions(-) create mode 100644 scripts/check-line-length.mjs create mode 100644 scripts/lib/line-length.mjs create mode 100644 test/line-length.test.mjs diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 1ca8a777..947b4377 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -185,6 +185,17 @@ jobs: if: ${{ !cancelled() }} run: npm run check:conventions + # a page that is WRAPPED has to stay wrapped. The manual is not written to + # one column - 39 pages are wrapped, 59 are one line per paragraph - so a + # site-wide column would be a reformat, not a gate. What is decidable is + # the drift: an editor rewrites a paragraph, hands it back as one long + # line, and the next diff of that paragraph is one changed line instead of + # three, so every later correction to it reads as a rewrite. It arrived + # twice in two days on the same page. npm run fix:line-length rewraps. + - name: line length + if: ${{ !cancelled() }} + run: npm run check:line-length + # every complete app class either carries a Run button or a marker on its # page saying why it cannot run in the playground. The rules that offer # the button fail towards NOT offering one, so without this an example diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 515e866d..6d61d22c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -165,6 +165,9 @@ jobs: - name: house conventions if: ${{ !cancelled() }} run: npm run check:conventions + - name: line length + if: ${{ !cancelled() }} + run: npm run check:line-length - name: Run-button coverage if: ${{ !cancelled() }} run: npm run check:playground diff --git a/AGENTS.md b/AGENTS.md index 080bf539..35adce5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,7 @@ person reads the page. Do not put "as an AI, …" prose back into `docs/`. | `docs/.vitepress/playground.mjs` | Decides which fenced ABAP example gets a **Run** button, and wraps the fence; `theme/playground.js` is the browser half | | `scripts/list-runnable.mjs` | The measurement's worklist: every fenced example that carries a Run button, out of the same `playground.mjs` that decides the button. `--json` adds each example's ABAP verbatim - what the button sends - so the measurement below can be driven rather than clicked | | `scripts/check-playground.mjs` | The Run-button bookkeeping: every complete app class either gets a button from `playground.mjs` or carries a `` marker above its fence saying why it cannot run; a stale marker fails as loudly as a missing one. `--list` prints the deliberate exclusions with both reasons | +| `scripts/check-line-length.mjs` | The wrapped-page rule and `--fix` (`npm run fix:line-length`), which rewraps a drifted paragraph. `scripts/lib/line-length.mjs` is what a prose line IS here and what a rewrap may touch; pinned by `test/line-length.test.mjs`, which holds the manual to the rule as its last case | | `scripts/check-conventions.mjs` | The two house conventions the sample corpora gate and this one did not: the view-chain layout in every fenced chain (the linter's `chain-house-layout`, which is opt-in — `check-examples.mjs` writes its config without a `rules` block, so the rule was never emitted), and the three class section blocks in every fenced app class. `--fix` (`npm run fmt:chains`) reformats a drifted chain; the sections are a judgement and stay by hand | | `scripts/lib/catalogue.mjs` | Parses and counts a sample catalogue, for `link-samples.mjs` and for the figures `generate-llms.mjs` writes into `llms.txt` — from a sibling checkout when one is here, else from the `catalogue.json` each sample repository commits at its root; pinned by `test/catalogue.test.mjs`, because it has stopped matching twice and both times answered wrongly instead of failing | | `scripts/build-site.mjs` | **What is published.** Writes all 167 pages, the 404, the sitemap and one stylesheet; borrows the bar, its script, `catalogue.css`, `sample.css`, `search.mjs` (bundled INTO `site.js`, so a page loads one module rather than two) and `abap-highlight.mjs` from a built playground checkout or from the published site; refuses to finish on a dead internal link | @@ -56,8 +57,8 @@ person reads the page. Do not put "as an AI, …" prose back into `docs/`. npm run check # test + check:version + docs:build + check:cross-site + check:design + check:images + check:examples + check:conventions + check:playground + check:api-names + check:api-reference + check:samples ``` -A documentation repository has no compiler for its prose, but thirteen things -in it are decidable, and all thirteen are decided before a merge: +A documentation repository has no compiler for its prose, but fourteen things +in it are decidable, and all fourteen are decided before a merge: | | | |---|---| @@ -68,6 +69,7 @@ in it are decidable, and all thirteen are decided before a merge: | `check:api-names` | every `client->` name on the site — method, parameter, `cs_*` constant — against `z2ui5_if_client` on `main`, plus every `blob/main/` link into the framework's tree, plus **no name from the frozen package** (`src/99`: `z2ui5_cl_util*`, `z2ui5_cl_pop_*`, `z2ui5_cl_xml_view*`, `z2ui5_if_exit`, `z2ui5_if_types`, …) anywhere but on the deprecations page and in the changelog. `check:examples` compiles the fenced blocks that are whole CLASSES; this is the rest of the page: the sentence, the two-line snippet, the constant block a page reproduces, the source link. Four pages taught API that 1.143.0 had deleted and nothing was red | | `check:api-reference` | the committed client API reference — the generated block in `resources/api.md` and `docs/public/api/client-api.json` — regenerated from `z2ui5_if_client` on `main` and compared byte for byte. Goes stale whenever the interface changes over there and the committed reference still describes the shape before it. `npm run generate:api` rewrites both | | `check:conventions` | the fenced ABAP against the house style the reader meets next: the view-chain layout, and the three section blocks of an app class. `check:examples` asks whether an example compiles and names real API — both questions about the framework; neither can see that a snippet is written in a different style from every sample. Measured against [samples-controls](https://github.com/abap2UI5/samples-controls) (637 classes, gated, at zero): five chains here showed the reader a different tree than the one that renders, and 57 of 86 app classes carried neither `PROTECTED SECTION.` nor `PRIVATE SECTION.`. What this gate deliberately does NOT take over is the blank-line and `t_arg` continuation rules — those are pattern-lint *warnings* over there and that corpus carries 382 of them | +| `check:line-length` | **a page that is wrapped has to stay wrapped.** The manual is not written to one column and should not be: 39 pages are wrapped, 59 are one line per paragraph, 64 are in between, so a site-wide column would be a reformat of two thirds of the pages rather than a gate. The drift is the decidable part - a page whose prose already sits inside 80 characters may not acquire a line outside it. That is what an editor does to a paragraph it rewrites: it hands it back as ONE long line, invisible in the editor, and from then on every diff of that paragraph is one changed line instead of three, so each later correction reads as a rewrite. It happened twice in two days on the same page. Held: 66 pages; the other 97 are left alone and start being held the day somebody wraps them. Not prose, and the reason each one is not: fenced code, frontmatter, tables (they wrap at the cell), HTML, headings and image alt text (one line by construction), the caption under an image when it repeats that alt text word for word (the two are meant to be comparable at a glance), a line that is only long because of one unbreakable URL, and the generated `samples:` and `api:` blocks (wrapping one by hand is a change the next regeneration undoes). `npm run fix:line-length` rewraps a drifted paragraph - whitespace only, and verified as such: the words are identical and the rendered article is identical, which is the check that caught the two bugs in the rewrapper (it tore the punctuation off a code span, and it swallowed a `:::` container into the paragraph) | | `check:playground` | every complete app class on the site either carries a **Run** button or a marker on its page saying why it cannot run. The rules that offer the button fail towards *not* offering one, so without this an example nobody ever measured is indistinguishable from an example that can never run — which is exactly how the coverage ledger below went stale. What stays undecidable by CI — does a *buttoned* example actually start — is the measurement the Run-button section describes | | `check:cross-site` | every link that leaves this deployment for a neighbouring one on the same origin — the playground, the catalogue, the linter's rule pages — carries a `target`. Without it VitePress's router treats the link as a route of THIS site, finds no page behind `/playground/` and renders the 404 *at that URL*, which reads as the other site being broken. Every way out of the manual was in that state at once: both bar items, the Linter rules row in the menu and the Run bar's link. Judges the built HTML, so it runs straight after `docs:build`. What it cannot see is the Run bar's link — built in a browser, in no built page — which is why `test/cross-site.test.mjs` pins that one as source | | `check:design` | the values the four bars are made of — the seven palette colours, the two type stacks, the two radii — against the copy [abap2UI5/playground](https://github.com/abap2UI5/playground) keeps, in **both** schemes. They are copied by hand on purpose (a stylesheet fetched across two deployments is a request in front of the first paint), and until this gate nothing compared the copies: they agreed because whoever touched one remembered the other. One had already drifted — two font stacks leading with different families, which is the same face on macOS and Windows and two different ones on Linux, so the same four words in the same bar measured 59/122/78/97px here and 65/141/87/110 there. It compares the EFFECTIVE value (a property the dark block does not redeclare keeps its light one), because the two sides switch schemes differently: `.dark` here, `[data-theme]` over there. @@ -76,16 +78,18 @@ in it are decidable, and all thirteen are decided before a merge: | `check:images` | every image under `docs/public`, against the three things a page can afford and the one it cannot: a screenshot is WebP (the PNG captures were 200 to 335 kB each, 2.9 MB across the manual, on pages of 20 kB of text; the same captures as WebP are a fifth of that), a deliverable is one of the PNGs the logo page hands out, nothing is over its budget, and the build can measure every one - an image it cannot size gets no width and height and moves the page when it lands | | `check:samples` | the **Working Samples** blocks and the source links a page writes by hand, against [abap2UI5/samples](https://github.com/abap2UI5/samples), [samples-controls](https://github.com/abap2UI5/samples-controls) and [samples-stack](https://github.com/abap2UI5/samples-stack) — a page may declare a class of any of the three | -**All four walking gates carry a floor.** A gate that checked nothing reports +**All five walking gates carry a floor.** A gate that checked nothing reports the same shape as a gate that found nothing wrong — which is precisely how `check:examples` passed for years on an abaplint config with no rules in it. So `check:examples`, `check:conventions` and `check:playground` each exit 1 when their walk finds no example at all, and say which of the fence language, the -page layout or the builder name is the likely cause. `check:cross-site` carries +page layout or the builder name is the likely cause. `check:line-length` exits 1 +when no page of the site counts as wrapped, which would mean its fence handling +or its glob stopped matching rather than that the manual went loose. `check:cross-site` carries the same floor twice over: no HTML in `dist` at all, and no cross-site link on a site whose bar carries three of them on every page. -The thirteen are written out in **three** places, and all three have to name the +The fourteen are written out in **three** places, and all three have to name the same set: `package.json`'s `check` script, `.github/workflows/check.yml` for a pull request, and `.github/workflows/deploy.yml` before the site is published. `check.yml` runs them in the script's order; `deploy.yml` cannot, because it diff --git a/CLAUDE.md b/CLAUDE.md index b7842c7c..a6f86925 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md All project guidance lives in **[AGENTS.md](AGENTS.md)** — the single source of -truth for this repository (how the site is built, the thirteen gates, the playground rule engine, and what may be written where). +truth for this repository (how the site is built, the fourteen gates, the playground rule engine, and what may be written where). Read `AGENTS.md` before making any change. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ec264722..5d7210b8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,4 +21,4 @@ Nothing here is a source of truth about the framework: the API reference, the sample catalogue and the corpus figures are generated or checked against `abap2UI5`, `abap2UI5/samples` and its siblings. If a fact is wrong, it is usually wrong there first. [AGENTS.md](AGENTS.md) is the full contract — how -the site is built, the thirteen gates, and what may be written where. +the site is built, the fourteen gates, and what may be written where. diff --git a/README.md b/README.md index a7e40467..c6ae0041 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,13 @@ Every contribution makes the documentation better for the community! ```sh npm ci npm run docs:dev # the site, with hot reload -npm run check # what CI runs, all thirteen steps +npm run check # what CI runs, all fourteen steps ``` ### What CI checks -A documentation repository has no compiler for its prose, but thirteen things in -it are decidable, and `npm run check` decides all thirteen before a merge — the +A documentation repository has no compiler for its prose, but fourteen things in +it are decidable, and `npm run check` decides all fourteen before a merge — the prose builds (`docs:build`), the four bars are still made of the same palette, type and radii as the playground's (`check:design`), every link into a neighbouring site on this origin @@ -45,7 +45,7 @@ passed. Several of these go stale without anybody touching this repository (a release is published elsewhere, a sample class is renamed elsewhere), which is why the deploy re-runs them rather than trusting the merge. -**[AGENTS.md](AGENTS.md) describes each of the thirteen**, what a failure means and +**[AGENTS.md](AGENTS.md) describes each of the fourteen**, what a failure means and which of them need a sibling checkout to say anything at all — read it before changing anything beyond prose. diff --git a/docs/advanced/insights/22-who-may-start-which-app.md b/docs/advanced/insights/22-who-may-start-which-app.md index fc05a6c2..a250de52 100644 --- a/docs/advanced/insights/22-who-may-start-which-app.md +++ b/docs/advanced/insights/22-who-may-start-which-app.md @@ -41,8 +41,8 @@ instead of a view: One authorization object with one field, the app class as the value, roles as usual in PFCG. Nothing here is new to anybody in the room, and that is the -point: the check sits in the class it protects, so a transport carries the app and its -guard together, and nothing on the node has to know which classes exist. +point: the check sits in the class it protects, so a transport carries the app +and its guard together, and nothing on the node has to know which classes exist. It also holds on the way into an app that the URL never names: a `nav_app_call( )` from another app arrives as an ordinary roundtrip with no diff --git a/docs/advanced/insights/24-abap-unit-for-a-screen.md b/docs/advanced/insights/24-abap-unit-for-a-screen.md index a2323e05..eff35fea 100644 --- a/docs/advanced/insights/24-abap-unit-for-a-screen.md +++ b/docs/advanced/insights/24-abap-unit-for-a-screen.md @@ -10,9 +10,9 @@ decision in the app rather than in the framework: **the logic does not touch `client`.** `main( )` dispatches — [#16](/advanced/insights/16-one-click-one-request). The -methods it dispatches to read data, decide, and change attributes. Only `view_display( )` and the message calls need the -client, so a test calls the other methods directly and looks at the attributes -afterwards: +methods it dispatches to read data, decide, and change attributes. Only +`view_display( )` and the message calls need the client, so a test calls the +other methods directly and looks at the attributes afterwards: ```abap CLASS zcl_app_overdue DEFINITION PUBLIC. diff --git a/docs/advanced/insights/36-written-for-agents.md b/docs/advanced/insights/36-written-for-agents.md index cf768f24..1ae8b8b9 100644 --- a/docs/advanced/insights/36-written-for-agents.md +++ b/docs/advanced/insights/36-written-for-agents.md @@ -27,8 +27,9 @@ still has a method per control. **A check that needs no system.** The [linter](/advanced/linter) reconstructs the UI5 view out of the ABAP that builds it and reports what UI5 does not have. -An agent that can verify its own work stops handing over apps that do not -render — the difference between a helper and a generator of plausible nonsense. The same linter gates the sample repositories. +An agent that can verify its own work stops handing over apps that do not render +— the difference between a helper and a generator of plausible nonsense. The +same linter gates the sample repositories. **Several hundred worked examples.** The sample catalogs hold a complete, tested app per pattern — value help, tree, navigation, upload — so *has diff --git a/docs/advanced/linter.md b/docs/advanced/linter.md index 37dc1b1c..2be54629 100644 --- a/docs/advanced/linter.md +++ b/docs/advanced/linter.md @@ -106,8 +106,8 @@ Two gates run over every file, and they answer different questions. Everything the view writes is resolved against a **UI5 metadata snapshot** — every control OpenUI5 ships, with its full member list and types, and every -enum, generated from the OpenUI5 sources. It is instant, needs no browser, and catches the whole -family of *this name does not exist* defects: +enum, generated from the OpenUI5 sources. It is instant, needs no browser, and +catches the whole family of *this name does not exist* defects: | | | | --- | --- | diff --git a/docs/advanced/vscode.md b/docs/advanced/vscode.md index 66fab2b3..a8ebc5cb 100644 --- a/docs/advanced/vscode.md +++ b/docs/advanced/vscode.md @@ -261,9 +261,10 @@ can help while the chain is being written rather than after it: with run, preview and check on it: the list that says which thirty apps a repository has. - **Show Examples for this Control** — put the cursor on an `ele( )` call and - the [sample catalog](https://abap2ui5.github.io/playground/samples/) is searched for - working uses of that control, richest first, opening at the line. It reads the catalogs - from `abap2ui5.mcp.reposRoot`, so it needs those checkouts. + the [sample catalog](https://abap2ui5.github.io/playground/samples/) is + searched for working uses of that control, richest first, opening at the line. + It reads the catalogs from `abap2ui5.mcp.reposRoot`, so it needs those + checkouts. ### Starting from a template diff --git a/docs/cookbook/browser_interaction/url_handling.md b/docs/cookbook/browser_interaction/url_handling.md index 87871fd3..a6ac5759 100644 --- a/docs/cookbook/browser_interaction/url_handling.md +++ b/docs/cookbook/browser_interaction/url_handling.md @@ -7,7 +7,8 @@ samples: --- # URL Handling -Working with URLs is common — reading parameters from the current URL, opening links in new tabs, or managing browser history. +Working with URLs is common — reading parameters from the current URL, opening +links in new tabs, or managing browser history. ## Read URL Parameters diff --git a/docs/get_started/hello_world.md b/docs/get_started/hello_world.md index ad5a03d5..70b2e7cd 100644 --- a/docs/get_started/hello_world.md +++ b/docs/get_started/hello_world.md @@ -28,7 +28,8 @@ Open the abap2UI5 startup page in your browser — the page the [Quickstart](/get_started/quickstart#_3-first-launch) ends on — enter the class name `ZCL_APP_HELLO_WORLD` in the input field, and launch it. -That is a complete abap2UI5 app: one class, one method, no frontend project and no OData service. +That is a complete abap2UI5 app: one class, one method, no frontend project and +no OData service. ## Starting an App by URL @@ -51,7 +52,8 @@ roundtrip — is cataloged with symptom, cause and fix in [Common Failures](/cookbook/troubleshooting/common_failures). ::: tip **Naming** -Name your own apps in your customer namespace (`Z...`/`Y...`). The `Z2UI5_` prefix is reserved for the framework and its samples. +Name your own apps in your customer namespace (`Z...`/`Y...`). The `Z2UI5_` +prefix is reserved for the framework and its samples. ::: ## A Real Screen, an Event and Data Exchange diff --git a/docs/resources/deprecations.md b/docs/resources/deprecations.md index d559fe21..abe732c8 100644 --- a/docs/resources/deprecations.md +++ b/docs/resources/deprecations.md @@ -4,8 +4,8 @@ description: What in abap2UI5 has a successor - each deprecated call with what t --- # Deprecations -Things in abap2UI5 that have a successor. Every entry says what to write instead, with the old and the new code next to -each other. +Things in abap2UI5 that have a successor. Every entry says what to write +instead, with the old and the new code next to each other. ::: tip Not the same as deprecated UI5 controls This page is about **abap2UI5's own** API. Controls SAP has deprecated in UI5 @@ -76,8 +76,9 @@ tell you what is coming if you do not. ### The model-update methods do nothing -`view_model_update( )`, `nest_view_model_update( )`, `nest2_view_model_update( )`, -`popup_model_update( )` and `popover_model_update( )` are **empty methods**. +`view_model_update( )`, `nest_view_model_update( )`, +`nest2_view_model_update( )`, `popup_model_update( )` and +`popover_model_update( )` are **empty methods**. The model is pushed automatically now: the framework compares the model state before `main( )` — taken after the incoming client deltas were applied — with @@ -279,8 +280,8 @@ DATA(view) = z2ui5_cl_ui5_view_builder=>factory( client->view_display( view->stringify( ) ). ``` -The chain is `factory` / `ele` / `tag` / `a` / `end` / `stringify`: `ele( )` adds -a child and descends into it, `tag( )` adds one and stays, `a( )` sets an +The chain is `factory` / `ele` / `tag` / `a` / `end` / `stringify`: `ele( )` +adds a child and descends into it, `tag( )` adds one and stays, `a( )` sets an attribute on the element it follows, `end( )` ascends. One rule carries the whole builder — `a( )` applies to the element the chain is **pointing at** — so give an element its attributes before its first child. @@ -326,8 +327,8 @@ existing calls keep compiling. See [Add-ons](/resources/addons). `z2ui5_cl_util`, `z2ui5_cl_util_ext`, `z2ui5_cl_util_db`, `z2ui5_cl_util_http`, `z2ui5_cl_util_log`, `z2ui5_cl_util_msg`, `z2ui5_cl_util_range`, -`z2ui5_cl_util_xml`, `z2ui5_cx_util_error` and the table `Z2UI5_T_91` are frozen. -Inside the framework they were replaced by an internal context class. +`z2ui5_cl_util_xml`, `z2ui5_cx_util_error` and the table `Z2UI5_T_91` are +frozen. Inside the framework they were replaced by an internal context class. ::: warning No drop-in successor for apps There is no public replacement API for app code. The classes still ship and diff --git a/docs/resources/logo.md b/docs/resources/logo.md index a613eb8c..71ba61e7 100644 --- a/docs/resources/logo.md +++ b/docs/resources/logo.md @@ -198,9 +198,10 @@ a button still keeps its color: that rule was never about which color it was. The site's own tokens come from the playground's own stylesheet, which this build borrows whole (`scripts/site-css/docs.css` adds what only the manual -needs), so that the four stay one palette. Take the hex values from this table rather than picking them out of a -screenshot with a color dropper: a PNG scaled in a browser hands you an -interpolated pixel, which is a color that appears nowhere in the brand. +needs), so that the four stay one palette. Take the hex values from this table +rather than picking them out of a screenshot with a color dropper: a PNG scaled +in a browser hands you an interpolated pixel, which is a color that appears +nowhere in the brand. ## Using the Mark diff --git a/package.json b/package.json index 560ca628..8fed1c6d 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "docs:preview": "vitepress preview docs", "check:examples": "node scripts/check-examples.mjs", "check:conventions": "node scripts/check-conventions.mjs", + "check:line-length": "node scripts/check-line-length.mjs", + "fix:line-length": "node scripts/check-line-length.mjs --fix", "fmt:chains": "node scripts/check-conventions.mjs --fix", "check:playground": "node scripts/check-playground.mjs", "check:cross-site": "node scripts/check-cross-site.mjs", @@ -29,7 +31,7 @@ "link:samples": "node scripts/link-samples.mjs", "check:samples": "node scripts/link-samples.mjs --check", "test": "node --test test/*.test.mjs", - "check": "npm run test && npm run check:version && npm run build && npm run docs:build && npm run check:cross-site && npm run check:design && npm run check:images && npm run check:examples && npm run check:conventions && npm run check:playground && npm run check:api-names && npm run check:api-reference && npm run check:samples", + "check": "npm run test && npm run check:version && npm run build && npm run docs:build && npm run check:cross-site && npm run check:design && npm run check:images && npm run check:examples && npm run check:conventions && npm run check:line-length && npm run check:playground && npm run check:api-names && npm run check:api-reference && npm run check:samples", "llms": "node scripts/generate-llms.mjs", "search": "node scripts/generate-search.mjs", "check:version": "node scripts/check-version.mjs", diff --git a/scripts/check-line-length.mjs b/scripts/check-line-length.mjs new file mode 100644 index 00000000..d132b1db --- /dev/null +++ b/scripts/check-line-length.mjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node +// A page that is wrapped has to stay wrapped. See scripts/lib/line-length.mjs +// for what that means and why it is not a site-wide column. +// +// node scripts/check-line-length.mjs fail on a drifted line +// node scripts/check-line-length.mjs --list print which pages are held + +import { readFile } from 'node:fs/promises'; +import { glob } from 'node:fs/promises'; +import { writeFile } from 'node:fs/promises'; +import { judge, rewrap, LIMIT } from './lib/line-length.mjs'; + +const list = process.argv.includes('--list'); +const fix = process.argv.includes('--fix'); + +const pages = []; +for await (const file of glob('docs/**/*.md')) { + // docs/public holds the GENERATED per-page markdown, which is a projection + // of the pages next to it and gitignored. + if (file.startsWith('docs/public/') || file.includes('/.vitepress/')) continue; + pages.push(file); +} +pages.sort(); + +let held = 0; +let loose = 0; +const failures = []; +const fixed = []; + +for (const file of pages) { + const source = await readFile(file, 'utf8'); + const verdict = judge(source); + if (!verdict.wrapped) { loose++; continue; } + held++; + if (list) console.log(` ${file}`); + if (!verdict.over.length) continue; + if (fix) { + const rewrapped = rewrap(source, verdict.over.map((l) => l.line)); + if (rewrapped) { + await writeFile(file, rewrapped); + fixed.push({ file, lines: verdict.over.length }); + continue; + } + } + for (const line of verdict.over) { + failures.push({ file, ...line }); + } +} + +// The floor every walking gate here carries: a gate that checked nothing +// reports the same shape as a gate that found nothing wrong. +if (held === 0) { + console.error( + `check-line-length: no page of this site counts as wrapped, out of ${pages.length} walked.\n` + + ` That is not a corpus this gate can judge - the likely cause is the\n` + + ` page glob, the fence handling, or the limit in scripts/lib/line-length.mjs.`, + ); + process.exit(1); +} + +if (fixed.length) { + console.log(`check-line-length --fix: rewrapped ${fixed.length} page(s):`); + for (const f of fixed) console.log(` ${f.file} (${f.lines} line(s) were over)`); + console.log('\nRead the diff: a rewrap changes only whitespace, and it is worth seeing that.'); +} + +if (failures.length) { + console.error( + `check-line-length: ${failures.length} line(s) over ${LIMIT} characters on a page that is otherwise wrapped:\n`, + ); + for (const f of failures) { + console.error(` ${f.file}:${f.line} ${f.length} characters`); + console.error(` ${f.text.slice(0, 100)}${f.text.length > 100 ? '…' : ''}`); + } + console.error( + `\nRewrap the paragraph to the column the rest of the page is set in. An editor\n` + + `that hands a rewritten paragraph back as one line is how these arrive; the cost\n` + + `is that the next diff of that paragraph is one changed line instead of three.`, + ); + process.exit(1); +} + +console.log( + `check-line-length: ${held} wrapped page(s) held to ${LIMIT} characters, ${loose} not wrapped and left alone\n` + + ` every wrapped page is still wrapped.`, +); diff --git a/scripts/lib/line-length.mjs b/scripts/lib/line-length.mjs new file mode 100644 index 00000000..eb0c7209 --- /dev/null +++ b/scripts/lib/line-length.mjs @@ -0,0 +1,203 @@ +// What a PROSE line of this manual is, and how wide it may be. +// +// The pages are not written to one column. 39 of them are wrapped, 59 are one +// line per paragraph, and 64 are somewhere between - so a site-wide column +// would be a reformat of two thirds of the manual, not a gate. What IS +// decidable, and what actually went wrong twice in a row, is narrower: a page +// that is wrapped stops being wrapped. An editor rewrites a paragraph, the +// browser hands it back as one long line, and the next diff of that paragraph +// is one changed line instead of three - so every later correction to it reads +// as a rewrite. +// +// Hence: a page whose prose is ALREADY within the column has to stay within +// it. A page that is not wrapped is left alone; it can be wrapped later, and +// the day it is, this gate starts holding it too. + +export const LIMIT = 80; + +// A page counts as wrapped when at most this share of its prose sits over the +// limit. Not zero, because one unwrappable line must not exempt a whole page +// from the rule - those lines are named below instead. +const WRAPPED_BELOW = 0.05; +const MIN_PROSE_LINES = 5; + +const GENERATED = [ + // Written by scripts/link-samples.mjs and scripts/generate-api-reference.mjs. + // Wrapping one of these by hand is a change the next regeneration undoes. + [/^/], + [/^/], +]; + +// A token nothing can break: a URL, or a root-absolute path of some length. +const UNBREAKABLE = /\S*(?:https?:\/\/|\/[a-z0-9_/-]{12,})\S*/g; + +/** + * The prose lines of a page, as {line, length, text} - everything the column + * rule applies to, and nothing else. + */ +export function proseLines(source) { + const lines = source.split('\n'); + const out = []; + let fenced = false; + let frontmatter = lines[0] === '---'; + let generatedEnd = null; + let lastAlt = null; + + for (let i = 0; i < lines.length; i++) { + const text = lines[i].replace(/\r$/, ''); + const trimmed = text.trimStart(); + + if (frontmatter) { + if (i > 0 && text === '---') frontmatter = false; + continue; + } + if (generatedEnd) { + if (generatedEnd.test(trimmed)) generatedEnd = null; + continue; + } + if (trimmed.startsWith('```') || trimmed.startsWith('~~~')) { + fenced = !fenced; + continue; + } + if (fenced || !trimmed) continue; + + const opens = GENERATED.find(([start]) => start.test(trimmed)); + if (opens) { generatedEnd = opens[1]; continue; } + + // A table row wraps at the cell, not at the column; HTML is markup, not + // prose; an image carries its alt text on one line by construction. + if (trimmed.startsWith('|') || trimmed.startsWith('<')) continue; + + // ::: tip / ::: warning / ::: details and their closing line are VitePress + // container directives. Each has to stand on a line of its own. + if (trimmed.startsWith(':::')) continue; + + // A heading is one line by construction - there is no wrapping it. + if (trimmed.startsWith('#')) continue; + + // An image carries its alt text on one line by construction. + const image = /^!\[(.*)\]\(/.exec(trimmed); + if (image) { lastAlt = image[1]; continue; } + + // ...and the caption under it repeats that alt text word for word, on + // purpose: the two are meant to be comparable at a glance. Wrapping the + // caption alone would end that. Only a caption that IS the alt text is + // exempt - an ordinary italic line is prose like any other. + const caption = /^\*(.+)\*$/.exec(trimmed); + if (caption && lastAlt && caption[1].trim() === lastAlt.trim()) continue; + + // A line that is only over because of one thing nobody can break. + const longest = Math.max(0, ...(text.match(UNBREAKABLE) ?? []).map((t) => t.length)); + if (longest > 20 && text.length - longest <= LIMIT) continue; + + out.push({ line: i + 1, length: text.length, text }); + } + return out; +} + +/** + * How this page stands: whether the column rule applies to it at all, and + * which of its lines break it. + */ +export function judge(source) { + const prose = proseLines(source); + const over = prose.filter((l) => l.length > LIMIT); + if (prose.length < MIN_PROSE_LINES) { + return { wrapped: false, reason: 'too few prose lines', prose, over: [] }; + } + const wrapped = over.length / prose.length < WRAPPED_BELOW; + return { wrapped, prose, over: wrapped ? over : [] }; +} + +// --- rewrapping ----------------------------------------------------------- + +// Things that must not be split across a line break: a code span, a link (the +// `](` in the middle of it is the whole point), an image, and a bare URL. +const ATOMIC = /`[^`]*`|!?\[[^\]]*\]\([^)]*\)|\S*https?:\/\/\S*/g; + +// Split on whitespace - but never on whitespace that sits INSIDE one of those +// constructs, and never between one of them and the punctuation attached to it. +// `ele( )` holds a space and is one word; so does [a link](url); and the comma +// after a code span belongs to the same token as the span. +function tokenize(text) { + const inside = new Array(text.length).fill(false); + for (const m of text.matchAll(ATOMIC)) { + for (let i = m.index; i < m.index + m[0].length; i++) inside[i] = true; + } + const tokens = []; + let current = ''; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (/\s/.test(c) && !inside[i]) { + if (current) { tokens.push(current); current = ''; } + } else { + current += c; + } + } + if (current) tokens.push(current); + return tokens; +} + +/** The paragraph around `index`, as [from, to] inclusive line indices. */ +function paragraphAround(lines, index) { + const holds = (l) => { + const t = l.trimStart(); + return t && !t.startsWith('```') && !t.startsWith('~~~') && !t.startsWith('|') && + !t.startsWith('<') && !t.startsWith('#') && !t.startsWith(':::') && + !/^!\[/.test(t); + }; + let from = index; + let to = index; + // A list item or a quote starts its own block - do not swallow the one above. + const starts = (l) => /^\s*(?:[-*+]|\d+\.|>)\s/.test(l); + while (from > 0 && holds(lines[from - 1]) && !starts(lines[from])) from--; + while (to < lines.length - 1 && holds(lines[to + 1]) && !starts(lines[to + 1])) to++; + return [from, to]; +} + +/** + * Rewrap the paragraphs that hold the given 1-based line numbers. Returns the + * new source, or null when nothing could be changed. + */ +export function rewrap(source, lineNumbers) { + const lines = source.split('\n'); + const done = new Set(); + let touched = false; + + for (const number of [...lineNumbers].sort((a, b) => b - a)) { + const index = number - 1; + if (done.has(index)) continue; + const [from, to] = paragraphAround(lines, index); + for (let i = from; i <= to; i++) done.add(i); + + const first = lines[from]; + const marker = /^(\s*(?:[-*+]|\d+\.|>)\s+)/.exec(first); + const lead = marker ? marker[1] : (/^\s*/.exec(first)?.[0] ?? ''); + const hang = marker ? ' '.repeat(marker[1].length) : lead; + + const text = lines.slice(from, to + 1) + .map((l, i) => (i === 0 ? l.slice(lead.length) : l.trimStart())) + .join(' '); + + const out = []; + let current = lead; + let empty = true; + for (const token of tokenize(text)) { + const candidate = empty ? current + token : `${current} ${token}`; + if (!empty && candidate.length > LIMIT) { + out.push(current); + current = hang + token; + } else { + current = candidate; + empty = false; + } + } + if (!empty) out.push(current); + + if (out.join('\n') !== lines.slice(from, to + 1).join('\n')) { + lines.splice(from, to - from + 1, ...out); + touched = true; + } + } + return touched ? lines.join('\n') : null; +} diff --git a/test/gates.test.mjs b/test/gates.test.mjs index a15f4d35..2d4aaf7b 100644 --- a/test/gates.test.mjs +++ b/test/gates.test.mjs @@ -1,5 +1,5 @@ /* - * The thirteen gates are written out in three places. Do all three name the + * The fourteen gates are written out in three places. Do all three name the * same set? * * `package.json`'s `check` script is what a contributor runs; check.yml is @@ -16,7 +16,7 @@ * the one gate that reads the house style. * * Both were found by eye, weeks apart. This is the same reading done by a - * machine, in `npm test`, so a twelfth gate added to one list and forgotten + * machine, in `npm test`, so a new gate added to one list and forgotten * in the other two is red before it is merged rather than after. * * The ORDER is deliberately not compared: deploy.yml has to build the site it @@ -88,13 +88,13 @@ test('check.yml keeps the script order, so a green run locally is a green run th assert.deepEqual(workflowGates('.github/workflows/check.yml'), scriptGates()); }); -test('the documents say thirteen, and there are thirteen', () => { +test('the documents say fourteen, and there are fourteen', () => { /* AGENTS.md, README.md, CONTRIBUTING.md and CLAUDE.md all count them in - * prose. A fourteenth gate that left the four documents saying "thirteen" is the + * prose. A fifteenth gate that left the four documents saying "fourteen" is the * same drift as a gate missing from a workflow, one document over. */ const gates = scriptGates(); - assert.equal(gates.length, 13, `the count in the four documents is 13, the lists have ${gates.length}`); + assert.equal(gates.length, 14, `the count in the four documents is 14, the lists have ${gates.length}`); for (const doc of ['AGENTS.md', 'README.md', 'CONTRIBUTING.md', 'CLAUDE.md']) { - assert.match(read(doc), /thirteen/, `${doc} counts the gates`); + assert.match(read(doc), /fourteen/, `${doc} counts the gates`); } }); diff --git a/test/line-length.test.mjs b/test/line-length.test.mjs new file mode 100644 index 00000000..0be60ce5 --- /dev/null +++ b/test/line-length.test.mjs @@ -0,0 +1,106 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { judge, proseLines, rewrap, LIMIT } from '../scripts/lib/line-length.mjs'; + +const long = (n) => 'word '.repeat(Math.ceil(n / 5)).slice(0, n); + +test('a page whose prose is within the column is held to it', () => { + const page = ['# Title', '', ...Array(6).fill('a short line of prose here')].join('\n'); + assert.equal(judge(page).wrapped, true); + assert.equal(judge(page).over.length, 0); +}); + +test('one drifted line on an otherwise wrapped page is reported', () => { + const page = ['# Title', '', ...Array(20).fill('a short line of prose here'), '', long(140)].join('\n'); + const verdict = judge(page); + assert.equal(verdict.wrapped, true); + assert.equal(verdict.over.length, 1); + assert.equal(verdict.over[0].length, 140); +}); + +test('a page that is not wrapped at all is left alone', () => { + const page = ['# Title', '', ...Array(6).fill(long(140))].join('\n'); + assert.equal(judge(page).wrapped, false); + assert.equal(judge(page).over.length, 0); +}); + +test('code, tables, html and frontmatter are not prose', () => { + const page = [ + '---', `description: ${long(140)}`, '---', + '# Title', '', + '```abap', long(140), '```', '', + `| a | ${long(140)} |`, '', + `
`, '', + 'real prose', + ].join('\n'); + assert.deepEqual(proseLines(page).map((l) => l.text), ['real prose']); +}); + +test('a generated block is not this gate\'s business', () => { + const page = [ + '# Title', '', + '', + long(140), + '', '', + 'real prose', + ].join('\n'); + assert.deepEqual(proseLines(page).map((l) => l.text), ['real prose']); +}); + +test('an image and the caption that repeats its alt text are exempt', () => { + const alt = long(120); + const page = ['# Title', '', `![${alt}](/x.svg)`, '', `*${alt}*`, '', 'real prose'].join('\n'); + assert.deepEqual(proseLines(page).map((l) => l.text), ['real prose']); +}); + +test('an italic line that is NOT the alt text is prose like any other', () => { + const page = ['# Title', '', '![short alt](/x.svg)', '', `*${long(120)}*`, '', 'real prose'].join('\n'); + assert.equal(proseLines(page).length, 2); +}); + +test('a line that is only long because of one URL is exempt', () => { + const url = `https://example.com/${'a'.repeat(120)}`; + const page = ['# Title', '', `See ${url}`, '', 'real prose'].join('\n'); + assert.deepEqual(proseLines(page).map((l) => l.text), ['real prose']); +}); + +test('rewrap changes whitespace and nothing else', () => { + const source = `# Title\n\n${long(140)}\n`; + const out = rewrap(source, [3]); + assert.notEqual(out, null); + assert.equal(out.replace(/\s+/g, ' ').trim(), source.replace(/\s+/g, ' ').trim()); + for (const line of out.split('\n')) assert.ok(line.length <= LIMIT, line); +}); + +test('rewrap never splits a code span, a link or a container directive', () => { + const source = [ + '# Title', '', + '::: tip **Naming**', + `Use ${'x '.repeat(30)}\`ele( )\` and [the sample catalog](https://example.com/a/b) here.`, + ':::', '', + ].join('\n'); + const out = rewrap(source, [4]); + const lines = out.split('\n'); + // the directive lines still stand alone + assert.ok(lines.includes('::: tip **Naming**')); + assert.ok(lines.includes(':::')); + // and nothing was broken in the middle + assert.ok(out.includes('`ele( )`')); + assert.ok(out.includes('[the sample catalog](https://example.com/a/b)')); +}); + +test('the manual itself passes the rule it sets', async () => { + const { glob } = await import('node:fs/promises'); + let held = 0; + const over = []; + for await (const file of glob('docs/**/*.md')) { + if (file.startsWith('docs/public/') || file.includes('/.vitepress/')) continue; + const verdict = judge(await readFile(file, 'utf8')); + if (!verdict.wrapped) continue; + held++; + for (const line of verdict.over) over.push(`${file}:${line.line} (${line.length})`); + } + assert.ok(held > 40, `only ${held} page(s) counted as wrapped`); + assert.deepEqual(over, []); +});