@bytesbrains/weblocks 0.2.0

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 (109) hide show
  1. package/AGENT.md +214 -0
  2. package/CATALOG.md +387 -0
  3. package/CHANGELOG.md +63 -0
  4. package/LICENSE +21 -0
  5. package/README.md +294 -0
  6. package/catalog.json +1850 -0
  7. package/lib/blocks/about.d.ts +2 -0
  8. package/lib/blocks/about.js +50 -0
  9. package/lib/blocks/accordion.d.ts +2 -0
  10. package/lib/blocks/accordion.js +51 -0
  11. package/lib/blocks/announcementBar.d.ts +2 -0
  12. package/lib/blocks/announcementBar.js +56 -0
  13. package/lib/blocks/appShell.d.ts +2 -0
  14. package/lib/blocks/appShell.js +54 -0
  15. package/lib/blocks/auth.d.ts +2 -0
  16. package/lib/blocks/auth.js +82 -0
  17. package/lib/blocks/blogList.d.ts +2 -0
  18. package/lib/blocks/blogList.js +81 -0
  19. package/lib/blocks/blogPost.d.ts +2 -0
  20. package/lib/blocks/blogPost.js +60 -0
  21. package/lib/blocks/carousel.d.ts +2 -0
  22. package/lib/blocks/carousel.js +56 -0
  23. package/lib/blocks/contactDetails.d.ts +2 -0
  24. package/lib/blocks/contactDetails.js +50 -0
  25. package/lib/blocks/contactForm.d.ts +2 -0
  26. package/lib/blocks/contactForm.js +100 -0
  27. package/lib/blocks/cta.d.ts +2 -0
  28. package/lib/blocks/cta.js +40 -0
  29. package/lib/blocks/divider.d.ts +2 -0
  30. package/lib/blocks/divider.js +28 -0
  31. package/lib/blocks/faq.d.ts +2 -0
  32. package/lib/blocks/faq.js +52 -0
  33. package/lib/blocks/features.d.ts +2 -0
  34. package/lib/blocks/features.js +58 -0
  35. package/lib/blocks/feed.d.ts +2 -0
  36. package/lib/blocks/feed.js +81 -0
  37. package/lib/blocks/footer.d.ts +2 -0
  38. package/lib/blocks/footer.js +40 -0
  39. package/lib/blocks/gallery.d.ts +2 -0
  40. package/lib/blocks/gallery.js +64 -0
  41. package/lib/blocks/hero.d.ts +2 -0
  42. package/lib/blocks/hero.js +48 -0
  43. package/lib/blocks/heroApp.d.ts +2 -0
  44. package/lib/blocks/heroApp.js +65 -0
  45. package/lib/blocks/logos.d.ts +2 -0
  46. package/lib/blocks/logos.js +59 -0
  47. package/lib/blocks/map.d.ts +2 -0
  48. package/lib/blocks/map.js +43 -0
  49. package/lib/blocks/nav.d.ts +2 -0
  50. package/lib/blocks/nav.js +39 -0
  51. package/lib/blocks/newsletter.d.ts +2 -0
  52. package/lib/blocks/newsletter.js +56 -0
  53. package/lib/blocks/pricing.d.ts +2 -0
  54. package/lib/blocks/pricing.js +89 -0
  55. package/lib/blocks/prose.d.ts +5 -0
  56. package/lib/blocks/prose.js +46 -0
  57. package/lib/blocks/richText.d.ts +2 -0
  58. package/lib/blocks/richText.js +46 -0
  59. package/lib/blocks/services.d.ts +2 -0
  60. package/lib/blocks/services.js +65 -0
  61. package/lib/blocks/sidebar.d.ts +2 -0
  62. package/lib/blocks/sidebar.js +58 -0
  63. package/lib/blocks/socialLinks.d.ts +2 -0
  64. package/lib/blocks/socialLinks.js +53 -0
  65. package/lib/blocks/spacer.d.ts +2 -0
  66. package/lib/blocks/spacer.js +25 -0
  67. package/lib/blocks/split.d.ts +2 -0
  68. package/lib/blocks/split.js +67 -0
  69. package/lib/blocks/stats.d.ts +2 -0
  70. package/lib/blocks/stats.js +64 -0
  71. package/lib/blocks/steps.d.ts +2 -0
  72. package/lib/blocks/steps.js +63 -0
  73. package/lib/blocks/tabs.d.ts +2 -0
  74. package/lib/blocks/tabs.js +57 -0
  75. package/lib/blocks/team.d.ts +2 -0
  76. package/lib/blocks/team.js +94 -0
  77. package/lib/blocks/testimonials.d.ts +2 -0
  78. package/lib/blocks/testimonials.js +54 -0
  79. package/lib/blocks/timeline.d.ts +2 -0
  80. package/lib/blocks/timeline.js +53 -0
  81. package/lib/blocks/video.d.ts +2 -0
  82. package/lib/blocks/video.js +61 -0
  83. package/lib/catalog.d.ts +26 -0
  84. package/lib/catalog.js +62 -0
  85. package/lib/generate.d.ts +35 -0
  86. package/lib/generate.js +160 -0
  87. package/lib/index.d.ts +19 -0
  88. package/lib/index.js +11 -0
  89. package/lib/ops.d.ts +85 -0
  90. package/lib/ops.js +248 -0
  91. package/lib/presets.d.ts +16 -0
  92. package/lib/presets.js +50 -0
  93. package/lib/pwa.d.ts +30 -0
  94. package/lib/pwa.js +79 -0
  95. package/lib/registry.d.ts +53 -0
  96. package/lib/registry.js +81 -0
  97. package/lib/render.d.ts +8 -0
  98. package/lib/render.js +105 -0
  99. package/lib/runtime.d.ts +68 -0
  100. package/lib/runtime.js +46 -0
  101. package/lib/schema.d.ts +67 -0
  102. package/lib/schema.js +150 -0
  103. package/lib/tokens.d.ts +37 -0
  104. package/lib/tokens.js +136 -0
  105. package/lib/types.d.ts +108 -0
  106. package/lib/types.js +10 -0
  107. package/lib/validate.d.ts +10 -0
  108. package/lib/validate.js +48 -0
  109. package/package.json +65 -0
