@plitzi/sdk-server 0.38.2 → 0.38.3
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 +128 -0
- package/README.md +1 -0
- package/dist/core/createServer.devReload.test.d.ts +1 -0
- package/dist/core/http/stages/devReload.d.ts +18 -0
- package/dist/core/http/stages/devReload.js +40 -0
- package/dist/core/server/baseServer.d.ts +2 -0
- package/dist/core/server/baseServer.js +1 -0
- package/dist/core/server/drain.d.ts +2 -2
- package/dist/core/server/drain.js +17 -2
- package/dist/core/server/pageServer.js +8 -2
- package/dist/modules/ssr/prepareRender.js +1 -0
- package/dist/modules/ssr/views/template.ejs +6 -0
- package/package.json +3 -3
- package/skills/plitzi-authoring/CHEATSHEET.md +5 -3
- package/skills/plitzi-authoring/SKILL.md +1 -0
- package/skills/plitzi-authoring/recipes/carousel.ts +14 -9
- package/skills/plitzi-authoring/recipes/detail-page.ts +10 -6
- package/skills/plitzi-authoring/recipes/every-few-seconds.ts +13 -8
- package/skills/plitzi-authoring/recipes/filter-a-list.ts +2 -2
- package/skills/plitzi-authoring/recipes/forms-and-modals.ts +1 -2
- package/skills/plitzi-authoring/recipes/from-tailwind.ts +1 -1
- package/skills/plitzi-authoring/recipes/link-to-a-section.ts +2 -2
- package/skills/plitzi-authoring/recipes/link-with-data.ts +12 -7
- package/skills/plitzi-authoring/recipes/marquee.ts +2 -2
- package/skills/plitzi-authoring/recipes/scroll-a-row.ts +5 -6
- package/skills/plitzi-authoring/recipes/show-data.ts +12 -7
- package/skills/plitzi-authoring/recipes/typed-data.ts +9 -6
- package/skills/plitzi-authoring/recipes/usable-without-sight.ts +2 -2
- package/skills/plitzi-authoring/reference/accessibility.md +2 -2
- package/skills/plitzi-authoring/reference/authoring-errors.md +5 -0
- package/skills/plitzi-authoring/reference/colours-and-motion.md +3 -0
- package/skills/plitzi-authoring/reference/components.md +2 -1
- package/skills/plitzi-authoring/reference/drawing.md +22 -0
- package/skills/plitzi-authoring/reference/elements-and-styles.md +2 -2
- package/skills/plitzi-authoring/reference/lists.md +11 -6
- package/skills/plitzi-authoring/reference/plugins.md +5 -0
- package/skills/plitzi-authoring/reference/structure.md +1 -1
- package/skills/plitzi-authoring/reference/testing.md +9 -4
- package/skills/plitzi-authoring/reference/typed-sources.md +1 -1
- package/skills/plitzi-authoring/reference/validation.md +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,133 @@
|
|
|
1
1
|
# @plitzi/sdk-server
|
|
2
2
|
|
|
3
|
+
## 0.38.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- e047e1d: ## `plitzi upgrade`: a project brought up to its CLI
|
|
8
|
+
|
|
9
|
+
A project written by an older CLI had no way up but `plitzi create` in a scratch folder and a file-by-file comparison:
|
|
10
|
+
`skills update` brought the skills and nothing else, so the `check` and `shot` scripts they teach did not exist.
|
|
11
|
+
`plitzi upgrade` (also `update`) shows what this CLI writes today for what is the CLI's in the project, and `--write`
|
|
12
|
+
makes it. One part or several: `plitzi upgrade skills`, `plitzi upgrade files packages`.
|
|
13
|
+
|
|
14
|
+
- **`files`:** the CLI's machinery — `author.ts`, `main.ts`, the Playwright, TypeScript and lint configs, AGENTS.md. One
|
|
15
|
+
nobody changed since the CLI wrote it is replaced, a missing one added; one the project made its own is shown as a
|
|
16
|
+
diff and left, until `--take <file>` (or `all`). `plitzi create` now records what it wrote, by digest, in
|
|
17
|
+
`.plitzi/scaffold.json`; a project made before that sees every file that differs as its own.
|
|
18
|
+
- **`packages`:** `package.json` merged, never replaced — the scripts and dependencies it lacks added, `@plitzi/*`
|
|
19
|
+
raised to this version, then the install (`--no-install` to leave it). A script of the project's own stays.
|
|
20
|
+
- **`skills`:** `.claude/skills/plitzi-*` from the packages installed, each replaced whole, and the ones a newer CLI
|
|
21
|
+
writes added. `plitzi skills update` is `plitzi upgrade skills --write`.
|
|
22
|
+
- **`renames`:** a name a version renamed with no alias, at its file and line — `canTemplate` → `canSnippet`,
|
|
23
|
+
`authorTemplate` → `authorSnippet` and its kin (renamed only where imported from `@plitzi/sdk-authoring`) — renamed
|
|
24
|
+
with `--write`.
|
|
25
|
+
|
|
26
|
+
`npm run author` says when the skills or the CLI's files are older than the SDK installed, and `plitzi --version`
|
|
27
|
+
prints the CLI's.
|
|
28
|
+
|
|
29
|
+
## Page checks that can be the definition of done
|
|
30
|
+
|
|
31
|
+
`inspectPage`, `plitzi check` and the scaffold's visual test failed pages that were whole, so an agent learnt to ignore
|
|
32
|
+
them:
|
|
33
|
+
|
|
34
|
+
- **A breakpoint hiding an element on purpose** — the desktop navigation on a phone, a bottom bar only a phone shows —
|
|
35
|
+
is no longer "not visible": an element whose `display` or `visibility` a breakpoint rule sets is checked at the width
|
|
36
|
+
it shows at.
|
|
37
|
+
- **A lazy image out of sight** waits whichever side it is out of: below the fold, as before, or beside the screen in a
|
|
38
|
+
carousel's track — cut off by any ancestor that clips, as the browser decides when to fetch it.
|
|
39
|
+
- **A plugin that draws nothing** says so: `drawsNothing: true` in its declaration (a clock that fires a flow), and its
|
|
40
|
+
handle is `boxless`, so no check waits for it on screen.
|
|
41
|
+
|
|
42
|
+
`plitzi check --json` and `PageReport` carry `issues` beside `problems`: each with its `code` (`element-hidden`,
|
|
43
|
+
`image-not-loaded`, `sideways-scroll`…), the `elementId` it is about and, in `check`, the `width`.
|
|
44
|
+
|
|
45
|
+
## Authoring
|
|
46
|
+
- **A link's `content` names it.** `control-without-name` read only a link's `label`, so applying the
|
|
47
|
+
`content-attribute` suggestion as written turned every link into a warning.
|
|
48
|
+
- **`plitzi fix` writes `content-attribute`** where it has one reading: a button's or link's children that are only
|
|
49
|
+
`text(…)` and `fontAwesome({ icon })` become its own `content` and `icon` — the words kept as written, an import left
|
|
50
|
+
unused taken out. A child with an id, options or a class of the space's own stays, and is said. `npm run author`'s
|
|
51
|
+
`[fix]` line counts these too.
|
|
52
|
+
- **The recipes, the skill's examples and the catalog template** hold to the suggestions as well as the warnings, and
|
|
53
|
+
their tests say so: six recipes and the template wrote a link's or a button's words as a child.
|
|
54
|
+
|
|
55
|
+
## Declared motion — in code, in the builder and over MCP
|
|
56
|
+
|
|
57
|
+
An element says how it arrives and whether it keeps moving with `motion`, and the SDK's stylesheet plays it — no
|
|
58
|
+
keyframes to write: `{ enter: 'fade-up', on: 'view' }` arrives as it scrolls into view, `{ enter: 'scale', stagger:
|
|
59
|
+
60 }` brings its children one by one (a grid, a list's rows), `{ loop: 'float' }` keeps it moving. Enters: `fade`,
|
|
60
|
+
`fade-up`, `fade-down`, `slide-left`, `slide-right`, `scale`; loops: `float`, `pulse`, `spin`, `sway`; `duration`,
|
|
61
|
+
`delay`, `stagger` in ms. Opacity and the transforms only — an arrival on `translate`/`scale`, so it composes with an
|
|
62
|
+
element's own `transform` — a loop held until the page is live (`data-hydrated`), and none of it for a visitor who asked
|
|
63
|
+
for less motion. `view` follows the scroll where the browser has scroll timelines and plays on load where not.
|
|
64
|
+
|
|
65
|
+
- **Authoring:** `motion` on any factory; refused where it cannot play (`motion-invalid`, `motion-no-tag`), and carried
|
|
66
|
+
by `specFromSpace` and `compareSpaces`.
|
|
67
|
+
- **Builder:** a **Motion** tab in an element's tools: the presets as chips, a stage that plays the one pointed at (a
|
|
68
|
+
loop shown stronger than the page plays it, and saying so), when and how long, children one by one, and the choices
|
|
69
|
+
read back as a sentence — laid out side by side once the sidebar is wide enough. The canvas holds motion still while
|
|
70
|
+
editing; **▶** in the header (or **Play on the canvas** in the tab) plays it from the start, restarting what already
|
|
71
|
+
played, and an arrival tied to the scroll plays by the clock there, where it is usually in view already.
|
|
72
|
+
- **MCP:** `motion` on `upsertElement` and `patchElement`, checked the same way; the guide names the presets.
|
|
73
|
+
- **Schema:** `definition.motion` (`ElementMotion`, `@plitzi/sdk-shared/schema/motion` — the presets, `motionProblems`,
|
|
74
|
+
`motionAttributes`, `isMotionAnimation`, and each preset's frames, which the stylesheet is tested against), in both
|
|
75
|
+
init queries. **The platform's GraphQL schema has to declare `SpaceElementMotion`
|
|
76
|
+
before this version's builder or SDK queries it.**
|
|
77
|
+
|
|
78
|
+
## Dev tools QA: x-ray and motion
|
|
79
|
+
|
|
80
|
+
The **QA** tab gains an **X-ray**: every element the document wires something to — bound to data, shown on a condition,
|
|
81
|
+
running a flow, moving, behind a flag — outlined in its colour and named on the page, read from the space's document by
|
|
82
|
+
the element's name; its legend counts each kind on the page and picks one to show alone. Beside **Pause**, **Slow**
|
|
83
|
+
plays every animation at a quarter of its speed and **Replay** plays the declared motion again from the start, without
|
|
84
|
+
reloading the page.
|
|
85
|
+
|
|
86
|
+
## Plugins that draw
|
|
87
|
+
|
|
88
|
+
`useCanvas2d`, `useWebGL`, `useWebGL2` and `useAnimationFrame` (`@plitzi/plitzi-sdk`): a canvas sized to the device (at
|
|
89
|
+
most 2×), followed as it resizes, animating only while somebody can see it move — a live page, no reduced motion, the
|
|
90
|
+
tab in front, the canvas on screen — and one still frame otherwise (the builder included). `createShaderProgram`
|
|
91
|
+
compiles and links, and a shader that fails throws a `ShaderError` with the driver's log, printed as
|
|
92
|
+
`[plugin <type> "<id>"] fragment shader failed: …` instead of an empty canvas; `error` and `ready` say where it is.
|
|
93
|
+
`useReducedMotion` for the rest.
|
|
94
|
+
|
|
95
|
+
## A space re-authored without a restart
|
|
96
|
+
|
|
97
|
+
A project's `npm run start:dev` restarts for its server code and its plugins only: a save to the space is re-authored
|
|
98
|
+
in a process of its own and every open page loads again (`server.reloadPages()` over an SSE endpoint, on with
|
|
99
|
+
`createServer({ devReload: true })` — never by `devMode` alone, since every open page holds a connection for it); an
|
|
100
|
+
edit authoring refuses is printed and the page keeps the last space that authored. A shutdown also ends WebSockets at
|
|
101
|
+
once (`1001`, going away) rather than waiting out the grace — the ten seconds a restart used to wait on an open page.
|
|
102
|
+
|
|
103
|
+
## Checks and authoring, from the stripe.com experiment
|
|
104
|
+
- **A component's instance** is found by its own name: its root carries `data-plitzi-instance`, and the instance's
|
|
105
|
+
handle selects it — `inspectPage` no longer reports every named instance as missing.
|
|
106
|
+
- **Hidden at this width on purpose** — the burger on a desktop, the desktop menu on a phone — is listed apart
|
|
107
|
+
(`hiddenAtWidth`, and a dim line under `plitzi check`'s ✓) rather than as a problem.
|
|
108
|
+
- **An element whose every child is conditional** — four flyouts in one list, each opening on the state that names
|
|
109
|
+
it — is conditional too: at rest it shows nothing, and that is it working.
|
|
110
|
+
- **A carousel that scrolls by itself** (`overflow-x: auto`) is not the page scrolling sideways; the page's own pane
|
|
111
|
+
still is.
|
|
112
|
+
- **Templates:** `{{ state.faq ?? -1 }}` — a sign on the right of `??` — reads.
|
|
113
|
+
- **A plugin attribute named as an element field** (`variant`, `class`, `id`…) is warned about
|
|
114
|
+
(`plugin-attribute-reserved`): a factory never hands it to the plugin. A `variant` no class or type style declares is
|
|
115
|
+
`unknown-variant`.
|
|
116
|
+
- **Lists:** a list with `items` renders as the `<ul>` (or `<ol>`) its `subType` says — it was a `<div>` — with no
|
|
117
|
+
markers and the same spacing, so a page looks as it did; the builder offers the list type for it too. Its rows are
|
|
118
|
+
`<li>`s: `list-row-not-li` warns of one that is not — a plain container is made one by `fixSpace`, a link or a button
|
|
119
|
+
is wrapped — and the recipes, the catalog template and the docs write rows as `listItem`.
|
|
120
|
+
- **CSS:** `mask-size`, `mask-position`, `mask-repeat`, `mask-composite`, `-webkit-mask-image`,
|
|
121
|
+
`-webkit-background-clip`, `box-decoration-break` and `-webkit-box-decoration-break`.
|
|
122
|
+
- **`plitzi shot`:** `--clip <element>`, `--scroll-to <element>` and `--viewport`; `--frames` takes the same framing.
|
|
123
|
+
- **SVG from files:** `svgFile` and `svgFiles` (`@plitzi/sdk-authoring/node`) read a logo or a folder of them,
|
|
124
|
+
compacted (`compactSvg`, also on the main entry), instead of strings in the space's source.
|
|
125
|
+
- The plugin scaffolds say that an inline `style` on `RootElement` outranks the element's classes.
|
|
126
|
+
|
|
127
|
+
- Updated dependencies [e047e1d]
|
|
128
|
+
- @plitzi/sdk-shared@0.38.3
|
|
129
|
+
- @plitzi/plitzi-sdk@0.38.3
|
|
130
|
+
|
|
3
131
|
## 0.38.2
|
|
4
132
|
|
|
5
133
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -167,6 +167,7 @@ runtime, and its files into `public/` — a draft, or any published snapshot (`-
|
|
|
167
167
|
| `httpVersion` | `1 \| 2 \| 3` | `2` with `tls`, else `1` | HTTP protocol version. Falls back to the nearest available lower version. |
|
|
168
168
|
| `tls` | `{ key, cert, minVersion? }` | — | TLS key and certificate. Required for versions 2 and 3; optional for version 1. |
|
|
169
169
|
| `devMode` | `boolean` | `false` | Enables development mode: appends `?dev` to esm.sh CDN URLs for React, and activates per-request timing metrics (see [Dev metrics](#dev-metrics)). Off, the process must run with `NODE_ENV=production` or React renders with its development build; the server says so once, at `error`. |
|
|
170
|
+
| `devReload` | `boolean` | `false` | Every page this server renders listens for `server.reloadPages()` and loads again — for a project that re-authors its space on save (what `plitzi create` writes into `main.ts`). Not turned on by `devMode`: each open page holds a connection for it. |
|
|
170
171
|
| `assetVersion` | `string` | — | Cache-buster appended as `?v=<assetVersion>` to all default SDK asset URLs. Compute from file mtime or package version at startup. |
|
|
171
172
|
| `cacheTtlMs` | `number` | `300000` | TTL in milliseconds for the SSR render cache. Set to `0` to disable. |
|
|
172
173
|
| `flags` | `Record<string, boolean> \| ({ spaceId, environment }) => Record<string, boolean>` | — | This deployment's say over the spaces' feature flags: above what a space declares, below the SDK's `flags` prop and a tester's dev tools, and only for flags the space declares. A function answers per space. Server actions and RSC resolve with it too. See `docs/en/feature-flags.md` in the workspace. |
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Stage } from '../types';
|
|
2
|
+
/** Where an open page listens for "the space changed: load again", in development. */
|
|
3
|
+
export declare const DEV_RELOAD_PATH = "/__plitzi/reload";
|
|
4
|
+
/**
|
|
5
|
+
* The page reloading itself when the space it shows changed — the edit loop of a project that authors its space in
|
|
6
|
+
* code, without restarting the server: `main.ts` re-authors in a process of its own and calls `reloadPages()`, and
|
|
7
|
+
* every page open on this server loads again.
|
|
8
|
+
*
|
|
9
|
+
* Only with `devReload` on: every open page keeps a connection for it, which a server that never calls `reloadPages`
|
|
10
|
+
* — a published one, or a platform's — would pay for and get nothing from. The template listens only then too.
|
|
11
|
+
*/
|
|
12
|
+
export declare const createDevReload: () => {
|
|
13
|
+
stage: Stage;
|
|
14
|
+
/** Every page open on this server loads again. */
|
|
15
|
+
reload: () => void;
|
|
16
|
+
/** The streams ended — the server is going away, and each page reconnects to whichever comes up next. */
|
|
17
|
+
close: () => void;
|
|
18
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { openEventStream } from "../sse.js";
|
|
2
|
+
//#region src/core/http/stages/devReload.ts
|
|
3
|
+
/** Where an open page listens for "the space changed: load again", in development. */
|
|
4
|
+
var DEV_RELOAD_PATH = "/__plitzi/reload";
|
|
5
|
+
/**
|
|
6
|
+
* The page reloading itself when the space it shows changed — the edit loop of a project that authors its space in
|
|
7
|
+
* code, without restarting the server: `main.ts` re-authors in a process of its own and calls `reloadPages()`, and
|
|
8
|
+
* every page open on this server loads again.
|
|
9
|
+
*
|
|
10
|
+
* Only with `devReload` on: every open page keeps a connection for it, which a server that never calls `reloadPages`
|
|
11
|
+
* — a published one, or a platform's — would pay for and get nothing from. The template listens only then too.
|
|
12
|
+
*/
|
|
13
|
+
var createDevReload = () => {
|
|
14
|
+
const listening = /* @__PURE__ */ new Set();
|
|
15
|
+
const stage = (ctx) => {
|
|
16
|
+
if (!ctx.config.devReload || ctx.req.path !== "/__plitzi/reload") return Promise.resolve(false);
|
|
17
|
+
const stream = openEventStream(ctx.rawRes, {
|
|
18
|
+
onAbort: () => listening.delete(stream),
|
|
19
|
+
retryMs: 1e3
|
|
20
|
+
});
|
|
21
|
+
listening.add(stream);
|
|
22
|
+
ctx.raw.once("close", () => {
|
|
23
|
+
listening.delete(stream);
|
|
24
|
+
stream.close();
|
|
25
|
+
});
|
|
26
|
+
return Promise.resolve(true);
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
stage,
|
|
30
|
+
/** Every page open on this server loads again. */
|
|
31
|
+
reload: () => listening.forEach((stream) => stream.send("reload", {})),
|
|
32
|
+
/** The streams ended — the server is going away, and each page reconnects to whichever comes up next. */
|
|
33
|
+
close: () => {
|
|
34
|
+
listening.forEach((stream) => stream.close());
|
|
35
|
+
listening.clear();
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
//#endregion
|
|
40
|
+
export { DEV_RELOAD_PATH, createDevReload };
|
|
@@ -18,5 +18,7 @@ export interface HttpServerParts {
|
|
|
18
18
|
/** What the primary does once, before it starts the workers — work each of them would otherwise repeat, or race
|
|
19
19
|
* the others to do. */
|
|
20
20
|
beforeFork?: () => Promise<void>;
|
|
21
|
+
/** The open pages told to load again (`SSRServer.reloadPages`) — a page server in `devMode`; nothing elsewhere. */
|
|
22
|
+
reloadPages?: () => void;
|
|
21
23
|
}
|
|
22
24
|
export declare const createHttpServer: (config: SSRServerConfig, makeHandlerForPort: (port: number) => Handler, parts: HttpServerParts) => SSRServer;
|
|
@@ -34,6 +34,7 @@ var createHttpServer = (config, makeHandlerForPort, parts) => {
|
|
|
34
34
|
return {
|
|
35
35
|
cache: channel && parts.cache ? relayCache(parts.cache, `${channel}.cache`) : parts.cache,
|
|
36
36
|
plugins: channel && parts.forgetPlugins ? relayPlugins(parts.plugins, parts.forgetPlugins, `${channel}.plugins`, label) : parts.plugins,
|
|
37
|
+
reloadPages: parts.reloadPages ?? (() => void 0),
|
|
37
38
|
listen(port, host = "0.0.0.0") {
|
|
38
39
|
if (plan.requested !== void 0 && role !== "worker") serverLog.warn(label, `${plan.requested} workers asked for, ${plan.count} cores to run them on: starting ${plan.count}`);
|
|
39
40
|
if (role === "primary") {
|
|
@@ -10,8 +10,8 @@ export declare const SHUTDOWN_GRACE_MS = 10000;
|
|
|
10
10
|
* Node's `server.close()` stops taking connections and then waits for every open one to end. A request ends; a
|
|
11
11
|
* WebSocket or an event stream does not — a board open in a tab, an agent's app listening — so a server with one open
|
|
12
12
|
* waited for ever, and a Ctrl+C or a SIGTERM hung until something killed it. `drain` stops taking connections, closes
|
|
13
|
-
* the idle ones, ends every event stream at once (
|
|
14
|
-
* answered `graceMs`, and then cuts what remains.
|
|
13
|
+
* the idle ones, ends every event stream and WebSocket at once (neither has anything left to finish), gives what is
|
|
14
|
+
* still being answered `graceMs`, and then cuts what remains.
|
|
15
15
|
*/
|
|
16
16
|
export declare const watchConnections: (server: PrimaryServer) => {
|
|
17
17
|
drain: (label: string, graceMs?: number) => Promise<void>;
|
|
@@ -5,6 +5,13 @@ import { serverLog } from "../../helpers/serverLog.js";
|
|
|
5
5
|
* called a moment ago. Well under the 30 seconds a cluster gives a pod between SIGTERM and SIGKILL.
|
|
6
6
|
*/
|
|
7
7
|
var SHUTDOWN_GRACE_MS = 1e4;
|
|
8
|
+
/** A WebSocket close frame, as a server sends it (unmasked): status 1001, "going away". */
|
|
9
|
+
var GOING_AWAY = Buffer.from([
|
|
10
|
+
136,
|
|
11
|
+
2,
|
|
12
|
+
3,
|
|
13
|
+
233
|
|
14
|
+
]);
|
|
8
15
|
/**
|
|
9
16
|
* Whether a response is an event stream: said by its content type — or, since headers written with `writeHead(status,
|
|
10
17
|
* headers)` are not kept where `getHeader` looks, by what it answers: a GET that asked for one, as an EventSource and
|
|
@@ -21,8 +28,8 @@ var isEventStream = (asking, answering) => {
|
|
|
21
28
|
* Node's `server.close()` stops taking connections and then waits for every open one to end. A request ends; a
|
|
22
29
|
* WebSocket or an event stream does not — a board open in a tab, an agent's app listening — so a server with one open
|
|
23
30
|
* waited for ever, and a Ctrl+C or a SIGTERM hung until something killed it. `drain` stops taking connections, closes
|
|
24
|
-
* the idle ones, ends every event stream at once (
|
|
25
|
-
* answered `graceMs`, and then cuts what remains.
|
|
31
|
+
* the idle ones, ends every event stream and WebSocket at once (neither has anything left to finish), gives what is
|
|
32
|
+
* still being answered `graceMs`, and then cuts what remains.
|
|
26
33
|
*/
|
|
27
34
|
var watchConnections = (server) => {
|
|
28
35
|
const sockets = /* @__PURE__ */ new Set();
|
|
@@ -35,6 +42,13 @@ var watchConnections = (server) => {
|
|
|
35
42
|
sockets.add(socket);
|
|
36
43
|
socket.once("close", () => sockets.delete(socket));
|
|
37
44
|
});
|
|
45
|
+
const websockets = /* @__PURE__ */ new Set();
|
|
46
|
+
server.on("upgrade", (request, socket) => {
|
|
47
|
+
if ([request.headers.upgrade].flat().some((value) => value?.toLowerCase() === "websocket")) {
|
|
48
|
+
websockets.add(socket);
|
|
49
|
+
socket.once("close", () => websockets.delete(socket));
|
|
50
|
+
}
|
|
51
|
+
});
|
|
38
52
|
server.on("request", (request, response) => {
|
|
39
53
|
answering.set(response, request);
|
|
40
54
|
response.once("close", () => {
|
|
@@ -61,6 +75,7 @@ var watchConnections = (server) => {
|
|
|
61
75
|
answering.forEach((request, response) => {
|
|
62
76
|
if (isEventStream(request, response)) response.end(closeIdle);
|
|
63
77
|
});
|
|
78
|
+
websockets.forEach((socket) => socket.end(GOING_AWAY));
|
|
64
79
|
}) };
|
|
65
80
|
};
|
|
66
81
|
//#endregion
|
|
@@ -14,6 +14,7 @@ import { DRAFT_STORE_METHODS, createMemoryDraftStore } from "../../modules/ssr/p
|
|
|
14
14
|
import { compileTemplate } from "../../modules/ssr/template.js";
|
|
15
15
|
import { PluginManager } from "../../plugins/manager.js";
|
|
16
16
|
import { makeHandler } from "../http/dispatcher.js";
|
|
17
|
+
import { createDevReload } from "../http/stages/devReload.js";
|
|
17
18
|
import { buildPagePipeline } from "../services/registry.js";
|
|
18
19
|
//#region src/core/server/pageServer.ts
|
|
19
20
|
/** The page-serving machinery: html/rsc caches, the render template and the plugin manager, driving the page
|
|
@@ -49,7 +50,8 @@ var createPageServer = (config, services, extensions) => {
|
|
|
49
50
|
};
|
|
50
51
|
const actions = actionsModuleFor(config);
|
|
51
52
|
const realtime = realtimeModuleFor(config);
|
|
52
|
-
const
|
|
53
|
+
const devReload = config.devReload ? createDevReload() : void 0;
|
|
54
|
+
const stages = [...devReload ? [devReload.stage] : [], ...buildPagePipeline(services, extensions)];
|
|
53
55
|
const makeHandlerForPort = (port) => {
|
|
54
56
|
const buildContext = (raw, rawRes, req, res) => ({
|
|
55
57
|
raw,
|
|
@@ -81,7 +83,11 @@ var createPageServer = (config, services, extensions) => {
|
|
|
81
83
|
pluginManager.forget(name, version);
|
|
82
84
|
invalidatePluginComponentCache();
|
|
83
85
|
},
|
|
84
|
-
onClosing: () =>
|
|
86
|
+
onClosing: () => {
|
|
87
|
+
realtime?.close();
|
|
88
|
+
devReload?.close();
|
|
89
|
+
},
|
|
90
|
+
...devReload ? { reloadPages: devReload.reload } : {},
|
|
85
91
|
onDestroy: async () => {
|
|
86
92
|
await actions?.jobs?.stop();
|
|
87
93
|
destroyServerCaches(caches);
|
|
@@ -228,6 +228,7 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
|
|
|
228
228
|
plugins: templatePlugins,
|
|
229
229
|
debugMode: debugAuthorized,
|
|
230
230
|
ssrOnly: config.ssrOnly === true,
|
|
231
|
+
devReload: config.devReload === true,
|
|
231
232
|
offlineData: offlineDataStr
|
|
232
233
|
},
|
|
233
234
|
cacheable: actionRuns === void 0 && forcedFlags === void 0
|
|
@@ -139,5 +139,11 @@
|
|
|
139
139
|
render('plitzi', __plitziParams, __plitziPlugins, <%= locals.debugMode ? true : false %>, true);
|
|
140
140
|
</script>
|
|
141
141
|
<% } %>
|
|
142
|
+
<% if (locals.devReload) { %>
|
|
143
|
+
<%# Development only: the server says the space changed (re-authored, `reloadPages`), and the page loads again. %>
|
|
144
|
+
<script>
|
|
145
|
+
new EventSource('/__plitzi/reload').addEventListener('reload', () => location.reload());
|
|
146
|
+
</script>
|
|
147
|
+
<% } %>
|
|
142
148
|
</body>
|
|
143
149
|
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plitzi/sdk-server",
|
|
3
|
-
"version": "0.38.
|
|
3
|
+
"version": "0.38.3",
|
|
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.3",
|
|
83
|
+
"@plitzi/sdk-shared": "0.38.3",
|
|
84
84
|
"ejs": "^6.0.1",
|
|
85
85
|
"esbuild": "^0.28.2",
|
|
86
86
|
"jsonwebtoken": "^9.0.3",
|
|
@@ -31,15 +31,17 @@ text('Words', { class }) // also text({ content }); a <div
|
|
|
31
31
|
heading('Title', { subType: 'h2' }) // h1–h6; holds no children
|
|
32
32
|
paragraph('A paragraph.')
|
|
33
33
|
container([children], { subType: 'section' }) // div, section, nav, header, footer, main, article, li, h1–h6, span…
|
|
34
|
-
link({ href: 'about',
|
|
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 scrolls in; stagger, loop too
|
|
36
37
|
button({ content: 'Save', flows }) // holds children too: then content: ''
|
|
37
38
|
image({ src, alt }) // or decorative: true
|
|
38
39
|
embed({ src, title }) // a map, a player: another page in a frame
|
|
39
40
|
svg('<svg …>…</svg>', { label }) // checked, sanitised; currentColor follows the class
|
|
41
|
+
svg(svgFile(new URL('./logo.svg', import.meta.url))) // a file, compacted: svgFile/svgFiles from '@plitzi/sdk-authoring/node'
|
|
40
42
|
fontAwesome({ icon: 'fa-solid fa-xmark' })
|
|
41
|
-
list({ id: 'rows', items: 'p.data.rows', row: 'row-card' }) // a <ul>; a component per row
|
|
42
|
-
list({ id: 'rows', items: 'p.data.rows', row: r => text({ from: `${r.item}.title` }) })
|
|
43
|
+
list({ id: 'rows', items: 'p.data.rows', row: 'row-card' }) // a <ul>; a component per row, its root an <li>
|
|
44
|
+
list({ id: 'rows', items: 'p.data.rows', row: r => listItem({ children: [text({ from: `${r.item}.title` })] }) })
|
|
43
45
|
carousel({ id: 'hero', items: 'p.data.slides', autoplay: 5000, row: 'slide-card', children: [/* carouselNext('hero') arrows */] })
|
|
44
46
|
// rows follow their item's `id`, or `itemKey: 'slug'`
|
|
45
47
|
apiContainer({ id: 'p', query: '/data/x.json', cache: true, children }) // publishes p.data; no box of its own
|
|
@@ -158,6 +158,7 @@ Each is a whole file that authors with no warning — CI holds it to that. Copy
|
|
|
158
158
|
| [flows.md](reference/flows.md) | Clicks, submits, page loads, every few seconds, server actions, modals, state |
|
|
159
159
|
| [realtime.md](reference/realtime.md) | Pages that see each other: channels, presence, cursors, a shared board, who may hear a topic |
|
|
160
160
|
| [plugins.md](reference/plugins.md) | A component of your own: props, binding them, writing state, channels, registering, behaving in the builder |
|
|
161
|
+
| [drawing.md](reference/drawing.md) | A plugin that draws or animates: a canvas sized to the device, WebGL shaders that say why they failed, a loop that stops when nobody sees it |
|
|
161
162
|
| [structure.md](reference/structure.md) | A space bigger than one screen: files, helpers, naming, keeping it short |
|
|
162
163
|
| [testing.md](reference/testing.md) | Any test: `inspectPage` (one call, every problem), handles, fixtures, catching a flash from the first frame, shortcuts, counting renders |
|
|
163
164
|
| [performance.md](reference/performance.md) | A page with many elements, a busy flow, something that feels slow: what renders, what it costs, how to measure it |
|
|
@@ -16,7 +16,8 @@ import {
|
|
|
16
16
|
list,
|
|
17
17
|
onClick,
|
|
18
18
|
styles,
|
|
19
|
-
text
|
|
19
|
+
text,
|
|
20
|
+
listItem
|
|
20
21
|
} from '@plitzi/sdk-authoring';
|
|
21
22
|
|
|
22
23
|
import type { SpaceSpec } from '@plitzi/sdk-authoring';
|
|
@@ -74,14 +75,18 @@ export const recipe: SpaceSpec = {
|
|
|
74
75
|
class: dots,
|
|
75
76
|
items: 'hero.items',
|
|
76
77
|
row: r =>
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
78
|
+
listItem({
|
|
79
|
+
children: [
|
|
80
|
+
button({
|
|
81
|
+
content: '',
|
|
82
|
+
class: dot,
|
|
83
|
+
bind: [
|
|
84
|
+
bindTemplate('title', r.index, 'Slide {{ source + 1 }}'),
|
|
85
|
+
activeWhen(dot, `{{ ${r.inTemplate.index} == carousel_hero.index }}`)
|
|
86
|
+
],
|
|
87
|
+
flows: [[onClick(), carouselGoTo('hero', `{{ ${r.inTemplate.index} }}`)]]
|
|
88
|
+
})
|
|
89
|
+
]
|
|
85
90
|
})
|
|
86
91
|
})
|
|
87
92
|
]
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* A page per record: a card links to `/products/<slug>`, and the detail page narrows the same file to the product its
|
|
4
4
|
* address names — with a message, not an empty page, when no product has that name.
|
|
5
5
|
*/
|
|
6
|
-
import { apiContainer, heading, link, list, text } from '@plitzi/sdk-authoring';
|
|
6
|
+
import { apiContainer, heading, link, list, text, listItem } from '@plitzi/sdk-authoring';
|
|
7
7
|
|
|
8
8
|
import type { SpaceSpec } from '@plitzi/sdk-authoring';
|
|
9
9
|
|
|
@@ -25,10 +25,14 @@ export const recipe: SpaceSpec = {
|
|
|
25
25
|
id: 'products',
|
|
26
26
|
items: 'catalog.data.products',
|
|
27
27
|
children: [
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
listItem({
|
|
29
|
+
children: [
|
|
30
|
+
// An attribute reads a name with filters, spelled in full.
|
|
31
|
+
link({
|
|
32
|
+
href: '/products/{{ list_products.item.slug|url_encode }}',
|
|
33
|
+
children: [text({ from: 'products.item.title' })]
|
|
34
|
+
})
|
|
35
|
+
]
|
|
32
36
|
})
|
|
33
37
|
]
|
|
34
38
|
})
|
|
@@ -54,7 +58,7 @@ export const recipe: SpaceSpec = {
|
|
|
54
58
|
source: 'catalog-one.data.products',
|
|
55
59
|
template: "{{ source|find('slug', navigation.routeParams.slug) }}"
|
|
56
60
|
},
|
|
57
|
-
children: [heading({ subType: 'h1', from: 'this-product.item.title' })]
|
|
61
|
+
children: [listItem({ children: [heading({ subType: 'h1', from: 'this-product.item.title' })] })]
|
|
58
62
|
}),
|
|
59
63
|
text('No product by that name.', {
|
|
60
64
|
visible: {
|
|
@@ -18,7 +18,8 @@ import {
|
|
|
18
18
|
styles,
|
|
19
19
|
text,
|
|
20
20
|
activeWhen,
|
|
21
|
-
when
|
|
21
|
+
when,
|
|
22
|
+
listItem
|
|
22
23
|
} from '@plitzi/sdk-authoring';
|
|
23
24
|
|
|
24
25
|
import type { SpaceSpec } from '@plitzi/sdk-authoring';
|
|
@@ -81,7 +82,7 @@ export const recipe: SpaceSpec = {
|
|
|
81
82
|
id: 'slides',
|
|
82
83
|
items: SLIDES,
|
|
83
84
|
children: [
|
|
84
|
-
|
|
85
|
+
listItem({
|
|
85
86
|
class: slide,
|
|
86
87
|
visible: { source: 'slides.index', template: '{{ source == (state.slide ?? 0) }}' },
|
|
87
88
|
children: [heading({ subType: 'h2', from: 'slides.item.title' }), text({ from: 'slides.item.body' })]
|
|
@@ -101,12 +102,16 @@ export const recipe: SpaceSpec = {
|
|
|
101
102
|
class: dots,
|
|
102
103
|
items: SLIDES,
|
|
103
104
|
children: [
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
105
|
+
listItem({
|
|
106
|
+
children: [
|
|
107
|
+
button({
|
|
108
|
+
content: '',
|
|
109
|
+
title: 'Show this offer',
|
|
110
|
+
class: dot,
|
|
111
|
+
bind: [activeWhen(dot, '{{ list_dots.index == (state.slide ?? 0) }}')],
|
|
112
|
+
flows: [[onClick(), setState({ key: 'slide', type: 'number', value: '{{ list_dots.index }}' })]]
|
|
113
|
+
})
|
|
114
|
+
]
|
|
110
115
|
})
|
|
111
116
|
]
|
|
112
117
|
}),
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Filter and sort a list: a select writes the chosen category to `state`, and the list is bound through a template
|
|
4
4
|
* that hands over its VALUE — only the matching rows are rendered, with a count and an empty state over the same rows.
|
|
5
5
|
*/
|
|
6
|
-
import { apiContainer, formControl, list, named, on, setState, text } from '@plitzi/sdk-authoring';
|
|
6
|
+
import { apiContainer, formControl, list, named, on, setState, text, listItem } from '@plitzi/sdk-authoring';
|
|
7
7
|
|
|
8
8
|
import type { SpaceSpec } from '@plitzi/sdk-authoring';
|
|
9
9
|
|
|
@@ -47,7 +47,7 @@ export const recipe: SpaceSpec = {
|
|
|
47
47
|
id: 'shown',
|
|
48
48
|
from: 'catalog.data.products',
|
|
49
49
|
as: `{{ ${shown} }}`,
|
|
50
|
-
children: [text({ from: 'shown.item.title' })]
|
|
50
|
+
children: [listItem({ children: [text({ from: 'shown.item.title' })] })]
|
|
51
51
|
}),
|
|
52
52
|
text('Nothing in that category.', {
|
|
53
53
|
visible: {
|
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
import {
|
|
6
6
|
button,
|
|
7
7
|
closeModal,
|
|
8
|
-
fontAwesome,
|
|
9
8
|
form,
|
|
10
9
|
formControl,
|
|
11
10
|
modalContainer,
|
|
@@ -52,7 +51,7 @@ export const recipe: SpaceSpec = {
|
|
|
52
51
|
button({
|
|
53
52
|
content: '',
|
|
54
53
|
title: 'Close',
|
|
55
|
-
|
|
54
|
+
icon: 'fa-solid fa-xmark',
|
|
56
55
|
flows: [[onClick(), closeModal('details')]]
|
|
57
56
|
})
|
|
58
57
|
]
|
|
@@ -45,7 +45,7 @@ export const recipe: SpaceSpec = {
|
|
|
45
45
|
container({ class: card, children: [heading(name, { subType: 'h3', class: title }), text('…')] })
|
|
46
46
|
)
|
|
47
47
|
}),
|
|
48
|
-
link({ href: '/start', class: cta,
|
|
48
|
+
link({ href: '/start', class: cta, content: 'Get started' })
|
|
49
49
|
]
|
|
50
50
|
}
|
|
51
51
|
]
|
|
@@ -15,7 +15,7 @@ export const recipe: SpaceSpec = {
|
|
|
15
15
|
name: 'Home',
|
|
16
16
|
slug: '',
|
|
17
17
|
body: [
|
|
18
|
-
link({ href: 'home', hash: 'plans',
|
|
18
|
+
link({ href: 'home', hash: 'plans', content: 'See the plans' }),
|
|
19
19
|
container({
|
|
20
20
|
id: 'plans-section',
|
|
21
21
|
subType: 'section',
|
|
@@ -29,7 +29,7 @@ export const recipe: SpaceSpec = {
|
|
|
29
29
|
name: 'About',
|
|
30
30
|
slug: 'about',
|
|
31
31
|
// → /#plans
|
|
32
|
-
body: [link({ href: 'home', hash: 'plans',
|
|
32
|
+
body: [link({ href: 'home', hash: 'plans', content: 'Plans' })]
|
|
33
33
|
}
|
|
34
34
|
]
|
|
35
35
|
};
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* A link built from the row it is in — here "ask about this product" on WhatsApp, with the product's name in the
|
|
3
3
|
* message. An attribute resolves a name with filters, so the text is encoded with `url_encode`.
|
|
4
4
|
*/
|
|
5
|
-
import { apiContainer,
|
|
5
|
+
import { apiContainer, link, list, listItem } from '@plitzi/sdk-authoring';
|
|
6
6
|
|
|
7
7
|
import type { SpaceSpec } from '@plitzi/sdk-authoring';
|
|
8
8
|
|
|
@@ -24,12 +24,17 @@ export const recipe: SpaceSpec = {
|
|
|
24
24
|
id: 'products',
|
|
25
25
|
items: 'catalog.data.products',
|
|
26
26
|
children: [
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
listItem({
|
|
28
|
+
children: [
|
|
29
|
+
link({
|
|
30
|
+
target: 'blank',
|
|
31
|
+
href: 'https://wa.me/34600000000?text={{ list_products.item.title|url_encode }}',
|
|
32
|
+
icon: 'fa-brands fa-whatsapp',
|
|
33
|
+
content: 'Ask about it'
|
|
34
|
+
}),
|
|
35
|
+
link({ href: 'mailto:shop@example.com', content: 'Write to us' })
|
|
36
|
+
]
|
|
37
|
+
})
|
|
33
38
|
]
|
|
34
39
|
})
|
|
35
40
|
]
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* A marquee: a row of names that scrolls for ever and stops under the pointer. The items are rendered three times
|
|
3
3
|
* and the keyframe moves the track by a third, so the end meets the start with no jump.
|
|
4
4
|
*/
|
|
5
|
-
import { apiContainer, container, list, styles, text } from '@plitzi/sdk-authoring';
|
|
5
|
+
import { apiContainer, container, list, styles, text, listItem } from '@plitzi/sdk-authoring';
|
|
6
6
|
|
|
7
7
|
import type { SpaceSpec } from '@plitzi/sdk-authoring';
|
|
8
8
|
|
|
@@ -45,7 +45,7 @@ export const recipe: SpaceSpec = {
|
|
|
45
45
|
class: track,
|
|
46
46
|
from: 'catalog.data.brands',
|
|
47
47
|
as: '{{ source|merge(source)|merge(source) }}',
|
|
48
|
-
children: [text({ from: 'brands.item' })]
|
|
48
|
+
children: [listItem({ children: [text({ from: 'brands.item' })] })]
|
|
49
49
|
})
|
|
50
50
|
]
|
|
51
51
|
})
|
|
@@ -12,7 +12,8 @@ import {
|
|
|
12
12
|
scrollBy,
|
|
13
13
|
setState,
|
|
14
14
|
styles,
|
|
15
|
-
text
|
|
15
|
+
text,
|
|
16
|
+
listItem
|
|
16
17
|
} from '@plitzi/sdk-authoring';
|
|
17
18
|
|
|
18
19
|
import type { SpaceSpec } from '@plitzi/sdk-authoring';
|
|
@@ -45,10 +46,9 @@ export const recipe: SpaceSpec = {
|
|
|
45
46
|
children: [
|
|
46
47
|
// Shown until the row says it is at that end: the arrows are there before anybody scrolls.
|
|
47
48
|
button({
|
|
48
|
-
content: '',
|
|
49
|
+
content: '‹',
|
|
49
50
|
title: 'Previous categories',
|
|
50
51
|
visible: '!state.rowAtStart',
|
|
51
|
-
children: [text('‹')],
|
|
52
52
|
flows: [[onClick(), scrollBy('categories', { x: '-80%' })]]
|
|
53
53
|
}),
|
|
54
54
|
list({
|
|
@@ -62,13 +62,12 @@ export const recipe: SpaceSpec = {
|
|
|
62
62
|
setState({ key: 'rowAtEnd', type: 'boolean', value: '{{ moved.atEnd }}' })
|
|
63
63
|
]
|
|
64
64
|
],
|
|
65
|
-
children: [
|
|
65
|
+
children: [listItem({ class: card, children: [text({ from: 'categories.item' })] })]
|
|
66
66
|
}),
|
|
67
67
|
button({
|
|
68
|
-
content: '',
|
|
68
|
+
content: '›',
|
|
69
69
|
title: 'More categories',
|
|
70
70
|
visible: '!state.rowAtEnd',
|
|
71
|
-
children: [text('›')],
|
|
72
71
|
flows: [[onClick(), scrollBy('categories', { x: '80%' })]]
|
|
73
72
|
})
|
|
74
73
|
]
|
|
@@ -3,18 +3,17 @@
|
|
|
3
3
|
* Show data with no backend: a JSON file the project serves (`public/data/products.json`), read by a provider — one
|
|
4
4
|
* card per product, a skeleton while it loads, a count, a computed value, and an empty state that never flashes.
|
|
5
5
|
*/
|
|
6
|
-
import { apiContainer, container, heading, list, styles, text } from '@plitzi/sdk-authoring';
|
|
6
|
+
import { apiContainer, container, heading, list, styles, text, listItem } from '@plitzi/sdk-authoring';
|
|
7
7
|
|
|
8
8
|
import type { SpaceSpec } from '@plitzi/sdk-authoring';
|
|
9
9
|
|
|
10
|
-
// A list is a `<ul
|
|
10
|
+
// A list is a `<ul>`, its rows `<li>`s straight inside: the grid is all its class says, the indent taken off.
|
|
11
11
|
const grid = styles('product-grid', {
|
|
12
12
|
display: 'grid',
|
|
13
13
|
'grid-template-columns': 'repeat(auto-fill, minmax(220px, 1fr))',
|
|
14
14
|
gap: '16px',
|
|
15
15
|
margin: '0px',
|
|
16
|
-
padding: '0px'
|
|
17
|
-
'list-style-type': 'none'
|
|
16
|
+
padding: '0px'
|
|
18
17
|
});
|
|
19
18
|
|
|
20
19
|
const card = styles('product-card', { padding: '16px', 'border-radius': '12px', 'background-color': 'var(--card)' });
|
|
@@ -56,9 +55,15 @@ export const recipe: SpaceSpec = {
|
|
|
56
55
|
class: grid,
|
|
57
56
|
items: 'catalog.data.products',
|
|
58
57
|
children: [
|
|
59
|
-
// Inside a row, a binding names the row short (`products.item`), a template in
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
// One `<li>` per product. Inside a row, a binding names the row short (`products.item`), a template in
|
|
59
|
+
// full (`list_products`).
|
|
60
|
+
listItem({
|
|
61
|
+
class: card,
|
|
62
|
+
children: [
|
|
63
|
+
heading({ subType: 'h3', from: 'products.item.title' }),
|
|
64
|
+
text({ from: 'products.item.price', as: 'price' })
|
|
65
|
+
]
|
|
66
|
+
})
|
|
62
67
|
]
|
|
63
68
|
}),
|
|
64
69
|
// Revealed by the data, so it starts hidden: nothing flashes while the file loads.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* `site.data.hero.titel` is a type error, not an empty heading. In a project the sample is the file itself:
|
|
5
5
|
* `import home from '../../public/data/home.json' with { type: 'json' }`.
|
|
6
6
|
*/
|
|
7
|
-
import { apiContainer, heading, list, source, styles, text, twig } from '@plitzi/sdk-authoring';
|
|
7
|
+
import { apiContainer, heading, list, source, styles, text, twig, listItem } from '@plitzi/sdk-authoring';
|
|
8
8
|
|
|
9
9
|
import type { SpaceSpec } from '@plitzi/sdk-authoring';
|
|
10
10
|
|
|
@@ -50,11 +50,14 @@ export const recipe: SpaceSpec = {
|
|
|
50
50
|
class: grid,
|
|
51
51
|
items: site.data.products,
|
|
52
52
|
// The row's item is typed as the sample's: every field any product has.
|
|
53
|
-
row: product =>
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
53
|
+
row: product =>
|
|
54
|
+
listItem({
|
|
55
|
+
children: [
|
|
56
|
+
text({ from: product.item.title }),
|
|
57
|
+
text({ from: product.item.price, as: 'price' }),
|
|
58
|
+
text({ from: product.item.badge, visible: product.item.badge })
|
|
59
|
+
]
|
|
60
|
+
})
|
|
58
61
|
})
|
|
59
62
|
]
|
|
60
63
|
})
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* button, a field keeps its label out of sight rather than dropping it, a picture says what it shows or that it only
|
|
4
4
|
* decorates.
|
|
5
5
|
*/
|
|
6
|
-
import { button,
|
|
6
|
+
import { button, formControl, image, setState, styles, text, onClick } from '@plitzi/sdk-authoring';
|
|
7
7
|
|
|
8
8
|
import type { SpaceSpec } from '@plitzi/sdk-authoring';
|
|
9
9
|
|
|
@@ -23,7 +23,7 @@ export const recipe: SpaceSpec = {
|
|
|
23
23
|
name: 'Home',
|
|
24
24
|
slug: '',
|
|
25
25
|
body: [
|
|
26
|
-
button({ content: '', title: 'Search',
|
|
26
|
+
button({ content: '', title: 'Search', icon: 'fa-solid fa-magnifying-glass' }),
|
|
27
27
|
button({
|
|
28
28
|
content: '',
|
|
29
29
|
class: card,
|
|
@@ -13,10 +13,10 @@ warns about each case below (`control-without-name`, `image-without-alt`, `click
|
|
|
13
13
|
button({ content: 'Save' })
|
|
14
14
|
|
|
15
15
|
// An icon only: `title` is its name, and also its tooltip. The icon is hidden from the tree by itself.
|
|
16
|
-
button({ content: '', title: 'Close',
|
|
16
|
+
button({ content: '', title: 'Close', icon: 'fa-solid fa-xmark' })
|
|
17
17
|
|
|
18
18
|
// Words that do not say what it does — a key hint, a count: `label` is read in their place.
|
|
19
|
-
button({ content: '', label: 'Select (V)', title: 'Select — V',
|
|
19
|
+
button({ content: 'V', label: 'Select (V)', title: 'Select — V', icon: 'fa-solid fa-arrow-pointer' })
|
|
20
20
|
|
|
21
21
|
// A link around a whole card: without `label` it is announced as every word inside the card.
|
|
22
22
|
link({ href: '/posts/field-notes', label: 'Read “Field notes”', children: [ /* cover, topic, title, byline */ ] })
|
|
@@ -92,6 +92,8 @@ The space is not written until these are fixed.
|
|
|
92
92
|
| `loading-slot-unknown` | an `apiContainer`'s `loadingSlot` that is not the id of one of its children | the id of the child to show until the first answer — `loadingSlot: 'catalog-skeleton'` beside that child |
|
|
93
93
|
| `modifier-count` | more than one set of rules in a class list | one set, after the classes: `class: [card, { opacity: '0.5', 'margin-top': '8px' }]` |
|
|
94
94
|
| `modifier-without-id` | rules on top of a class on an element with no `id` — the id names the class they become | `id: 'hero-bg'` |
|
|
95
|
+
| `motion-invalid` | a `motion` with a preset, a trigger or a timing the page cannot play | `motion: { enter: 'fade-up', on: 'view' }` — the message lists the presets |
|
|
96
|
+
| `motion-no-tag` | a `motion` on an element with no tag of its own — nothing to move | the motion on what it wraps, or a `subType` on it |
|
|
95
97
|
| `no-pages` | a space with no pages | `pages: [{ id: 'home', name: 'Home', slug: '', body: [] }]` |
|
|
96
98
|
| `notifications-shape` | `notifications` with a field it does not have, or a value that is not one CSS value | the fields it lists; a colour or a length, like 'var(--card)' or '12px' |
|
|
97
99
|
| `outside-ancestor` | an element that reads the state of an element it is not inside | nest it in the element the message names |
|
|
@@ -172,9 +174,11 @@ The space renders, and renders something you probably did not mean. Fix every on
|
|
|
172
174
|
| `image-without-alt` | an image that is not `decorative` and has no `alt` | say what it shows, or `decorative: true` |
|
|
173
175
|
| `label-ignored` | a `label` on a container whose tag is named by what it holds (`li`, a heading) | the words inside, or a landmark tag (`nav`, `section`…) |
|
|
174
176
|
| `list-item-key-missing` | a list's `itemKey` that some of its items lack, or two of them share — the rows fall back to `id`, then position | a field every item has, once each |
|
|
177
|
+
| `list-row-not-li` | a row of a list with `items` that is not an `<li>` — that list is a `<ul>` (or `<ol>`), so the row is a box inside a list, no item to a screen reader | `container({ subType: 'li' })` — or the component's root one, or a wrapper for a link or a button |
|
|
175
178
|
| `overlay-never-opened` | a modal or a dialog that starts hidden and that no step opens | a flow with `openModal('id')` / `openDialog('id')` |
|
|
176
179
|
| `overlay-starts-open` | a modal or a dialog open when the page loads | `visible: false`, opened by `openModal` |
|
|
177
180
|
| `painted-state-without-keep-state` | `paintedState` without `keepState` — nothing is kept for the server to draw with | `settings.keepState: true`, or remove `paintedState` |
|
|
181
|
+
| `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`) |
|
|
178
182
|
| `provider-without-source` | an `apiContainer` that asks nothing | a `query` (or `action`, `connector`, `resource`) |
|
|
179
183
|
| `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` |
|
|
180
184
|
| `server-data-without-rsc` | a `runtime: 'server'` provider with a `connector` or `action` in a space that does not turn server data on | `rsc: { enabled: true }` on the space |
|
|
@@ -189,6 +193,7 @@ The space renders, and renders something you probably did not mean. Fix every on
|
|
|
189
193
|
| `unknown-global-callback` | a global callback no built-in source declares — it runs only if something registers it | the built-in it suggests, or make sure a plugin or module of the space registers it |
|
|
190
194
|
| `unknown-utility` | a utility that is not one of the built-in ones | one of the built-in utilities |
|
|
191
195
|
| `unknown-variable` | a `var(--x)` nothing in the space declares — the property it is in is dropped | declare it under `variables`, fix the name, or give it a fallback: `var(--x, …)` |
|
|
196
|
+
| `unknown-variant` | a variant no class of the element and no style of its type declares — nothing applies | declare it (`styles(name, { variants: { … } })`), or name one that is |
|
|
192
197
|
|
|
193
198
|
## Suggested
|
|
194
199
|
|
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
Only `opacity` and `transform` (`translate`, `scale`, `rotate`) animate off the main thread: they stay smooth while
|
|
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
|
+
- **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.md lists them).
|
|
24
|
+
|
|
22
25
|
- **Slide, grow, fade** with `transform` and `opacity` — never `left`, `top`, `width`, `height` or `margin`. Something
|
|
23
26
|
that travels across a box is `transform: translate(%)` on an element as large as the box.
|
|
24
27
|
- **No animated blur:** no `filter: blur()` in an entrance or a loop, no `backdrop-filter` over moving content. A soft
|
|
@@ -56,7 +56,8 @@ pages: [{ name: 'Shop', slug: 'shop', body: [
|
|
|
56
56
|
a `json` prop `item`, read as `{{ props.item.name }}`.
|
|
57
57
|
- **Slots** are elements of the component's tree that an instance fills. `children: [ … ]` fills a component with one
|
|
58
58
|
slot; `children: { '<slot>': [ … ] }` names the slot for each. What fills a slot is the instance's, not the
|
|
59
|
-
component's: it is authored on the page and sees the page's sources.
|
|
59
|
+
component's: it is authored on the page and sees the page's sources. A prop's value is read as a template on the
|
|
60
|
+
instance, so words that show `{{ … }}` as text (docs, a tutorial) go in a slot — a `markdown` child — not in a prop.
|
|
60
61
|
- **Closed.** Inside, nothing but `props` and the globals (`state`, `auth`, `navigation`, `theme`, `variables`,
|
|
61
62
|
`computed`). A binding inside a component onto a provider of the page is refused — "nothing in this component answers
|
|
62
63
|
to …" — because the component would render differently wherever it was placed. Put the provider inside the component,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Drawing and animating in a plugin
|
|
2
|
+
|
|
3
|
+
A plugin that draws on a canvas — a WebGL hero, a field of particles — takes the canvas from `useCanvas2d`, `useWebGL`
|
|
4
|
+
or `useWebGL2`, and writes only `setup` (once per context) and `draw` (each frame, in CSS pixels):
|
|
5
|
+
|
|
6
|
+
```tsx
|
|
7
|
+
import { createShaderProgram, RootElement, useWebGL } from '@plitzi/plitzi-sdk';
|
|
8
|
+
|
|
9
|
+
const { ref, ready, error } = useWebGL({
|
|
10
|
+
setup: gl => ({ program: createShaderProgram(gl, { vertex: VERTEX, fragment: FRAGMENT }) }),
|
|
11
|
+
draw: (gl, { time, state }) => { gl.useProgram(state.program); /* uniforms, drawArrays */ }
|
|
12
|
+
});
|
|
13
|
+
return <RootElement className={className}>{!ready && <img src={still} alt="" />}<canvas ref={ref} /></RootElement>;
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
It sizes the canvas to the device (at most 2× — `maxPixelRatio`), follows its size, and animates only while somebody
|
|
17
|
+
can see it move: on a live page (in the builder it draws one still frame), for a visitor who did not ask for less
|
|
18
|
+
motion, with the tab in front and the canvas on screen; `paused` holds it (bind it to a hover). A shader that does not
|
|
19
|
+
compile — GLSL ES 1.0 wants `196.0`, not `196` — throws a `ShaderError` with the driver's log, printed as
|
|
20
|
+
`[plugin <type> "<id>"] fragment shader failed: …` (`plitzi check` lists it under the console) and `error` says it;
|
|
21
|
+
show the still image meanwhile, as above. `useAnimationFrame(onFrame, { target })` is the same loop for motion that
|
|
22
|
+
is not a canvas.
|
|
@@ -66,8 +66,8 @@ says `content: ''` (`default-content-beside-children` warns otherwise). Its acce
|
|
|
66
66
|
keeps its own value and fires `onChange` with `{ value, name }` on every change — a search box or a select that
|
|
67
67
|
filters a screen needs no form. Bind `defaultValue` to the state it writes and a reset of that state reaches it.
|
|
68
68
|
|
|
69
|
-
**A `list` is a `<ul
|
|
70
|
-
`{ margin: '0px', padding: '0px'
|
|
69
|
+
**A `list` is a `<ul>`, its rows `<li>`s straight inside** — `listItem({ … })`, or `list-row-not-li` warns. It is
|
|
70
|
+
indented with space below it: `{ margin: '0px', padding: '0px' }` on its class takes that off.
|
|
71
71
|
|
|
72
72
|
**A provider has no box unless you give it a tag.** An `apiContainer` renders no element of its own by default, so its
|
|
73
73
|
children sit in the parent's layout and a `class` on it styles nothing (`STYLE_WITHOUT_TAG`). Put the layout on the
|
|
@@ -8,8 +8,8 @@ list bound to its records (see the provider in data-and-visibility).
|
|
|
8
8
|
|
|
9
9
|
```ts
|
|
10
10
|
list({ id: 'games', class: gameGrid, items: 'catalog.data.games', row: 'game-card' }) // a component per game
|
|
11
|
-
list({ id: 'games', items: 'catalog.data.games', row: r => text({ from: `${r.item}.title` }) })
|
|
12
|
-
list({ id: 'plans', items: ['Free', 'Pro'], children: [text({ from: 'plans.item' })] }) // fixed
|
|
11
|
+
list({ id: 'games', items: 'catalog.data.games', row: r => listItem({ children: [text({ from: `${r.item}.title` })] }) })
|
|
12
|
+
list({ id: 'plans', items: ['Free', 'Pro'], children: [listItem({ children: [text({ from: 'plans.item' })] })] }) // fixed
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
`items` makes a list controlled: an array of its own, or the source its rows come from. `row` is what it renders once
|
|
@@ -17,15 +17,20 @@ per item — a component's id (placed with the row bound to its `item` prop, or
|
|
|
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
|
|
19
19
|
`items` a typed source's path (`items: site.data.games`, see typed-sources.md), the row is typed too:
|
|
20
|
-
`row: g => text({ from: g.item.title })`, and a field the sample's games lack is a type error.
|
|
20
|
+
`row: g => listItem({ children: [text({ from: g.item.title })] })`, and a field the sample's games lack is a type error.
|
|
21
21
|
|
|
22
22
|
- Each row publishes **`list_<id>.item`** (the row) and **`list_<id>.index`** (its position, a number from 0 —
|
|
23
23
|
`index + 1` counts, and `==` compares it with text or a number alike). Inside the row,
|
|
24
24
|
bindings read the short form (`'games.item.title'`), templates and attribute tokens the full one
|
|
25
25
|
(`{{ list_games.item.slug }}`).
|
|
26
26
|
- A **nested list** sees the outer row: inside `list_features`, `list_games.item` is still the game.
|
|
27
|
-
-
|
|
28
|
-
|
|
27
|
+
- A list is a `<ul>` (`subType: 'ol'` for a numbered one) and its `class` styles that root; each row is rendered
|
|
28
|
+
straight into it, with no wrapper — so **a row is an `<li>`**: `listItem({ class, children })`, a
|
|
29
|
+
`container({ subType: 'li' })`, or a component whose root is one. Anything else is a box inside a list, no item to a
|
|
30
|
+
screen reader (`list-row-not-li`; `fixSpace` makes a plain container one). A link or a button is wrapped in a
|
|
31
|
+
`listItem`, which is then what the list lays out: a card that is a link fills its item with `display: 'grid'` on the
|
|
32
|
+
`listItem`. Every list is indented (40px) with 10px below it, and one without `items` is bulleted too:
|
|
33
|
+
`{ margin: '0px', padding: '0px' }` on the root takes the space off. A grid of cards is the root's class.
|
|
29
34
|
- Fixed data is `items: [ … ]` on the list itself — no provider needed. Worth it when the rows ARE data — written from
|
|
30
35
|
an array, or rows that come and go (`repeated-shape` points at sibling copies). Three cards a person rewords on the
|
|
31
36
|
canvas are easier to edit as three cards than as a JSON array.
|
|
@@ -68,7 +73,7 @@ apiContainer({ id: 'catalog', query: '/data/games.json', cache: true, children:
|
|
|
68
73
|
source: 'controlled',
|
|
69
74
|
bind: [bindTemplate('items', 'catalog.data.games',
|
|
70
75
|
'{{ source|filter(g => g.slug == navigation.routeParams.slug) }}', { returns: 'value' })],
|
|
71
|
-
children: [gameDossier()]
|
|
76
|
+
children: [listItem({ children: [gameDossier()] })] // reads list_game.item
|
|
72
77
|
}),
|
|
73
78
|
text('Signal lost — no game by that name.', {
|
|
74
79
|
id: 'game-missing',
|
|
@@ -107,6 +107,11 @@ const newChart = useFlag('newChart'); // false for a flag the space does not dec
|
|
|
107
107
|
Gating the whole plugin needs no code: give its element `flag: 'newChart'`. `useFlag` is for a plugin that changes
|
|
108
108
|
inside — a new mode, an extra panel.
|
|
109
109
|
|
|
110
|
+
## Drawing and animating
|
|
111
|
+
|
|
112
|
+
A canvas, WebGL or a loop of its own: `useCanvas2d`, `useWebGL`, `useWebGL2` and `useAnimationFrame` — see
|
|
113
|
+
[drawing](drawing.md).
|
|
114
|
+
|
|
110
115
|
## Registering
|
|
111
116
|
|
|
112
117
|
A project `plitzi create` wrote registers every folder of `src/plugins` by itself, under the folder's name in
|
|
@@ -39,7 +39,7 @@ export const panel = (params: { id: string; title: string; sub?: string; link?:
|
|
|
39
39
|
children: [
|
|
40
40
|
heading(params.title, { id: 'title', subType: 'h3', class: panelTitle }),
|
|
41
41
|
...(params.sub ? [text(params.sub, { id: 'sub', class: panelSub })] : []),
|
|
42
|
-
...(params.link ? [link({ id: 'link', href: params.link.href, class: panelLink,
|
|
42
|
+
...(params.link ? [link({ id: 'link', href: params.link.href, class: panelLink, content: params.link.label })] : [])
|
|
43
43
|
]
|
|
44
44
|
}),
|
|
45
45
|
...params.children
|
|
@@ -13,8 +13,11 @@ expect((await inspectPage(page, handles)).problems).toEqual([]);
|
|
|
13
13
|
It checks the open page and returns EVERY problem at once, one sentence each, naming the element and the reason:
|
|
14
14
|
|
|
15
15
|
- every element the page owes is present and visible — its own and those of the layouts around it
|
|
16
|
-
(`heading "hero-title" is on the page but not visible: display:none on "hero"`)
|
|
17
|
-
|
|
16
|
+
(`heading "hero-title" is on the page but not visible: display:none on "hero"`). One a breakpoint hides at this
|
|
17
|
+
width (`mobile: { display: 'none' }`, or shown only at another width) is laid out by width, not missing: listed in
|
|
18
|
+
`hiddenAtWidth`, and checked at the width it shows at — run the check at each width you author for;
|
|
19
|
+
- every image arrived — an image element that failed says so (`data-plitzi-failed`), though its fallback loads. A lazy
|
|
20
|
+
image out of sight (below the fold, or beside the screen in a carousel's track) has not been asked for yet, and waits;
|
|
18
21
|
- nothing scrolls sideways (`the page scrolls sideways by 37px — widest: "cards"`);
|
|
19
22
|
- no text is drawn in the colour painted behind it.
|
|
20
23
|
|
|
@@ -33,10 +36,12 @@ the list `inspectPage` holds a page to.
|
|
|
33
36
|
|
|
34
37
|
Three flags say what a bare visit cannot promise, and `onScreen` leaves out:
|
|
35
38
|
|
|
36
|
-
- `conditional` — on screen only under a condition of its own or of an ancestor
|
|
39
|
+
- `conditional` — on screen only under a condition of its own or of an ancestor, or with every child conditional (a
|
|
40
|
+
list of flyouts at rest);
|
|
37
41
|
- `repeated` — inside a list row, so rendered once per row: several copies, or none while the list is empty. Address
|
|
38
42
|
one with `.first()` / `.nth(i)`, in a test that knows the data;
|
|
39
|
-
- `boxless` — a provider with no tag, which renders its children and no element of its own
|
|
43
|
+
- `boxless` — a provider with no tag, which renders its children and no element of its own; or a plugin whose
|
|
44
|
+
declaration says `drawsNothing: true` — behaviour rather than content, a clock that fires a flow.
|
|
40
45
|
|
|
41
46
|
A `formControl`'s id names its wrapper: type into `locate('email').locator('input')`.
|
|
42
47
|
|
|
@@ -9,7 +9,7 @@ const site = source('site', home);
|
|
|
9
9
|
apiContainer({ id: site.id, query: '/data/home.json', children: [
|
|
10
10
|
heading({ from: site.data.hero.title }),
|
|
11
11
|
text({ content: twig`{{ ${site.data.products}|length }} products` }),
|
|
12
|
-
list({ id: 'products', items: site.data.products, row: p => text({ from: p.item.title }) })
|
|
12
|
+
list({ id: 'products', items: site.data.products, row: p => listItem({ children: [text({ from: p.item.title })] }) })
|
|
13
13
|
] })
|
|
14
14
|
```
|
|
15
15
|
|
|
@@ -47,8 +47,9 @@ bar.
|
|
|
47
47
|
|
|
48
48
|
2. **Fix, re-run, until it prints zero warnings.** What has one fix (`author` says how many) is written for you: `npx
|
|
49
49
|
plitzi fix` shows it as a diff of your source, `--write` writes it and keeps it only if the space authors with each
|
|
50
|
-
fix gone and nothing new — a fix it cannot place, or that would add a problem, is said with the reason.
|
|
51
|
-
|
|
50
|
+
fix gone and nothing new — a fix it cannot place, or that would add a problem, is said with the reason. A
|
|
51
|
+
suggestion with one reading is written the same way (`content-attribute`: words and an icon held as children become
|
|
52
|
+
the button's or link's own `content` and `icon`). Then typecheck the project.
|
|
52
53
|
3. **Look at it** — in a browser, at desktop and phone width, in both themes (see [testing](testing.md)). Stage 3 is
|
|
53
54
|
thorough about MEANING and blind to what follows.
|
|
54
55
|
|