@plitzi/sdk-server 0.38.3 → 0.38.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +197 -0
- package/README.md +16 -7
- package/dist/core/http/stages/images.js +1 -1
- package/dist/modules/images/imageProxy.js +19 -1
- package/dist/modules/rsc/connectorRscData.js +1 -1
- package/dist/modules/rsc/publicFileResolver.d.ts +3 -0
- package/dist/modules/rsc/publicFileResolver.js +10 -4
- package/dist/plugins/manager.js +1 -1
- package/package.json +3 -3
- package/skills/plitzi-authoring/CHEATSHEET.md +5 -3
- package/skills/plitzi-authoring/SKILL.md +5 -2
- package/skills/plitzi-authoring/recipes/accordion.ts +58 -0
- package/skills/plitzi-authoring/recipes/link-to-a-section.ts +1 -1
- package/skills/plitzi-authoring/recipes/server-data.ts +65 -0
- package/skills/plitzi-authoring/recipes/style-a-page.ts +1 -1
- package/skills/plitzi-authoring/reference/accessibility.md +6 -2
- package/skills/plitzi-authoring/reference/authoring-errors.md +8 -3
- package/skills/plitzi-authoring/reference/colours-and-motion.md +5 -1
- package/skills/plitzi-authoring/reference/data-and-visibility.md +26 -16
- package/skills/plitzi-authoring/reference/efficiency.md +10 -1
- package/skills/plitzi-authoring/reference/elements-and-styles.md +5 -9
- package/skills/plitzi-authoring/reference/lists.md +1 -1
- package/skills/plitzi-authoring/reference/review-checklist.md +14 -2
- package/skills/plitzi-authoring/reference/snippets-and-export.md +2 -2
- package/skills/plitzi-authoring/reference/templates.md +1 -1
- package/skills/plitzi-authoring/reference/testing.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,202 @@
|
|
|
1
1
|
# @plitzi/sdk-server
|
|
2
2
|
|
|
3
|
+
## 0.38.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 6ae4161: ## `plitzi push`: a self-hosted project back on its space
|
|
8
|
+
|
|
9
|
+
`plitzi create --from` and `plitzi pull` took a space out as a project and kept it in step; nothing put the project's
|
|
10
|
+
own changes back but a command per part, and no command at all for its pages. `plitzi push` is the way back
|
|
11
|
+
(`docs/en/projects-from-spaces.md`):
|
|
12
|
+
|
|
13
|
+
- **What changed, or what is named.** `plitzi push` sends what changed since the project last had the space — at a
|
|
14
|
+
terminal, offered as a ticked list to choose from (`↑/↓`, space, Enter); `plitzi push space functions` sends only
|
|
15
|
+
those parts: `space`, `functions`, `runtime`, `plugins`.
|
|
16
|
+
- **In order:** each changed plugin packed and uploaded (`pack plugin` + `upload plugin`, `--cdn`/`--bucket`), the
|
|
17
|
+
functions, the runtime, then the space — `src/space.ts` authored, the actions `src/actions.ts` serves and the manifests
|
|
18
|
+
in `src/connectors/` — as the space's draft. Never a published environment.
|
|
19
|
+
- **Never over the builder's work unseen.** The export now carries which state the draft is in (`SpaceExport.draft`),
|
|
20
|
+
recorded in `.plitzi/space.json` by `create --from` and `pull`; a push names it, and a draft edited since is refused
|
|
21
|
+
until `--force`. A project that never had the space may take one nobody has worked on; one holding work takes
|
|
22
|
+
`--force`.
|
|
23
|
+
- **Then `pull` follows it.** `.plitzi/space.json` records what was sent — only that, so a builder's change to a part
|
|
24
|
+
not pushed is still the next pull's — and a project that started on its own works with `pull` from then on.
|
|
25
|
+
- `@plitzi/sdk-shared/source`: `SpaceImport` / `SpaceImportResult` (`SPACE_IMPORT_FORMAT`), the push's one shape for
|
|
26
|
+
both ends, beside `SpaceExport`, which gains `draft`.
|
|
27
|
+
- `functions push`, `runtime push` and `upload plugin` keep their behaviour; their cores are what `push` runs.
|
|
28
|
+
|
|
29
|
+
## A project's folders: `tmp/` for what it writes, `.plitzi/` committed, `public/` said to be public
|
|
30
|
+
- **`tmp/` is what a project writes for itself.** `@plitzi/sdk-server` keeps the plugins it builds in `tmp/.sdk-plugins`
|
|
31
|
+
and resized pictures in `tmp/images` by default (were `.sdk-plugins` and `.plitzi/images`). A project the CLI writes
|
|
32
|
+
puts the port it took (`tmp/dev-server.json`), the space as last authored while developing (`tmp/space.json`) and
|
|
33
|
+
Playwright's output (`tmp/visual`) there too, and its `.gitignore` ignores `tmp` — one line for all of it.
|
|
34
|
+
- **`.plitzi/` is committed.** The scaffold ignored it whole, so a clone lost `space.json`, `functions.json` and
|
|
35
|
+
`scaffold.json` — what `pull`, `push`, `functions push` and `upgrade` stand on. Now it holds only what the CLI records.
|
|
36
|
+
`npx plitzi upgrade --write` brings an existing project's `.gitignore` up.
|
|
37
|
+
- **No `space/offline-data.json`.** `npm run author` checks the space and writes nothing; the server asks it for the
|
|
38
|
+
documents with `--out tmp/space.json` while developing. The space is `src/space.ts`, and nothing beside it says
|
|
39
|
+
otherwise.
|
|
40
|
+
- **`public/` is on the internet.** The generated `README.md` and `AGENTS.md`, the CLI and authoring skills, the MCP
|
|
41
|
+
guide, `@plitzi/sdk-server`'s README and the docs now say so where data goes: never a secret, a key or data only
|
|
42
|
+
some visitors may read — in `public/`, or in a space's documents, which reach every visitor too.
|
|
43
|
+
|
|
44
|
+
## An agent leaves the project clean
|
|
45
|
+
|
|
46
|
+
`AGENTS.md` gains **Keep the project clean** — nothing unused left behind, scratch work in `tmp/`, one of everything,
|
|
47
|
+
files a reader can find, and `author`, `typecheck`, `lint`, `format` and `check` passing — and the authoring skill's
|
|
48
|
+
review checklist a **Nothing left behind** section.
|
|
49
|
+
|
|
50
|
+
## The dev tools' X-ray is the outlines too
|
|
51
|
+
|
|
52
|
+
The QA tab had **Outlines** (every element's box, its type and id when pointed at) and **X-ray** (the same boxes,
|
|
53
|
+
fainter, with what is wired to each). They are one tool now: the X-ray draws every box with its type and id, and marks
|
|
54
|
+
the wiring picked — all of it, one kind, or **Boxes only**.
|
|
55
|
+
|
|
56
|
+
## A new space that says where to go
|
|
57
|
+
|
|
58
|
+
The space a new account and `plitzi create` start from keeps its welcome, and gains the three ways to change it — the
|
|
59
|
+
builder, code (with the command to run) and an agent — before the guides, which are shorter. Header, navigation, main
|
|
60
|
+
and footer are landmarks; the grid behind the top fades out instead of ending at an edge; the footer's line spans the
|
|
61
|
+
content; the links say their words and icon themselves, with no suggestion left.
|
|
62
|
+
|
|
63
|
+
## Server data reads the same everywhere
|
|
64
|
+
- **One shape for a provider with a `query`.** A `runtime: 'server'` provider that only reads a JSON file the server
|
|
65
|
+
serves answered the body itself, while the same provider in the browser publishes `{ status, data }` — so moving it
|
|
66
|
+
to the server broke every binding. `publicFileResolver` and the builder's mock now answer `{ status, data }` too:
|
|
67
|
+
`<source>.data.<field>` whichever runtime.
|
|
68
|
+
- **Server data is on unless a space turns it off.** The page render read a missing `rsc` as on and the connector
|
|
69
|
+
resolver as off, so a server provider in a space the builder or the MCP made (neither writes `rsc`) resolved to
|
|
70
|
+
nothing, silently. Both read it as on now; only `rsc: { enabled: false }` turns it off (and is warned with a server
|
|
71
|
+
element). `authorSpace` writes `enabled: true` for a space that has one.
|
|
72
|
+
- **Never inside a component.** The page server resolves a page's tree and its layouts only, so a server provider in a
|
|
73
|
+
component rendered nothing, silently. Refused now (`server-provider-in-component`), with the fix: the provider on
|
|
74
|
+
the page, the rows handed in as a prop.
|
|
75
|
+
- **Bindings held to the data.** `authorSpace` takes `data` — what a provider's `query` answers — and
|
|
76
|
+
`publicData(folder)` (`@plitzi/sdk-authoring/node`) reads it from `public/`. A binding onto a path the file does not
|
|
77
|
+
have is warned (`path-not-in-data`) with the keys it does have. Generated projects pass it.
|
|
78
|
+
- **`plitzi check` reads the data too.** A binding that reads a path its provider's answer lacks, a provider that
|
|
79
|
+
failed (once, not as every element it left empty) and the rows each list rendered (`dataIssues`). `--ssr` names what
|
|
80
|
+
the server's HTML lacks that the hydrated page has. The dev tools' badge and panel (`data-plitzi-devtools`) are hidden
|
|
81
|
+
while `check` and `shot` look.
|
|
82
|
+
|
|
83
|
+
## Authoring, clearer where agents tripped
|
|
84
|
+
- **`controls` on a button** (`aria-controls`): named by the element's id; authoring gives that element the anchor and
|
|
85
|
+
refuses an id the space lacks (`controls-unknown`); a saved document naming no anchor is warned
|
|
86
|
+
(`controls-no-anchor`). The accordion recipe uses it.
|
|
87
|
+
- **`live` on a container** (`aria-live`, `polite` or `assertive`) for words that change while a visitor reads them.
|
|
88
|
+
- **`subType: 'p'`** for a paragraph made of parts; `span-holds-block` covers it.
|
|
89
|
+
- **`formControl` types `search`, `url` and `tel`.**
|
|
90
|
+
- **Images:** no `src` draws the SDK's own placeholder (the CDN one answered 404); `resize: false` keeps a picture as
|
|
91
|
+
written; an SVG is never resized — `/_plitzi/img` redirects to it (`307`) instead of refusing it.
|
|
92
|
+
- **Messages:** an id taken by another page or layout names it and the id to write; a hyphenated source in a template
|
|
93
|
+
(`list_study-plans`) is explained as valid; an unknown CSS property says to write it in `customCss`; an unknown
|
|
94
|
+
variant on `custom` says it is never a prop of the component it hosts.
|
|
95
|
+
- **`plitzi explain` knows the helpers** — `bindTemplate`, `visibleWhen`, `variantFrom`, `activeOn`, `activeWhen`,
|
|
96
|
+
`when`, `named`, `scope`, `source`, `twig` — and `motion`, from the presets the SDK plays (`MOTION_ENTERS`,
|
|
97
|
+
`MOTION_TRIGGERS`, `MOTION_LOOPS`, now exported). `--list helpers` lists them.
|
|
98
|
+
- **`plitzi shot`** writes to `tmp/shots/` by default, and a full page shows the arrivals tied to the scroll as they
|
|
99
|
+
end. `inspectPage` no longer reports an image a hidden ancestor hides.
|
|
100
|
+
- **Style inspector:** `container-type` in Size.
|
|
101
|
+
- **New recipes:** `server-data.ts`, `accordion.ts`.
|
|
102
|
+
|
|
103
|
+
## Matching a page to another, measured
|
|
104
|
+
- **A whole page is the whole page.** The SDK scrolls a pane of its own, so `shot`'s full-page picture was one
|
|
105
|
+
screen; the pane is unrolled now (`unrollPage`) — the viewport untouched, so a `100vh` hero stays one screen.
|
|
106
|
+
- **`plitzi shot --compare` says why, not only how much.** Every lazy picture on both pages is loaded before they are
|
|
107
|
+
taken (`loadImages`), so a section no longer swings between 23 % and 40 % on how many covers had arrived. Each section
|
|
108
|
+
is compared where it is on the other page (`alignPictures` over row profiles): a page 400 px longer is said once, with
|
|
109
|
+
the section the drift starts at, instead of a footer with the same styles reading 100 %. And the texts both pages
|
|
110
|
+
have are paired (`pageTexts`, `compareTexts`), each with what it does differently there —
|
|
111
|
+
`h1 "Learn CSS" — font-size 68px → 60px · y +19px` — position less the drift of its section. `comparePictures` takes
|
|
112
|
+
its options as an object now (`{ regions, tolerance, align }`).
|
|
113
|
+
- **`--scheme` is the space's theme.** A space whose default is dark painted dark under `--scheme light`, and the file
|
|
114
|
+
said light. `check` and `shot` now set the `theme` cookie a visitor's toggle writes; left out, the space's default is
|
|
115
|
+
pictured and the file is named by the theme actually painted.
|
|
116
|
+
|
|
117
|
+
## `motion: { on: 'view' }` arrives once
|
|
118
|
+
|
|
119
|
+
`view` was tied to the scroll both ways: cards faded out again as the reader scrolled back up. Now it plays once, the
|
|
120
|
+
first time the element comes into view, with its `duration`, and stays — `revealOnView` (`@plitzi/sdk-shared/schema/
|
|
121
|
+
motionReveal`), one observer under the SDK's root, marks it `data-motion-seen`. The scroll-driven arrival is
|
|
122
|
+
`on: 'scroll'`. A page read without scripts shows them as they end (`@media (scripting: none)`); the builder's previews
|
|
123
|
+
and the MCP's local screenshots hold motion at its end. The builder's Motion tab has the three.
|
|
124
|
+
|
|
125
|
+
The builder's preview plays motion as the published page does. It held everything at its end, and nothing marked
|
|
126
|
+
the canvas `data-hydrated`, so neither the loops nor a space's own `[data-hydrated]` animations ever started there.
|
|
127
|
+
Now an arrival plays as it comes into view, and the loops and keyframes run; **▶** on the canvas being edited runs the
|
|
128
|
+
space's own `[data-hydrated]` loops too. The motion guide adds the rule this needs from a space: `[data-hydrated]` is for
|
|
129
|
+
loops; an entrance of its own that plays once (`transform`/`opacity` only) plays from the first paint with
|
|
130
|
+
`backwards`, as the presets do — gated on hydration it jumps when the page goes live, and a canvas that never goes
|
|
131
|
+
live holds it on its first frame.
|
|
132
|
+
|
|
133
|
+
## Thirteen ways in, nine loops
|
|
134
|
+
|
|
135
|
+
`motion` grows from six arrivals and four loops to thirteen and nine, all still `opacity` and the transforms:
|
|
136
|
+
|
|
137
|
+
- **Bold arrivals:** `slide-up` (from well below), `zoom-in` (from 60%), `zoom-out` (settling from 125%), `tilt`
|
|
138
|
+
(rising as it turns straight), `spin-in` (half a turn as it grows).
|
|
139
|
+
- **Springy arrivals:** `pop` overshoots and settles; `bounce-in` falls into place and bounces.
|
|
140
|
+
- **Loops:** `bounce`, `wobble`, `orbit` (a slow circle round where it is), `heartbeat`, `drift`.
|
|
141
|
+
|
|
142
|
+
An arrival is now a list of frames (`MOTION_ENTER_FRAMES` in `@plitzi/sdk-shared/schema/motion`; `MOTION_ENTER_FROM`
|
|
143
|
+
is its first), and so is a loop (`MOTION_LOOP_FRAMES[name].frames`, replacing `transform`/`at`). Arrivals move the
|
|
144
|
+
individual `translate`, `scale` and `rotate`, so they still compose with an element's own `transform` and with a loop.
|
|
145
|
+
The builder's Motion tab previews every frame; authoring, the MCP, `explain motion` and GraphQL read the same lists.
|
|
146
|
+
|
|
147
|
+
An arrival from the side no longer scrolls the page sideways. `slide-left` starts 32px to the right of where it lands,
|
|
148
|
+
past a phone's 16px gutter, and until it had played the page could be dragged sideways. The SDK's root clips that way
|
|
149
|
+
now (`overflow-x: clip`, which keeps the window the scroller: sticky bars and scroll timelines are untouched), and the
|
|
150
|
+
builder's preview does the same. Content wider than the page is cut rather than scrolling the page — give it a box of
|
|
151
|
+
its own with `overflow-x: auto`.
|
|
152
|
+
|
|
153
|
+
**3D and the transform parts are style properties.** `transform-style`, `perspective-origin`, `backface-visibility`,
|
|
154
|
+
`translate`, `rotate` and `scale` join the vocabulary (they were refused as `css-property-unknown` and had to go in
|
|
155
|
+
`customCss`); `scale: 0.94` is a factor, not pixels.
|
|
156
|
+
|
|
157
|
+
**The builder's placeholder for an empty element is 24px** (was 50px), one size for every element that has one. It
|
|
158
|
+
is a minimum, so it also grew empty shapes with a size of their own — a dot, a planet — while editing; 24px is still
|
|
159
|
+
a box to see, pick and drop into.
|
|
160
|
+
|
|
161
|
+
The desktop app's window was blank: authoring, run in the renderer to build the rail, asked Vite's `process` shim for
|
|
162
|
+
a working directory it does not have. It reads one only where there is one now — and only when a class conflict is
|
|
163
|
+
reported, rather than for every element of every space it writes.
|
|
164
|
+
|
|
165
|
+
## Authoring advice that knows where and when
|
|
166
|
+
- **`class-conflict` says where both declarations are:** `styles('md-menu-button') at src/components/dropdown.ts:12,
|
|
167
|
+
used by button at …, and styles('md-menu-button') at src/site/layout.ts:8, used by …` — `styles()` records its line as
|
|
168
|
+
an element factory does.
|
|
169
|
+
- **`repeated-shape` leaves controls alone.** Siblings that read different sources or write different state keys — a
|
|
170
|
+
menu for the language, one for the level — are not offered as a list.
|
|
171
|
+
- **`quiet: ['repeated-shape']`** on an element says a suggestion was left on purpose, and it is not offered again.
|
|
172
|
+
Only suggestions' codes (`quiet-unknown`). It is saved on the element (`definition.quiet`), so the builder's problems
|
|
173
|
+
panel, the MCP's `upsertElement`/`patchElement` and a project pulled back with `plitzi pull` all leave it alone.
|
|
174
|
+
|
|
175
|
+
## What the docs say is what the code does
|
|
176
|
+
|
|
177
|
+
A pass across authoring's own messages, the skills, the MCP guide, `docs/en`, the CLI's README and the website, so an
|
|
178
|
+
agent reading two of them is never told two things:
|
|
179
|
+
|
|
180
|
+
- **A binding's `source` is the bound value, and only that.** With no value yet it fell back to the attribute's
|
|
181
|
+
current one — `sourceTo` — so `{{ source|default('…') }}` never took its default and `{{ source ? … }}` read an
|
|
182
|
+
element's placeholder ("Text") as an answer. It is `undefined` until the source has a value now; `sourceTo` is still
|
|
183
|
+
the value before.
|
|
184
|
+
- **`explain` writes a step the way its builder is called:** `delay(ms)`, `openModal('modal-id', data?)`,
|
|
185
|
+
`scrollBy('row-id', { x: '80%' })` — not `delay(…)` above the document's param names, which read as `delay({ time })`.
|
|
186
|
+
- **A page field nobody takes is told what it was probably for** — `seo` → `seoTitle` and `seoDescription` — instead of a
|
|
187
|
+
hint about keeping state appended to every one.
|
|
188
|
+
- Docs set straight: an element's id is `data-plitzi-el` in the page (`data-id` is the builder's, with debugging on);
|
|
189
|
+
a visibility condition writes nothing while its source has no value and is a yes or a no once it has one; `mobile`
|
|
190
|
+
is up to and including 48rem; `loadStrategy` defaults by type (`lazy` for a modal); the accessibility codes listed
|
|
191
|
+
in full everywhere (`embed-without-title`, `dropdown-without-control`, `controls-no-anchor`); the generated
|
|
192
|
+
`AGENTS.md` says a repeated block is a component and rows of data a list, as the skill does; `plitzi runtime` is
|
|
193
|
+
documented in the CLI's README, its skill and the website, which no longer says state across requests needs a
|
|
194
|
+
server of your own.
|
|
195
|
+
|
|
196
|
+
- Updated dependencies [6ae4161]
|
|
197
|
+
- @plitzi/sdk-shared@0.38.4
|
|
198
|
+
- @plitzi/plitzi-sdk@0.38.4
|
|
199
|
+
|
|
3
200
|
## 0.38.3
|
|
4
201
|
|
|
5
202
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -158,7 +158,8 @@ serve it — worth running over anything that arrives as a file.
|
|
|
158
158
|
A space on Plitzi comes out whole, as a project wired to this server: `npx @plitzi/cli create my-site --from <space>`
|
|
159
159
|
writes its pages as authoring code, its actions as `defineAction` code, its functions, the source of its plugins and
|
|
160
160
|
runtime, and its files into `public/` — a draft, or any published snapshot (`--environment`, `--revision`) — and
|
|
161
|
-
`plitzi pull` keeps it in step
|
|
161
|
+
`plitzi pull` keeps it in step, and `plitzi push` puts what changed in it back on the space. See
|
|
162
|
+
`docs/en/projects-from-spaces.md` in the workspace.
|
|
162
163
|
|
|
163
164
|
## Configuration
|
|
164
165
|
|
|
@@ -176,11 +177,11 @@ runtime, and its files into `public/` — a draft, or any published snapshot (`-
|
|
|
176
177
|
| `signIn` | `SSRSignInConfig` | — | Sign visitors in by redirect through an OAuth 2.1 authorization server: `GET /auth/sign-in?return=/x` registers this host as a client, sends the browser out with PKCE (state in a `__Host-` cookie), and `/auth/sign-in/callback` redeems the code server to server and hands the token to `exchangeCredential`, which makes the session here. Needs the `exchangeCredential` adapter. |
|
|
177
178
|
| `templateFn` | `SSRTemplateFn` | built-in EJS template | Custom render function. Receives all template params and returns an HTML string. |
|
|
178
179
|
| `plugins` | `Record<string, PluginSource>` | — | Named plugin definitions. Compiled or copied on first use and cached for `pluginsTtlMs`. |
|
|
179
|
-
| `pluginsCacheDir` | `string` |
|
|
180
|
+
| `pluginsCacheDir` | `string` | `tmp/.sdk-plugins` | Directory where compiled plugin files are stored — under `tmp/`, with everything else the server writes for itself. |
|
|
180
181
|
| `pluginsTtlMs` | `number` | `604800000` | TTL in milliseconds for compiled plugins (default: 1 week). |
|
|
181
182
|
| `autoLoadSchemaPlugins` | `boolean` | `true` | Auto-download and cache plugins declared in the schema's `offlineData.plugins` list. A type the deployment registers itself (`plugins`, named in `pluginNames`) is never looked for there: its own build is the one rendered. Set to `false` to manage plugin loading manually. |
|
|
182
183
|
| `allowPrivatePluginHosts` | `boolean` | `false` | Read a schema plugin from a private address (`localhost`, this server's own network). A plugin's address is typed by whoever edits a space, so leave it off anywhere but a development machine whose bucket is local. |
|
|
183
|
-
| `publicDir` | `string` | — | Absolute path to a directory served at the root URL level (e.g. `robots.txt`, `favicon.png`). Files are checked before `static` prefix routes. |
|
|
184
|
+
| `publicDir` | `string` | — | Absolute path to a directory served at the root URL level (e.g. `robots.txt`, `favicon.png`). Files are checked before `static` prefix routes. **Everything in it is public:** served to anyone who asks, with no check. |
|
|
184
185
|
| `static` | `Record<string, string>` | — | URL prefix → filesystem path mappings for static file serving. |
|
|
185
186
|
| `images` | `{ domains, cacheDir? }` | — | Pictures from other sites resized at `/_plitzi/img`, for an `image` to offer as a `srcset` (see [Images](#images)). |
|
|
186
187
|
| `ssrOnly` | `boolean` | `false` | Omit client-side JS from the rendered page. Useful for verifying SSR HTML without hydration. |
|
|
@@ -397,11 +398,13 @@ createServer({ images: { domains: ['images.example.com', '*.cdn.example.com'] },
|
|
|
397
398
|
|
|
398
399
|
An `image` whose `src` is another site's then offers the browser a `srcset` of widths this server makes (320 to 1920
|
|
399
400
|
px) at `/_plitzi/img?url=…&w=…`, in AVIF or WebP when the browser takes them. Everything is kept on disk (`cacheDir`,
|
|
400
|
-
default
|
|
401
|
+
default `tmp/images`): each original is downloaded once, and each size is made once from it. A week on, the kept
|
|
401
402
|
files keep answering while the original is asked for with its `ETag` / `Last-Modified` — unchanged, nothing is resized
|
|
402
403
|
again; changed, its sizes are made anew. A picture that could not be fetched is not asked for again for five minutes.
|
|
403
404
|
Only the listed hosts are fetched, every redirect is held to the same
|
|
404
|
-
list and to the rule every outbound request follows (no private network)
|
|
405
|
+
list and to the rule every outbound request follows (no private network). An SVG is never resized nor served from
|
|
406
|
+
this origin: the proxy answers it with a redirect to its own address (`307`), so the picture still shows. An `image`
|
|
407
|
+
with `resize: false` keeps its `src` as written. Resizing needs `sharp`
|
|
405
408
|
(an optional peer): without it pictures are passed through and kept, and the server says so once.
|
|
406
409
|
|
|
407
410
|
### Public directory
|
|
@@ -417,6 +420,10 @@ createServer({
|
|
|
417
420
|
|
|
418
421
|
The lookup order for a request is: `publicDir` → `static` prefix routes → SSR renderer.
|
|
419
422
|
|
|
423
|
+
**Everything in `publicDir` is on the internet.** A file there is served to anyone who asks for it, as it is — no
|
|
424
|
+
session, no permission, no check. Never put a secret, a key, an `.env`, a private document, a database dump or data only
|
|
425
|
+
some visitors may read in it: that is served by a server action whose `access` checks who is asking.
|
|
426
|
+
|
|
420
427
|
`/.well-known/` paths follow the same lookup order: served from `publicDir` if a matching file exists, otherwise `404 Not Found`. They are never handled by the SSR renderer.
|
|
421
428
|
|
|
422
429
|
## Compression
|
|
@@ -518,7 +525,7 @@ const server = createServer({
|
|
|
518
525
|
// From a source file — compiled to ESM with esbuild
|
|
519
526
|
'my-chart': {
|
|
520
527
|
js: '/abs/path/to/MyChart.tsx',
|
|
521
|
-
css: '/abs/path/to/MyChart.css', // filesystem path — copied to
|
|
528
|
+
css: '/abs/path/to/MyChart.css', // filesystem path — copied to tmp/.sdk-plugins
|
|
522
529
|
version: '1.2.0'
|
|
523
530
|
},
|
|
524
531
|
// Pre-compiled local file — copied as-is (version defaults to '1.0.0')
|
|
@@ -671,7 +678,9 @@ The SSR server includes a lightweight RSC endpoint that delivers server-side dat
|
|
|
671
678
|
|
|
672
679
|
### Schema setup
|
|
673
680
|
|
|
674
|
-
|
|
681
|
+
Server data is on unless the schema turns it off with `"rsc": { "enabled": false }` — the page render and the
|
|
682
|
+
default `getRscData` (`connectorRscData`) read a missing `rsc` alike. `authorSpace` writes it out for a space with a
|
|
683
|
+
server provider:
|
|
675
684
|
|
|
676
685
|
```json
|
|
677
686
|
{
|
|
@@ -15,7 +15,7 @@ var proxyFor = (images) => {
|
|
|
15
15
|
if (!transform) serverLog.warn("IMAGES", "sharp is not installed: pictures are passed through and kept, not resized (npm i sharp).");
|
|
16
16
|
return createImageProxy({
|
|
17
17
|
domains: images.domains,
|
|
18
|
-
cacheDir: path.resolve(images.cacheDir ?? "
|
|
18
|
+
cacheDir: path.resolve(images.cacheDir ?? "tmp/images"),
|
|
19
19
|
fetch: globalThis.fetch,
|
|
20
20
|
transform
|
|
21
21
|
});
|
|
@@ -45,6 +45,19 @@ var Refusal = class extends Error {
|
|
|
45
45
|
}
|
|
46
46
|
};
|
|
47
47
|
var refuse = (status, message) => new Refusal(answer(status, message));
|
|
48
|
+
/**
|
|
49
|
+
* A vector picture is sent back to where it is: there is no size of it to make, and an SVG served from this origin is a
|
|
50
|
+
* document that can run script. The browser loads it from its own host, as it would without this endpoint.
|
|
51
|
+
*/
|
|
52
|
+
var toOriginal = (source) => new Refusal({
|
|
53
|
+
status: 307,
|
|
54
|
+
headers: {
|
|
55
|
+
Location: source.href,
|
|
56
|
+
"Cache-Control": CACHE_CONTROL
|
|
57
|
+
},
|
|
58
|
+
body: ""
|
|
59
|
+
});
|
|
60
|
+
var isVector = (pathname) => /\.svgz?$/i.test(pathname);
|
|
48
61
|
/** One of each at a time: a second request for what is being made waits for the first. */
|
|
49
62
|
var oneAtATime = () => {
|
|
50
63
|
const running = /* @__PURE__ */ new Map();
|
|
@@ -142,6 +155,10 @@ var createImageProxy = (options) => {
|
|
|
142
155
|
return { changed: false };
|
|
143
156
|
}
|
|
144
157
|
const type = (response.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
|
|
158
|
+
if (response.ok && type === "image/svg+xml") {
|
|
159
|
+
await response.body?.cancel();
|
|
160
|
+
throw toOriginal(source);
|
|
161
|
+
}
|
|
145
162
|
if (!response.ok || !Object.hasOwn(SOURCE_TYPES, type)) {
|
|
146
163
|
await response.body?.cancel();
|
|
147
164
|
throw refuse(502, response.ok ? `Not a picture this endpoint serves (${type || "no type"}).` : "The picture could not be fetched.");
|
|
@@ -205,7 +222,7 @@ var createImageProxy = (options) => {
|
|
|
205
222
|
await keepOriginal(urlKey, fetched, meta);
|
|
206
223
|
return fetched;
|
|
207
224
|
} catch (error) {
|
|
208
|
-
failedUntil.set(urlKey, now() + FAILED_MS);
|
|
225
|
+
if (!(error instanceof Refusal && error.answer.status === 307)) failedUntil.set(urlKey, now() + FAILED_MS);
|
|
209
226
|
throw error;
|
|
210
227
|
}
|
|
211
228
|
});
|
|
@@ -238,6 +255,7 @@ var createImageProxy = (options) => {
|
|
|
238
255
|
const requested = Number(width);
|
|
239
256
|
if (!isImageWidth(requested)) return answer(400, `\`w\` is one of ${IMAGE_WIDTHS.join(", ")}.`);
|
|
240
257
|
if (!isAllowedImageHost(domains, source.hostname)) return answer(403, `${source.hostname} is not one of the hosts this server takes pictures from.`);
|
|
258
|
+
if (isVector(source.pathname)) return toOriginal(source).answer;
|
|
241
259
|
const format = transform ? negotiate(accept) : "original";
|
|
242
260
|
const urlKey = sha(source.href);
|
|
243
261
|
try {
|
|
@@ -37,7 +37,7 @@ var connectorRscData = ({ connectors, actions, publicDir, elementTimeoutMs }) =>
|
|
|
37
37
|
};
|
|
38
38
|
return async ({ req, spaceId, environment, user, ids, loadOfflineData, flagOverrides }) => {
|
|
39
39
|
const offlineData = await loadOfflineData();
|
|
40
|
-
if (!offlineData
|
|
40
|
+
if (!offlineData || offlineData.schema.rsc?.enabled === false) return {};
|
|
41
41
|
return resolveRscData({
|
|
42
42
|
schema: offlineData.schema,
|
|
43
43
|
req,
|
|
@@ -9,5 +9,8 @@ import { RscElementResolver } from './resolveRscData';
|
|
|
9
9
|
*
|
|
10
10
|
* Only a plain path inside `publicDir`: a URL, one that climbs out of the folder, or one with `{{tokens}}` (resolved
|
|
11
11
|
* in the browser against the visitor's route and state) is not this resolver's, and the element is left as it was.
|
|
12
|
+
*
|
|
13
|
+
* Answered in the shape a browser request publishes — `{ status, data }`, the body under `data` — so a provider reads
|
|
14
|
+
* `<source>.data.<field>` whichever runtime it has, and moving it to the server changes no binding.
|
|
12
15
|
*/
|
|
13
16
|
export declare const publicFileResolver: (publicDir: string) => RscElementResolver;
|
|
@@ -11,6 +11,9 @@ import { readFile } from "node:fs/promises";
|
|
|
11
11
|
*
|
|
12
12
|
* Only a plain path inside `publicDir`: a URL, one that climbs out of the folder, or one with `{{tokens}}` (resolved
|
|
13
13
|
* in the browser against the visitor's route and state) is not this resolver's, and the element is left as it was.
|
|
14
|
+
*
|
|
15
|
+
* Answered in the shape a browser request publishes — `{ status, data }`, the body under `data` — so a provider reads
|
|
16
|
+
* `<source>.data.<field>` whichever runtime it has, and moving it to the server changes no binding.
|
|
14
17
|
*/
|
|
15
18
|
var publicFileResolver = (publicDir) => {
|
|
16
19
|
const root = path.resolve(publicDir);
|
|
@@ -26,10 +29,13 @@ var publicFileResolver = (publicDir) => {
|
|
|
26
29
|
const file = path.resolve(root, `.${pathname}`);
|
|
27
30
|
if (!file.startsWith(`${root}${path.sep}`)) return;
|
|
28
31
|
try {
|
|
29
|
-
return
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
return {
|
|
33
|
+
status: 200,
|
|
34
|
+
data: JSON.parse(await readFile(file, {
|
|
35
|
+
encoding: "utf8",
|
|
36
|
+
signal
|
|
37
|
+
}))
|
|
38
|
+
};
|
|
33
39
|
} catch {
|
|
34
40
|
return null;
|
|
35
41
|
}
|
package/dist/plugins/manager.js
CHANGED
|
@@ -10,7 +10,7 @@ import fs from "node:fs/promises";
|
|
|
10
10
|
//#region src/plugins/manager.ts
|
|
11
11
|
var META_FILE = "meta.json";
|
|
12
12
|
var DEFAULT_TTL_MS = 6048e5;
|
|
13
|
-
var DEFAULT_CACHE_DIR = "
|
|
13
|
+
var DEFAULT_CACHE_DIR = "tmp/.sdk-plugins";
|
|
14
14
|
/** One hash over every input's path and content — a file gone counts as changed, like a file edited. */
|
|
15
15
|
var digestOf = async (files) => {
|
|
16
16
|
const hash = createHash("sha256");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plitzi/sdk-server",
|
|
3
|
-
"version": "0.38.
|
|
3
|
+
"version": "0.38.4",
|
|
4
4
|
"license": "AGPL-3.0",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -79,8 +79,8 @@
|
|
|
79
79
|
"type": "module",
|
|
80
80
|
"sideEffects": false,
|
|
81
81
|
"dependencies": {
|
|
82
|
-
"@plitzi/plitzi-sdk": "0.38.
|
|
83
|
-
"@plitzi/sdk-shared": "0.38.
|
|
82
|
+
"@plitzi/plitzi-sdk": "0.38.4",
|
|
83
|
+
"@plitzi/sdk-shared": "0.38.4",
|
|
84
84
|
"ejs": "^6.0.1",
|
|
85
85
|
"esbuild": "^0.28.2",
|
|
86
86
|
"jsonwebtoken": "^9.0.3",
|
|
@@ -33,7 +33,7 @@ paragraph('A paragraph.')
|
|
|
33
33
|
container([children], { subType: 'section' }) // div, section, nav, header, footer, main, article, li, h1–h6, span…
|
|
34
34
|
link({ href: 'about', content: 'About' }) // a page id, a '/path', or a URL — the mode follows from the href
|
|
35
35
|
link({ href: 'home', hash: 'plans' }) // → /#plans, onto the element with anchor: 'plans'
|
|
36
|
-
container({ motion: { enter: 'fade-up', on: 'view' }, children }) // arrives as it
|
|
36
|
+
container({ motion: { enter: 'fade-up', on: 'view' }, children }) // arrives once as it comes into view; stagger, loop too
|
|
37
37
|
button({ content: 'Save', flows }) // holds children too: then content: ''
|
|
38
38
|
image({ src, alt }) // or decorative: true
|
|
39
39
|
embed({ src, title }) // a map, a player: another page in a frame
|
|
@@ -45,6 +45,7 @@ list({ id: 'rows', items: 'p.data.rows', row: r => listItem({ children: [text({
|
|
|
45
45
|
carousel({ id: 'hero', items: 'p.data.slides', autoplay: 5000, row: 'slide-card', children: [/* carouselNext('hero') arrows */] })
|
|
46
46
|
// rows follow their item's `id`, or `itemKey: 'slug'`
|
|
47
47
|
apiContainer({ id: 'p', query: '/data/x.json', cache: true, children }) // publishes p.data; no box of its own
|
|
48
|
+
apiContainer({ id: 'p', query: '/data/x.json', runtime: 'server', children }) // read by the page server: still p.data
|
|
48
49
|
// loadingSlot: 'p-skeleton' — a child shown until it answers
|
|
49
50
|
form({ id, managedByInteractions: true, flows, children })
|
|
50
51
|
formControl({ name: 'email', label: 'Email', subType: 'email' }) // select: options: [{ label, value }]
|
|
@@ -64,7 +65,7 @@ container({ id: 'hero', class: [card, { opacity: '0.5' }] }); // …plus one th
|
|
|
64
65
|
container({ css: { padding: '8px' } }); // rules of its own — never with `class`
|
|
65
66
|
```
|
|
66
67
|
|
|
67
|
-
Breakpoints: `desktop` (base), `tablet` (48–64rem), `mobile` (
|
|
68
|
+
Breakpoints: `desktop` (base), `tablet` (48–64rem), `mobile` (≤48rem), `compact` (tablet AND mobile). Each inherits
|
|
68
69
|
from `desktop` alone. States: `hover`, `focus`, `focus-visible`, `active`, `disabled`…. An ancestor's state:
|
|
69
70
|
`ancestors: { [card.name]: { states: { hover: { … } } } }`.
|
|
70
71
|
|
|
@@ -87,7 +88,8 @@ visible: false // hidden until a
|
|
|
87
88
|
|
|
88
89
|
Inside a list row: `rows.item` in a binding, `{{ list_rows.item.x }}` in a template or an attribute,
|
|
89
90
|
`list_rows.index` is a number from 0. A provider's source in a template is `apiContainer_p`… —
|
|
90
|
-
spelled in full, as the element publishes it
|
|
91
|
+
spelled in full, as the element publishes it; an id with a `-` keeps it (`list_study-plans`: a minus takes spaces,
|
|
92
|
+
`a - b`). Globals: `state`, `navigation` (`routeParams`, `queryParams`),
|
|
91
93
|
`auth`, `variables`, `computed`, `flags`, `theme`, `host`.
|
|
92
94
|
|
|
93
95
|
## Flows
|
|
@@ -61,7 +61,7 @@ second into the first. If the MCP asks for a sign-in nobody can give, a project
|
|
|
61
61
|
1. **Name what is referred to.** Give an `id` to every element a binding, a flow or a test addresses. Ids are ONE
|
|
62
62
|
namespace for the whole space — layouts and every page share it — so a helper that runs more than once builds inside
|
|
63
63
|
`scope('promos', ref => …)`: every id in it is prefixed (`promos-panel`), and `ref('slides')` names one in full. An `id` is the element's name in the
|
|
64
|
-
space (`data-
|
|
64
|
+
space (`data-plitzi-el` in the page); the DOM id a URL's `#fragment` lands on is its `anchor`.
|
|
65
65
|
2. **Share with classes, layouts and components, never with copies.** A look used twice is a `styles()` class.
|
|
66
66
|
Chrome shown on several pages — a header, a sidebar, a footer — is a **layout** the pages name, written once. A block
|
|
67
67
|
placed many times with different content — a product card, a testimonial — is a **component** placed with
|
|
@@ -91,11 +91,12 @@ second into the first. If the MCP asks for a sign-in nobody can give, a project
|
|
|
91
91
|
checked from the first commit.
|
|
92
92
|
7. **Times in UTC, and say so.** Format with an explicit zone (`|date('j M · H:i', 'UTC')`) and print "UTC" beside it:
|
|
93
93
|
a page rendered on a server and hydrated in a browser in another zone must agree on the hour.
|
|
94
|
-
8. **Breakpoints are ranges.** `tablet` (48–64rem) and `mobile` (
|
|
94
|
+
8. **Breakpoints are ranges.** `tablet` (48–64rem) and `mobile` (up to 48rem) each inherit only from `desktop`; a rule
|
|
95
95
|
meant for both is written under `compact`.
|
|
96
96
|
9. **Never invent data.** Numbers, names and states on screen come from a source. A panel with nothing true to say is
|
|
97
97
|
an empty state, not a placeholder figure. With no backend, the data is JSON the project serves
|
|
98
98
|
(`public/data/*.json`) read by an `apiContainer` — see [data and visibility](reference/data-and-visibility.md).
|
|
99
|
+
`public/` is public: never a secret there.
|
|
99
100
|
10. **Never hand-write** `flat`, derived ids, `styleSelectors`, `beforeNode`/`afterNode`/`flowId`, or a
|
|
100
101
|
`styleVariant` binding's key — use the factories, `variantFrom` and `activeOn`.
|
|
101
102
|
11. **A switch is named for how it leaves its default.** `toggleState` turns a key nobody has set yet ON, so a key
|
|
@@ -121,6 +122,8 @@ Each is a whole file that authors with no warning — CI holds it to that. Copy
|
|
|
121
122
|
| --- | --- |
|
|
122
123
|
| Show data with no backend: a JSON file, a card per row, a count, an empty state, a computed value | [recipes/show-data.ts](recipes/show-data.ts) |
|
|
123
124
|
| Data typed by a sample of it: completed and checked paths, typed rows | [recipes/typed-data.ts](recipes/typed-data.ts) |
|
|
125
|
+
| Data read by the page server (`--mode server`) | [recipes/server-data.ts](recipes/server-data.ts) |
|
|
126
|
+
| An accordion, one answer open at a time | [recipes/accordion.ts](recipes/accordion.ts) |
|
|
124
127
|
| Filter and sort a list from a select | [recipes/filter-a-list.ts](recipes/filter-a-list.ts) |
|
|
125
128
|
| A page per record (`/products/:slug`), and "not found" | [recipes/detail-page.ts](recipes/detail-page.ts) |
|
|
126
129
|
| Link to a section of a page (`/#plans`) | [recipes/link-to-a-section.ts](recipes/link-to-a-section.ts) |
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An accordion — questions whose answers open one at a time — written once and mapped over the questions: the open one
|
|
3
|
+
* is a key in `state`, and each question reads it the same way. The button says whether its answer is open
|
|
4
|
+
* (`ariaExpanded`) and which panel it opens (`controls`, by the panel's id), so a screen reader follows it; the panel is
|
|
5
|
+
* revealed by the state, so it starts hidden.
|
|
6
|
+
*/
|
|
7
|
+
import { bindTemplate, button, container, onClick, paragraph, setState, styles } from '@plitzi/sdk-authoring';
|
|
8
|
+
|
|
9
|
+
import type { ElementSpec, SpaceSpec } from '@plitzi/sdk-authoring';
|
|
10
|
+
|
|
11
|
+
const QUESTIONS = [
|
|
12
|
+
{ id: 'shipping', question: 'How long does shipping take?', answer: 'Three working days, anywhere in Europe.' },
|
|
13
|
+
{ id: 'returns', question: 'Can I return an order?', answer: 'Within thirty days, unopened, at no cost.' },
|
|
14
|
+
{ id: 'warranty', question: 'Is there a warranty?', answer: 'Two years on every product we sell.' }
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const question = styles('faq-question', {
|
|
18
|
+
css: {
|
|
19
|
+
display: 'flex',
|
|
20
|
+
'justify-content': 'space-between',
|
|
21
|
+
width: '100%',
|
|
22
|
+
padding: '16px 0px',
|
|
23
|
+
border: 'none',
|
|
24
|
+
'background-color': 'transparent',
|
|
25
|
+
'font-size': '16px',
|
|
26
|
+
'font-weight': '600',
|
|
27
|
+
cursor: 'pointer'
|
|
28
|
+
},
|
|
29
|
+
states: { 'focus-visible': { outline: '2px solid currentColor', 'outline-offset': '2px' } }
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const answer = styles('faq-answer', { 'padding-bottom': '16px', margin: '0px' });
|
|
33
|
+
|
|
34
|
+
/** One question: the button that opens it — closing the one open, or this one again — and the answer it opens. */
|
|
35
|
+
const faqItem = ({ id, question: words, answer: text }: (typeof QUESTIONS)[number]): ElementSpec[] => [
|
|
36
|
+
button({
|
|
37
|
+
id: `${id}-question`,
|
|
38
|
+
content: words,
|
|
39
|
+
class: question,
|
|
40
|
+
// Named by the panel's id: authoring gives the panel the anchor the page needs for `aria-controls`.
|
|
41
|
+
controls: `${id}-answer`,
|
|
42
|
+
ariaExpanded: false,
|
|
43
|
+
bind: [bindTemplate('ariaExpanded', 'state.faq', `{{ source == '${id}' }}`, { returns: 'value' })],
|
|
44
|
+
flows: [[onClick(), setState({ key: 'faq', type: 'text', value: `{{ state.faq == '${id}' ? '' : '${id}' }}` })]]
|
|
45
|
+
}),
|
|
46
|
+
// Revealed by the state, so it starts hidden: nothing flashes open before the page knows which one is.
|
|
47
|
+
container({
|
|
48
|
+
id: `${id}-answer`,
|
|
49
|
+
visible: { source: 'state.faq', template: `{{ source == '${id}' }}` },
|
|
50
|
+
children: [paragraph({ content: text, class: answer })]
|
|
51
|
+
})
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
export const recipe: SpaceSpec = {
|
|
55
|
+
name: 'Questions',
|
|
56
|
+
permanentUrl: 'questions',
|
|
57
|
+
pages: [{ id: 'home', name: 'Home', slug: '', body: QUESTIONS.flatMap(faqItem) }]
|
|
58
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Link to a section: the section carries an `anchor` — its id in the page; an element's `id` is only its name in the
|
|
3
|
-
* space, `data-
|
|
3
|
+
* space, `data-plitzi-el` in the DOM — and a link's `hash` lands on it, from any page, even when the section renders late.
|
|
4
4
|
*/
|
|
5
5
|
import { container, heading, link, text } from '@plitzi/sdk-authoring';
|
|
6
6
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data in a server project (`create --mode server`): a JSON file the project serves (`public/data/plans.json`), read
|
|
3
|
+
* by the page server before it answers — so the page arrives with the plans in its HTML, their anchors in place, and
|
|
4
|
+
* nothing to fetch once the browser has it.
|
|
5
|
+
*
|
|
6
|
+
* - `runtime: 'server'` on the provider is the whole switch: server data is on unless a space turns it off.
|
|
7
|
+
* - The answer is read exactly as in the browser — `plans.data.…` — so moving a provider between runtimes changes no
|
|
8
|
+
* binding.
|
|
9
|
+
* - The page server resolves what a page and its layouts hold, never inside a component: the provider stays on the page
|
|
10
|
+
* and each row reaches the card component as a prop.
|
|
11
|
+
* - `mockData` is what the builder shows while editing; the running page always reads the file.
|
|
12
|
+
*/
|
|
13
|
+
import { apiContainer, heading, list, listItem, styles, text } from '@plitzi/sdk-authoring';
|
|
14
|
+
|
|
15
|
+
import type { SpaceSpec } from '@plitzi/sdk-authoring';
|
|
16
|
+
|
|
17
|
+
const grid = styles('plan-grid', {
|
|
18
|
+
display: 'grid',
|
|
19
|
+
'grid-template-columns': 'repeat(auto-fill, minmax(220px, 1fr))',
|
|
20
|
+
gap: '16px',
|
|
21
|
+
margin: '0px',
|
|
22
|
+
padding: '0px'
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const card = styles('plan-card', { padding: '16px', 'border-radius': '12px', 'background-color': 'var(--card)' });
|
|
26
|
+
|
|
27
|
+
export const recipe: SpaceSpec = {
|
|
28
|
+
name: 'Pricing',
|
|
29
|
+
permanentUrl: 'pricing',
|
|
30
|
+
variables: { color: { card: { light: '#ffffff', dark: '#16161d', default: '#ffffff' } } },
|
|
31
|
+
components: [
|
|
32
|
+
{
|
|
33
|
+
id: 'plan-card',
|
|
34
|
+
props: { item: { type: 'json', description: 'The plan: name and price' } },
|
|
35
|
+
// The row of the list: an `<li>` reading the plan it was handed.
|
|
36
|
+
root: listItem({
|
|
37
|
+
id: 'plan-item',
|
|
38
|
+
class: card,
|
|
39
|
+
children: [heading({ subType: 'h3', from: 'props.item.name' }), text({ from: 'props.item.price' })]
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
],
|
|
43
|
+
pages: [
|
|
44
|
+
{
|
|
45
|
+
id: 'home',
|
|
46
|
+
name: 'Home',
|
|
47
|
+
slug: '',
|
|
48
|
+
body: [
|
|
49
|
+
apiContainer({
|
|
50
|
+
id: 'plans',
|
|
51
|
+
query: '/data/plans.json',
|
|
52
|
+
runtime: 'server',
|
|
53
|
+
mockData: { plans: [{ name: 'Starter', price: '$0' }] },
|
|
54
|
+
children: [
|
|
55
|
+
// `plans.data.plans` — the file's `plans` — in either runtime.
|
|
56
|
+
list({ id: 'plan-list', class: grid, items: 'plans.data.plans', row: 'plan-card' }),
|
|
57
|
+
text('No plans yet.', {
|
|
58
|
+
visible: { source: 'plans.data.plans', template: '{{ source is defined and source|length == 0 }}' }
|
|
59
|
+
})
|
|
60
|
+
]
|
|
61
|
+
})
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
};
|
|
@@ -10,7 +10,7 @@ import type { SpaceSpec } from '@plitzi/sdk-authoring';
|
|
|
10
10
|
const card = styles('card', {
|
|
11
11
|
css: {
|
|
12
12
|
desktop: { display: 'flex', 'flex-direction': 'column', gap: '8px', padding: '24px', 'border-radius': '16px' },
|
|
13
|
-
// Tablet (48–64rem) and mobile (
|
|
13
|
+
// Tablet (48–64rem) and mobile (up to 48rem) each inherit from desktop alone: `compact` is both.
|
|
14
14
|
compact: { padding: '16px' }
|
|
15
15
|
},
|
|
16
16
|
states: { hover: { 'border-color': 'var(--primary)' } }
|
|
@@ -4,7 +4,8 @@ A screen reader and a browser agent (Claude in Chrome) read a page the same way.
|
|
|
4
4
|
tree** the browser builds from the markup, where every control is a role and a name. If a control has no name, or a
|
|
5
5
|
click sits on a box that is not a control, those readers can't find it, even though the page looks fine. The linter
|
|
6
6
|
warns about each case below (`control-without-name`, `image-without-alt`, `click-on-static-element`,
|
|
7
|
-
`heading-level-skipped`, `label-ignored`
|
|
7
|
+
`dropdown-without-control`, `heading-level-skipped`, `label-ignored`, `control-in-decorative`, `embed-without-title`,
|
|
8
|
+
`controls-no-anchor`). Fix them the same way you fix any other warning.
|
|
8
9
|
|
|
9
10
|
## Controls have words
|
|
10
11
|
|
|
@@ -75,7 +76,10 @@ button({ content: 'Grid', bind: { ariaPressed: 'computed.gridOn' }, class: chip
|
|
|
75
76
|
button({ content: 'Menu', bind: { ariaExpanded: 'state.menuOpen' }, flows: [[onClick(), toggleState({ key: 'menuOpen' })]] })
|
|
76
77
|
```
|
|
77
78
|
|
|
78
|
-
An "active" variant is for the eyes. `ariaPressed` and `ariaExpanded` are for everyone else. Use both.
|
|
79
|
+
An "active" variant is for the eyes. `ariaPressed` and `ariaExpanded` are for everyone else. Use both. A button that
|
|
80
|
+
opens a panel also names it — `controls: 'menu-panel'`, the panel's id (authoring gives the panel the anchor
|
|
81
|
+
`aria-controls` needs; [recipes/accordion.ts](../recipes/accordion.ts)). Words that change while somebody is on the page
|
|
82
|
+
— a count, a total, a status — sit in `container({ live: 'polite', … })`, so a screen reader says them when they change.
|
|
79
83
|
|
|
80
84
|
## The page has an outline
|
|
81
85
|
|
|
@@ -59,6 +59,7 @@ The space is not written until these are fixed.
|
|
|
59
59
|
| `computed-not-template` | a computed value that is not a template | `'{{ state.x * 2 }}'` |
|
|
60
60
|
| `computed-reads-element` | a computed value reading an element's source — no element is around the whole space | compute it from the globals and the variables, or bind it on the element |
|
|
61
61
|
| `computed-unknown` | a computed value read before it is declared, or never declared | declare it in `computed`, above the one that reads it |
|
|
62
|
+
| `controls-unknown` | a button's `controls` naming no element of the space | the id of the element it shows and hides: `controls: 'faq-answer'` |
|
|
62
63
|
| `css-property-twice` | one property written twice in a rule set — `paddingTop` beside `'padding-top'` — so one would silently win | keep one |
|
|
63
64
|
| `css-property-unknown` | a CSS property that does not exist | the property it suggests; a custom property starts with `--` |
|
|
64
65
|
| `css-value` | an empty CSS value, or one with `;` or `{}` | one value per property; leave a property out instead of writing it empty |
|
|
@@ -84,7 +85,7 @@ The space is not written until these are fixed.
|
|
|
84
85
|
| `global-callback-undeclared` | a global-callback step builder naming an action no source declares | one of the actions it lists |
|
|
85
86
|
| `id-invalid` | an id a binding, a template or a test cannot name | a letter first, then letters, digits, `-` and `_`: `'hero-title'` |
|
|
86
87
|
| `id-shadows-global` | an element named like a global data source (`state`, `navigation`, `auth`…) | another id |
|
|
87
|
-
| `id-taken` | two elements with one id — ids are one namespace for the whole space |
|
|
88
|
+
| `id-taken` | two elements with one id — ids are one namespace for the whole space | a name taken by a page or a layout: a name of its own; a helper called twice: `scope('promos', ref => …)`, every id inside prefixed |
|
|
88
89
|
| `layout-slot-unknown` | a layout `slot` that is not an element inside that layout | the id of the element in the shell where the body goes |
|
|
89
90
|
| `layout-undeclared` | a page or a layout inside a layout the space does not declare | the layout's id, or declare it in `layouts` |
|
|
90
91
|
| `list-items-ignored` | a list whose items nothing reads — `source: 'none'` renders its children once | `source: 'controlled'` |
|
|
@@ -109,6 +110,7 @@ The space is not written until these are fixed.
|
|
|
109
110
|
| `prop-unknown` | a prop the component does not declare, handed in or read | the prop it suggests, or declare it: `props: { name: { type: 'text' } }` |
|
|
110
111
|
| `prop-value` | a prop handed in with a value its declaration does not take | a value of the declared type, or one of its options |
|
|
111
112
|
| `props-outside-component` | `props.x` read outside a component | read the source it would have come from |
|
|
113
|
+
| `quiet-unknown` | an element's `quiet` naming something that is not a suggestion's code — a problem is never quieted | the code of the suggestion it was offered: `quiet: ['repeated-shape']` |
|
|
112
114
|
| `redirect-target-unknown` | a page that sends visitors it is not for to a page the space does not have | a page's id or slug (`''` is the home page), or a full URL |
|
|
113
115
|
| `row-and-children` | a list with a `row` and `children` — the row is what it renders | one of the two |
|
|
114
116
|
| `row-component` | a list's `row` naming a component the space does not declare, or one with no prop to take the row | a declared component with an `item` prop — or a single prop — for the row |
|
|
@@ -117,6 +119,7 @@ The space is not written until these are fixed.
|
|
|
117
119
|
| `rule-set-mixed` | a style that writes its rules beside `states`, `variants` or `ancestors` instead of under `css` | `{ css: { desktop: { … } }, states: { hover: { … } } }` — the rules under `css`, each of the others beside it |
|
|
118
120
|
| `selector-invalid` | a `selector` that is not a CSS class name | letters, digits, `-` and `_` |
|
|
119
121
|
| `selector-taken` | a `selector` that is a declared class, or another element's | `class` to share rules; a selector of an element's own is its alone |
|
|
122
|
+
| `server-provider-in-component` | a `runtime: 'server'` element inside a component: the page server resolves a page's and its layouts', never a component's | put the provider on the page, around the instance, and hand the component its rows as a prop |
|
|
120
123
|
| `setting-misplaced` | `settings.computed` or `settings.channels` written inside `settings` | `computed` and `channels` at the top of the space |
|
|
121
124
|
| `slot-children` | children handed to an instance outside its slots | `children: { slotName: [ … ] }`; a component with no slots takes no children |
|
|
122
125
|
| `slot-unknown` | a component slot that is not an element of its tree | the id of an element inside the component — usually an empty container |
|
|
@@ -161,6 +164,7 @@ The space renders, and renders something you probably did not mean. Fix every on
|
|
|
161
164
|
| `condition-starts-visible` | a computed visibility that shows until its data answers | `visible: { source, template }` |
|
|
162
165
|
| `control-in-decorative` | a control inside a `decorative` container — Tab lands on something nothing announces | move it out of the illustration |
|
|
163
166
|
| `control-without-name` | a button or a link with no words, or a field nothing names | `title` on the button, `label` on the link, `label` (with `hideLabel: true`) on the field |
|
|
167
|
+
| `controls-no-anchor` | a button's `controls` naming no anchor — `aria-controls` points at nothing | an `anchor` on the element it shows and hides, and `controls` naming it |
|
|
164
168
|
| `default-content-beside-children` | a button printing its default "Button" beside its children | `content: ''` |
|
|
165
169
|
| `dropdown-without-control` | a dropdown opened from a box or an icon — no keyboard opens it | a `button` as what opens it (`title` if it is only an icon) |
|
|
166
170
|
| `embed-without-title` | an `embed` with no `title` — a frame a screen reader cannot describe | say what it shows in `title` |
|
|
@@ -178,11 +182,12 @@ The space renders, and renders something you probably did not mean. Fix every on
|
|
|
178
182
|
| `overlay-never-opened` | a modal or a dialog that starts hidden and that no step opens | a flow with `openModal('id')` / `openDialog('id')` |
|
|
179
183
|
| `overlay-starts-open` | a modal or a dialog open when the page loads | `visible: false`, opened by `openModal` |
|
|
180
184
|
| `painted-state-without-keep-state` | `paintedState` without `keepState` — nothing is kept for the server to draw with | `settings.keepState: true`, or remove `paintedState` |
|
|
185
|
+
| `path-not-in-data` | a binding onto a provider whose answer the author could read (`data`), through a path that answer does not have | the path the message lists the keys for — `p.data.plans`, not `p.data.landing.plans` |
|
|
181
186
|
| `plugin-attribute-reserved` | a plugin attribute named as one of the element’s own fields — a factory never hands it to the plugin | rename it in the plugin (`variant` → `kind`) |
|
|
182
187
|
| `provider-without-source` | an `apiContainer` that asks nothing | a `query` (or `action`, `connector`, `resource`) |
|
|
183
188
|
| `route-param-undeclared` | `navigation.routeParams.x` read on a page whose slug has no `:x` — always empty | add `:x` to the slug, or read `navigation.queryParams.x` |
|
|
184
|
-
| `server-data-without-rsc` | a `runtime: 'server'` provider
|
|
185
|
-
| `span-holds-block` | a `container
|
|
189
|
+
| `server-data-without-rsc` | a `runtime: 'server'` provider — `connector`, `action` or `query` — in a space that turns server data off (`rsc: { enabled: false }`) | drop `rsc: { enabled: false }`: server data is on unless a space turns it off |
|
|
190
|
+
| `span-holds-block` | a `container` with `subType: 'span'` or `'p'` holding a heading, a paragraph, a list, a form or prose | a `div` (leave `subType` out), or words and inline elements: a `text`, a `link` |
|
|
186
191
|
| `state-key-has-runtime-prefix` | a state key written with `runtime.state.` in front — the state callbacks already write below it | `key: 'cart'`, not `'runtime.state.cart'` |
|
|
187
192
|
| `state-toggled-in-branches` | two `setState` of one key, each under a `when` on that key — the second flips back what the first wrote | `toggleState({ key })`; for something shown by default, a key named for hiding it |
|
|
188
193
|
| `STYLE_WITHOUT_TAG` | style on a provider that renders no element of its own | `subType: 'div'` on the provider, or style its parent |
|
|
@@ -20,7 +20,9 @@ Only `opacity` and `transform` (`translate`, `scale`, `rotate`) animate off the
|
|
|
20
20
|
the page hydrates and on a slow phone. Everything else repaints, or lays the page out again, on every frame.
|
|
21
21
|
|
|
22
22
|
- **A preset first:** `motion: { enter: 'fade-up', on: 'view' }` on any element, `stagger: 60` for children one by one,
|
|
23
|
-
`loop: 'float'` — played by the SDK, cheap and stilled for reduced motion by construction (motion
|
|
23
|
+
`loop: 'float'` — played by the SDK, cheap and stilled for reduced motion by construction (`npx plitzi explain motion` lists them; `MOTION_ENTERS` and `MOTION_LOOPS` in code).
|
|
24
|
+
`on: 'view'` plays once, the first time the element comes into view, and it stays; `on: 'scroll'` follows the scroll
|
|
25
|
+
both ways — it goes back out as the reader scrolls up past it — so keep it for an effect that is about the scroll.
|
|
24
26
|
|
|
25
27
|
- **Slide, grow, fade** with `transform` and `opacity` — never `left`, `top`, `width`, `height` or `margin`. Something
|
|
26
28
|
that travels across a box is `transform: translate(%)` on an element as large as the box.
|
|
@@ -31,6 +33,8 @@ the page hydrates and on a slow phone. Everything else repaints, or lays the pag
|
|
|
31
33
|
- **Main-thread decoration waits for hydration:** a sweep on a custom property, a `background-position` seam — start
|
|
32
34
|
it paused, run it once the SDK's root says `data-hydrated`:
|
|
33
35
|
`'.glow { animation: glow 8s linear infinite paused; } [data-hydrated] .glow { animation-play-state: running; }'`.
|
|
36
|
+
That is for loops. An entrance of your own that plays once — `transform`/`opacity` only — plays from the first paint
|
|
37
|
+
with `backwards`, as the presets do: gated on `[data-hydrated]` it jumps when the page goes live.
|
|
34
38
|
- **Entrances:** `opacity` + a small `translateY`, under ~600ms, staggered by tens of ms.
|
|
35
39
|
- **One ambient loop per screen**, slow — not one per card. `infinite` runs on every visitor's battery for as long
|
|
36
40
|
as the page is open.
|
|
@@ -50,7 +50,7 @@ text('', { bind: [bindTemplate('content', 'computed.level', 'Level {{ source }}'
|
|
|
50
50
|
|
|
51
51
|
A computed value reads the globals (`state`, `auth`, `navigation`, `variables`, `theme`, `host`) and the ones declared
|
|
52
52
|
above it — not an element's source: bind that on the element. One `{{ expression }}` gives its value (a number, a
|
|
53
|
-
list); anything else gives text. `authorSpace` refuses a name read before it is declared
|
|
53
|
+
list); anything else gives text. `authorSpace` refuses a name read before it is declared.
|
|
54
54
|
|
|
55
55
|
## Providers
|
|
56
56
|
|
|
@@ -61,12 +61,16 @@ apiContainer({ id: 'board', runtime: 'server', action: 'queue-board' })
|
|
|
61
61
|
|
|
62
62
|
- A provider's source is readable by its **descendants** only. Wrap what reads it; a sibling cannot see it.
|
|
63
63
|
- It publishes `data` plus `isLoading`, `isEmpty`, `hasError`, `errorMessage` — bind a state to those, not to guesses.
|
|
64
|
+
A `query`'s body is `data` in **either runtime** (`p.data.plans`), so moving a provider to the server changes no
|
|
65
|
+
binding. A connector publishes `records` (or `record`), an action its output.
|
|
64
66
|
`isEmpty` reads what arrived: a `query`'s body when it is missing, `null`, `''`, `[]` or `{}`; a connector list by
|
|
65
67
|
its `records`; a `singleRecord` provider by its `record`. It is also true before the first answer, so pair it with
|
|
66
68
|
`not isLoading` for an empty state.
|
|
67
69
|
- A query that depends on state is a binding on `query`; it answers `''` (and fetches nothing) until the state exists:
|
|
68
70
|
`"{{ source ? apiUrl ~ '/workspaces/' ~ source ~ '/stats' : '' }}"` with `source: 'state.workspace.id'`.
|
|
69
|
-
-
|
|
71
|
+
- `runtime: 'server'` is resolved by the page server — server data is on unless a space says `rsc: { enabled: false }`.
|
|
72
|
+
Only a page and its layouts are resolved — never inside a component: keep the provider on the page and hand the
|
|
73
|
+
component its rows as a prop ([recipes/server-data.ts](../recipes/server-data.ts)).
|
|
70
74
|
|
|
71
75
|
### Typed by a sample
|
|
72
76
|
|
|
@@ -81,11 +85,15 @@ An offline project (`offlineMode`) still never invents data: the content lives i
|
|
|
81
85
|
backend, so swapping the file for a real endpoint later changes one `query`. `mockData` is what the BUILDER shows while
|
|
82
86
|
editing; it is not a data source for the running page. Say on the page that demo content is demo content.
|
|
83
87
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
88
|
+
**`public/` is on the internet.** Every file in it is served to anyone who asks, as it is, once the project is deployed —
|
|
89
|
+
and so is everything the space's documents hold (pages, variables, attributes, `mockData`): they reach every visitor's
|
|
90
|
+
browser. Never put a secret, a key, a private document or data only some visitors may read in either. A secret is a
|
|
91
|
+
credential that an action or a connector names; data for some visitors only comes from a server action whose `access`
|
|
92
|
+
checks who is asking, or a `runtime: 'server'` provider behind one.
|
|
93
|
+
|
|
94
|
+
**In a server project (`create --mode server`), put the provider on the server** — `runtime: 'server'` — and the page
|
|
95
|
+
server reads the file from `public/` itself: the page arrives with those sections (and their anchors) in it. A `query`
|
|
96
|
+
with `{{tokens}}` is still read in the browser, against the visitor's route and state.
|
|
89
97
|
|
|
90
98
|
### Live, cached, refreshed
|
|
91
99
|
|
|
@@ -116,7 +124,7 @@ modalContainer({ visible: false, … }) // starts hidden; a flow opens
|
|
|
116
124
|
| The logic… | Example | Write |
|
|
117
125
|
| --- | --- | --- |
|
|
118
126
|
| reveals it: hidden until the data says so | empty state, "get started", admin-only panel, error note | `visible: 'src'`, or `visible: false` + a computed binding |
|
|
119
|
-
| hides it: shown unless a flag says otherwise | sidebar labels until the sidebar is folded, a banner until dismissed |
|
|
127
|
+
| hides it: shown unless a flag says otherwise | sidebar labels until the sidebar is folded, a banner until dismissed | `visible: '!state.folded'` — on screen while nobody has set it |
|
|
120
128
|
|
|
121
129
|
`visible` starts the element **hidden**, and it appears when its data says so. A revealing condition you have to
|
|
122
130
|
compute is `visible: { source, template }` — it starts hidden too, and the template's value is read as a yes or a
|
|
@@ -135,17 +143,19 @@ container({
|
|
|
135
143
|
|
|
136
144
|
Rules for a condition's template:
|
|
137
145
|
|
|
138
|
-
- **A
|
|
139
|
-
|
|
140
|
-
condition on data. `authorSpace` warns `condition-starts-visible` when a computed one on a provider starts on screen.
|
|
146
|
+
- **A visibility binding written by hand in `bind` keeps the element on screen until it answers** — a flash for a
|
|
147
|
+
condition on data (`condition-starts-visible` warns); `visible` starts it hidden.
|
|
141
148
|
- **A hidden element stays in the DOM** (`plitzi-component--hidden`), its subtree mounted. `loadStrategy: 'visible'`
|
|
142
149
|
mounts the subtree only while it is shown, and `'lazy'` from the first time it is shown (modals do that already).
|
|
143
150
|
Use it on heavy panels that are usually hidden; leave the default on what is usually on screen.
|
|
144
|
-
-
|
|
145
|
-
|
|
146
|
-
- **
|
|
147
|
-
|
|
148
|
-
|
|
151
|
+
- **Once its source has a value, the answer is a yes or a no**: `false`, `0`, `''` and an empty list hide it,
|
|
152
|
+
anything else shows it.
|
|
153
|
+
- **While its source has no value, only a definite answer is written.** `!source` is `true` — shown; a template's
|
|
154
|
+
`'true'` or `'false'` is written as it says. A plain source, or a template that answers nothing (`''`), writes
|
|
155
|
+
nothing: the element stays as it started — hidden for `visible`, shown for a binding written by hand.
|
|
156
|
+
- **So a hiding flag is `visible: '!state.folded'`** (or a binding with no `visible`): on screen before anybody has set
|
|
157
|
+
it. A condition that answers "not known yet" with `'false'` from a value that IS there — a "folded" read off
|
|
158
|
+
something always set — hid every label of a sidebar for everybody.
|
|
149
159
|
- **Hide the element itself.** Never wrap it in a container that is shown while the inner one is hidden: an empty
|
|
150
160
|
visible wrapper still takes a slot in its parent's `gap`, and leaves a hole in the page.
|
|
151
161
|
|
|
@@ -19,6 +19,15 @@ const { warnings, suggestions } = authorSpace(space);
|
|
|
19
19
|
is a bug to fix; a suggestion is a shorter way to the same page — take it unless you have a reason not to, and the
|
|
20
20
|
reason is usually that the copies are about to diverge. `npx @plitzi/cli explain repeated-on-pages` explains any code.
|
|
21
21
|
|
|
22
|
+
Left on purpose, say so where it is written — `quiet` on an element it is about, and it is not offered again, in the
|
|
23
|
+
builder or over MCP either (it is kept in the document):
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
const includes = (words: string) => container({ class: row, quiet: ['repeated-shape'], children: [tick, text(words)] });
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Only a suggestion's code: a problem is never quieted (`quiet-unknown`).
|
|
30
|
+
|
|
22
31
|
## The long way, and the short one
|
|
23
32
|
|
|
24
33
|
| Written the long way | The short way | Suggestion |
|
|
@@ -26,7 +35,7 @@ reason is usually that the copies are about to diverge. `npx @plitzi/cli explain
|
|
|
26
35
|
| The same header, footer or sidebar in every page | A **layout** holding it once; each page names it (`layout: { id, slot }`) — [layouts](layouts.md) | `repeated-on-pages` |
|
|
27
36
|
| A header copied per page so one link can be styled "active" | The link marks its own page: `states: { current: { … } }` on its class; `activeOn` for an entry lit on several pages | `repeated-on-pages` |
|
|
28
37
|
| The same card, row or tile written again with other words | A **component** with props, placed with `component(id, { props })` — [components](components.md) | `repeated-shape` |
|
|
29
|
-
| Rows of data written one by one, side by side | One `list` over the rows — `items: [ … ]` fixed, or bound to a source — with its row written once — [lists](lists.md). Data is the test: three cards a person rewords on the canvas read better as three cards | `repeated-shape` |
|
|
38
|
+
| Rows of data written one by one, side by side | One `list` over the rows — `items: [ … ]` fixed, or bound to a source — with its row written once — [lists](lists.md). Data is the test: three cards a person rewords on the canvas read better as three cards. Copies that read different sources or write different state keys are controls written alike, and not offered | `repeated-shape` |
|
|
30
39
|
| `button({ children: [text('Save')] })`, `link({ children: [text('Docs'), fontAwesome({ icon })] })` | `button({ content: 'Save' })`, `link({ href, content: 'Docs', icon, iconPlacement: 'after' })` — the icon's class on the `icon` slot; what the text's class adds (`whiteSpace: 'nowrap'`) moves to the box's class — never the class itself, whose `inherit` would then point past the box and whose `pointerEvents: 'none'` would switch the box off | `content-attribute` |
|
|
31
40
|
| `.card:hover { … }`, `.card .icon { … }` in `customCss` | The class's own `states` and `ancestors` | `custom-css-class` |
|
|
32
41
|
| A reduced-motion reset, or rules showing one icon of the theme toggle, in `customCss` | Nothing: the SDK does both for every space | `custom-css-sdk-default` |
|
|
@@ -25,7 +25,7 @@ container([hero, grid]) // an array is the children
|
|
|
25
25
|
| `visible` | a condition (`'src'`, `'!src'`, `{ source, template }`), or `false` to start hidden |
|
|
26
26
|
| `flows` | what happens on click, on submit, on load |
|
|
27
27
|
| `runtime` | `'server'` resolves this provider's data on the server |
|
|
28
|
-
| `loadStrategy` | when the contents mount relative to visibility: `eager`
|
|
28
|
+
| `loadStrategy` | when the contents mount relative to visibility: `eager`, `lazy`, `visible`. Left out, the type decides — `eager`, `lazy` for a modal or a dialog |
|
|
29
29
|
| `children` | the tree |
|
|
30
30
|
| `meta` | `meta.label` is the element's name in the builder's tree |
|
|
31
31
|
|
|
@@ -44,7 +44,7 @@ another link — make the card the link, or the button, not both.
|
|
|
44
44
|
link to the page being shown is `aria-current="page"`; its class's `current` state styles it, so one header in a
|
|
45
45
|
layout lights the right item everywhere.
|
|
46
46
|
|
|
47
|
-
**An element's `id` is not its HTML id.** It reaches the DOM as `data-
|
|
47
|
+
**An element's `id` is not its HTML id.** It reaches the DOM as `data-plitzi-el`. For a section a URL can land on, give it
|
|
48
48
|
an `anchor` (`anchor: 'plans'` → `id="plans"`: lowercase, digits, `-`) and link to it with `hash`:
|
|
49
49
|
`link({ href: 'home', hash: 'plans' })` goes to `/#plans` and scrolls there, waiting for a section that renders once
|
|
50
50
|
its data arrives. One anchor per page, layouts included; never inside a list row or a component, which would repeat
|
|
@@ -78,12 +78,8 @@ A type this SDK does not ship — a plugin — is authored the same way: `define
|
|
|
78
78
|
|
|
79
79
|
**What a type takes, answered by the package** instead of the 7,000-line `.d.ts`: every catalogue is exported, keyed by
|
|
80
80
|
type — `elementCatalog` (what it is for), `elementDefaultAttributes`, `elementAttributeNames`, `elementTriggers`,
|
|
81
|
-
`elementCallbacks`, `elementSlots`, `elementSourceTypes`, `elementLeafTypes`, plus `BUILTIN_TRANSFORMERS
|
|
82
|
-
prints
|
|
83
|
-
|
|
84
|
-
```bash
|
|
85
|
-
node --input-type=module -e "import * as a from '@plitzi/sdk-authoring'; const t = 'formControl'; console.log({ attributes: a.elementDefaultAttributes[t], triggers: a.elementTriggers[t], slots: a.elementSlots[t] })"
|
|
86
|
-
```
|
|
81
|
+
`elementCallbacks`, `elementSlots`, `elementSourceTypes`, `elementLeafTypes`, plus `BUILTIN_TRANSFORMERS` — and
|
|
82
|
+
`npx @plitzi/cli explain formControl` prints them for one type.
|
|
87
83
|
|
|
88
84
|
## CSS
|
|
89
85
|
|
|
@@ -92,7 +88,7 @@ kebab-case or camelCase (`paddingTop`, as a React style object), and a bare numb
|
|
|
92
88
|
`fontWeight: 800`, `opacity: 0.5` stay numbers). A property outside the vocabulary is refused.
|
|
93
89
|
|
|
94
90
|
- `column(gap, extra?)`, `row(gap, extra?)`, `grid(columns, gap, extra?)` for the three layouts every space repeats.
|
|
95
|
-
- **Breakpoints are ranges, not a cascade.** `tablet` is 48–64rem, `mobile`
|
|
91
|
+
- **Breakpoints are ranges, not a cascade.** `tablet` is 48–64rem, `mobile` up to 48rem, and each inherits only from
|
|
96
92
|
`desktop` — a rule for `tablet` never reaches a phone (`authorSpace` warns `tablet-rule-skips-mobile`). A rule for
|
|
97
93
|
everything narrower than a desktop is `compact`, written to both: `css: { desktop: { … }, compact: { 'grid-template-columns':
|
|
98
94
|
'1fr' }, mobile: { gap: '8px' } }` — what `tablet` or `mobile` say for themselves wins.
|
|
@@ -12,7 +12,7 @@ list({ id: 'games', items: 'catalog.data.games', row: r => listItem({ children:
|
|
|
12
12
|
list({ id: 'plans', items: ['Free', 'Pro'], children: [listItem({ children: [text({ from: 'plans.item' })] })] }) // fixed
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
`items` makes a list controlled: an array of its own, or the source its rows come from. `row` is what it renders once
|
|
15
|
+
`items` (or `from`) makes a list controlled: an array of its own, or the source its rows come from. `row` is what it renders once
|
|
16
16
|
per item — a component's id (placed with the row bound to its `item` prop, or its only prop), or a function handed the
|
|
17
17
|
row's names: `r.item` and `r.index` for a binding or `from`, `r.inTemplate.item` for a template
|
|
18
18
|
(`` `/games/{{ ${r.inTemplate.item}.slug }}` ``). A function needs the list's `id`, which names those sources. With
|
|
@@ -7,7 +7,8 @@ of it; each line is something that has shipped broken before.
|
|
|
7
7
|
|
|
8
8
|
- [ ] `authorSpace` returns **zero warnings**. Each one is written code that will not do what it says.
|
|
9
9
|
- [ ] Every **suggestion** is taken, or left for a reason you can say (the copies are about to diverge; three cards a
|
|
10
|
-
person rewords on the canvas)
|
|
10
|
+
person rewords on the canvas) — and then quieted where it is written, `quiet: ['repeated-shape']`, so it stops
|
|
11
|
+
burying the ones that matter. The ones your change opened up first — [efficiency.md](efficiency.md).
|
|
11
12
|
- [ ] No console errors on any page you touched.
|
|
12
13
|
|
|
13
14
|
## Nothing flashes, nothing jumps
|
|
@@ -39,7 +40,8 @@ Screen readers and browser agents (Claude in Chrome) work a page through its acc
|
|
|
39
40
|
a `label` (`hideLabel: true` when the design shows what it is).
|
|
40
41
|
- [ ] Every image has an `alt` saying what it shows, or `decorative: true`.
|
|
41
42
|
- [ ] Every click is on a `button` or a `link`; the whole page can be worked from the keyboard.
|
|
42
|
-
- [ ] A toggle binds `ariaPressed`, a button that opens something `ariaExpanded
|
|
43
|
+
- [ ] A toggle binds `ariaPressed`, a button that opens something `ariaExpanded` and names it with `controls`; words
|
|
44
|
+
that change on their own are in a `live` container.
|
|
43
45
|
- [ ] Headings step down one level at a time; parts of the page a person jumps to are landmarks (`nav`, `main`, a
|
|
44
46
|
labelled `section`).
|
|
45
47
|
- [ ] What a canvas or a plugin draws is also there as elements, with a visible way to reach them.
|
|
@@ -66,6 +68,16 @@ Screen readers and browser agents (Claude in Chrome) work a page through its acc
|
|
|
66
68
|
- [ ] What only an administrator may read is shown only to administrators — and the check is a real boolean, not
|
|
67
69
|
a template left as text.
|
|
68
70
|
|
|
71
|
+
## Nothing left behind
|
|
72
|
+
|
|
73
|
+
- [ ] Nothing you made and no longer use is still there — a page, a component, a class, a token, a data file, an
|
|
74
|
+
import, a plugin folder. No commented-out code, no debug `console.log`, no copy of a file kept "just in case".
|
|
75
|
+
- [ ] Scratch work — a one-off script, a dump, a picture to look at — went in `tmp/` (never committed) and is gone from
|
|
76
|
+
everywhere else.
|
|
77
|
+
- [ ] Nothing secret, and nothing only some visitors may read, is in `public/` or in the space's documents: both reach
|
|
78
|
+
every visitor.
|
|
79
|
+
- [ ] `typecheck`, `lint` and `format` are clean.
|
|
80
|
+
|
|
69
81
|
## Proven
|
|
70
82
|
|
|
71
83
|
- [ ] You looked at it: the visual check ran, on the pages you changed, in both themes.
|
|
@@ -31,8 +31,8 @@ Never rewrite an exported space by hand. `specFromSpace({ schema, style })` read
|
|
|
31
31
|
A server action reads back the same way: `actionSpecFromEntry({ id, document })` gives the `defineAction` spec that writes
|
|
32
32
|
it — only when that spec writes the very same document, otherwise `reason` says why and it stays JSON — and
|
|
33
33
|
`actionToSource(spec)` writes the module. For the whole space as a project — pages, actions, functions, the source of
|
|
34
|
-
its plugins and runtime, its files — use `plitzi create --from <space>` (
|
|
35
|
-
the parts by hand.
|
|
34
|
+
its plugins and runtime, its files — use `plitzi create --from <space>` (then `plitzi pull` to bring the space's changes in, and
|
|
35
|
+
`plitzi push` to put the project's back) rather than exporting the parts by hand.
|
|
36
36
|
|
|
37
37
|
For a document you did not author — an export, a JSON edited by hand — run the gate before serving it:
|
|
38
38
|
`validateSpace({ schema, style })`.
|
|
@@ -6,7 +6,7 @@ Plitzi's templates are Twig, run by Plitzi's own interpreter. What a `{{ … }}`
|
|
|
6
6
|
|
|
7
7
|
| Written in | What is evaluated | What it can read |
|
|
8
8
|
| --- | --- | --- |
|
|
9
|
-
| a binding's `twigTemplate` transformer | the whole template: conditions, filters, `{% set %}`, loops, tests | `source` (the bound value), `sourceTo
|
|
9
|
+
| a binding's `twigTemplate` transformer | the whole template: conditions, filters, `{% set %}`, loops, tests | `source` (the bound value — undefined until it has one: `{{ source|default('…') }}`), `sourceTo` (the attribute's value before it), the globals, the variables by bare name, and every source around the element |
|
|
10
10
|
| a flow step's params | the whole template | the trigger's payload and earlier steps by name, the globals, sources around the element |
|
|
11
11
|
| an ATTRIBUTE (`href`, `src`, `content`…) | only `{{ name }}` tokens (a filter after the name is allowed) | the globals, the variables and the page's route params by bare name (`{{ apiUrl }}`, `{{ slug }}`), and every source around the element (`{{ list_games.item.slug }}`) |
|
|
12
12
|
|
|
@@ -87,7 +87,7 @@ One code on one element; it comes back in `warnings` with the reason, and an ent
|
|
|
87
87
|
|
|
88
88
|
## What to check
|
|
89
89
|
|
|
90
|
-
- **Every viewport and both themes.** Desktop, tablet (48–64rem) and mobile (
|
|
90
|
+
- **Every viewport and both themes.** Desktop, tablet (48–64rem) and mobile (up to 48rem); `colorScheme: 'dark'` as well
|
|
91
91
|
as light.
|
|
92
92
|
- **No horizontal scroll on a phone.** A column child grows to its content unless its parent stretches it; a code
|
|
93
93
|
block or a wide table is what finds out.
|