@plitzi/sdk-server 0.37.9 → 0.37.10
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 +589 -0
- package/README.md +17 -0
- package/dist/adapters/cloudAdapters.d.ts +5 -3
- package/dist/adapters/cloudAdapters.js +120 -34
- package/dist/adapters/space/types.d.ts +3 -0
- package/dist/core/configSeam.d.ts +14 -0
- package/dist/core/configSeam.js +114 -0
- package/dist/core/configSeam.test.d.ts +1 -0
- package/dist/core/createServer.js +11 -5
- package/dist/core/createServer.publicData.test.d.ts +1 -0
- package/dist/core/freePort.d.ts +10 -0
- package/dist/core/freePort.js +36 -0
- package/dist/core/freePort.test.d.ts +1 -0
- package/dist/core/http/stages/images.d.ts +6 -0
- package/dist/core/http/stages/images.js +47 -0
- package/dist/core/http/stages/images.test.d.ts +1 -0
- package/dist/core/server/fleet/channel.js +1 -1
- package/dist/core/server/fleet/relay.js +1 -1
- package/dist/core/services/action.js +5 -4
- package/dist/core/services/registry.js +2 -0
- package/dist/helpers/cache/keys.d.ts +23 -10
- package/dist/helpers/cache/keys.js +16 -2
- package/dist/helpers/flagOverrides.d.ts +27 -0
- package/dist/helpers/flagOverrides.js +25 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -1
- package/dist/modules/actions/moduleFor.js +5 -3
- package/dist/modules/actions/runtime/flags.d.ts +16 -0
- package/dist/modules/actions/runtime/flags.js +49 -0
- package/dist/modules/actions/runtime/flags.test.d.ts +1 -0
- package/dist/modules/actions/runtime/kvList.js +1 -1
- package/dist/modules/actions/runtime/runAction.js +2 -0
- package/dist/modules/actions/transport/callHandler.js +8 -0
- package/dist/modules/actions/types.d.ts +14 -1
- package/dist/modules/connectors/engine.js +1 -1
- package/dist/modules/functions/capabilities.js +1 -1
- package/dist/modules/functions/load.js +2 -2
- package/dist/modules/functions/manifest.js +1 -1
- package/dist/modules/functions/runner/messages.js +1 -1
- package/dist/modules/images/allowlist.d.ts +5 -0
- package/dist/modules/images/allowlist.js +18 -0
- package/dist/modules/images/imageProxy.d.ts +36 -0
- package/dist/modules/images/imageProxy.js +273 -0
- package/dist/modules/images/imageProxy.test.d.ts +1 -0
- package/dist/modules/images/transform.d.ts +10 -0
- package/dist/modules/images/transform.js +28 -0
- package/dist/modules/images/transform.test.d.ts +1 -0
- package/dist/modules/rsc/connectorRscData.d.ts +12 -7
- package/dist/modules/rsc/connectorRscData.js +12 -8
- package/dist/modules/rsc/handler.js +18 -4
- package/dist/modules/rsc/publicFileResolver.d.ts +13 -0
- package/dist/modules/rsc/publicFileResolver.js +39 -0
- package/dist/modules/rsc/publicFileResolver.test.d.ts +1 -0
- package/dist/modules/rsc/resolveRscData.d.ts +4 -2
- package/dist/modules/rsc/resolveRscData.js +10 -2
- package/dist/modules/runtime/bundle.js +2 -2
- package/dist/modules/ssr/Component.d.ts +5 -1
- package/dist/modules/ssr/Component.js +3 -1
- package/dist/modules/ssr/prepareRender.js +36 -8
- package/package.json +9 -4
- package/skills/plitzi-authoring/CHEATSHEET.md +135 -0
- package/skills/plitzi-authoring/SKILL.md +71 -71
- package/skills/plitzi-authoring/recipes/carousel.ts +114 -0
- package/skills/plitzi-authoring/recipes/detail-page.ts +70 -0
- package/skills/plitzi-authoring/recipes/embed-and-svg.ts +48 -0
- package/skills/plitzi-authoring/recipes/every-few-seconds.ts +119 -0
- package/skills/plitzi-authoring/recipes/feature-flag.ts +24 -0
- package/skills/plitzi-authoring/recipes/filter-a-list.ts +63 -0
- package/skills/plitzi-authoring/recipes/forms-and-modals.ts +64 -0
- package/skills/plitzi-authoring/recipes/from-tailwind.ts +52 -0
- package/skills/plitzi-authoring/recipes/link-to-a-section.ts +35 -0
- package/skills/plitzi-authoring/recipes/link-with-data.ts +40 -0
- package/skills/plitzi-authoring/recipes/marquee.ts +57 -0
- package/skills/plitzi-authoring/recipes/plugin.ts +53 -0
- package/skills/plitzi-authoring/recipes/scroll-a-row.ts +79 -0
- package/skills/plitzi-authoring/recipes/show-data.ts +73 -0
- package/skills/plitzi-authoring/recipes/style-a-page.ts +45 -0
- package/skills/plitzi-authoring/recipes/typed-data.ts +64 -0
- package/skills/plitzi-authoring/recipes/usable-without-sight.ts +39 -0
- package/skills/plitzi-authoring/reference/accessibility.md +2 -2
- package/skills/plitzi-authoring/reference/authoring-errors.md +195 -53
- package/skills/plitzi-authoring/reference/colours-and-motion.md +63 -0
- package/skills/plitzi-authoring/reference/components.md +9 -1
- package/skills/plitzi-authoring/reference/data-and-visibility.md +29 -29
- package/skills/plitzi-authoring/reference/efficiency.md +53 -0
- package/skills/plitzi-authoring/reference/elements-and-styles.md +39 -24
- package/skills/plitzi-authoring/reference/feature-flags.md +78 -0
- package/skills/plitzi-authoring/reference/flows.md +25 -36
- package/skills/plitzi-authoring/reference/kept-state.md +28 -0
- package/skills/plitzi-authoring/reference/layouts.md +20 -3
- package/skills/plitzi-authoring/reference/lists.md +45 -16
- package/skills/plitzi-authoring/reference/plugins.md +15 -0
- package/skills/plitzi-authoring/reference/realtime.md +35 -0
- package/skills/plitzi-authoring/reference/review-checklist.md +8 -3
- package/skills/plitzi-authoring/reference/{templates-and-export.md → snippets-and-export.md} +9 -8
- package/skills/plitzi-authoring/reference/structure.md +29 -20
- package/skills/plitzi-authoring/reference/tailwind.md +19 -0
- package/skills/plitzi-authoring/reference/templates.md +6 -5
- package/skills/plitzi-authoring/reference/testing.md +11 -0
- package/skills/plitzi-authoring/reference/typed-sources.md +20 -0
- package/skills/plitzi-authoring/reference/validation.md +6 -2
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { requestFlagOverrides } from "../../helpers/flagOverrides.js";
|
|
1
2
|
import { authorizesDebugging } from "../../helpers/debugAuthorization.js";
|
|
2
3
|
import { realtimeModuleFor } from "../realtime/index.js";
|
|
3
4
|
import { matchRscPage } from "../rsc/matchRscPage.js";
|
|
@@ -11,10 +12,12 @@ import { createOfflineDataLoader } from "../../helpers/offlineDataLoader.js";
|
|
|
11
12
|
import { registerExternalPlugins } from "./registerExternalPlugins.js";
|
|
12
13
|
import { reportMissingPlugins } from "./reportMissingPlugins.js";
|
|
13
14
|
import { resolvePageSeo } from "./resolvePageSeo.js";
|
|
15
|
+
import { imagesPathOf } from "../../core/http/stages/images.js";
|
|
14
16
|
import { sdkAssetVersion } from "../../core/sdkAssets.js";
|
|
15
17
|
import { buildServerInfo } from "../../helpers/buildServerInfo.js";
|
|
16
18
|
import { hydrationPayload } from "../../helpers/hydrationPayload.js";
|
|
17
19
|
import { resolveDebugMode } from "../../helpers/resolveDebugMode.js";
|
|
20
|
+
import { flagUserFromSSR, flagValues, resolveFlags } from "@plitzi/sdk-shared/flags";
|
|
18
21
|
import { hasServerElements } from "@plitzi/sdk-shared/schema/serverElements";
|
|
19
22
|
import { debugCookieName } from "@plitzi/sdk-shared/devTools";
|
|
20
23
|
import { themeFromCookies } from "@plitzi/sdk-shared/theme";
|
|
@@ -35,7 +38,7 @@ var declaredTheme = (style) => {
|
|
|
35
38
|
};
|
|
36
39
|
var prepareRender = async (req, config, spaceId, environment, revision, pluginManager, offlineDataCache, metrics, offlineDataOverride) => {
|
|
37
40
|
const m = (name, fn) => metrics ? metrics.measure(name, fn) : Promise.resolve(fn());
|
|
38
|
-
const offlineCacheKey = environment !== "main" ? buildOfflineDataCacheKey(spaceId, environment, revision) : void 0;
|
|
41
|
+
const offlineCacheKey = environment !== "main" ? buildOfflineDataCacheKey(spaceId, environment, revision, req.ctx.spaceDeployment?.flagsVersion) : void 0;
|
|
39
42
|
const cachedOfflineStr = offlineDataOverride === void 0 && offlineCacheKey ? offlineDataCache?.get(offlineCacheKey) : void 0;
|
|
40
43
|
const loadOfflineData = createOfflineDataLoader(() => {
|
|
41
44
|
if (offlineDataOverride !== void 0) return Promise.resolve(offlineDataOverride);
|
|
@@ -46,13 +49,27 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
|
|
|
46
49
|
const rscPath = resolveRscEndpoint(config);
|
|
47
50
|
const schema = offlineData?.schema;
|
|
48
51
|
const pageMatch = schema !== void 0 ? matchRscPage(schema, req.path, req.ctx.user) : void 0;
|
|
49
|
-
const
|
|
52
|
+
const rscEnabled = rscPath !== void 0 && schema !== void 0 && schema.rsc?.enabled !== false;
|
|
53
|
+
const debugAuthorized = !Boolean(req.query["__pt"]) && authorizesDebugging(config, schema?.settings);
|
|
54
|
+
const flagOverrides = await requestFlagOverrides(config, req, {
|
|
55
|
+
spaceId: req.ctx.spaceDeployment?.spaceId ?? spaceId,
|
|
56
|
+
environment: req.ctx.spaceDeployment?.environment ?? environment
|
|
57
|
+
}, debugAuthorized);
|
|
58
|
+
const pageFlags = schema !== void 0 && pageMatch !== void 0 ? flagValues(resolveFlags(schema.flags, {
|
|
59
|
+
environment: req.ctx.spaceDeployment?.environment ?? environment,
|
|
60
|
+
hostname: req.hostname,
|
|
61
|
+
routeParams: pageMatch.routeParams,
|
|
62
|
+
queryParams: req.query,
|
|
63
|
+
user: flagUserFromSSR(req.ctx.user)
|
|
64
|
+
}, flagOverrides)) : void 0;
|
|
65
|
+
const rscData = rscEnabled && pageMatch !== void 0 && hasServerElements(schema, pageMatch.pageId, pageFlags) && config.adapters.getRscData ? await m("rsc", () => config.adapters.getRscData?.({
|
|
50
66
|
req,
|
|
51
67
|
spaceId: req.ctx.spaceDeployment?.spaceId ?? spaceId,
|
|
52
68
|
environment: req.ctx.spaceDeployment?.environment ?? environment,
|
|
53
69
|
revision: req.ctx.spaceDeployment?.revision ?? revision,
|
|
54
70
|
user: req.ctx.user,
|
|
55
|
-
loadOfflineData
|
|
71
|
+
loadOfflineData,
|
|
72
|
+
flagOverrides
|
|
56
73
|
})) : rscPath ? { serverData: {} } : void 0;
|
|
57
74
|
const pageSeo = resolvePageSeo(schema, pageMatch?.pageId);
|
|
58
75
|
const server = buildServerInfo(req, config, {
|
|
@@ -60,7 +77,8 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
|
|
|
60
77
|
rscData,
|
|
61
78
|
actionPath: resolveActionEndpoint(config),
|
|
62
79
|
realtimePath: realtimeModuleFor(config)?.path,
|
|
63
|
-
realtimeTransport: realtimeModuleFor(config)?.transport
|
|
80
|
+
realtimeTransport: realtimeModuleFor(config)?.transport,
|
|
81
|
+
imagePath: imagesPathOf(config)
|
|
64
82
|
});
|
|
65
83
|
if (offlineDataOverride === void 0 && !cachedOfflineStr && offlineCacheKey && offlineData !== void 0) offlineDataCache?.set(offlineCacheKey, JSON.stringify(offlineData));
|
|
66
84
|
const version = config.assetVersion ?? sdkAssetVersion();
|
|
@@ -81,7 +99,6 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
|
|
|
81
99
|
* so does the space itself (`settings.debugMode`) — an owner inspecting their own published site. The space is read
|
|
82
100
|
* from what this server loaded, never from the request, so a visitor has no say in it.
|
|
83
101
|
*/
|
|
84
|
-
const debugAuthorized = !Boolean(req.query["__pt"]) && authorizesDebugging(config, schema?.settings);
|
|
85
102
|
const debugRendered = resolveDebugMode(debugAuthorized, readCookie(req.headers.cookie, debugCookieName(req.headers.host)));
|
|
86
103
|
/**
|
|
87
104
|
* The runs this render's server elements started, handed to a page that may debug them — and to no other.
|
|
@@ -123,6 +140,13 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
|
|
|
123
140
|
* would hydrate other markup. Only the keys the space declares, and only with `keepState` on.
|
|
124
141
|
*/
|
|
125
142
|
const paintedState = schema?.settings.keepState ? paintedStateFor(req.headers.cookie, ssrPaintedCookieName(req.headers.host), paintedKeys(schema.settings)) : void 0;
|
|
143
|
+
/**
|
|
144
|
+
* The two layers of the space's flags this server answers for. Its own (`config.flags`), asked per space when it
|
|
145
|
+
* serves several; and the ones a tester forced from the dev tools, read from their cookie — only for a page allowed
|
|
146
|
+
* to debug, or any visitor could switch on a feature still behind a flag. Both travel to the browser beside the
|
|
147
|
+
* page, so it hydrates with the flags it was drawn with.
|
|
148
|
+
*/
|
|
149
|
+
const { server: serverFlags, qa: forcedFlags } = flagOverrides;
|
|
126
150
|
const offlineDataStr = hydrationPayload(offlineData, {
|
|
127
151
|
offlineMode: true,
|
|
128
152
|
environment,
|
|
@@ -133,7 +157,9 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
|
|
|
133
157
|
...paintedState ? { state: paintedState } : {},
|
|
134
158
|
...clientAnalytics ? { analytics: clientAnalytics } : {},
|
|
135
159
|
...overQuota ? { overQuota } : {},
|
|
136
|
-
...actionRuns ? { actionRuns } : {}
|
|
160
|
+
...actionRuns ? { actionRuns } : {},
|
|
161
|
+
...serverFlags ? { serverFlags } : {},
|
|
162
|
+
...forcedFlags ? { forcedFlags } : {}
|
|
137
163
|
});
|
|
138
164
|
const pluginNames = req.ctx.spaceDeployment?.pluginNames ?? [];
|
|
139
165
|
const pluginSources = req.ctx.spaceDeployment?.pluginSources;
|
|
@@ -167,7 +193,9 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
|
|
|
167
193
|
sdkDevToolsStylePath,
|
|
168
194
|
overQuota,
|
|
169
195
|
theme,
|
|
170
|
-
...paintedState ? { state: paintedState } : {}
|
|
196
|
+
...paintedState ? { state: paintedState } : {},
|
|
197
|
+
...serverFlags ? { serverFlags } : {},
|
|
198
|
+
...forcedFlags ? { forcedFlags } : {}
|
|
171
199
|
},
|
|
172
200
|
entries,
|
|
173
201
|
templateParams: {
|
|
@@ -202,7 +230,7 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
|
|
|
202
230
|
ssrOnly: config.ssrOnly === true,
|
|
203
231
|
offlineData: offlineDataStr
|
|
204
232
|
},
|
|
205
|
-
cacheable: actionRuns === void 0
|
|
233
|
+
cacheable: actionRuns === void 0 && forcedFlags === void 0
|
|
206
234
|
};
|
|
207
235
|
};
|
|
208
236
|
//#endregion
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plitzi/sdk-server",
|
|
3
|
-
"version": "0.37.
|
|
3
|
+
"version": "0.37.10",
|
|
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.37.
|
|
83
|
-
"@plitzi/sdk-shared": "0.37.
|
|
82
|
+
"@plitzi/plitzi-sdk": "0.37.10",
|
|
83
|
+
"@plitzi/sdk-shared": "0.37.10",
|
|
84
84
|
"ejs": "^6.0.1",
|
|
85
85
|
"esbuild": "^0.28.2",
|
|
86
86
|
"jsonwebtoken": "^9.0.3",
|
|
@@ -94,7 +94,8 @@
|
|
|
94
94
|
"mongodb": "^6 || ^7",
|
|
95
95
|
"mysql2": "^3",
|
|
96
96
|
"react": "^19",
|
|
97
|
-
"react-dom": "^19"
|
|
97
|
+
"react-dom": "^19",
|
|
98
|
+
"sharp": "^0.34.0"
|
|
98
99
|
},
|
|
99
100
|
"peerDependenciesMeta": {
|
|
100
101
|
"core-js": {
|
|
@@ -108,6 +109,9 @@
|
|
|
108
109
|
},
|
|
109
110
|
"mysql2": {
|
|
110
111
|
"optional": true
|
|
112
|
+
},
|
|
113
|
+
"sharp": {
|
|
114
|
+
"optional": true
|
|
111
115
|
}
|
|
112
116
|
},
|
|
113
117
|
"devDependencies": {
|
|
@@ -129,6 +133,7 @@
|
|
|
129
133
|
"mysql2": "^3.24.4",
|
|
130
134
|
"react": "^19.3.0",
|
|
131
135
|
"react-dom": "^19.3.0",
|
|
136
|
+
"sharp": "^0.34.5",
|
|
132
137
|
"tsx": "^4.23.15",
|
|
133
138
|
"typescript": "^6.0.3",
|
|
134
139
|
"vite": "^8.3.1",
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Plitzi authoring — cheatsheet
|
|
2
|
+
|
|
3
|
+
What most changes need, on one page. Everything is imported from `@plitzi/sdk-authoring`. For the rest, ask by name —
|
|
4
|
+
`npx @plitzi/cli explain navigate` (an element, a step, a trigger, a problem's code), `--list steps` for every one of a
|
|
5
|
+
kind — rather than reading its `.d.ts`.
|
|
6
|
+
|
|
7
|
+
## The space
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
const space: SpaceSpec = {
|
|
11
|
+
name, permanentUrl, // permanentUrl: lowercase, digits, `-`
|
|
12
|
+
variables: { color: { card: { light, dark, default } } }, // every colour a token, both themes; tokens(variables).card
|
|
13
|
+
classes: { card }, // styles() declarations, by their own name; styles('card', tw('p-4 rounded-xl'))
|
|
14
|
+
computed: { total: '{{ … }}' }, // values read anywhere as computed.total
|
|
15
|
+
flags: { beta: { description, value: false, rules: [] } },
|
|
16
|
+
customCss: '@keyframes …', // keyframes and what no property says
|
|
17
|
+
layouts: [{ id: 'shell', body }], // chrome shared by pages
|
|
18
|
+
components: [{ id, props, slots, root }], // a block placed many times with different content
|
|
19
|
+
pages: [{ id, name, slug: '', layout: { id: 'shell', slot: 'main' }, body: [] }] // slot: where the body goes
|
|
20
|
+
}; // slug '' is home; 'products/:slug' takes a param
|
|
21
|
+
const { schema, style, warnings } = authorSpace(space, { plugins: [declaration] });
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Elements
|
|
25
|
+
|
|
26
|
+
Every factory takes the element's attributes and these fields: `id`, `class`, `css`, `states`, `slots`, `bind`,
|
|
27
|
+
`visible`, `flows`, `anchor`, `flag`, `runtime`, `loadStrategy`, `children`.
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
text('Words', { class }) // also text({ content }); a <div> — `display: inline` to flow
|
|
31
|
+
heading('Title', { subType: 'h2' }) // h1–h6; holds no children
|
|
32
|
+
paragraph('A paragraph.')
|
|
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
|
|
35
|
+
link({ href: 'home', hash: 'plans' }) // → /#plans, onto the element with anchor: 'plans'
|
|
36
|
+
button({ content: 'Save', flows }) // holds children too: then content: ''
|
|
37
|
+
image({ src, alt }) // or decorative: true
|
|
38
|
+
embed({ src, title }) // a map, a player: another page in a frame
|
|
39
|
+
svg('<svg …>…</svg>', { label }) // checked, sanitised; currentColor follows the class
|
|
40
|
+
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
|
+
carousel({ id: 'hero', items: 'p.data.slides', autoplay: 5000, row: 'slide-card', children: [/* carouselNext('hero') arrows */] })
|
|
44
|
+
// rows follow their item's `id`, or `itemKey: 'slug'`
|
|
45
|
+
apiContainer({ id: 'p', query: '/data/x.json', cache: true, children }) // publishes p.data; no box of its own
|
|
46
|
+
// loadingSlot: 'p-skeleton' — a child shown until it answers
|
|
47
|
+
form({ id, managedByInteractions: true, flows, children })
|
|
48
|
+
formControl({ name: 'email', label: 'Email', subType: 'email' }) // select: options: [{ label, value }]
|
|
49
|
+
modalContainer({ id, visible: false, title, children })
|
|
50
|
+
component('card', { props: { title }, children: { body: [ … ] } })
|
|
51
|
+
custom({ renderType: 'myPlugin' }); defineElement<Props>(declaration) // a plugin
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Styles
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
const card = styles('card', { padding: 24, borderRadius: 16 }); // shorthands, camelCase, numbers as px
|
|
58
|
+
styles('title', { fontSize: { desktop: '48px', compact: '32px' }, fontWeight: 700 }); // one property per breakpoint
|
|
59
|
+
styles('card', { css: { desktop: { … }, compact: { … } }, states: { hover: { … } }, variants: { active: { … } } });
|
|
60
|
+
container({ class: card }); // a shared class…
|
|
61
|
+
container({ id: 'hero', class: [card, { opacity: '0.5' }] }); // …plus one thing (needs an id)
|
|
62
|
+
container({ css: { padding: '8px' } }); // rules of its own — never with `class`
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Breakpoints: `desktop` (base), `tablet` (48–64rem), `mobile` (<48rem), `compact` (tablet AND mobile). Each inherits
|
|
66
|
+
from `desktop` alone. States: `hover`, `focus`, `focus-visible`, `active`, `disabled`…. An ancestor's state:
|
|
67
|
+
`ancestors: { [card.name]: { states: { hover: { … } } } }`.
|
|
68
|
+
|
|
69
|
+
## Data, bindings, visibility
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
from: 'p.data.title' // the main attribute: content, src, href, items
|
|
73
|
+
from: 'rows.item.price', as: 'price' // shown as a format (space `formats`) or a template
|
|
74
|
+
from: site.data.title // const site = source('p', sampleJson): typed, checked
|
|
75
|
+
bind: { alt: 'p.data.title' } // other attributes; short source names
|
|
76
|
+
bind: [bindTemplate('content', 'p.data.items', '{{ source|length }} items')]
|
|
77
|
+
bind: [bindTemplate('items', 'p.data.rows', '{{ source|filter(r => r.on) }}', { returns: 'value' })]
|
|
78
|
+
bind: [variantFrom(pill, 'rows.item.status')] // a variant chosen by the data
|
|
79
|
+
bind: [activeOn(navLink, 'about')] // the current page's link
|
|
80
|
+
bind: [activeWhen(dot, '{{ list_dots.index == state.slide }}')] // a variant while a condition holds
|
|
81
|
+
visible: 'p.data.signedIn' // shown while true; '!p.data.signedIn' inverts
|
|
82
|
+
visible: { source: 'p.data.rows', template: '{{ source is defined and source|length == 0 }}' }
|
|
83
|
+
visible: false // hidden until a step shows it
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Inside a list row: `rows.item` in a binding, `{{ list_rows.item.x }}` in a template or an attribute,
|
|
87
|
+
`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`),
|
|
89
|
+
`auth`, `variables`, `computed`, `flags`, `theme`, `host`.
|
|
90
|
+
|
|
91
|
+
## Flows
|
|
92
|
+
|
|
93
|
+
A flow is `[trigger, …steps]`, in an element's `flows`.
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
[onClick(), setState({ key: 'saved', type: 'boolean', value: true })] // types: boolean, number, text, json
|
|
97
|
+
[named('sent', onSubmit()), setState({ key: 'email', type: 'text', value: '{{ sent.values.email }}' })]
|
|
98
|
+
[on('onChange'), …]; [on('onMouseEnter'), …]; [onPageLoad(), …]; [onKey('mod+k, escape'), …]
|
|
99
|
+
[onInterval(5000), …] // every 5 s, while the tab is in view
|
|
100
|
+
[onClick(), scrollBy('cards', { x: '80%' })]; scrollTo('cards', { x: 'end' }); scrollIntoView('answer')
|
|
101
|
+
[named('row', onScroll()), …] // { x, y, atStart, atEnd } of its own box
|
|
102
|
+
[onClick(), toggleState({ key: 'menuOpen' })]
|
|
103
|
+
[onClick(), cycleState({ key: 'slide', length: 3, by: -1 })] // goes round; stepState({ key, by, min, max }) stops
|
|
104
|
+
[onClick(), openModal('details')]; closeModal('details')
|
|
105
|
+
[onClick(), navigate({ urlType: 'page', url: 'about' })]
|
|
106
|
+
[onClick(), runServerAction({ actionId: 'checkout', input: { id: '{{ list_rows.item.id }}' } })]
|
|
107
|
+
[onClick(), when({ field: 'state.step', operator: '=', value: '2' }, setState({ … }))]
|
|
108
|
+
[whileRunning('queue', onClick()), delay(1000), …]
|
|
109
|
+
[named('picked', declaredTrigger(declaration, 'onPick')), …]; declaredCallback(declaration, 'reset', { on: 'seats' })
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## The problems met most
|
|
113
|
+
|
|
114
|
+
Each comes with its code in brackets; reference/authoring-errors.md has a row for every one — search it by code.
|
|
115
|
+
|
|
116
|
+
| Code | Write instead |
|
|
117
|
+
| --- | --- |
|
|
118
|
+
| `class-and-css` | `class: [card, { … }]` with an `id`, or the rules in the class |
|
|
119
|
+
| `id-taken` | ids are one namespace for the whole space, pages included: build a repeated helper inside `scope()` |
|
|
120
|
+
| `unknown-attribute` | the attribute it lists; an event is a flow, not an attribute |
|
|
121
|
+
| `children-in-leaf` | a `container` — a heading made of parts is `container({ subType: 'h1' })` |
|
|
122
|
+
| `template-short-source` | the source in full in templates: `list_rows`, `apiContainer_p` |
|
|
123
|
+
| `template-unknown-name` | a source, a variable, `navigation.queryParams.x` or `source` |
|
|
124
|
+
| `template-text-into-value` | `{ returns: 'value' }` on a template feeding `items` |
|
|
125
|
+
| `list-items-ignored` | `source: 'controlled'` |
|
|
126
|
+
| `condition-starts-visible` | `visible: { source, template }`, which starts hidden |
|
|
127
|
+
| `tablet-rule-skips-mobile` | the rule under `compact` |
|
|
128
|
+
| `colour-without-dark` | `{ light, dark, default }` |
|
|
129
|
+
| `click-on-static-element` | the flow on a `button` (it holds children) or a `link` |
|
|
130
|
+
| `image-without-alt` / `control-without-name` | `alt` or `decorative: true`; `title` on an icon button |
|
|
131
|
+
| `anchor-missing` | the `anchor` on the section the `hash` names |
|
|
132
|
+
|
|
133
|
+
## Recipes
|
|
134
|
+
|
|
135
|
+
Whole, authoring examples by intent — copy one, then change the names: see the Recipes table in SKILL.md.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: plitzi-authoring
|
|
3
3
|
description: >-
|
|
4
|
-
Write or change a Plitzi space or
|
|
4
|
+
Write or change a Plitzi space or snippet in TypeScript with @plitzi/sdk-authoring — pages, layouts, components,
|
|
5
5
|
elements, CSS classes, data bindings, providers and flows — instead of hand-writing schema JSON. Use whenever the task is to
|
|
6
6
|
create, extend, restyle or fix a space: adding a page or a section, sharing a header across pages, binding an
|
|
7
7
|
element to data, showing or hiding something, wiring what happens on click, or turning an exported JSON into code.
|
|
@@ -27,10 +27,14 @@ export const space = {
|
|
|
27
27
|
const { schema, style, warnings } = authorSpace(space);
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
**Start with [the cheatsheet](CHEATSHEET.md)** — the factories, fields, steps and the problems met most, on one page —
|
|
31
|
+
and open a reference below only when the task names its subject. What an element takes, a step's params, what a
|
|
32
|
+
trigger hands its flow: `npx @plitzi/cli explain <name>` answers in a few lines (over MCP, `plitzi://explain/<name>`).
|
|
33
|
+
The published `.d.ts` documents the rest — search it by name, never read it whole.
|
|
34
|
+
|
|
35
|
+
This skill writes a space that lives in code. One that lives on Plitzi — edited in the builder, published from there —
|
|
36
|
+
is edited through the Plitzi MCP server instead, never both on one space; `plitzi create --from <space>` turns the
|
|
37
|
+
second into the first. If the MCP asks for a sign-in nobody can give, a project in code needs no account.
|
|
34
38
|
|
|
35
39
|
## How to work
|
|
36
40
|
|
|
@@ -41,8 +45,13 @@ type, the exported catalogues answer faster (`elementDefaultAttributes`, `elemen
|
|
|
41
45
|
do exactly that. Never work around a check, cast past it, or move the logic into a plugin to escape it. The first
|
|
42
46
|
refusals come one at a time; the linter's come as ONE list — fix every line of it before running again. How it
|
|
43
47
|
checks, a one-file author script for any project, and what it cannot see: [validation](reference/validation.md).
|
|
44
|
-
3. **Read every warning.**
|
|
45
|
-
|
|
48
|
+
3. **Read every warning, then every suggestion.** A warning names something written that will not do what it says —
|
|
49
|
+
zero warnings is the bar. A suggestion (`[suggest]`, `suggestions` in `authorSpace`'s result) names a shorter way to
|
|
50
|
+
the same page: a layout for chrome on every page, a component or a `list` for copies, a button's own `content`. Take
|
|
51
|
+
them, the ones that save the most first. See [efficiency](reference/efficiency.md).
|
|
52
|
+
4. **Look at it.** `npm run check -- /about --width 1440,390` says in text whether the page is whole — every element on
|
|
53
|
+
screen, nothing overflowing, a clean console; `npm run shot -- /about --width 390 --scheme dark` is the picture, for
|
|
54
|
+
when it says something is wrong (`--frames 4` shows what moves); `npm run visual`
|
|
46
55
|
runs the checks. Look at desktop, tablet and mobile, light and dark, and the page while its data is still loading.
|
|
47
56
|
5. **Go through the [review checklist](reference/review-checklist.md) before saying it is done.** It is the feedback a
|
|
48
57
|
reviewer gives on every change, written down so you do not need to hear it.
|
|
@@ -50,22 +59,28 @@ type, the exported catalogues answer faster (`elementDefaultAttributes`, `elemen
|
|
|
50
59
|
## The rules that matter most
|
|
51
60
|
|
|
52
61
|
1. **Name what is referred to.** Give an `id` to every element a binding, a flow or a test addresses. Ids are ONE
|
|
53
|
-
namespace for the whole space — layouts and every page share it — so
|
|
54
|
-
|
|
62
|
+
namespace for the whole space — layouts and every page share it — so a helper that runs more than once builds inside
|
|
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`.
|
|
55
65
|
2. **Share with classes, layouts and components, never with copies.** A look used twice is a `styles()` class.
|
|
56
66
|
Chrome shown on several pages — a header, a sidebar, a footer — is a **layout** the pages name, written once. A block
|
|
57
67
|
placed many times with different content — a product card, a testimonial — is a **component** placed with
|
|
58
|
-
`component(id, { props })`. A menu, a card grid, a list of steps is DATA
|
|
59
|
-
(`entries.map(entry => link(
|
|
60
|
-
|
|
68
|
+
`component(id, { props })`. A menu, a card grid, a list of steps is DATA, never a block pasted per item: a short
|
|
69
|
+
menu mapped in code (`entries.map(entry => link({ href, content: entry.title }))`) is fine; rows — cards, dishes,
|
|
70
|
+
tiles — are one `list` over them (`items: [ … ]`) whose row is written once. A link or a button says its words as
|
|
71
|
+
its own `content`, not through a `text` inside it. See [layouts and duplication](reference/layouts.md),
|
|
72
|
+
[components](reference/components.md) and [efficiency](reference/efficiency.md).
|
|
61
73
|
3. **One element, one base selector.** An element takes a shared `class` OR its own `css`, never both — authoring
|
|
62
|
-
refuses the pair.
|
|
74
|
+
refuses the pair. "This class plus one thing" is the class with rules on top, last in the list:
|
|
75
|
+
`class: [cover, { opacity: '0.25' }]` (needs the element's `id`; it becomes the class `<id>--own`). A look that
|
|
76
|
+
never changes is its own class.
|
|
63
77
|
4. **Visible by default; decide which way the logic flips.** An element is on screen unless something hides it. When
|
|
64
78
|
the logic REVEALS it (hidden → shown: an empty state, a "get started" card, an admin-only panel), it starts hidden —
|
|
65
79
|
`visible: 'source'`, or `visible: false` plus a binding for a computed condition — so it never flashes while data
|
|
66
80
|
loads (`condition-starts-visible` warns). When a flag HIDES it (shown → hidden: a sidebar label until the sidebar
|
|
67
81
|
is folded), it keeps the default and an absent flag must leave it shown. See
|
|
68
|
-
[data and visibility](reference/data-and-visibility.md).
|
|
82
|
+
[data and visibility](reference/data-and-visibility.md). What a PERSON switches on — a feature in beta, a rollout —
|
|
83
|
+
is a feature flag, not a visibility: gated off, it is not rendered at all. See [feature flags](reference/feature-flags.md).
|
|
69
84
|
5. **Know where a template is evaluated.** A binding's template (`bindTemplate`) and a step's params evaluate full
|
|
70
85
|
Twig. An ATTRIBUTE resolves a name with filters (`{{ list_games.item.slug }}`, `{{ post.slug|url_encode }}`) against
|
|
71
86
|
the sources around the element; a condition there is used as written. Inside any template a source is spelled in
|
|
@@ -95,82 +110,67 @@ type, the exported catalogues answer faster (`elementDefaultAttributes`, `elemen
|
|
|
95
110
|
(`hideLabel: true` hides it and keeps the name), every image an `alt` or `decorative: true`. Clicks go on a
|
|
96
111
|
`button` — it holds children, so a whole card can be one — or a `link`, never on a container. See
|
|
97
112
|
[accessibility](reference/accessibility.md).
|
|
113
|
+
14. **Motion that stays smooth.** Animate `opacity` and `transform` only; no animated blur, shadow or size. See
|
|
114
|
+
[motion](reference/colours-and-motion.md).
|
|
98
115
|
|
|
99
116
|
## Recipes
|
|
100
117
|
|
|
101
|
-
|
|
118
|
+
Each is a whole file that authors with no warning — CI holds it to that. Copy the one that fits, then change the names.
|
|
102
119
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
]
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
// Shown only when a computed condition says so (starts hidden, no flash).
|
|
123
|
-
text('Nothing here yet', { visible: { source: 'catalog.data.games', template: '{{ source is defined and source|length == 0 }}' } })
|
|
124
|
-
|
|
125
|
-
// A value used in many places: declare it once in the space, read it anywhere as computed.xp.
|
|
126
|
-
computed: { xp: '{{ (state.favourites|length) * 10 }}' }
|
|
127
|
-
|
|
128
|
-
// A button that does something: a flow is [trigger, steps…].
|
|
129
|
-
button({ content: 'Save', flows: [[onClick(), setState({ key: 'saved', type: 'boolean', value: true })]] })
|
|
130
|
-
|
|
131
|
-
// A form: the flow goes on the form, which manages its own submit.
|
|
132
|
-
form({ id: 'signup', managedByInteractions: true,
|
|
133
|
-
flows: [[named('sent', onSubmit()), setState({ key: 'email', type: 'text', value: '{{ sent.values.email }}' })]],
|
|
134
|
-
children: [formControl({ name: 'email', label: 'Email', subType: 'email' }), button({ content: 'Sign up', subType: 'submit' })] })
|
|
135
|
-
|
|
136
|
-
// A modal: hidden until a flow opens it.
|
|
137
|
-
modalContainer({ id: 'details', visible: false, title: 'Details', children: [ … ] })
|
|
138
|
-
button({ content: 'Open', flows: [[onClick(), openModal('details')]] })
|
|
139
|
-
|
|
140
|
-
// A plugin: author it from its own declaration, flow on its events, call its actions — all checked.
|
|
141
|
-
const seats = defineElement<SeatPickerAttributes>(declaration);
|
|
142
|
-
seats({ id: 'seats', flows: [[named('picked', declaredTrigger(declaration, 'onPick')), setState({ key: 'seat', type: 'text', value: '{{ picked.seat }}' })]] })
|
|
143
|
-
button({ content: 'Clear', flows: [[onClick(), declaredCallback(declaration, 'reset', { on: 'seats' })]] })
|
|
144
|
-
authorSpace(space, { plugins: [declaration] })
|
|
145
|
-
|
|
146
|
-
// Usable without sight: an icon-only button says what it does; a clickable card IS a button; a field keeps its label
|
|
147
|
-
// out of sight rather than dropping it; a picture says what it shows, or that it only decorates.
|
|
148
|
-
button({ content: '', title: 'Close', children: [fontAwesome({ icon: 'fa-solid fa-xmark' })], flows: [[onClick(), closeModal('details')]] })
|
|
149
|
-
button({ content: '', class: card, children: [text('Pro'), text('12 € a month')], flows: [[onClick(), setState({ key: 'plan', type: 'text', value: 'pro' })]] })
|
|
150
|
-
formControl({ name: 'q', label: 'Search the docs', hideLabel: true, placeholder: 'Search…' })
|
|
151
|
-
image({ src: '/team.jpg', alt: 'The team at the 2026 offsite' }); image({ src: '/grain.png', decorative: true })
|
|
152
|
-
|
|
153
|
-
// A link: to a page by its id, to a path with mode 'internal', to anything else with mode 'external'.
|
|
154
|
-
link({ href: 'about' }); link({ href: '/games/nebula', mode: 'internal' }); link({ href: 'mailto:hi@x.com', mode: 'external' })
|
|
155
|
-
```
|
|
120
|
+
| To… | Open |
|
|
121
|
+
| --- | --- |
|
|
122
|
+
| 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
|
+
| Data typed by a sample of it: completed and checked paths, typed rows | [recipes/typed-data.ts](recipes/typed-data.ts) |
|
|
124
|
+
| Filter and sort a list from a select | [recipes/filter-a-list.ts](recipes/filter-a-list.ts) |
|
|
125
|
+
| A page per record (`/products/:slug`), and "not found" | [recipes/detail-page.ts](recipes/detail-page.ts) |
|
|
126
|
+
| Link to a section of a page (`/#plans`) | [recipes/link-to-a-section.ts](recipes/link-to-a-section.ts) |
|
|
127
|
+
| A carousel: slides with arrows, dots and autoplay; a marquee; a row that swipes | [recipes/carousel.ts](recipes/carousel.ts) |
|
|
128
|
+
| Do something every few seconds — a ticker, a poll, a slideshow by hand | [recipes/every-few-seconds.ts](recipes/every-few-seconds.ts) |
|
|
129
|
+
| A marquee that scrolls for ever and stops under the pointer | [recipes/marquee.ts](recipes/marquee.ts) |
|
|
130
|
+
| A row that scrolls sideways, with arrows that hide at its ends | [recipes/scroll-a-row.ts](recipes/scroll-a-row.ts) |
|
|
131
|
+
| A link built from a row: WhatsApp with the product's name, `mailto:` | [recipes/link-with-data.ts](recipes/link-with-data.ts) |
|
|
132
|
+
| React to a click or a submit; a modal; a switch | [recipes/forms-and-modals.ts](recipes/forms-and-modals.ts) |
|
|
133
|
+
| A feature behind a flag | [recipes/feature-flag.ts](recipes/feature-flag.ts) |
|
|
134
|
+
| An element of your own (a plugin), checked | [recipes/plugin.ts](recipes/plugin.ts) |
|
|
135
|
+
| Controls a screen reader and a browser agent can use | [recipes/usable-without-sight.ts](recipes/usable-without-sight.ts) |
|
|
136
|
+
| Tokens, a shared class, "the class plus one thing", tablet and phone at once | [recipes/style-a-page.ts](recipes/style-a-page.ts) |
|
|
137
|
+
| A design in Tailwind classes, kept as classes the builder edits | [recipes/from-tailwind.ts](recipes/from-tailwind.ts) |
|
|
138
|
+
| A page from elsewhere in a frame (`embed`: a map, a player) and a drawing of your own (`svg`) | [recipes/embed-and-svg.ts](recipes/embed-and-svg.ts) |
|
|
156
139
|
|
|
157
140
|
## References
|
|
158
141
|
|
|
159
142
|
| Read | When |
|
|
160
143
|
| --- | --- |
|
|
161
144
|
| [elements-and-styles.md](reference/elements-and-styles.md) | Any element or CSS: factories, fields, classes, states, variants, tokens, fonts, lists, links |
|
|
145
|
+
| [colours-and-motion.md](reference/colours-and-motion.md) | Colours for both themes, tokens, keyframes, and motion that stays smooth (good practices) |
|
|
146
|
+
| [tailwind.md](reference/tailwind.md) | A design written in Tailwind classes: `tw()`, its breakpoints and states, what it refuses |
|
|
162
147
|
| [layouts.md](reference/layouts.md) | Anything shown on more than one page; menus; reducing duplication of elements and styles |
|
|
148
|
+
| [efficiency.md](reference/efficiency.md) | The same page with fewer elements: the suggestions `authorSpace` makes, and the short way for each long one |
|
|
163
149
|
| [components.md](reference/components.md) | One block placed many times — a card, a tier, a testimonial: props, slots, binding a row into one, why it is closed |
|
|
164
150
|
| [data-and-visibility.md](reference/data-and-visibility.md) | Bindings, providers, offline data, loading/empty/error states, live data, caching, showing and hiding, kept state |
|
|
165
|
-
| [
|
|
151
|
+
| [kept-state.md](reference/kept-state.md) | State that outlives a reload: `keepState`, transient and painted keys |
|
|
152
|
+
| [feature-flags.md](reference/feature-flags.md) | Switching a part of the space on or off — a beta, a rollout, the old version kept until the new one ships |
|
|
153
|
+
| [lists.md](reference/lists.md) | Rendering rows, filtering and sorting them, a detail page for one record, carousels |
|
|
154
|
+
| [typed-sources.md](reference/typed-sources.md) | Data typed by a sample of it: `source()`, typed rows, `twig` for templates |
|
|
166
155
|
| [validation.md](reference/validation.md) | How `authorSpace` checks, the loop that wastes no attempts, and what it cannot see |
|
|
167
156
|
| [authoring-errors.md](reference/authoring-errors.md) | What `authorSpace` refuses or warns about, and what to write instead |
|
|
168
157
|
| [templates.md](reference/templates.md) | Any `{{ … }}` or `{% … %}`: where it runs, naming sources, filters, tests, dates |
|
|
169
|
-
| [flows.md](reference/flows.md) | Clicks, submits, page loads,
|
|
158
|
+
| [flows.md](reference/flows.md) | Clicks, submits, page loads, every few seconds, server actions, modals, state |
|
|
159
|
+
| [realtime.md](reference/realtime.md) | Pages that see each other: channels, presence, cursors, a shared board, who may hear a topic |
|
|
170
160
|
| [plugins.md](reference/plugins.md) | A component of your own: props, binding them, writing state, channels, registering, behaving in the builder |
|
|
171
161
|
| [structure.md](reference/structure.md) | A space bigger than one screen: files, helpers, naming, keeping it short |
|
|
172
162
|
| [testing.md](reference/testing.md) | Any test: `inspectPage` (one call, every problem), handles, fixtures, catching a flash from the first frame, shortcuts, counting renders |
|
|
173
163
|
| [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 |
|
|
174
|
-
| [
|
|
164
|
+
| [snippets-and-export.md](reference/snippets-and-export.md) | Publishing a snippet; turning an exported JSON — or a server action — into code |
|
|
175
165
|
| [accessibility.md](reference/accessibility.md) | Icon buttons, fields, images, clickable cards, toggles, headings, landmarks, a canvas — anything a screen reader or a browser agent has to use |
|
|
176
166
|
| [review-checklist.md](reference/review-checklist.md) | Before you say it is done |
|
|
167
|
+
|
|
168
|
+
**What to read for a kind of task** — the cheatsheet, then only these:
|
|
169
|
+
|
|
170
|
+
- A site — landing, catalogue, blog: elements-and-styles, layouts, components, lists.
|
|
171
|
+
- An app with state and actions: data-and-visibility, templates, flows — realtime when pages see each other.
|
|
172
|
+
- A component of your own: plugins.
|
|
173
|
+
- A refusal or a warning: search authoring-errors.md for its code — never read it whole.
|
|
174
|
+
- Tests: testing.
|
|
175
|
+
|
|
176
|
+
Nothing else needs reading up front.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Three carousels from one element: slides that change on their own with arrows and dots, a marquee of logos that
|
|
3
|
+
* never ends, and a row of cards a visitor swipes. The slides, the dots and anything else inside read the carousel's
|
|
4
|
+
* source — `carousel_<id>.index`, `.count`, `.item`, `.items` — and the arrows and dots are steps on it.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
activeWhen,
|
|
8
|
+
bindTemplate,
|
|
9
|
+
button,
|
|
10
|
+
carousel,
|
|
11
|
+
carouselGoTo,
|
|
12
|
+
carouselNext,
|
|
13
|
+
carouselPrevious,
|
|
14
|
+
container,
|
|
15
|
+
heading,
|
|
16
|
+
list,
|
|
17
|
+
onClick,
|
|
18
|
+
styles,
|
|
19
|
+
text
|
|
20
|
+
} from '@plitzi/sdk-authoring';
|
|
21
|
+
|
|
22
|
+
import type { SpaceSpec } from '@plitzi/sdk-authoring';
|
|
23
|
+
|
|
24
|
+
const SLIDES = [
|
|
25
|
+
{ id: 'one', title: 'Lamps', lede: 'Warm light for long evenings' },
|
|
26
|
+
{ id: 'two', title: 'Paper', lede: 'Notebooks that lie flat' },
|
|
27
|
+
{ id: 'three', title: 'Pens', lede: 'Six colours, quick to dry' }
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const slide = styles('hero-slide', { padding: '64px 32px', backgroundColor: 'var(--card)', borderRadius: '16px' });
|
|
31
|
+
|
|
32
|
+
const dot = styles('hero-dot', {
|
|
33
|
+
css: { width: 10, height: 10, padding: '0px', borderRadius: '999px', backgroundColor: 'var(--muted)' },
|
|
34
|
+
variants: { active: { backgroundColor: 'var(--primary)' } }
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const dots = styles('hero-dots', { display: 'flex', gap: 8, margin: '0px', padding: '0px', listStyleType: 'none' });
|
|
38
|
+
|
|
39
|
+
// Several slides at once: the carousel's class says how wide each is and the gap between them.
|
|
40
|
+
const cards = styles('picks-row', { '--plitzi-carousel-slide-width': '240px', '--plitzi-carousel-gap': '16px' });
|
|
41
|
+
|
|
42
|
+
export const recipe: SpaceSpec = {
|
|
43
|
+
name: 'Carousels',
|
|
44
|
+
permanentUrl: 'carousels',
|
|
45
|
+
variables: {
|
|
46
|
+
color: {
|
|
47
|
+
card: { light: '#f4f4f8', dark: '#1c1c22', default: '#f4f4f8' },
|
|
48
|
+
muted: { light: '#c4c4cc', dark: '#3a3a44', default: '#c4c4cc' },
|
|
49
|
+
primary: { light: '#4f46e5', dark: '#818cf8', default: '#4f46e5' }
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
pages: [
|
|
53
|
+
{
|
|
54
|
+
id: 'home',
|
|
55
|
+
name: 'Home',
|
|
56
|
+
slug: '',
|
|
57
|
+
body: [
|
|
58
|
+
// One slide at a time, on its own every 5 s — held while the pointer or the keyboard is on it.
|
|
59
|
+
carousel({
|
|
60
|
+
id: 'hero',
|
|
61
|
+
label: 'Featured',
|
|
62
|
+
items: SLIDES,
|
|
63
|
+
autoplay: 5000,
|
|
64
|
+
row: r =>
|
|
65
|
+
container({
|
|
66
|
+
class: slide,
|
|
67
|
+
children: [heading({ subType: 'h2', from: `${r.item}.title` }), text({ from: `${r.item}.lede` })]
|
|
68
|
+
}),
|
|
69
|
+
children: [
|
|
70
|
+
button({ content: '‹', title: 'Previous slide', flows: [[onClick(), carouselPrevious('hero')]] }),
|
|
71
|
+
button({ content: '›', title: 'Next slide', flows: [[onClick(), carouselNext('hero')]] }),
|
|
72
|
+
list({
|
|
73
|
+
id: 'hero-dots',
|
|
74
|
+
class: dots,
|
|
75
|
+
items: 'hero.items',
|
|
76
|
+
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} }}`)]]
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
]
|
|
88
|
+
}),
|
|
89
|
+
// Scrolling past for ever at 40 px a second, still under the pointer.
|
|
90
|
+
carousel({
|
|
91
|
+
id: 'logos',
|
|
92
|
+
label: 'Brands we carry',
|
|
93
|
+
mode: 'marquee',
|
|
94
|
+
speed: 40,
|
|
95
|
+
items: ['Acme', 'Globex', 'Initech', 'Umbrella', 'Hooli'],
|
|
96
|
+
row: r => text({ from: r.item, css: { padding: '0px 24px', fontWeight: 600 } })
|
|
97
|
+
}),
|
|
98
|
+
// A row that swipes on a phone and snaps to each card; the arrows move it a card at a time.
|
|
99
|
+
carousel({
|
|
100
|
+
id: 'picks',
|
|
101
|
+
label: 'Picks',
|
|
102
|
+
mode: 'scroll',
|
|
103
|
+
class: cards,
|
|
104
|
+
items: SLIDES,
|
|
105
|
+
row: r => container({ class: slide, children: [text({ from: `${r.item}.title` })] }),
|
|
106
|
+
children: [
|
|
107
|
+
button({ content: '›', title: 'More picks', flows: [[onClick(), carouselNext('picks')]] }),
|
|
108
|
+
text({ from: 'picks.index', as: '{{ source + 1 }} of {{ carousel_picks.count }}' })
|
|
109
|
+
]
|
|
110
|
+
})
|
|
111
|
+
]
|
|
112
|
+
}
|
|
113
|
+
]
|
|
114
|
+
};
|