@plitzi/sdk-server 0.37.9 → 0.38.1

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 (102) hide show
  1. package/CHANGELOG.md +615 -0
  2. package/README.md +17 -0
  3. package/dist/adapters/cloudAdapters.d.ts +5 -3
  4. package/dist/adapters/cloudAdapters.js +120 -34
  5. package/dist/adapters/space/types.d.ts +3 -0
  6. package/dist/core/configSeam.d.ts +14 -0
  7. package/dist/core/configSeam.js +114 -0
  8. package/dist/core/configSeam.test.d.ts +1 -0
  9. package/dist/core/createServer.js +11 -5
  10. package/dist/core/createServer.publicData.test.d.ts +1 -0
  11. package/dist/core/freePort.d.ts +10 -0
  12. package/dist/core/freePort.js +36 -0
  13. package/dist/core/freePort.test.d.ts +1 -0
  14. package/dist/core/http/stages/images.d.ts +6 -0
  15. package/dist/core/http/stages/images.js +47 -0
  16. package/dist/core/http/stages/images.test.d.ts +1 -0
  17. package/dist/core/server/drain.js +9 -4
  18. package/dist/core/server/fleet/channel.js +1 -1
  19. package/dist/core/server/fleet/relay.js +1 -1
  20. package/dist/core/services/action.js +5 -4
  21. package/dist/core/services/registry.js +2 -0
  22. package/dist/helpers/cache/keys.d.ts +23 -10
  23. package/dist/helpers/cache/keys.js +16 -2
  24. package/dist/helpers/flagOverrides.d.ts +27 -0
  25. package/dist/helpers/flagOverrides.js +25 -0
  26. package/dist/index.d.ts +1 -0
  27. package/dist/index.js +2 -1
  28. package/dist/modules/actions/moduleFor.js +5 -3
  29. package/dist/modules/actions/runtime/flags.d.ts +16 -0
  30. package/dist/modules/actions/runtime/flags.js +49 -0
  31. package/dist/modules/actions/runtime/flags.test.d.ts +1 -0
  32. package/dist/modules/actions/runtime/kvList.js +1 -1
  33. package/dist/modules/actions/runtime/runAction.js +2 -0
  34. package/dist/modules/actions/transport/callHandler.js +8 -0
  35. package/dist/modules/actions/types.d.ts +14 -1
  36. package/dist/modules/connectors/engine.js +1 -1
  37. package/dist/modules/functions/capabilities.js +1 -1
  38. package/dist/modules/functions/load.js +2 -2
  39. package/dist/modules/functions/manifest.js +1 -1
  40. package/dist/modules/functions/runner/messages.js +1 -1
  41. package/dist/modules/images/allowlist.d.ts +5 -0
  42. package/dist/modules/images/allowlist.js +18 -0
  43. package/dist/modules/images/imageProxy.d.ts +36 -0
  44. package/dist/modules/images/imageProxy.js +273 -0
  45. package/dist/modules/images/imageProxy.test.d.ts +1 -0
  46. package/dist/modules/images/transform.d.ts +10 -0
  47. package/dist/modules/images/transform.js +28 -0
  48. package/dist/modules/images/transform.test.d.ts +1 -0
  49. package/dist/modules/rsc/connectorRscData.d.ts +12 -7
  50. package/dist/modules/rsc/connectorRscData.js +12 -8
  51. package/dist/modules/rsc/handler.js +18 -4
  52. package/dist/modules/rsc/publicFileResolver.d.ts +13 -0
  53. package/dist/modules/rsc/publicFileResolver.js +39 -0
  54. package/dist/modules/rsc/publicFileResolver.test.d.ts +1 -0
  55. package/dist/modules/rsc/resolveRscData.d.ts +4 -2
  56. package/dist/modules/rsc/resolveRscData.js +10 -2
  57. package/dist/modules/runtime/bundle.js +2 -2
  58. package/dist/modules/ssr/Component.d.ts +5 -1
  59. package/dist/modules/ssr/Component.js +3 -1
  60. package/dist/modules/ssr/prepareRender.js +36 -8
  61. package/package.json +19 -14
  62. package/skills/plitzi-authoring/CHEATSHEET.md +135 -0
  63. package/skills/plitzi-authoring/SKILL.md +71 -71
  64. package/skills/plitzi-authoring/recipes/carousel.ts +114 -0
  65. package/skills/plitzi-authoring/recipes/detail-page.ts +70 -0
  66. package/skills/plitzi-authoring/recipes/embed-and-svg.ts +48 -0
  67. package/skills/plitzi-authoring/recipes/every-few-seconds.ts +119 -0
  68. package/skills/plitzi-authoring/recipes/feature-flag.ts +24 -0
  69. package/skills/plitzi-authoring/recipes/filter-a-list.ts +63 -0
  70. package/skills/plitzi-authoring/recipes/forms-and-modals.ts +64 -0
  71. package/skills/plitzi-authoring/recipes/from-tailwind.ts +52 -0
  72. package/skills/plitzi-authoring/recipes/link-to-a-section.ts +35 -0
  73. package/skills/plitzi-authoring/recipes/link-with-data.ts +40 -0
  74. package/skills/plitzi-authoring/recipes/marquee.ts +57 -0
  75. package/skills/plitzi-authoring/recipes/plugin.ts +53 -0
  76. package/skills/plitzi-authoring/recipes/scroll-a-row.ts +79 -0
  77. package/skills/plitzi-authoring/recipes/show-data.ts +73 -0
  78. package/skills/plitzi-authoring/recipes/style-a-page.ts +45 -0
  79. package/skills/plitzi-authoring/recipes/typed-data.ts +64 -0
  80. package/skills/plitzi-authoring/recipes/usable-without-sight.ts +39 -0
  81. package/skills/plitzi-authoring/reference/accessibility.md +2 -2
  82. package/skills/plitzi-authoring/reference/authoring-errors.md +195 -53
  83. package/skills/plitzi-authoring/reference/colours-and-motion.md +63 -0
  84. package/skills/plitzi-authoring/reference/components.md +9 -1
  85. package/skills/plitzi-authoring/reference/data-and-visibility.md +29 -29
  86. package/skills/plitzi-authoring/reference/efficiency.md +53 -0
  87. package/skills/plitzi-authoring/reference/elements-and-styles.md +39 -24
  88. package/skills/plitzi-authoring/reference/feature-flags.md +78 -0
  89. package/skills/plitzi-authoring/reference/flows.md +25 -36
  90. package/skills/plitzi-authoring/reference/kept-state.md +28 -0
  91. package/skills/plitzi-authoring/reference/layouts.md +20 -3
  92. package/skills/plitzi-authoring/reference/lists.md +45 -16
  93. package/skills/plitzi-authoring/reference/plugins.md +15 -0
  94. package/skills/plitzi-authoring/reference/realtime.md +35 -0
  95. package/skills/plitzi-authoring/reference/review-checklist.md +8 -3
  96. package/skills/plitzi-authoring/reference/{templates-and-export.md → snippets-and-export.md} +9 -8
  97. package/skills/plitzi-authoring/reference/structure.md +29 -20
  98. package/skills/plitzi-authoring/reference/tailwind.md +19 -0
  99. package/skills/plitzi-authoring/reference/templates.md +6 -5
  100. package/skills/plitzi-authoring/reference/testing.md +11 -0
  101. package/skills/plitzi-authoring/reference/typed-sources.md +20 -0
  102. package/skills/plitzi-authoring/reference/validation.md +6 -2
