@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.
- package/CHANGELOG.md +325 -0
- package/README.md +17 -7
- 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/http/stages/images.js +1 -1
- 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/images/imageProxy.js +19 -1
- package/dist/modules/rsc/connectorRscData.js +1 -1
- package/dist/modules/rsc/publicFileResolver.d.ts +3 -0
- package/dist/modules/rsc/publicFileResolver.js +10 -4
- package/dist/modules/ssr/prepareRender.js +1 -0
- package/dist/modules/ssr/views/template.ejs +6 -0
- package/dist/plugins/manager.js +1 -1
- package/package.json +3 -3
- package/skills/plitzi-authoring/CHEATSHEET.md +9 -5
- package/skills/plitzi-authoring/SKILL.md +6 -2
- package/skills/plitzi-authoring/recipes/accordion.ts +58 -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 +3 -3
- 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/server-data.ts +65 -0
- package/skills/plitzi-authoring/recipes/show-data.ts +12 -7
- package/skills/plitzi-authoring/recipes/style-a-page.ts +1 -1
- 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 +8 -4
- package/skills/plitzi-authoring/reference/authoring-errors.md +13 -3
- package/skills/plitzi-authoring/reference/colours-and-motion.md +7 -0
- package/skills/plitzi-authoring/reference/components.md +2 -1
- package/skills/plitzi-authoring/reference/data-and-visibility.md +26 -16
- package/skills/plitzi-authoring/reference/drawing.md +22 -0
- package/skills/plitzi-authoring/reference/efficiency.md +10 -1
- package/skills/plitzi-authoring/reference/elements-and-styles.md +7 -11
- package/skills/plitzi-authoring/reference/lists.md +12 -7
- package/skills/plitzi-authoring/reference/plugins.md +5 -0
- package/skills/plitzi-authoring/reference/review-checklist.md +14 -2
- package/skills/plitzi-authoring/reference/snippets-and-export.md +2 -2
- package/skills/plitzi-authoring/reference/structure.md +1 -1
- package/skills/plitzi-authoring/reference/templates.md +1 -1
- package/skills/plitzi-authoring/reference/testing.md +10 -5
- package/skills/plitzi-authoring/reference/typed-sources.md +1 -1
- 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
|
|
40
|
+
if (!offlineData || offlineData.schema.rsc?.enabled === false) return {};
|
|
41
41
|
return resolveRscData({
|
|
42
42
|
schema: offlineData.schema,
|
|
43
43
|
req,
|
|
@@ -9,5 +9,8 @@ import { RscElementResolver } from './resolveRscData';
|
|
|
9
9
|
*
|
|
10
10
|
* Only a plain path inside `publicDir`: a URL, one that climbs out of the folder, or one with `{{tokens}}` (resolved
|
|
11
11
|
* in the browser against the visitor's route and state) is not this resolver's, and the element is left as it was.
|
|
12
|
+
*
|
|
13
|
+
* Answered in the shape a browser request publishes — `{ status, data }`, the body under `data` — so a provider reads
|
|
14
|
+
* `<source>.data.<field>` whichever runtime it has, and moving it to the server changes no binding.
|
|
12
15
|
*/
|
|
13
16
|
export declare const publicFileResolver: (publicDir: string) => RscElementResolver;
|
|
@@ -11,6 +11,9 @@ import { readFile } from "node:fs/promises";
|
|
|
11
11
|
*
|
|
12
12
|
* Only a plain path inside `publicDir`: a URL, one that climbs out of the folder, or one with `{{tokens}}` (resolved
|
|
13
13
|
* in the browser against the visitor's route and state) is not this resolver's, and the element is left as it was.
|
|
14
|
+
*
|
|
15
|
+
* Answered in the shape a browser request publishes — `{ status, data }`, the body under `data` — so a provider reads
|
|
16
|
+
* `<source>.data.<field>` whichever runtime it has, and moving it to the server changes no binding.
|
|
14
17
|
*/
|
|
15
18
|
var publicFileResolver = (publicDir) => {
|
|
16
19
|
const root = path.resolve(publicDir);
|
|
@@ -26,10 +29,13 @@ var publicFileResolver = (publicDir) => {
|
|
|
26
29
|
const file = path.resolve(root, `.${pathname}`);
|
|
27
30
|
if (!file.startsWith(`${root}${path.sep}`)) return;
|
|
28
31
|
try {
|
|
29
|
-
return
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
return {
|
|
33
|
+
status: 200,
|
|
34
|
+
data: JSON.parse(await readFile(file, {
|
|
35
|
+
encoding: "utf8",
|
|
36
|
+
signal
|
|
37
|
+
}))
|
|
38
|
+
};
|
|
33
39
|
} catch {
|
|
34
40
|
return null;
|
|
35
41
|
}
|
|
@@ -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/dist/plugins/manager.js
CHANGED
|
@@ -10,7 +10,7 @@ import fs from "node:fs/promises";
|
|
|
10
10
|
//#region src/plugins/manager.ts
|
|
11
11
|
var META_FILE = "meta.json";
|
|
12
12
|
var DEFAULT_TTL_MS = 6048e5;
|
|
13
|
-
var DEFAULT_CACHE_DIR = "
|
|
13
|
+
var DEFAULT_CACHE_DIR = "tmp/.sdk-plugins";
|
|
14
14
|
/** One hash over every input's path and content — a file gone counts as changed, like a file edited. */
|
|
15
15
|
var digestOf = async (files) => {
|
|
16
16
|
const hash = createHash("sha256");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plitzi/sdk-server",
|
|
3
|
-
"version": "0.38.
|
|
3
|
+
"version": "0.38.4",
|
|
4
4
|
"license": "AGPL-3.0",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -79,8 +79,8 @@
|
|
|
79
79
|
"type": "module",
|
|
80
80
|
"sideEffects": false,
|
|
81
81
|
"dependencies": {
|
|
82
|
-
"@plitzi/plitzi-sdk": "0.38.
|
|
83
|
-
"@plitzi/sdk-shared": "0.38.
|
|
82
|
+
"@plitzi/plitzi-sdk": "0.38.4",
|
|
83
|
+
"@plitzi/sdk-shared": "0.38.4",
|
|
84
84
|
"ejs": "^6.0.1",
|
|
85
85
|
"esbuild": "^0.28.2",
|
|
86
86
|
"jsonwebtoken": "^9.0.3",
|
|
@@ -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',
|
|
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
|
|
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
|
|
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` (
|
|
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
|
|
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-
|
|
64
|
+
space (`data-plitzi-el` in the page); the DOM id a URL's `#fragment` lands on is its `anchor`.
|
|
65
65
|
2. **Share with classes, layouts and components, never with copies.** A look used twice is a `styles()` class.
|
|
66
66
|
Chrome shown on several pages — a header, a sidebar, a footer — is a **layout** the pages name, written once. A block
|
|
67
67
|
placed many times with different content — a product card, a testimonial — is a **component** placed with
|
|
@@ -91,11 +91,12 @@ second into the first. If the MCP asks for a sign-in nobody can give, a project
|
|
|
91
91
|
checked from the first commit.
|
|
92
92
|
7. **Times in UTC, and say so.** Format with an explicit zone (`|date('j M · H:i', 'UTC')`) and print "UTC" beside it:
|
|
93
93
|
a page rendered on a server and hydrated in a browser in another zone must agree on the hour.
|
|
94
|
-
8. **Breakpoints are ranges.** `tablet` (48–64rem) and `mobile` (
|
|
94
|
+
8. **Breakpoints are ranges.** `tablet` (48–64rem) and `mobile` (up to 48rem) each inherit only from `desktop`; a rule
|
|
95
95
|
meant for both is written under `compact`.
|
|
96
96
|
9. **Never invent data.** Numbers, names and states on screen come from a source. A panel with nothing true to say is
|
|
97
97
|
an empty state, not a placeholder figure. With no backend, the data is JSON the project serves
|
|
98
98
|
(`public/data/*.json`) read by an `apiContainer` — see [data and visibility](reference/data-and-visibility.md).
|
|
99
|
+
`public/` is public: never a secret there.
|
|
99
100
|
10. **Never hand-write** `flat`, derived ids, `styleSelectors`, `beforeNode`/`afterNode`/`flowId`, or a
|
|
100
101
|
`styleVariant` binding's key — use the factories, `variantFrom` and `activeOn`.
|
|
101
102
|
11. **A switch is named for how it leaves its default.** `toggleState` turns a key nobody has set yet ON, so a key
|
|
@@ -121,6 +122,8 @@ Each is a whole file that authors with no warning — CI holds it to that. Copy
|
|
|
121
122
|
| --- | --- |
|
|
122
123
|
| Show data with no backend: a JSON file, a card per row, a count, an empty state, a computed value | [recipes/show-data.ts](recipes/show-data.ts) |
|
|
123
124
|
| Data typed by a sample of it: completed and checked paths, typed rows | [recipes/typed-data.ts](recipes/typed-data.ts) |
|
|
125
|
+
| Data read by the page server (`--mode server`) | [recipes/server-data.ts](recipes/server-data.ts) |
|
|
126
|
+
| An accordion, one answer open at a time | [recipes/accordion.ts](recipes/accordion.ts) |
|
|
124
127
|
| Filter and sort a list from a select | [recipes/filter-a-list.ts](recipes/filter-a-list.ts) |
|
|
125
128
|
| A page per record (`/products/:slug`), and "not found" | [recipes/detail-page.ts](recipes/detail-page.ts) |
|
|
126
129
|
| Link to a section of a page (`/#plans`) | [recipes/link-to-a-section.ts](recipes/link-to-a-section.ts) |
|
|
@@ -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
|
-
|
|
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
|
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Link to a section: the section carries an `anchor` — its id in the page; an element's `id` is only its name in the
|
|
3
|
-
* space, `data-
|
|
3
|
+
* space, `data-plitzi-el` in the DOM — and a link's `hash` lands on it, from any page, even when the section renders late.
|
|
4
4
|
*/
|
|
5
5
|
import { container, heading, link, text } from '@plitzi/sdk-authoring';
|
|
6
6
|
|
|
@@ -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
|
]
|
|
@@ -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
|
+
};
|