@plitzi/sdk-server 0.38.2 → 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.
Files changed (54) hide show
  1. package/CHANGELOG.md +325 -0
  2. package/README.md +17 -7
  3. package/dist/core/createServer.devReload.test.d.ts +1 -0
  4. package/dist/core/http/stages/devReload.d.ts +18 -0
  5. package/dist/core/http/stages/devReload.js +40 -0
  6. package/dist/core/http/stages/images.js +1 -1
  7. package/dist/core/server/baseServer.d.ts +2 -0
  8. package/dist/core/server/baseServer.js +1 -0
  9. package/dist/core/server/drain.d.ts +2 -2
  10. package/dist/core/server/drain.js +17 -2
  11. package/dist/core/server/pageServer.js +8 -2
  12. package/dist/modules/images/imageProxy.js +19 -1
  13. package/dist/modules/rsc/connectorRscData.js +1 -1
  14. package/dist/modules/rsc/publicFileResolver.d.ts +3 -0
  15. package/dist/modules/rsc/publicFileResolver.js +10 -4
  16. package/dist/modules/ssr/prepareRender.js +1 -0
  17. package/dist/modules/ssr/views/template.ejs +6 -0
  18. package/dist/plugins/manager.js +1 -1
  19. package/package.json +3 -3
  20. package/skills/plitzi-authoring/CHEATSHEET.md +9 -5
  21. package/skills/plitzi-authoring/SKILL.md +6 -2
  22. package/skills/plitzi-authoring/recipes/accordion.ts +58 -0
  23. package/skills/plitzi-authoring/recipes/carousel.ts +14 -9
  24. package/skills/plitzi-authoring/recipes/detail-page.ts +10 -6
  25. package/skills/plitzi-authoring/recipes/every-few-seconds.ts +13 -8
  26. package/skills/plitzi-authoring/recipes/filter-a-list.ts +2 -2
  27. package/skills/plitzi-authoring/recipes/forms-and-modals.ts +1 -2
  28. package/skills/plitzi-authoring/recipes/from-tailwind.ts +1 -1
  29. package/skills/plitzi-authoring/recipes/link-to-a-section.ts +3 -3
  30. package/skills/plitzi-authoring/recipes/link-with-data.ts +12 -7
  31. package/skills/plitzi-authoring/recipes/marquee.ts +2 -2
  32. package/skills/plitzi-authoring/recipes/scroll-a-row.ts +5 -6
  33. package/skills/plitzi-authoring/recipes/server-data.ts +65 -0
  34. package/skills/plitzi-authoring/recipes/show-data.ts +12 -7
  35. package/skills/plitzi-authoring/recipes/style-a-page.ts +1 -1
  36. package/skills/plitzi-authoring/recipes/typed-data.ts +9 -6
  37. package/skills/plitzi-authoring/recipes/usable-without-sight.ts +2 -2
  38. package/skills/plitzi-authoring/reference/accessibility.md +8 -4
  39. package/skills/plitzi-authoring/reference/authoring-errors.md +13 -3
  40. package/skills/plitzi-authoring/reference/colours-and-motion.md +7 -0
  41. package/skills/plitzi-authoring/reference/components.md +2 -1
  42. package/skills/plitzi-authoring/reference/data-and-visibility.md +26 -16
  43. package/skills/plitzi-authoring/reference/drawing.md +22 -0
  44. package/skills/plitzi-authoring/reference/efficiency.md +10 -1
  45. package/skills/plitzi-authoring/reference/elements-and-styles.md +7 -11
  46. package/skills/plitzi-authoring/reference/lists.md +12 -7
  47. package/skills/plitzi-authoring/reference/plugins.md +5 -0
  48. package/skills/plitzi-authoring/reference/review-checklist.md +14 -2
  49. package/skills/plitzi-authoring/reference/snippets-and-export.md +2 -2
  50. package/skills/plitzi-authoring/reference/structure.md +1 -1
  51. package/skills/plitzi-authoring/reference/templates.md +1 -1
  52. package/skills/plitzi-authoring/reference/testing.md +10 -5
  53. package/skills/plitzi-authoring/reference/typed-sources.md +1 -1
  54. package/skills/plitzi-authoring/reference/validation.md +3 -2