@@ -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
+ };
@@ -0,0 +1,70 @@
1
+ /* eslint-disable quotes -- templates quote their own strings, and read best in the other quotes */
2
+ /**
3
+ * A page per record: a card links to `/products/<slug>`, and the detail page narrows the same file to the product its
4
+ * address names — with a message, not an empty page, when no product has that name.
5
+ */
6
+ import { apiContainer, heading, link, list, text } from '@plitzi/sdk-authoring';
7
+
8
+ import type { SpaceSpec } from '@plitzi/sdk-authoring';
9
+
10
+ export const recipe: SpaceSpec = {
11
+ name: 'Detail',
12
+ permanentUrl: 'detail',
13
+ pages: [
14
+ {
15
+ id: 'home',
16
+ name: 'Home',
17
+ slug: '',
18
+ body: [
19
+ apiContainer({
20
+ id: 'catalog',
21
+ query: '/data/products.json',
22
+ cache: true,
23
+ children: [
24
+ list({
25
+ id: 'products',
26
+ items: 'catalog.data.products',
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' })]
32
+ })
33
+ ]
34
+ })
35
+ ]
36
+ })
37
+ ]
38
+ },
39
+ {
40
+ id: 'product',
41
+ name: 'Product',
42
+ slug: 'products/:slug',
43
+ body: [
44
+ apiContainer({
45
+ id: 'catalog-one',
46
+ query: '/data/products.json',
47
+ cache: true,
48
+ children: [
49
+ list({
50
+ id: 'this-product',
51
+ from: 'catalog-one.data.products',
52
+ as: '{{ source|filter(p => p.slug == navigation.routeParams.slug) }}',
53
+ visible: {
54
+ source: 'catalog-one.data.products',
55
+ template: "{{ source|find('slug', navigation.routeParams.slug) }}"
56
+ },
57
+ children: [heading({ subType: 'h1', from: 'this-product.item.title' })]
58
+ }),
59
+ text('No product by that name.', {
60
+ visible: {
61
+ source: 'catalog-one.data.products',
62
+ template: "{{ source is defined and not (source|find('slug', navigation.routeParams.slug)) }}"
63
+ }
64
+ })
65
+ ]
66
+ })
67
+ ]
68
+ }
69
+ ]
70
+ };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Something drawn elsewhere — a map, a video player — in an `embed`, and a drawing of your own in an `svg`.
3
+ *
4
+ * An embed says what it shows in `title`, which is what a screen reader announces, and loads lazily. An `svg` takes
5
+ * the markup alone, checked to be an SVG and with anything that runs taken out; drawn with `currentColor`, it follows
6
+ * the colour of its class in both themes. Without a `label` it is decoration, hidden from readers; with one it is an
7
+ * image that says it.
8
+ */
9
+ import { container, embed, heading, styles, svg } from '@plitzi/sdk-authoring';
10
+
11
+ import type { SpaceSpec } from '@plitzi/sdk-authoring';
12
+
13
+ const frame = styles('map-frame', { aspectRatio: '16 / 9', width: '100%', borderRadius: '12px' });
14
+
15
+ const badge = styles('badge-icon', { width: 24, height: 24, color: 'var(--primary)' });
16
+
17
+ const CHECK =
18
+ '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2">' +
19
+ '<path d="M5 12l5 5L20 7"/></svg>';
20
+
21
+ export const recipe: SpaceSpec = {
22
+ name: 'Embeds',
23
+ permanentUrl: 'embeds',
24
+ variables: { color: { primary: { light: '#4f46e5', dark: '#818cf8', default: '#4f46e5' } } },
25
+ pages: [
26
+ {
27
+ id: 'home',
28
+ name: 'Home',
29
+ slug: '',
30
+ body: [
31
+ container({
32
+ subType: 'section',
33
+ children: [
34
+ heading('Find us', { subType: 'h2' }),
35
+ embed({
36
+ id: 'shop-map',
37
+ class: frame,
38
+ src: 'https://www.openstreetmap.org/export/embed.html?bbox=-3.71,40.41,-3.69,40.42',
39
+ title: 'Our shop on the map'
40
+ })
41
+ ]
42
+ }),
43
+ svg(CHECK, { class: badge }),
44
+ svg(CHECK, { class: badge, label: 'Verified' })
45
+ ]
46
+ }
47
+ ]
48
+ };
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Something every few seconds — here a carousel that turns by itself: `onInterval` moves `state.slide` on, a hover
3
+ * pauses it, arrows and dots move it by hand, and each slide enters with an animation.
4
+ *
5
+ * Every slide is rendered and all but one hidden by `visible`. A hidden element is `display: none`, so its CSS
6
+ * animation runs again each time it is shown: that is the entrance. The clock ticks only while the tab is in view.
7
+ */
8
+ import {
9
+ button,
10
+ container,
11
+ cycleState,
12
+ heading,
13
+ list,
14
+ on,
15
+ onClick,
16
+ onInterval,
17
+ setState,
18
+ styles,
19
+ text,
20
+ activeWhen,
21
+ when
22
+ } from '@plitzi/sdk-authoring';
23
+
24
+ import type { SpaceSpec } from '@plitzi/sdk-authoring';
25
+
26
+ const SLIDES = [
27
+ { title: 'New in store', body: 'This week’s arrivals.' },
28
+ { title: 'Free delivery', body: 'On orders over 50 €.' },
29
+ { title: 'Trade in', body: 'Your old card, against a new one.' }
30
+ ];
31
+
32
+ // The slide after the last is the first: a cycle as long as the slides.
33
+ const next = cycleState({ key: 'slide', length: SLIDES.length });
34
+ const previous = cycleState({ key: 'slide', length: SLIDES.length, by: -1 });
35
+
36
+ const slide = styles('slide', { padding: '48px 24px', animation: 'slide-in 400ms ease-out' });
37
+
38
+ const dots = styles('dots', { display: 'flex', gap: '8px', margin: '0px', padding: '0px', 'list-style-type': 'none' });
39
+
40
+ const dot = styles('dot', {
41
+ css: {
42
+ width: '10px',
43
+ height: '10px',
44
+ padding: '0px',
45
+ 'border-radius': '999px',
46
+ border: '0px solid transparent',
47
+ 'background-color': 'var(--muted)'
48
+ },
49
+ variants: { active: { 'background-color': 'var(--primary)' } }
50
+ });
51
+
52
+ export const recipe: SpaceSpec = {
53
+ name: 'Carousel',
54
+ permanentUrl: 'carousel',
55
+ variables: {
56
+ color: {
57
+ muted: { light: '#c4c4cc', dark: '#3a3a44', default: '#c4c4cc' },
58
+ primary: { light: '#4f46e5', dark: '#818cf8', default: '#4f46e5' }
59
+ }
60
+ },
61
+ customCss:
62
+ '@keyframes slide-in { from { opacity: 0; transform: translateX(24px); } to { opacity: 1; transform: none; } }',
63
+ pages: [
64
+ {
65
+ id: 'home',
66
+ name: 'Home',
67
+ slug: '',
68
+ body: [
69
+ container({
70
+ id: 'carousel',
71
+ subType: 'section',
72
+ label: 'Offers',
73
+ flows: [
74
+ // Each tick turns it, unless the pointer is on it.
75
+ [onInterval(5000), when({ field: 'state.carouselPaused', operator: '!=', value: 'true' }, next)],
76
+ [on('onMouseEnter'), setState({ key: 'carouselPaused', type: 'boolean', value: true })],
77
+ [on('onMouseLeave'), setState({ key: 'carouselPaused', type: 'boolean', value: false })]
78
+ ],
79
+ children: [
80
+ list({
81
+ id: 'slides',
82
+ items: SLIDES,
83
+ children: [
84
+ container({
85
+ class: slide,
86
+ visible: { source: 'slides.index', template: '{{ source == (state.slide ?? 0) }}' },
87
+ children: [heading({ subType: 'h2', from: 'slides.item.title' }), text({ from: 'slides.item.body' })]
88
+ })
89
+ ]
90
+ }),
91
+ button({
92
+ content: 'Previous',
93
+ flows: [[onClick(), previous]]
94
+ }),
95
+ button({
96
+ content: 'Next',
97
+ flows: [[onClick(), next]]
98
+ }),
99
+ list({
100
+ id: 'dots',
101
+ class: dots,
102
+ items: SLIDES,
103
+ 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 }}' })]]
110
+ })
111
+ ]
112
+ }),
113
+ text({ from: 'state.slide', as: '{{ (source ?? 0) + 1 }} of 3' })
114
+ ]
115
+ })
116
+ ]
117
+ }
118
+ ]
119
+ };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * A feature behind a flag: declared once in the space, both versions gated — exactly one is ever rendered, and the
3
+ * flag is switched per environment without touching the space.
4
+ */
5
+ import { container, text } from '@plitzi/sdk-authoring';
6
+
7
+ import type { SpaceSpec } from '@plitzi/sdk-authoring';
8
+
9
+ export const recipe: SpaceSpec = {
10
+ name: 'Flags',
11
+ permanentUrl: 'flags',
12
+ flags: { newCheckout: { description: 'The one-step checkout', value: false, rules: [] } },
13
+ pages: [
14
+ {
15
+ id: 'home',
16
+ name: 'Home',
17
+ slug: '',
18
+ body: [
19
+ container({ id: 'checkout-new', flag: 'newCheckout', children: [text('One step')] }),
20
+ container({ id: 'checkout-old', flag: '!newCheckout', children: [text('Three steps')] })
21
+ ]
22
+ }
23
+ ]
24
+ };
@@ -0,0 +1,63 @@
1
+ /* eslint-disable quotes -- templates quote their own strings, and read best in the other quotes */
2
+ /**
3
+ * Filter and sort a list: a select writes the chosen category to `state`, and the list is bound through a template
4
+ * that hands over its VALUE — only the matching rows are rendered, with a count and an empty state over the same rows.
5
+ */
6
+ import { apiContainer, formControl, list, named, on, setState, text } from '@plitzi/sdk-authoring';
7
+
8
+ import type { SpaceSpec } from '@plitzi/sdk-authoring';
9
+
10
+ // One predicate, read three times: the rows, their count, and whether there are none.
11
+ const shown =
12
+ "source|filter(p => (state.category ?? 'all') == 'all' or p.category == state.category)|sort((a, b) => a.price - b.price)";
13
+
14
+ export const recipe: SpaceSpec = {
15
+ name: 'Filtered',
16
+ permanentUrl: 'filtered',
17
+ pages: [
18
+ {
19
+ id: 'home',
20
+ name: 'Home',
21
+ slug: '',
22
+ body: [
23
+ // A control outside a form keeps its own value and fires `onChange` with `{ value, name }`: no form needed.
24
+ formControl({
25
+ name: 'category',
26
+ label: 'Category',
27
+ subType: 'select',
28
+ options: [
29
+ { label: 'Everything', value: 'all' },
30
+ { label: 'Processors', value: 'cpu' },
31
+ { label: 'Graphics cards', value: 'gpu' }
32
+ ],
33
+ defaultValue: 'all',
34
+ // A filter always has an answer: the select is not a question that must be filled in.
35
+ required: false,
36
+ flows: [
37
+ [named('picked', on('onChange')), setState({ key: 'category', type: 'text', value: '{{ picked.value }}' })]
38
+ ]
39
+ }),
40
+ apiContainer({
41
+ id: 'catalog',
42
+ query: '/data/products.json',
43
+ cache: true,
44
+ children: [
45
+ text({ from: 'catalog.data.products', as: `{{ ${shown}|length }} shown` }),
46
+ list({
47
+ id: 'shown',
48
+ from: 'catalog.data.products',
49
+ as: `{{ ${shown} }}`,
50
+ children: [text({ from: 'shown.item.title' })]
51
+ }),
52
+ text('Nothing in that category.', {
53
+ visible: {
54
+ source: 'catalog.data.products',
55
+ template: `{{ source is defined and ${shown}|length == 0 }}`
56
+ }
57
+ })
58
+ ]
59
+ })
60
+ ]
61
+ }
62
+ ]
63
+ };
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Do something on a click or a submit: a flow is `[trigger, steps…]`. A form manages its own submit and hands its
3
+ * values to the flow; a modal starts hidden and a step opens it.
4
+ */
5
+ import {
6
+ button,
7
+ closeModal,
8
+ fontAwesome,
9
+ form,
10
+ formControl,
11
+ modalContainer,
12
+ named,
13
+ onClick,
14
+ onSubmit,
15
+ openModal,
16
+ setState,
17
+ text,
18
+ toggleState
19
+ } from '@plitzi/sdk-authoring';
20
+
21
+ import type { SpaceSpec } from '@plitzi/sdk-authoring';
22
+
23
+ export const recipe: SpaceSpec = {
24
+ name: 'Flows',
25
+ permanentUrl: 'flows',
26
+ pages: [
27
+ {
28
+ id: 'home',
29
+ name: 'Home',
30
+ slug: '',
31
+ body: [
32
+ button({ content: 'Save', flows: [[onClick(), setState({ key: 'saved', type: 'boolean', value: true })]] }),
33
+ // A switch is named for how it leaves its default: shown until somebody hides it.
34
+ button({ content: 'Hide details', flows: [[onClick(), toggleState({ key: 'detailsHidden' })]] }),
35
+ form({
36
+ id: 'signup',
37
+ managedByInteractions: true,
38
+ flows: [
39
+ [named('sent', onSubmit()), setState({ key: 'email', type: 'text', value: '{{ sent.values.email }}' })]
40
+ ],
41
+ children: [
42
+ formControl({ name: 'email', label: 'Email', subType: 'email' }),
43
+ button({ content: 'Sign up', subType: 'submit' })
44
+ ]
45
+ }),
46
+ modalContainer({
47
+ id: 'details',
48
+ visible: false,
49
+ title: 'Details',
50
+ children: [
51
+ text('What the modal says.'),
52
+ button({
53
+ content: '',
54
+ title: 'Close',
55
+ children: [fontAwesome({ icon: 'fa-solid fa-xmark' })],
56
+ flows: [[onClick(), closeModal('details')]]
57
+ })
58
+ ]
59
+ }),
60
+ button({ content: 'Open', flows: [[onClick(), openModal('details')]] })
61
+ ]
62
+ }
63
+ ]
64
+ };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * A design written in Tailwind classes, kept as classes the builder edits: `tw()` turns them into rules when the space
3
+ * is written. The space's colours stay tokens with both themes — `createTw({ colors: tokens(variables) })` — and
4
+ * Tailwind's own palette is there for the rest.
5
+ */
6
+ import { container, createTw, heading, link, styles, text, tokens } from '@plitzi/sdk-authoring';
7
+
8
+ import type { SpaceSpec } from '@plitzi/sdk-authoring';
9
+
10
+ const variables = {
11
+ color: {
12
+ surface: { light: '#ffffff', dark: '#0f172a', default: '#ffffff' },
13
+ ink: { light: '#0f172a', dark: '#e2e8f0', default: '#0f172a' },
14
+ accent: { light: '#0891b2', dark: '#22d3ee', default: '#0891b2' }
15
+ }
16
+ } satisfies SpaceSpec['variables'];
17
+
18
+ const tw = createTw({ colors: tokens(variables) });
19
+
20
+ const grid = styles('feature-grid', tw('grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3'));
21
+ // The card's title moves with the card's hover: an ancestor named by its class.
22
+ const card = styles(
23
+ 'feature-card',
24
+ tw('rounded-xl border border-ink/10 bg-surface p-6 transition-shadow hover:shadow-lg')
25
+ );
26
+ const title = styles('feature-title', tw('text-lg font-semibold text-ink group-hover/feature-card:text-accent'));
27
+ const cta = styles(
28
+ 'feature-cta',
29
+ tw('inline-flex items-center gap-2 rounded-full bg-accent px-5 py-2 text-sm font-medium text-white hover:scale-105')
30
+ );
31
+
32
+ export const recipe: SpaceSpec = {
33
+ name: 'Features',
34
+ permanentUrl: 'features',
35
+ variables,
36
+ pages: [
37
+ {
38
+ id: 'home',
39
+ name: 'Home',
40
+ slug: '',
41
+ body: [
42
+ container({
43
+ class: grid,
44
+ children: ['Fast', 'Typed', 'Yours'].map(name =>
45
+ container({ class: card, children: [heading(name, { subType: 'h3', class: title }), text('…')] })
46
+ )
47
+ }),
48
+ link({ href: '/start', class: cta, children: [text('Get started')] })
49
+ ]
50
+ }
51
+ ]
52
+ };
@@ -0,0 +1,35 @@
1
+ /**
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.
4
+ */
5
+ import { container, heading, link, text } from '@plitzi/sdk-authoring';
6
+
7
+ import type { SpaceSpec } from '@plitzi/sdk-authoring';
8
+
9
+ export const recipe: SpaceSpec = {
10
+ name: 'Sections',
11
+ permanentUrl: 'sections',
12
+ pages: [
13
+ {
14
+ id: 'home',
15
+ name: 'Home',
16
+ slug: '',
17
+ body: [
18
+ link({ href: 'home', hash: 'plans', children: [text('See the plans')] }),
19
+ container({
20
+ id: 'plans-section',
21
+ subType: 'section',
22
+ anchor: 'plans',
23
+ children: [heading('Plans', { subType: 'h2' }), text('Three of them.')]
24
+ })
25
+ ]
26
+ },
27
+ {
28
+ id: 'about',
29
+ name: 'About',
30
+ slug: 'about',
31
+ // → /#plans
32
+ body: [link({ href: 'home', hash: 'plans', children: [text('Plans')] })]
33
+ }
34
+ ]
35
+ };
@@ -0,0 +1,40 @@
1
+ /**
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
+ * message. An attribute resolves a name with filters, so the text is encoded with `url_encode`.
4
+ */
5
+ import { apiContainer, fontAwesome, link, list, text } from '@plitzi/sdk-authoring';
6
+
7
+ import type { SpaceSpec } from '@plitzi/sdk-authoring';
8
+
9
+ export const recipe: SpaceSpec = {
10
+ name: 'Contact',
11
+ permanentUrl: 'contact',
12
+ pages: [
13
+ {
14
+ id: 'home',
15
+ name: 'Home',
16
+ slug: '',
17
+ body: [
18
+ apiContainer({
19
+ id: 'catalog',
20
+ query: '/data/products.json',
21
+ cache: true,
22
+ children: [
23
+ list({
24
+ id: 'products',
25
+ items: 'catalog.data.products',
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')] })
33
+ ]
34
+ })
35
+ ]
36
+ })
37
+ ]
38
+ }
39
+ ]
40
+ };