package/lib/pwa.js ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * PWA layer (§7) — derive an installable app shell from the `SiteManifest`.
3
+ *
4
+ * Two emitted artifacts sit alongside the rendered HTML:
5
+ * - `manifest.webmanifest` — name/icons/colors/display, defaulted from meta +
6
+ * design tokens so a bare `pwa: {}` already produces a valid, installable
7
+ * manifest;
8
+ * - `sw.js` — a tiny precache + offline-fallback service worker.
9
+ *
10
+ * Both are total (never throw) and host-neutral: the host serves the returned
11
+ * files at `/manifest.webmanifest` and `/sw.js`. `emitPwa` returns null when the
12
+ * manifest does not opt into PWA, so callers can `if (files) writeAll(files)`.
13
+ */
14
+ import { normalizeTokens } from './tokens.js';
15
+ const DEFAULT_ICONS = [
16
+ { src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
17
+ { src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
18
+ { src: '/icons/icon-maskable.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
19
+ ];
20
+ /** Build the Web App Manifest object from meta + design tokens + `pwa` opts. */
21
+ export function buildWebManifest(manifest) {
22
+ const pwa = manifest?.pwa ?? {};
23
+ const tokens = normalizeTokens(manifest?.design);
24
+ const name = pwa.name || manifest?.meta?.title || 'App';
25
+ return {
26
+ name,
27
+ short_name: pwa.shortName || name.slice(0, 12),
28
+ ...(pwa.description || manifest?.meta?.description
29
+ ? { description: pwa.description || manifest.meta.description }
30
+ : {}),
31
+ start_url: pwa.startUrl || '/',
32
+ scope: pwa.scope || '/',
33
+ display: pwa.display || 'standalone',
34
+ theme_color: pwa.themeColor || tokens.palette.primary,
35
+ background_color: pwa.backgroundColor || tokens.palette.bg,
36
+ icons: pwa.icons && pwa.icons.length ? pwa.icons : DEFAULT_ICONS,
37
+ };
38
+ }
39
+ /** Serialized `manifest.webmanifest` content. */
40
+ export function buildWebManifestJson(manifest) {
41
+ return JSON.stringify(buildWebManifest(manifest), null, 2) + '\n';
42
+ }
43
+ /**
44
+ * A minimal, dependency-free service worker: precache the app shell, serve
45
+ * cache-first for same-origin GETs, and fall back to the cached shell offline.
46
+ * The cache name is derived from the manifest `version` so a new publish
47
+ * invalidates the old cache deterministically (no timestamps — reproducible).
48
+ */
49
+ export function buildServiceWorker(manifest) {
50
+ const version = typeof manifest?.version === 'number' ? manifest.version : 1;
51
+ const cache = `weblocks-v${version}`;
52
+ const shell = JSON.stringify(['/', '/manifest.webmanifest']);
53
+ return `// Auto-generated by @bytesbrains/weblocks (PWA layer). Do not edit by hand.
54
+ const CACHE='${cache}';
55
+ const SHELL=${shell};
56
+ self.addEventListener('install',function(e){e.waitUntil(caches.open(CACHE).then(function(c){return c.addAll(SHELL)}).then(function(){return self.skipWaiting()}))});
57
+ self.addEventListener('activate',function(e){e.waitUntil(caches.keys().then(function(keys){return Promise.all(keys.filter(function(k){return k!==CACHE}).map(function(k){return caches.delete(k)}))}).then(function(){return self.clients.claim()}))});
58
+ self.addEventListener('fetch',function(e){
59
+ var req=e.request;
60
+ if(req.method!=='GET'||new URL(req.url).origin!==self.location.origin)return;
61
+ e.respondWith(caches.match(req).then(function(hit){
62
+ return hit||fetch(req).then(function(res){
63
+ var copy=res.clone();caches.open(CACHE).then(function(c){c.put(req,copy)});return res;
64
+ }).catch(function(){return caches.match('/')});
65
+ }));
66
+ });
67
+ `;
68
+ }
69
+ /** Files to serve for an installable PWA, or null when not opted in. */
70
+ export function emitPwa(manifest) {
71
+ if (!manifest?.pwa)
72
+ return null;
73
+ const files = {
74
+ 'manifest.webmanifest': buildWebManifestJson(manifest),
75
+ };
76
+ if (manifest.pwa.offline !== false)
77
+ files['sw.js'] = buildServiceWorker(manifest);
78
+ return files;
79
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The block registry — the *closed vocabulary*. A block type exists iff it is
3
+ * registered here; the AI can only compose from this set, and an unknown type
4
+ * is simply skipped by the renderer (total, never throws).
5
+ *
6
+ * Each brick owns its schema (studs), its own CSS (included once when used),
7
+ * and a markup renderer. Adding a capability = registering a new brick that
8
+ * clears the "brick definition-of-done" (see docs/APPS_BLOCKS_ARCHITECTURE.md).
9
+ */
10
+ import type { Schema } from './schema.js';
11
+ import type { DesignTokens } from './types.js';
12
+ import type { RuntimeAdapter } from './runtime.js';
13
+ /** What a brick's `render` receives beyond its config + tokens (powered bricks). */
14
+ export interface RenderContext {
15
+ /** The placed block's id — stable handle for the runtime contract. */
16
+ id: string;
17
+ /** Host-provided runtime; `resolve` returns null when unwired (inert render). */
18
+ runtime: RuntimeAdapter;
19
+ }
20
+ export interface BlockSpec {
21
+ type: string;
22
+ /** One line the AI sees in its menu — what this brick is for. */
23
+ description: string;
24
+ /** Typed contract the config must satisfy (defaults + validation). */
25
+ schema: Schema;
26
+ /** The brick's own styles; emitted once per document if the brick is used. */
27
+ css?: string;
28
+ /**
29
+ * Markup only — receives an already-normalized config + the shared tokens.
30
+ * Powered bricks also read `ctx` (block id + runtime) to wire the §6 contract;
31
+ * static bricks ignore it (signature is backwards-compatible).
32
+ */
33
+ render(config: Record<string, unknown>, tokens: DesignTokens, ctx?: RenderContext): string;
34
+ /** Client island to hydrate (dynamic/interactive bricks). */
35
+ island?: string;
36
+ /**
37
+ * Powered brick: the host runtime capabilities this brick needs (§6). Absent =
38
+ * a pure static brick that works with no backend.
39
+ */
40
+ runtime?: {
41
+ capabilities: string[];
42
+ };
43
+ }
44
+ export declare const REGISTRY: ReadonlyMap<string, BlockSpec>;
45
+ export declare function getSpec(type: string): BlockSpec | undefined;
46
+ /** The vocabulary the AI is allowed to emit — its "menu" of brick types. */
47
+ export declare function blockTypes(): string[];
48
+ /**
49
+ * Whether a used block needs its island hydrated in THIS config. Static bricks
50
+ * (and interactive bricks with their behaviour toggled off, e.g. gallery with
51
+ * `lightbox:false`) ship zero JS — the "static-first" invariant.
52
+ */
53
+ export declare function needsIsland(spec: BlockSpec, config: Record<string, unknown>): boolean;
@@ -0,0 +1,81 @@
1
+ import { nav } from './blocks/nav.js';
2
+ import { announcementBar } from './blocks/announcementBar.js';
3
+ import { hero } from './blocks/hero.js';
4
+ import { heroApp } from './blocks/heroApp.js';
5
+ import { features } from './blocks/features.js';
6
+ import { about } from './blocks/about.js';
7
+ import { richText } from './blocks/richText.js';
8
+ import { split } from './blocks/split.js';
9
+ import { steps } from './blocks/steps.js';
10
+ import { stats } from './blocks/stats.js';
11
+ import { services } from './blocks/services.js';
12
+ import { pricing } from './blocks/pricing.js';
13
+ import { logos } from './blocks/logos.js';
14
+ import { team } from './blocks/team.js';
15
+ import { gallery } from './blocks/gallery.js';
16
+ import { carousel } from './blocks/carousel.js';
17
+ import { video } from './blocks/video.js';
18
+ import { map } from './blocks/map.js';
19
+ import { timeline } from './blocks/timeline.js';
20
+ import { tabs } from './blocks/tabs.js';
21
+ import { accordion } from './blocks/accordion.js';
22
+ import { testimonials } from './blocks/testimonials.js';
23
+ import { faq } from './blocks/faq.js';
24
+ import { blogList } from './blocks/blogList.js';
25
+ import { blogPost } from './blocks/blogPost.js';
26
+ import { feed } from './blocks/feed.js';
27
+ import { contactForm } from './blocks/contactForm.js';
28
+ import { newsletter } from './blocks/newsletter.js';
29
+ import { auth } from './blocks/auth.js';
30
+ import { cta } from './blocks/cta.js';
31
+ import { socialLinks } from './blocks/socialLinks.js';
32
+ import { contactDetails } from './blocks/contactDetails.js';
33
+ import { divider } from './blocks/divider.js';
34
+ import { spacer } from './blocks/spacer.js';
35
+ import { appShell } from './blocks/appShell.js';
36
+ import { sidebar } from './blocks/sidebar.js';
37
+ import { footer } from './blocks/footer.js';
38
+ // Registration order = the order block CSS is emitted (stable output) and the
39
+ // order bricks appear in the AI's menu. Roughly top-to-bottom on a page.
40
+ const SPECS = [
41
+ // chrome / app shell
42
+ appShell, nav, announcementBar, sidebar,
43
+ // heroes
44
+ hero, heroApp,
45
+ // content
46
+ features, about, richText, split, steps, stats,
47
+ services, pricing, logos, team,
48
+ // media
49
+ gallery, carousel, video, map,
50
+ // structured content
51
+ timeline, tabs, accordion, testimonials, faq,
52
+ // collections
53
+ blogList, blogPost, feed,
54
+ // dynamic / powered
55
+ contactForm, newsletter, auth,
56
+ // conversion / contact
57
+ cta, socialLinks, contactDetails,
58
+ // rhythm
59
+ divider, spacer,
60
+ footer,
61
+ ];
62
+ export const REGISTRY = new Map(SPECS.map((s) => [s.type, s]));
63
+ export function getSpec(type) {
64
+ return REGISTRY.get(type);
65
+ }
66
+ /** The vocabulary the AI is allowed to emit — its "menu" of brick types. */
67
+ export function blockTypes() {
68
+ return [...REGISTRY.keys()];
69
+ }
70
+ /**
71
+ * Whether a used block needs its island hydrated in THIS config. Static bricks
72
+ * (and interactive bricks with their behaviour toggled off, e.g. gallery with
73
+ * `lightbox:false`) ship zero JS — the "static-first" invariant.
74
+ */
75
+ export function needsIsland(spec, config) {
76
+ if (!spec.island)
77
+ return false;
78
+ if (spec.type === 'gallery')
79
+ return config.lightbox === true;
80
+ return true;
81
+ }
@@ -0,0 +1,8 @@
1
+ import { type RuntimeAdapter } from './runtime.js';
2
+ import type { SiteManifest } from './types.js';
3
+ export interface RenderOptions {
4
+ /** Host runtime for powered bricks (§6). Defaults to the inert no-op adapter. */
5
+ runtime?: RuntimeAdapter;
6
+ }
7
+ /** The full document. */
8
+ export declare function renderSite(manifest: SiteManifest, options?: RenderOptions): string;
package/lib/render.js ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The renderer — a *total* function: manifest → one self-contained static HTML
3
+ * document. It never throws. Unknown block types are skipped; missing config
4
+ * fields are defaulted by each brick's schema; only the CSS of bricks actually
5
+ * used is included. This is the second "wall" (the first being schema
6
+ * validation): together they make a broken page structurally impossible.
7
+ *
8
+ * Powered bricks (forms, auth, search) receive a `RuntimeAdapter` via the render
9
+ * options and speak the §6 contract; with the default no-op runtime they render
10
+ * inert-but-valid. PWA/SEO head tags are emitted only when the manifest opts in.
11
+ */
12
+ import { escapeAttr, escapeHtml, parse } from './schema.js';
13
+ import { getSpec, needsIsland, REGISTRY } from './registry.js';
14
+ import { normalizeTokens, sectionOverrideCss, tokensToCss } from './tokens.js';
15
+ import { NOOP_RUNTIME } from './runtime.js';
16
+ const RESET_CSS = `
17
+ *,*::before,*::after{box-sizing:border-box}
18
+ body{margin:0;font-family:var(--font);font-size:var(--fs-base);color:var(--text);background:var(--bg);line-height:1.5;-webkit-font-smoothing:antialiased}
19
+ img{max-width:100%}
20
+ .blk-scope{display:block}
21
+ @media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
22
+ `.trim().replace(/\n/g, '');
23
+ /** Render one block: normalize its config, then hand markup to the brick. */
24
+ function renderBlock(block, manifest, runtime) {
25
+ const spec = getSpec(block.type);
26
+ if (!spec)
27
+ return ''; // unknown type → skip (closed vocabulary; total)
28
+ const { value } = parse(spec.schema, block.config ?? {});
29
+ const tokens = normalizeTokens(manifest.design);
30
+ const ctx = { id: block.id, runtime };
31
+ let html = spec.render(value, tokens, ctx);
32
+ // Opt-in per-section overrides: scope palette/radius/spacing as inherited CSS
33
+ // vars on a wrapper (escaped for the style attribute).
34
+ const style = sectionOverrideCss(block.overrides);
35
+ if (style)
36
+ html = `<div class="blk-scope" style="${escapeAttr(style)}">\n${html}\n</div>`;
37
+ return html;
38
+ }
39
+ /** The full document. */
40
+ export function renderSite(manifest, options = {}) {
41
+ const runtime = options.runtime ?? NOOP_RUNTIME;
42
+ const tokens = normalizeTokens(manifest?.design);
43
+ const meta = manifest?.meta ?? { title: '', description: '', lang: '' };
44
+ const blocks = (manifest?.blocks ?? []).filter((b) => b && b.visible !== false && getSpec(b.type));
45
+ // Collect each used brick's CSS once, in registration order (stable output).
46
+ const usedTypes = new Set(blocks.map((b) => b.type));
47
+ const blockCss = [...REGISTRY.values()]
48
+ .filter((s) => usedTypes.has(s.type) && s.css)
49
+ .map((s) => s.css)
50
+ .join('\n');
51
+ const body = blocks.map((b) => renderBlock(b, manifest, runtime)).filter(Boolean).join('\n');
52
+ // Islands to hydrate. Static-only pages emit none.
53
+ const islands = new Set();
54
+ for (const b of blocks) {
55
+ const spec = getSpec(b.type);
56
+ const { value } = parse(spec.schema, b.config ?? {});
57
+ if (needsIsland(spec, value))
58
+ islands.add(spec.island);
59
+ }
60
+ const islandTags = [...islands]
61
+ .map((name) => `<script type="module" src="/_island/${escapeAttr(name)}.js" data-island="${escapeAttr(name)}"></script>`)
62
+ .join('\n');
63
+ return `<!doctype html>
64
+ <html lang="${escapeAttr(meta.lang || 'en')}">
65
+ <head>
66
+ <meta charset="utf-8">
67
+ <meta name="viewport" content="width=device-width, initial-scale=1">
68
+ <title>${escapeHtml(meta.title || 'Untitled site')}</title>${meta.description ? `\n<meta name="description" content="${escapeAttr(meta.description)}">` : ''}${headExtras(manifest, tokens.palette.primary)}
69
+ <style>${tokensToCss(tokens)}\n${RESET_CSS}\n${blockCss}</style>
70
+ </head>
71
+ <body>
72
+ ${body}${islandTags ? '\n' + islandTags : ''}
73
+ </body>
74
+ </html>`;
75
+ }
76
+ /** SEO + PWA <head> tags, emitted only when the manifest opts in. */
77
+ function headExtras(manifest, primary) {
78
+ const out = [];
79
+ const seo = manifest?.seo;
80
+ const pwa = manifest?.pwa;
81
+ const title = manifest?.meta?.title ?? '';
82
+ const desc = manifest?.meta?.description ?? '';
83
+ if (seo) {
84
+ if (seo.canonical)
85
+ out.push(`<link rel="canonical" href="${escapeAttr(seo.canonical)}">`);
86
+ if (seo.noindex)
87
+ out.push('<meta name="robots" content="noindex">');
88
+ out.push(`<meta property="og:title" content="${escapeAttr(seo.ogTitle || title)}">`);
89
+ if (seo.ogDescription || desc)
90
+ out.push(`<meta property="og:description" content="${escapeAttr(seo.ogDescription || desc)}">`);
91
+ if (seo.ogImage)
92
+ out.push(`<meta property="og:image" content="${escapeAttr(seo.ogImage)}">`);
93
+ out.push(`<meta name="twitter:card" content="${escapeAttr(seo.twitterCard || 'summary_large_image')}">`);
94
+ }
95
+ if (pwa) {
96
+ out.push('<link rel="manifest" href="/manifest.webmanifest">');
97
+ out.push(`<meta name="theme-color" content="${escapeAttr(pwa.themeColor || primary)}">`);
98
+ out.push('<meta name="mobile-web-app-capable" content="yes">');
99
+ out.push('<meta name="apple-mobile-web-app-capable" content="yes">');
100
+ if (pwa.offline !== false) {
101
+ out.push(`<script>if('serviceWorker'in navigator){addEventListener('load',function(){navigator.serviceWorker.register('/sw.js').catch(function(){})})}</script>`);
102
+ }
103
+ }
104
+ return out.length ? '\n' + out.join('\n') : '';
105
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The dynamic-block runtime CONTRACT (§6).
3
+ *
4
+ * Static bricks are pure HTML. A few bricks are *powered* — a form submits, a
5
+ * search queries, an auth widget signs in — and can't be pure static markup.
6
+ * The engine stays host-neutral by **declaring** each powered brick's runtime
7
+ * needs (capabilities) and emitting a documented client contract; the HOST wires
8
+ * the actual endpoints through a small `RuntimeAdapter`. There is no bundled
9
+ * backend, and no bespoke per-block glue — one adapter serves every dynamic
10
+ * brick.
11
+ *
12
+ * The client contract every powered brick emits:
13
+ * - the interactive element carries `data-wl-capability="<cap>"` and
14
+ * `data-wl-block="<blockId>"`;
15
+ * - a form's `action`/`method` are resolved by the adapter (or left inert with
16
+ * a documented fallback when no runtime is configured).
17
+ * A host's client island can therefore find every powered element generically
18
+ * (`[data-wl-capability]`) and enhance it, and the server implements one route
19
+ * per capability — never one per block type.
20
+ */
21
+ import type { SiteManifest } from './types.js';
22
+ /** A resolved endpoint for one capability of one placed block. */
23
+ export interface RuntimeAction {
24
+ url: string;
25
+ method: 'GET' | 'POST';
26
+ }
27
+ /**
28
+ * The host-provided runtime. Given a capability a brick declares and the id of
29
+ * the placed block, return where its client should talk to — or `null` when the
30
+ * host does not provide that capability (the brick then renders an inert,
31
+ * clearly-labelled fallback, never a broken element).
32
+ */
33
+ export interface RuntimeAdapter {
34
+ resolve(capability: string, blockId: string): RuntimeAction | null;
35
+ }
36
+ /**
37
+ * The reference no-op adapter: provides nothing. Powered bricks render their
38
+ * markup in a documented "runtime not configured" state — valid HTML, visibly
39
+ * inert, with the `data-wl-capability` hooks intact so a host can still enhance
40
+ * them client-side. This is the default so `renderSite` needs no host wiring.
41
+ */
42
+ export declare const NOOP_RUNTIME: RuntimeAdapter;
43
+ /**
44
+ * Convenience adapter for the common case: every capability maps to one path
45
+ * template under a base, keyed by capability + block id. A host that exposes
46
+ * `POST /api/<capability>/<blockId>` can wire the whole catalog in one line.
47
+ */
48
+ export declare function pathRuntime(base?: string, method?: 'GET' | 'POST'): RuntimeAdapter;
49
+ /**
50
+ * Whitelist a form method to a safe lowercase token. A `RuntimeAdapter` is typed
51
+ * to return `'GET' | 'POST'`, but a host implements it at runtime — so a powered
52
+ * brick coerces the value before writing it into a `method` attribute (anything
53
+ * that isn't a clear GET falls back to POST). Defence-in-depth: no host string
54
+ * can break out of the attribute.
55
+ */
56
+ export declare function safeMethod(method: unknown): 'get' | 'post';
57
+ /** One block's declared runtime needs. */
58
+ export interface BlockRuntimeNeeds {
59
+ blockId: string;
60
+ type: string;
61
+ capabilities: string[];
62
+ }
63
+ /**
64
+ * Aggregate the runtime capabilities a manifest requires — what a host must
65
+ * implement to make this site fully functional. Empty ⇒ a purely static site
66
+ * that needs no backend at all.
67
+ */
68
+ export declare function runtimeNeeds(manifest: SiteManifest): BlockRuntimeNeeds[];
package/lib/runtime.js ADDED
@@ -0,0 +1,46 @@
1
+ import { getSpec } from './registry.js';
2
+ /**
3
+ * The reference no-op adapter: provides nothing. Powered bricks render their
4
+ * markup in a documented "runtime not configured" state — valid HTML, visibly
5
+ * inert, with the `data-wl-capability` hooks intact so a host can still enhance
6
+ * them client-side. This is the default so `renderSite` needs no host wiring.
7
+ */
8
+ export const NOOP_RUNTIME = { resolve: () => null };
9
+ /**
10
+ * Convenience adapter for the common case: every capability maps to one path
11
+ * template under a base, keyed by capability + block id. A host that exposes
12
+ * `POST /api/<capability>/<blockId>` can wire the whole catalog in one line.
13
+ */
14
+ export function pathRuntime(base = '/api', method = 'POST') {
15
+ const root = base.replace(/\/+$/, '');
16
+ return {
17
+ resolve: (capability, blockId) => ({ url: `${root}/${capability}/${encodeURIComponent(blockId)}`, method }),
18
+ };
19
+ }
20
+ /**
21
+ * Whitelist a form method to a safe lowercase token. A `RuntimeAdapter` is typed
22
+ * to return `'GET' | 'POST'`, but a host implements it at runtime — so a powered
23
+ * brick coerces the value before writing it into a `method` attribute (anything
24
+ * that isn't a clear GET falls back to POST). Defence-in-depth: no host string
25
+ * can break out of the attribute.
26
+ */
27
+ export function safeMethod(method) {
28
+ return String(method ?? '').toLowerCase() === 'get' ? 'get' : 'post';
29
+ }
30
+ /**
31
+ * Aggregate the runtime capabilities a manifest requires — what a host must
32
+ * implement to make this site fully functional. Empty ⇒ a purely static site
33
+ * that needs no backend at all.
34
+ */
35
+ export function runtimeNeeds(manifest) {
36
+ const out = [];
37
+ for (const b of manifest?.blocks ?? []) {
38
+ if (!b || b.visible === false)
39
+ continue;
40
+ const spec = getSpec(b.type);
41
+ const caps = spec?.runtime?.capabilities;
42
+ if (caps && caps.length)
43
+ out.push({ blockId: b.id, type: b.type, capabilities: [...caps] });
44
+ }
45
+ return out;
46
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * A tiny, dependency-free schema layer — the "studs" of a brick.
3
+ *
4
+ * It is the contract the AI must satisfy, and it separates two severities so
5
+ * edit ops can be strict about garbage yet lenient about incompleteness:
6
+ * - errors (HARD): wrong type, invalid enum, out-of-range int, non-array →
7
+ * the op is malformed; reject it (illegal states unrepresentable).
8
+ * - warnings (SOFT): a required field is missing, or a value was truncated to
9
+ * its max → apply with the default/truncation (the total renderer copes),
10
+ * and tell the author.
11
+ *
12
+ * [parse] ALWAYS returns a fully-defaulted `value`, so the renderer never sees a
13
+ * hole even when the input was partial. (In production these descriptors would
14
+ * be generated from each component's Custom Elements Manifest.)
15
+ */
16
+ export type Field = {
17
+ kind: 'string';
18
+ required?: boolean;
19
+ default?: string;
20
+ max?: number;
21
+ } | {
22
+ kind: 'enum';
23
+ values: readonly string[];
24
+ required?: boolean;
25
+ default?: string;
26
+ } | {
27
+ kind: 'boolean';
28
+ default?: boolean;
29
+ } | {
30
+ kind: 'int';
31
+ oneOf?: readonly number[];
32
+ default?: number;
33
+ min?: number;
34
+ max?: number;
35
+ } | {
36
+ kind: 'object';
37
+ fields: Schema;
38
+ required?: boolean;
39
+ } | {
40
+ kind: 'array';
41
+ of: Field;
42
+ required?: boolean;
43
+ max?: number;
44
+ };
45
+ export type Schema = Record<string, Field>;
46
+ export interface ParseResult {
47
+ ok: boolean;
48
+ value: Record<string, unknown>;
49
+ errors: string[];
50
+ warnings: string[];
51
+ }
52
+ /** Validate `input` against `schema`, applying defaults. Never throws. */
53
+ export declare function parse(schema: Schema, input: unknown): ParseResult;
54
+ export declare function escapeHtml(s: unknown): string;
55
+ /** Attribute-safe escape (same set; kept separate for intent + future divergence). */
56
+ export declare function escapeAttr(s: unknown): string;
57
+ /**
58
+ * Neutralize a link target so a config value can't inject a scheme-based XSS.
59
+ * Relative/anchor URLs and the http(s)/mailto/tel schemes pass through; anything
60
+ * with another scheme (javascript:, data:, vbscript:, …) collapses to '#'.
61
+ *
62
+ * C0 control chars are stripped first — browsers ignore tabs/newlines when
63
+ * detecting a scheme, so `java\nscript:alert(1)` must be caught, not waved
64
+ * through as "no scheme". Always feed the result through [escapeAttr] for the
65
+ * attribute context.
66
+ */
67
+ export declare function sanitizeUrl(url: unknown): string;
package/lib/schema.js ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * A tiny, dependency-free schema layer — the "studs" of a brick.
3
+ *
4
+ * It is the contract the AI must satisfy, and it separates two severities so
5
+ * edit ops can be strict about garbage yet lenient about incompleteness:
6
+ * - errors (HARD): wrong type, invalid enum, out-of-range int, non-array →
7
+ * the op is malformed; reject it (illegal states unrepresentable).
8
+ * - warnings (SOFT): a required field is missing, or a value was truncated to
9
+ * its max → apply with the default/truncation (the total renderer copes),
10
+ * and tell the author.
11
+ *
12
+ * [parse] ALWAYS returns a fully-defaulted `value`, so the renderer never sees a
13
+ * hole even when the input was partial. (In production these descriptors would
14
+ * be generated from each component's Custom Elements Manifest.)
15
+ */
16
+ /** Validate `input` against `schema`, applying defaults. Never throws. */
17
+ export function parse(schema, input) {
18
+ const ctx = { errors: [], warnings: [] };
19
+ const src = isObject(input) ? input : {};
20
+ const value = {};
21
+ for (const [key, field] of Object.entries(schema)) {
22
+ value[key] = parseField(field, src[key], key, ctx);
23
+ }
24
+ return { ok: ctx.errors.length === 0, value, errors: ctx.errors, warnings: ctx.warnings };
25
+ }
26
+ function parseField(field, raw, path, ctx) {
27
+ const present = raw !== undefined && raw !== null;
28
+ switch (field.kind) {
29
+ case 'string': {
30
+ if (!present)
31
+ return missing(field.required, path, ctx, field.default ?? '');
32
+ if (typeof raw !== 'string')
33
+ return hard(path, 'string', ctx, field.default ?? '');
34
+ if (field.max !== undefined && raw.length > field.max) {
35
+ ctx.warnings.push(`${path}: truncated to ${field.max} chars`);
36
+ return raw.slice(0, field.max);
37
+ }
38
+ return raw;
39
+ }
40
+ case 'enum': {
41
+ const fallback = field.default ?? field.values[0] ?? '';
42
+ if (!present)
43
+ return missing(field.required, path, ctx, fallback);
44
+ if (typeof raw !== 'string' || !field.values.includes(raw)) {
45
+ ctx.errors.push(`${path}: must be one of ${field.values.join(' | ')}`);
46
+ return fallback;
47
+ }
48
+ return raw;
49
+ }
50
+ case 'boolean': {
51
+ if (!present)
52
+ return field.default ?? false;
53
+ if (typeof raw !== 'boolean')
54
+ return hard(path, 'boolean', ctx, field.default ?? false);
55
+ return raw;
56
+ }
57
+ case 'int': {
58
+ const def = field.default ?? field.oneOf?.[0] ?? 0;
59
+ if (!present)
60
+ return def;
61
+ if (typeof raw !== 'number' || !Number.isInteger(raw))
62
+ return hard(path, 'integer', ctx, def);
63
+ if (field.oneOf && !field.oneOf.includes(raw)) {
64
+ ctx.errors.push(`${path}: must be one of ${field.oneOf.join(', ')}`);
65
+ return def;
66
+ }
67
+ if (field.min !== undefined && raw < field.min) {
68
+ ctx.errors.push(`${path}: below min ${field.min}`);
69
+ return field.min;
70
+ }
71
+ if (field.max !== undefined && raw > field.max) {
72
+ ctx.errors.push(`${path}: above max ${field.max}`);
73
+ return field.max;
74
+ }
75
+ return raw;
76
+ }
77
+ case 'object': {
78
+ if (!present) {
79
+ if (field.required)
80
+ ctx.warnings.push(`${path}: missing`);
81
+ return parse(field.fields, {}).value;
82
+ }
83
+ const r = parse(field.fields, raw);
84
+ for (const e of r.errors)
85
+ ctx.errors.push(`${path}.${e}`);
86
+ for (const w of r.warnings)
87
+ ctx.warnings.push(`${path}.${w}`);
88
+ return r.value;
89
+ }
90
+ case 'array': {
91
+ if (!present)
92
+ return missing(field.required, path, ctx, []);
93
+ if (!Array.isArray(raw))
94
+ return hard(path, 'array', ctx, []);
95
+ const items = field.max !== undefined ? raw.slice(0, field.max) : raw;
96
+ if (field.max !== undefined && raw.length > field.max)
97
+ ctx.warnings.push(`${path}: truncated to ${field.max} items`);
98
+ return items.map((item, i) => parseField(field.of, item, `${path}[${i}]`, ctx));
99
+ }
100
+ }
101
+ }
102
+ /** Missing but not malformed → warning (soft); returns the default. */
103
+ function missing(isRequired, path, ctx, fallback) {
104
+ if (isRequired)
105
+ ctx.warnings.push(`${path}: missing`);
106
+ return fallback;
107
+ }
108
+ /** Wrong type/shape → error (hard); returns the default so render stays total. */
109
+ function hard(path, expected, ctx, fallback) {
110
+ ctx.errors.push(`${path}: expected ${expected}`);
111
+ return fallback;
112
+ }
113
+ function isObject(v) {
114
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
115
+ }
116
+ // ── HTML-escaping (every brick renders text through these — no injection) ───────
117
+ const HTML_ESCAPES = {
118
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
119
+ };
120
+ export function escapeHtml(s) {
121
+ return String(s ?? '').replace(/[&<>"']/g, (c) => HTML_ESCAPES[c] ?? c);
122
+ }
123
+ /** Attribute-safe escape (same set; kept separate for intent + future divergence). */
124
+ export function escapeAttr(s) {
125
+ return escapeHtml(s);
126
+ }
127
+ // Link schemes allowed in an href. escapeAttr neutralizes the attribute
128
+ // *delimiters* but not a dangerous *scheme*, so a link target must also pass
129
+ // through here before it is escaped.
130
+ const SAFE_URL_SCHEME = /^(?:https?|mailto|tel):/i;
131
+ /**
132
+ * Neutralize a link target so a config value can't inject a scheme-based XSS.
133
+ * Relative/anchor URLs and the http(s)/mailto/tel schemes pass through; anything
134
+ * with another scheme (javascript:, data:, vbscript:, …) collapses to '#'.
135
+ *
136
+ * C0 control chars are stripped first — browsers ignore tabs/newlines when
137
+ * detecting a scheme, so `java\nscript:alert(1)` must be caught, not waved
138
+ * through as "no scheme". Always feed the result through [escapeAttr] for the
139
+ * attribute context.
140
+ */
141
+ export function sanitizeUrl(url) {
142
+ // Strip C0 control chars + DEL (tab/newline/CR) before scheme detection.
143
+ const s = String(url ?? '').replace(/[\x00-\x1F\x7F]/g, '').trim();
144
+ if (!s)
145
+ return '#';
146
+ const hasScheme = /^[a-z][a-z0-9+.-]*:/i.test(s);
147
+ if (hasScheme && !SAFE_URL_SCHEME.test(s))
148
+ return '#';
149
+ return s;
150
+ }