@@ -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?.schema.rsc?.enabled) return {};
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 JSON.parse(await readFile(file, {
30
- encoding: "utf8",
31
- signal
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
  }
@@ -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>
@@ -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 = ".sdk-plugins";
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.2",
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.2",
83
- "@plitzi/sdk-shared": "0.38.2",
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",
@@ -31,18 +31,21 @@ 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', children }) // a page id, a '/path', or a URL — the mode follows from the href
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 once as it comes into view; 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 (its `item` prop)
42
- list({ id: 'rows', items: 'p.data.rows', row: r => text({ from: `${r.item}.title` }) }) // or a tree per row
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
48
+ apiContainer({ id: 'p', query: '/data/x.json', runtime: 'server', children }) // read by the page server: still p.data
46
49
  // loadingSlot: 'p-skeleton' — a child shown until it answers
47
50
  form({ id, managedByInteractions: true, flows, children })
48
51
  formControl({ name: 'email', label: 'Email', subType: 'email' }) // select: options: [{ label, value }]
@@ -62,7 +65,7 @@ container({ id: 'hero', class: [card, { opacity: '0.5' }] }); // …plus one th
62
65
  container({ css: { padding: '8px' } }); // rules of its own — never with `class`
63
66
  ```
64
67
 
65
- Breakpoints: `desktop` (base), `tablet` (48–64rem), `mobile` (<48rem), `compact` (tablet AND mobile). Each inherits
68
+ Breakpoints: `desktop` (base), `tablet` (48–64rem), `mobile` (≤48rem), `compact` (tablet AND mobile). Each inherits
66
69
  from `desktop` alone. States: `hover`, `focus`, `focus-visible`, `active`, `disabled`…. An ancestor's state:
67
70
  `ancestors: { [card.name]: { states: { hover: { … } } } }`.
68
71
 
@@ -85,7 +88,8 @@ visible: false // hidden until a
85
88
 
86
89
  Inside a list row: `rows.item` in a binding, `{{ list_rows.item.x }}` in a template or an attribute,
87
90
  `list_rows.index` is a number from 0. A provider's source in a template is `apiContainer_p`… —
88
- spelled in full, as the element publishes it. Globals: `state`, `navigation` (`routeParams`, `queryParams`),
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`),
89
93
  `auth`, `variables`, `computed`, `flags`, `theme`, `host`.
90
94
 
91
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-id` in the page); the DOM id a URL's `#fragment` lands on is its `anchor`.
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` (below 48rem) each inherit only from `desktop`; a rule
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) |
@@ -158,6 +161,7 @@ Each is a whole file that authors with no warning — CI holds it to that. Copy
158
161
  | [flows.md](reference/flows.md) | Clicks, submits, page loads, every few seconds, server actions, modals, state |
159
162
  | [realtime.md](reference/realtime.md) | Pages that see each other: channels, presence, cursors, a shared board, who may hear a topic |
160
163
  | [plugins.md](reference/plugins.md) | A component of your own: props, binding them, writing state, channels, registering, behaving in the builder |
164
+ | [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
165
  | [structure.md](reference/structure.md) | A space bigger than one screen: files, helpers, naming, keeping it short |
162
166
  | [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
167
  | [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 |
@@ -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
+ };
@@ -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
- button({
78
- content: '',
79
- class: dot,
80
- bind: [
81
- bindTemplate('title', r.index, 'Slide {{ source + 1 }}'),
82
- activeWhen(dot, `{{ ${r.inTemplate.index} == carousel_hero.index }}`)
83
- ],
84
- flows: [[onClick(), carouselGoTo('hero', `{{ ${r.inTemplate.index} }}`)]]
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
- // An attribute reads a name with filters, spelled in full.
29
- link({
30
- href: '/products/{{ list_products.item.slug|url_encode }}',
31
- children: [text({ from: 'products.item.title' })]
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
- container({
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
- button({
105
- content: '',
106
- title: 'Show this offer',
107
- class: dot,
108
- bind: [activeWhen(dot, '{{ list_dots.index == (state.slide ?? 0) }}')],
109
- flows: [[onClick(), setState({ key: 'slide', type: 'number', value: '{{ list_dots.index }}' })]]
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
- children: [fontAwesome({ icon: 'fa-solid fa-xmark' })],
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, children: [text('Get started')] })
48
+ link({ href: '/start', class: cta, content: 'Get started' })
49
49
  ]
50
50
  }
51
51
  ]
@@ -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-id` in the DOM — and a link's `hash` lands on it, from any page, even when the section renders late.
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
 
@@ -15,7 +15,7 @@ export const recipe: SpaceSpec = {
15
15
  name: 'Home',
16
16
  slug: '',
17
17
  body: [
18
- link({ href: 'home', hash: 'plans', children: [text('See the 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', children: [text('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, fontAwesome, link, list, text } from '@plitzi/sdk-authoring';
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
- link({
28
- target: 'blank',
29
- href: 'https://wa.me/34600000000?text={{ list_products.item.title|url_encode }}',
30
- children: [fontAwesome({ icon: 'fa-brands fa-whatsapp' }), text('Ask about it')]
31
- }),
32
- link({ href: 'mailto:shop@example.com', children: [text('Write to us')] })
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: [text({ class: card, from: 'categories.item' })]
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
  ]
@@ -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
+ };