@bytesbrains/weblocks 0.2.0 → 0.4.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.
package/AGENT.md CHANGED
@@ -46,17 +46,19 @@ its exact config schema:
46
46
  - `catalogPrompt()` — a compact text menu for a system prompt.
47
47
  - `CATALOG.md` — the same, human-readable.
48
48
 
49
- The 37 block types, by group:
49
+ The 40 block types, by group:
50
50
 
51
51
  - **Chrome/app-shell:** `nav`, `app-shell`, `sidebar`, `announcement-bar`, `footer`
52
52
  - **Heroes:** `hero`, `hero-app`
53
53
  - **Content:** `features`, `about`, `rich-text`, `split`, `steps`, `stats`,
54
54
  `services-catalogue`, `pricing`, `logos`, `team`
55
55
  - **Media:** `gallery`, `carousel`, `video`, `map`
56
+ - **Location:** `directions` (deep links to open the visitor’s map app)
56
57
  - **Structured:** `timeline`, `tabs`, `accordion`, `testimonials`, `faq`
57
58
  - **Collections:** `blog-list`, `blog-post`, `feed`
58
- - **Dynamic (powered):** `contact-form`, `newsletter`, `auth`
59
+ - **Dynamic (powered):** `contact-form`, `newsletter`, `search`, `auth`
59
60
  - **Conversion/rhythm:** `cta`, `social-links`, `contact-details`, `divider`, `spacer`
61
+ - **Legal:** `legal` (terms/privacy links that open safe-Markdown dialogs; content is Markdown, never raw HTML)
60
62
 
61
63
  ## Composing a manifest
62
64
 
@@ -152,8 +154,8 @@ cheap:
152
154
 
153
155
  ## Powered (dynamic) blocks
154
156
 
155
- `contact-form`, `newsletter`, and `auth` need a backend. You still only fill their
156
- typed config (fields, labels, providers). The block declares the runtime
157
+ `contact-form`, `newsletter`, `search`, and `auth` need a backend. You still only
158
+ fill their typed config (fields, labels, providers, placeholder). The block declares the runtime
157
159
  capability it needs; the **host** wires the endpoint. If no runtime is wired, the
158
160
  block renders inert-but-valid — that is expected, not an error. Never put secrets,
159
161
  endpoints, captcha keys, or backend logic in the config.
package/CATALOG.md CHANGED
@@ -1,8 +1,8 @@
1
- # @bytesbrains/weblocks — Block Catalog (v0.2.0)
1
+ # @bytesbrains/weblocks — Block Catalog (v0.4.0)
2
2
 
3
3
  The AI composes a `SiteManifest` (`{ meta, design, blocks[] }`) using **only** the block types below, then the engine validates + renders it to static HTML. This file is generated from the code (`npm run emit:catalog`) — do not edit by hand.
4
4
 
5
- **Block types:** `app-shell` · `nav` · `announcement-bar` · `sidebar` · `hero` · `hero-app` · `features` · `about` · `rich-text` · `split` · `steps` · `stats` · `services-catalogue` · `pricing` · `logos` · `team` · `gallery` · `carousel` · `video` · `map` · `timeline` · `tabs` · `accordion` · `testimonials` · `faq` · `blog-list` · `blog-post` · `feed` · `contact-form` · `newsletter` · `auth` · `cta` · `social-links` · `contact-details` · `divider` · `spacer` · `footer`
5
+ **Block types:** `app-shell` · `nav` · `announcement-bar` · `sidebar` · `hero` · `hero-app` · `features` · `about` · `rich-text` · `split` · `steps` · `stats` · `services-catalogue` · `pricing` · `logos` · `team` · `gallery` · `carousel` · `video` · `map` · `timeline` · `tabs` · `accordion` · `testimonials` · `faq` · `blog-list` · `blog-post` · `feed` · `contact-form` · `newsletter` · `search` · `auth` · `cta` · `social-links` · `contact-details` · `directions` · `legal` · `divider` · `spacer` · `footer`
6
6
 
7
7
  ## `app-shell`
8
8
 
@@ -315,6 +315,19 @@ An email-capture form that posts to a host-provided runtime; renders inert until
315
315
  | `submitLabel` | string | | |
316
316
  | `successMessage` | string | | |
317
317
 
318
+ ## `search`
319
+
320
+ A site search rendered as a full search bar or a compact expanding icon button; queries a host-provided search runtime.
321
+
322
+ | field | type | required | notes |
323
+ |---|---|---|---|
324
+ | `layout` | undefined (bar\|icon) | | |
325
+ | `placeholder` | string | | |
326
+ | `label` | string | | |
327
+ | `buttonLabel` | string | | |
328
+ | `name` | string | | |
329
+ | `align` | undefined (start\|center\|end) | | |
330
+
318
331
  ## `auth`
319
332
 
320
333
  A provider-agnostic sign in / sign up panel: social provider buttons and an optional email form that start auth via a host runtime.
@@ -358,6 +371,32 @@ A contact info block: address, phone, email, and opening hours (each optional).
358
371
  | `email` | string | | |
359
372
  | `hours` | string | | |
360
373
 
374
+ ## `directions`
375
+
376
+ A location card with deep links that open the visitor’s map app (Google/Apple Maps) for directions, from an address, GPS coordinates, or a pasted map link.
377
+
378
+ | field | type | required | notes |
379
+ |---|---|---|---|
380
+ | `title` | string | | |
381
+ | `place` | string | | |
382
+ | `address` | string | | |
383
+ | `lat` | string | | |
384
+ | `lng` | string | | |
385
+ | `mapUrl` | string | | |
386
+ | `directionsLabel` | string | | |
387
+ | `appleMaps` | boolean | | |
388
+
389
+ ## `legal`
390
+
391
+ Policy links (terms, privacy, cookies, …) that open as scrollable, dismissible dialogs; each body is safe Markdown (never raw HTML). No JavaScript.
392
+
393
+ | field | type | required | notes |
394
+ |---|---|---|---|
395
+ | `title` | string | | |
396
+ | `align` | undefined (start\|center\|end) | | |
397
+ | `separator` | string | | |
398
+ | `documents` | array | | |
399
+
361
400
  ## `divider`
362
401
 
363
402
  A visual section break rendered as a thin line, a dotted rule, or a gradient bar.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,34 @@ follows [semantic versioning](https://semver.org): the **block catalog** and the
5
5
  **`SiteManifest` shape** are the public contract — additive block/field changes
6
6
  are minor, breaking changes to either are major.
7
7
 
8
+ ## 0.4.0 — 2026-07-16
9
+
10
+ Additive and non-breaking — every `0.3.0` manifest still validates and renders
11
+ identically. Catalog 39 → 40.
12
+
13
+ ### Added
14
+ - **`legal` block** — policy links (Terms, Privacy, Cookies, …) that open as
15
+ **scrollable, dismissible dialogs** (pure CSS `:target`, no JavaScript). Each
16
+ document's body is authored in a **safe Markdown subset** (headings, lists,
17
+ links, emphasis, quotes, rules) — rich formatting, but **never raw HTML** (any
18
+ literal HTML is escaped, not executed). Exports `renderMarkdown`. Catalog now
19
+ 40 blocks.
20
+
21
+ ## 0.3.0 — 2026-07-16
22
+
23
+ Additive and non-breaking — every `0.2.0` manifest still validates and renders
24
+ identically. Catalog 37 → 39.
25
+
26
+ ### Added
27
+ - **`search` block** — site search rendered as a full **search bar** or a
28
+ compact **expanding icon button** (CSS-only, no JavaScript). A powered block
29
+ that queries a host `search.query` runtime; degrades to an inert, valid GET
30
+ form when unwired.
31
+ - **`directions` block** — a location card with **deep links that open the
32
+ visitor’s map app for directions** (Google Maps universal URL + optional Apple
33
+ Maps), built from an address, GPS coordinates, or a pasted map link. Static,
34
+ no runtime. Catalog now 39 blocks.
35
+
8
36
  ## 0.2.0 — 2026-07-16
9
37
 
10
38
  Rich, app-like PWAs. Additive and **non-breaking**: every `0.1.0` manifest still
package/README.md CHANGED
@@ -139,7 +139,7 @@ import catalogJson from '@bytesbrains/weblocks/catalog.json' with { type: 'json'
139
139
 
140
140
  ## Block catalog
141
141
 
142
- **37 typed blocks.** Full field reference in [`CATALOG.md`](./CATALOG.md).
142
+ **40 typed blocks.** Full field reference in [`CATALOG.md`](./CATALOG.md).
143
143
 
144
144
  | Group | Blocks |
145
145
  |---|---|
@@ -147,10 +147,12 @@ import catalogJson from '@bytesbrains/weblocks/catalog.json' with { type: 'json'
147
147
  | Heroes | `hero` · `hero-app` |
148
148
  | Content | `features` · `about` · `rich-text` · `split` · `steps` · `stats` · `services-catalogue` · `pricing` · `logos` · `team` |
149
149
  | Media | `gallery` · `carousel` · `video` · `map` |
150
+ | Location | `directions` (deep links to the visitor’s map app) |
150
151
  | Structured | `timeline` · `tabs` · `accordion` · `testimonials` · `faq` |
151
152
  | Collections | `blog-list` · `blog-post` · `feed` |
152
- | Dynamic (powered) | `contact-form` · `newsletter` · `auth` |
153
+ | Dynamic (powered) | `contact-form` · `newsletter` · `search` · `auth` |
153
154
  | Conversion / rhythm | `cta` · `social-links` · `contact-details` · `divider` · `spacer` |
155
+ | Legal | `legal` (terms/privacy links → safe-Markdown dialogs) |
154
156
 
155
157
  `rich-text` and `blog-post` carry **typed** content nodes (headings, paragraphs,
156
158
  quotes, lists) — a safe freeform-content escape hatch that is never raw HTML.
package/catalog.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "package": "@bytesbrains/weblocks",
4
- "version": "0.2.0",
4
+ "version": "0.4.0",
5
5
  "description": "Block catalog for @bytesbrains/weblocks. Send this to the model as its API reference; it composes a SiteManifest ({ meta, design, blocks[] }) using ONLY these block types. The engine then validates and renders it.",
6
6
  "blockTypes": [
7
7
  "app-shell",
@@ -34,10 +34,13 @@
34
34
  "feed",
35
35
  "contact-form",
36
36
  "newsletter",
37
+ "search",
37
38
  "auth",
38
39
  "cta",
39
40
  "social-links",
40
41
  "contact-details",
42
+ "directions",
43
+ "legal",
41
44
  "divider",
42
45
  "spacer",
43
46
  "footer"
@@ -1581,6 +1584,56 @@
1581
1584
  ]
1582
1585
  }
1583
1586
  },
1587
+ {
1588
+ "type": "search",
1589
+ "description": "A site search rendered as a full search bar or a compact expanding icon button; queries a host-provided search runtime.",
1590
+ "schema": {
1591
+ "type": "object",
1592
+ "properties": {
1593
+ "layout": {
1594
+ "enum": [
1595
+ "bar",
1596
+ "icon"
1597
+ ],
1598
+ "default": "bar"
1599
+ },
1600
+ "placeholder": {
1601
+ "type": "string",
1602
+ "maxLength": 80,
1603
+ "default": "Search…"
1604
+ },
1605
+ "label": {
1606
+ "type": "string",
1607
+ "maxLength": 60,
1608
+ "default": "Search"
1609
+ },
1610
+ "buttonLabel": {
1611
+ "type": "string",
1612
+ "maxLength": 40,
1613
+ "default": "Search"
1614
+ },
1615
+ "name": {
1616
+ "type": "string",
1617
+ "maxLength": 40,
1618
+ "default": "q"
1619
+ },
1620
+ "align": {
1621
+ "enum": [
1622
+ "start",
1623
+ "center",
1624
+ "end"
1625
+ ],
1626
+ "default": "center"
1627
+ }
1628
+ },
1629
+ "additionalProperties": false
1630
+ },
1631
+ "runtime": {
1632
+ "capabilities": [
1633
+ "search.query"
1634
+ ]
1635
+ }
1636
+ },
1584
1637
  {
1585
1638
  "type": "auth",
1586
1639
  "description": "A provider-agnostic sign in / sign up panel: social provider buttons and an optional email form that start auth via a host runtime.",
@@ -1761,6 +1814,111 @@
1761
1814
  "additionalProperties": false
1762
1815
  }
1763
1816
  },
1817
+ {
1818
+ "type": "directions",
1819
+ "description": "A location card with deep links that open the visitor’s map app (Google/Apple Maps) for directions, from an address, GPS coordinates, or a pasted map link.",
1820
+ "schema": {
1821
+ "type": "object",
1822
+ "properties": {
1823
+ "title": {
1824
+ "type": "string",
1825
+ "maxLength": 120,
1826
+ "default": ""
1827
+ },
1828
+ "place": {
1829
+ "type": "string",
1830
+ "maxLength": 120,
1831
+ "default": ""
1832
+ },
1833
+ "address": {
1834
+ "type": "string",
1835
+ "maxLength": 240,
1836
+ "default": ""
1837
+ },
1838
+ "lat": {
1839
+ "type": "string",
1840
+ "maxLength": 24,
1841
+ "default": ""
1842
+ },
1843
+ "lng": {
1844
+ "type": "string",
1845
+ "maxLength": 24,
1846
+ "default": ""
1847
+ },
1848
+ "mapUrl": {
1849
+ "type": "string",
1850
+ "maxLength": 500,
1851
+ "default": ""
1852
+ },
1853
+ "directionsLabel": {
1854
+ "type": "string",
1855
+ "maxLength": 40,
1856
+ "default": "Get directions"
1857
+ },
1858
+ "appleMaps": {
1859
+ "type": "boolean",
1860
+ "default": true
1861
+ }
1862
+ },
1863
+ "additionalProperties": false
1864
+ }
1865
+ },
1866
+ {
1867
+ "type": "legal",
1868
+ "description": "Policy links (terms, privacy, cookies, …) that open as scrollable, dismissible dialogs; each body is safe Markdown (never raw HTML). No JavaScript.",
1869
+ "schema": {
1870
+ "type": "object",
1871
+ "properties": {
1872
+ "title": {
1873
+ "type": "string",
1874
+ "maxLength": 120,
1875
+ "default": ""
1876
+ },
1877
+ "align": {
1878
+ "enum": [
1879
+ "start",
1880
+ "center",
1881
+ "end"
1882
+ ],
1883
+ "default": "center"
1884
+ },
1885
+ "separator": {
1886
+ "type": "string",
1887
+ "maxLength": 4,
1888
+ "default": "·"
1889
+ },
1890
+ "documents": {
1891
+ "type": "array",
1892
+ "items": {
1893
+ "type": "object",
1894
+ "properties": {
1895
+ "label": {
1896
+ "type": "string",
1897
+ "maxLength": 60,
1898
+ "default": ""
1899
+ },
1900
+ "title": {
1901
+ "type": "string",
1902
+ "maxLength": 120,
1903
+ "default": ""
1904
+ },
1905
+ "content": {
1906
+ "type": "string",
1907
+ "maxLength": 40000,
1908
+ "default": ""
1909
+ }
1910
+ },
1911
+ "required": [
1912
+ "label"
1913
+ ],
1914
+ "additionalProperties": false
1915
+ },
1916
+ "maxItems": 8
1917
+ }
1918
+ },
1919
+ "additionalProperties": false
1920
+ }
1921
+ },
1764
1922
  {
1765
1923
  "type": "divider",
1766
1924
  "description": "A visual section break rendered as a thin line, a dotted rule, or a gradient bar.",
@@ -0,0 +1,2 @@
1
+ import type { BlockSpec } from '../registry.js';
2
+ export declare const directions: BlockSpec;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * `directions` — a location card with **deep links that open the visitor's map
3
+ * app for directions**. Unlike `map` (an inline embed), this is lightweight and
4
+ * link-based: give it an address, GPS coordinates, and/or a pasted map link, and
5
+ * it builds cross-platform "Get directions" links.
6
+ *
7
+ * - Google Maps universal URL (`.../maps/dir/?api=1&destination=…`) opens the
8
+ * Google Maps app on iOS/Android when installed, else the web.
9
+ * - An optional Apple Maps link (`maps.apple.com/?daddr=…`) for iOS users.
10
+ * - GPS coordinates, when valid, are used as a precise destination.
11
+ *
12
+ * Static brick — no runtime, no JS. Links are built with `encodeURIComponent`;
13
+ * a pasted `mapUrl` is scheme-sanitized.
14
+ */
15
+ import { escapeAttr, escapeHtml, sanitizeUrl } from '../schema.js';
16
+ const PIN = '<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21s-7-6.5-7-11a7 7 0 0 1 14 0c0 4.5-7 11-7 11z"></path><circle cx="12" cy="10" r="2.5"></circle></svg>';
17
+ const schema = {
18
+ title: { kind: 'string', default: '', max: 120 },
19
+ place: { kind: 'string', default: '', max: 120 }, // name of the location
20
+ address: { kind: 'string', default: '', max: 240 }, // shown + query fallback
21
+ lat: { kind: 'string', default: '', max: 24 }, // GPS latitude (e.g. 38.7223)
22
+ lng: { kind: 'string', default: '', max: 24 }, // GPS longitude (e.g. -9.1393)
23
+ mapUrl: { kind: 'string', default: '', max: 500 }, // a pasted Google/other map link
24
+ directionsLabel: { kind: 'string', default: 'Get directions', max: 40 },
25
+ appleMaps: { kind: 'boolean', default: true }, // also offer an Apple Maps link
26
+ };
27
+ const css = `
28
+ .blk-directions{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
29
+ .blk-directions .wrap{max-width:640px;margin:0 auto}
30
+ .blk-directions h2{font-size:var(--fs-xl);margin:0 0 var(--space);font-weight:800;text-align:center}
31
+ .blk-directions .card{display:flex;gap:1em;align-items:flex-start;background:var(--surface);border:1px solid color-mix(in srgb,var(--text) 12%,transparent);border-radius:var(--radius);padding:calc(var(--space)*1.2)}
32
+ .blk-directions .pin{flex:0 0 auto;display:inline-flex;color:var(--primary);line-height:0;padding-top:.15em}
33
+ .blk-directions .info{flex:1;min-width:0}
34
+ .blk-directions .place{margin:0;font-weight:700;font-size:var(--fs-lg)}
35
+ .blk-directions .addr{margin:.25em 0 0;color:var(--muted)}
36
+ .blk-directions .coords{margin:.25em 0 0;color:var(--muted);font-size:var(--fs-base);font-variant-numeric:tabular-nums}
37
+ .blk-directions .actions{display:flex;flex-wrap:wrap;gap:.5em;margin-top:calc(var(--space)*.9)}
38
+ .blk-directions .btn{display:inline-flex;align-items:center;gap:.4em;text-decoration:none;font-weight:600;font-size:var(--fs-base);padding:.55em 1em;border-radius:var(--radius);border:1px solid var(--primary);color:var(--primary);transition:opacity var(--motion),background var(--motion)}
39
+ .blk-directions .btn.primary{background:var(--primary);color:var(--on-primary);border-color:var(--primary)}
40
+ .blk-directions .btn:hover{opacity:.9}
41
+ @media(max-width:440px){.blk-directions .card{flex-direction:column}.blk-directions .actions .btn{flex:1;justify-content:center}}
42
+ `.trim();
43
+ /** Parse a coordinate string to a valid lat/lng pair, or null. */
44
+ function coords(latRaw, lngRaw) {
45
+ const latStr = String(latRaw ?? '').trim();
46
+ const lngStr = String(lngRaw ?? '').trim();
47
+ if (!latStr || !lngStr)
48
+ return null; // empty → not set (Number('') is 0, so guard first)
49
+ const lat = Number(latStr);
50
+ const lng = Number(lngStr);
51
+ if (!Number.isFinite(lat) || !Number.isFinite(lng))
52
+ return null;
53
+ if (Math.abs(lat) > 90 || Math.abs(lng) > 180)
54
+ return null;
55
+ return { lat, lng };
56
+ }
57
+ function render(config) {
58
+ const title = config.title;
59
+ const place = config.place;
60
+ const address = config.address;
61
+ const gps = coords(config.lat, config.lng);
62
+ const mapUrl = sanitizeUrl(config.mapUrl);
63
+ const hasMapUrl = mapUrl !== '#' && !!config.mapUrl;
64
+ const directionsLabel = config.directionsLabel || 'Get directions';
65
+ const appleMaps = config.appleMaps !== false;
66
+ // Precise coords win as the destination; else the address; else the place name.
67
+ const dest = gps ? `${gps.lat},${gps.lng}` : (address || place);
68
+ const enc = dest ? encodeURIComponent(dest) : '';
69
+ const gDir = enc ? `https://www.google.com/maps/dir/?api=1&destination=${enc}` : '';
70
+ const aDir = enc ? `https://maps.apple.com/?daddr=${enc}` : '';
71
+ const btn = (href, label, primary = false) => `<a class="btn${primary ? ' primary' : ''}" href="${escapeAttr(href)}" target="_blank" rel="noopener">${escapeHtml(label)}</a>`;
72
+ const actions = [
73
+ gDir && btn(gDir, directionsLabel, true),
74
+ appleMaps && aDir && btn(aDir, 'Apple Maps'),
75
+ hasMapUrl && btn(mapUrl, 'Open map'),
76
+ ].filter(Boolean).join('\n ');
77
+ return `<section class="blk-directions" aria-label="${escapeAttr(title || place || 'Directions')}">
78
+ <div class="wrap">
79
+ ${title ? `<h2>${escapeHtml(title)}</h2>` : ''}
80
+ <div class="card">
81
+ <span class="pin" aria-hidden="true">${PIN}</span>
82
+ <div class="info">
83
+ ${place ? `<p class="place">${escapeHtml(place)}</p>` : ''}
84
+ ${address ? `<p class="addr">${escapeHtml(address)}</p>` : ''}
85
+ ${gps ? `<p class="coords">${escapeHtml(`${gps.lat}, ${gps.lng}`)}</p>` : ''}
86
+ ${actions ? `<div class="actions">\n ${actions}\n </div>` : ''}
87
+ </div>
88
+ </div>
89
+ </div>
90
+ </section>`;
91
+ }
92
+ export const directions = {
93
+ type: 'directions',
94
+ description: 'A location card with deep links that open the visitor’s map app (Google/Apple Maps) for directions, from an address, GPS coordinates, or a pasted map link.',
95
+ schema,
96
+ css,
97
+ render,
98
+ };
@@ -0,0 +1,2 @@
1
+ import type { BlockSpec } from '../registry.js';
2
+ export declare const legal: BlockSpec;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * `legal` — policy links (Terms, Privacy, Cookies, …) that open as **scrollable,
3
+ * dismissible dialogs**. Each document's body is authored in a SAFE Markdown
4
+ * subset (see markdown.ts) — rich formatting, but never raw HTML (any literal
5
+ * HTML is escaped, not executed). Static brick: the dialog is pure CSS
6
+ * (`:target`), so it works with zero JavaScript — click a link to open, click
7
+ * the ✕ or the backdrop to dismiss.
8
+ *
9
+ * Drop it near the `footer` for the familiar "Terms · Privacy" footer row.
10
+ */
11
+ import { escapeAttr, escapeHtml } from '../schema.js';
12
+ import { renderMarkdown } from '../markdown.js';
13
+ const schema = {
14
+ title: { kind: 'string', default: '', max: 120 },
15
+ align: { kind: 'enum', values: ['start', 'center', 'end'], default: 'center' },
16
+ separator: { kind: 'string', default: '·', max: 4 },
17
+ documents: {
18
+ kind: 'array', max: 8,
19
+ of: {
20
+ kind: 'object',
21
+ fields: {
22
+ label: { kind: 'string', required: true, default: '', max: 60 }, // link text
23
+ title: { kind: 'string', default: '', max: 120 }, // dialog heading (defaults to label)
24
+ content: { kind: 'string', default: '', max: 40000 }, // Markdown body
25
+ },
26
+ },
27
+ },
28
+ };
29
+ const css = `
30
+ .blk-legal{padding:var(--space) var(--space);background:var(--bg);color:var(--text)}
31
+ .blk-legal .links{display:flex;flex-wrap:wrap;align-items:center;gap:.35em .8em;max-width:1080px;margin:0 auto}
32
+ .blk-legal.align-center .links{justify-content:center}
33
+ .blk-legal.align-end .links{justify-content:flex-end}
34
+ .blk-legal .lead{color:var(--muted);font-size:var(--fs-base)}
35
+ .blk-legal .links a.open{color:var(--muted);text-decoration:none;font-size:var(--fs-base);border-bottom:1px solid transparent}
36
+ .blk-legal .links a.open:hover,.blk-legal .links a.open:focus-visible{color:var(--primary);border-bottom-color:currentColor}
37
+ .blk-legal .sep{color:var(--muted);opacity:.6;user-select:none}
38
+ /* Dialog — hidden until its id is the URL :target. */
39
+ .blk-legal .modal{position:fixed;inset:0;z-index:1000;display:none;padding:calc(var(--space)*1.2)}
40
+ .blk-legal .modal:target{display:grid;place-items:center}
41
+ .blk-legal .backdrop{position:absolute;inset:0;background:color-mix(in srgb,#000 55%,transparent);border:0;display:block}
42
+ .blk-legal .panel{position:relative;display:flex;flex-direction:column;width:min(680px,100%);max-height:85vh;background:var(--surface);color:var(--text);border-radius:var(--radius);box-shadow:0 24px 60px -12px rgba(0,0,0,.5);overflow:hidden}
43
+ .blk-legal .pbar{display:flex;align-items:center;justify-content:space-between;gap:1em;padding:calc(var(--space)*1.1) calc(var(--space)*1.3);border-bottom:1px solid color-mix(in srgb,var(--text) 12%,transparent)}
44
+ .blk-legal .pbar h2{margin:0;font-size:var(--fs-lg);font-weight:800}
45
+ .blk-legal .close{flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;width:2em;height:2em;border-radius:999px;text-decoration:none;color:var(--muted);font-size:1.3em;line-height:1;background:color-mix(in srgb,var(--text) 6%,transparent)}
46
+ .blk-legal .close:hover,.blk-legal .close:focus-visible{color:var(--text);background:color-mix(in srgb,var(--text) 12%,transparent)}
47
+ .blk-legal .body{overflow:auto;padding:calc(var(--space)*1.3);line-height:1.65;overscroll-behavior:contain}
48
+ .blk-legal .body>:first-child{margin-top:0}
49
+ .blk-legal .body>:last-child{margin-bottom:0}
50
+ .blk-legal .body h2{font-size:var(--fs-lg);font-weight:800;margin:1.4em 0 .4em}
51
+ .blk-legal .body h3{font-size:var(--fs-lg);font-weight:700;margin:1.2em 0 .3em}
52
+ .blk-legal .body h4{font-size:var(--fs-base);font-weight:700;margin:1em 0 .3em;text-transform:uppercase;letter-spacing:.03em;color:var(--muted)}
53
+ .blk-legal .body p{margin:0 0 1em}
54
+ .blk-legal .body ul,.blk-legal .body ol{margin:0 0 1em;padding-left:1.4em}
55
+ .blk-legal .body li{margin:.3em 0}
56
+ .blk-legal .body a{color:var(--primary);text-decoration:underline;text-underline-offset:2px}
57
+ .blk-legal .body blockquote{margin:1em 0;padding:.2em 0 .2em 1em;border-left:3px solid var(--accent);color:var(--muted)}
58
+ .blk-legal .body code{background:color-mix(in srgb,var(--text) 8%,transparent);padding:.1em .35em;border-radius:4px;font-size:.9em}
59
+ .blk-legal .body hr{border:0;border-top:1px solid color-mix(in srgb,var(--text) 15%,transparent);margin:1.4em 0}
60
+ /* Lock background scroll while a dialog is open (progressive; no-op if unsupported). */
61
+ html:has(.blk-legal .modal:target){overflow:hidden}
62
+ `.trim();
63
+ function render(config, _tokens, ctx) {
64
+ const bid = String(ctx?.id ?? 'legal').replace(/[^A-Za-z0-9_-]/g, '') || 'legal';
65
+ const title = config.title;
66
+ const align = ['start', 'center', 'end'].includes(config.align) ? config.align : 'center';
67
+ const sep = escapeHtml(config.separator || '·');
68
+ const docs = (config.documents ?? []).filter((d) => d && d.label);
69
+ const links = docs
70
+ .map((d, i) => `<a class="open" id="open-${bid}-${i}" href="#${bid}-${i}">${escapeHtml(d.label)}</a>`)
71
+ .join(`<span class="sep" aria-hidden="true">${sep}</span>`);
72
+ const dialogs = docs.map((d, i) => {
73
+ const did = `${bid}-${i}`;
74
+ const heading = d.title || d.label;
75
+ return `<div class="modal" id="${did}" role="dialog" aria-modal="true" aria-labelledby="h-${did}">
76
+ <a class="backdrop" href="#open-${did}" aria-label="Close" tabindex="-1"></a>
77
+ <div class="panel">
78
+ <div class="pbar">
79
+ <h2 id="h-${did}">${escapeHtml(heading)}</h2>
80
+ <a class="close" href="#open-${did}" aria-label="Close dialog">&times;</a>
81
+ </div>
82
+ <div class="body">${renderMarkdown(d.content)}</div>
83
+ </div>
84
+ </div>`;
85
+ }).join('\n ');
86
+ return `<section class="blk-legal align-${align}" aria-label="${escapeAttr(title || 'Legal')}">
87
+ <div class="links">
88
+ ${title ? `<span class="lead">${escapeHtml(title)}</span>` : ''}
89
+ ${links}
90
+ </div>
91
+ ${dialogs}
92
+ </section>`;
93
+ }
94
+ export const legal = {
95
+ type: 'legal',
96
+ description: 'Policy links (terms, privacy, cookies, …) that open as scrollable, dismissible dialogs; each body is safe Markdown (never raw HTML). No JavaScript.',
97
+ schema,
98
+ css,
99
+ render,
100
+ };
@@ -0,0 +1,2 @@
1
+ import type { BlockSpec } from '../registry.js';
2
+ export declare const search: BlockSpec;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * `search` — site search as either a full **bar** or a compact **icon** button
3
+ * that expands on focus (CSS-only, no JavaScript). Powered brick (§6): the query
4
+ * target is resolved by the host via the `search.query` capability. With no
5
+ * runtime wired it degrades to a harmless same-page GET form, keeping the
6
+ * `data-wl-*` hooks so a host can enhance it client-side.
7
+ */
8
+ import { escapeAttr, escapeHtml } from '../schema.js';
9
+ import { NOOP_RUNTIME, safeMethod } from '../runtime.js';
10
+ const CAPABILITY = 'search.query';
11
+ // Inline magnifier — self-contained, inherits colour via currentColor.
12
+ const ICON = '<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><line x1="20" y1="20" x2="16.65" y2="16.65"></line></svg>';
13
+ const schema = {
14
+ layout: { kind: 'enum', values: ['bar', 'icon'], default: 'bar' },
15
+ placeholder: { kind: 'string', default: 'Search…', max: 80 },
16
+ label: { kind: 'string', default: 'Search', max: 60 }, // accessible name
17
+ buttonLabel: { kind: 'string', default: 'Search', max: 40 }, // submit text (bar layout)
18
+ name: { kind: 'string', default: 'q', max: 40 }, // query param name
19
+ align: { kind: 'enum', values: ['start', 'center', 'end'], default: 'center' },
20
+ };
21
+ const css = `
22
+ .blk-search{padding:var(--space) var(--space);background:var(--bg);color:var(--text);display:flex}
23
+ .blk-search.align-start{justify-content:flex-start}
24
+ .blk-search.align-center{justify-content:center}
25
+ .blk-search.align-end{justify-content:flex-end}
26
+ .blk-search .field{display:flex;align-items:center;background:var(--surface);border:1px solid color-mix(in srgb,var(--text) 16%,transparent);border-radius:var(--radius);transition:border-color var(--motion),box-shadow var(--motion)}
27
+ .blk-search .field:focus-within{border-color:var(--primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--primary) 18%,transparent)}
28
+ .blk-search .ico{display:inline-flex;align-items:center;justify-content:center;padding:.5em .2em .5em .65em;color:var(--muted);cursor:text;line-height:0}
29
+ .blk-search .input{font:inherit;color:inherit;background:transparent;border:0;outline:0;padding:.55em .5em;min-width:0}
30
+ .blk-search .input::placeholder{color:var(--muted)}
31
+ .blk-search .go{display:inline-flex;align-items:center;font:inherit;font-weight:700;cursor:pointer;border:0;border-radius:var(--radius);white-space:nowrap}
32
+ .blk-search .vh{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
33
+ /* Bar: input always open, text submit button. */
34
+ .blk-search.layout-bar .field{width:min(520px,100%)}
35
+ .blk-search.layout-bar .input{flex:1}
36
+ .blk-search.layout-bar .go{background:var(--primary);color:var(--on-primary);padding:.5em 1em;margin:3px}
37
+ .blk-search.layout-bar .go:hover{opacity:.9}
38
+ /* Icon: collapsed magnifier that expands on focus-within; Enter submits. */
39
+ .blk-search.layout-icon .field{border-radius:999px}
40
+ .blk-search.layout-icon .input{width:0;padding-left:0;padding-right:0;opacity:0;transition:width var(--motion),opacity var(--motion),padding var(--motion)}
41
+ .blk-search.layout-icon .field:focus-within .input{width:min(240px,60vw);opacity:1;padding:.55em .5em .55em .2em}
42
+ .blk-search.layout-icon .go{display:none}
43
+ @media(max-width:480px){.blk-search.layout-icon .field:focus-within .input{width:52vw}}
44
+ `.trim();
45
+ function render(config, _tokens, ctx) {
46
+ const id = ctx?.id ?? 'search';
47
+ const runtime = ctx?.runtime ?? NOOP_RUNTIME;
48
+ const layout = config.layout === 'icon' ? 'icon' : 'bar';
49
+ const align = ['start', 'center', 'end'].includes(config.align) ? config.align : 'center';
50
+ const label = config.label || 'Search';
51
+ const placeholder = config.placeholder;
52
+ const buttonLabel = config.buttonLabel || 'Search';
53
+ const qname = String(config.name ?? 'q').replace(/[^A-Za-z0-9_-]/g, '') || 'q';
54
+ const fid = `s-${String(id).replace(/[^A-Za-z0-9_-]/g, '') || 'search'}`;
55
+ const action = runtime.resolve(CAPABILITY, id);
56
+ // Search is a query, so GET by default; the host adapter may override.
57
+ const method = action ? safeMethod(action.method) : 'get';
58
+ return `<section class="blk-search layout-${layout} align-${align}" aria-label="${escapeAttr(label)}">
59
+ <form class="wrap" role="search" method="${method}" action="${action ? escapeAttr(action.url) : '#'}" data-wl-capability="${CAPABILITY}" data-wl-block="${escapeAttr(id)}"${action ? '' : ' data-wl-inert="true"'}>
60
+ <div class="field">
61
+ <label class="ico" for="${escapeAttr(fid)}" aria-hidden="true">${ICON}</label>
62
+ <input class="input" id="${escapeAttr(fid)}" type="search" name="${escapeAttr(qname)}" placeholder="${escapeAttr(placeholder)}" aria-label="${escapeAttr(label)}" autocomplete="off">
63
+ <button class="go" type="submit"><span class="go-label">${escapeHtml(buttonLabel)}</span></button>
64
+ </div>
65
+ </form>
66
+ </section>`;
67
+ }
68
+ export const search = {
69
+ type: 'search',
70
+ description: 'A site search rendered as a full search bar or a compact expanding icon button; queries a host-provided search runtime.',
71
+ schema,
72
+ css,
73
+ render,
74
+ runtime: { capabilities: [CAPABILITY] },
75
+ };
package/lib/index.d.ts CHANGED
@@ -14,6 +14,7 @@ export { NOOP_RUNTIME, pathRuntime, runtimeNeeds, type RuntimeAdapter, type Runt
14
14
  export { buildWebManifest, buildWebManifestJson, buildServiceWorker, emitPwa, type WebAppManifest, } from './pwa.js';
15
15
  export { validateBlock, validateManifest, type Validation } from './validate.js';
16
16
  export { parse, escapeHtml, escapeAttr, sanitizeUrl, type Field, type Schema, type ParseResult } from './schema.js';
17
+ export { renderMarkdown } from './markdown.js';
17
18
  export { catalog, catalogPrompt, type BlockCatalogEntry, type JsonSchema } from './catalog.js';
18
19
  export { applyOp, applyOps, type EditOp, type OpResult, type BatchResult } from './ops.js';
19
20
  export { generateSite, editSite, buildGenerationPrompt, buildEditPrompt, parseManifestResponse, parseOpsResponse, type ModelCall, type ComposeResult, type EditResult, } from './generate.js';
package/lib/index.js CHANGED
@@ -6,6 +6,7 @@ export { NOOP_RUNTIME, pathRuntime, runtimeNeeds, } from './runtime.js';
6
6
  export { buildWebManifest, buildWebManifestJson, buildServiceWorker, emitPwa, } from './pwa.js';
7
7
  export { validateBlock, validateManifest } from './validate.js';
8
8
  export { parse, escapeHtml, escapeAttr, sanitizeUrl } from './schema.js';
9
+ export { renderMarkdown } from './markdown.js';
9
10
  export { catalog, catalogPrompt } from './catalog.js';
10
11
  export { applyOp, applyOps } from './ops.js';
11
12
  export { generateSite, editSite, buildGenerationPrompt, buildEditPrompt, parseManifestResponse, parseOpsResponse, } from './generate.js';
@@ -0,0 +1,2 @@
1
+ /** Render a Markdown-subset string to safe HTML. */
2
+ export declare function renderMarkdown(src: unknown): string;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * A tiny, SAFE Markdown-subset renderer — escape-first, whitelist-only.
3
+ *
4
+ * This is NOT a raw-HTML passthrough (that would break the engine's no-injection
5
+ * invariant, see VISION.md §3). Every character of the source is HTML-escaped
6
+ * BEFORE any formatting is applied, so a literal `<script>` renders as text,
7
+ * never markup. Only a fixed set of safe elements is ever produced, and link
8
+ * hrefs are scheme-sanitized. Total: it never throws.
9
+ *
10
+ * Supported:
11
+ * #, ##, ###… headings (→ h2…h6)
12
+ * - or * unordered list 1. ordered list
13
+ * > blockquote --- / *** / ___ horizontal rule
14
+ * blank line paragraph break
15
+ * **bold** *italic* _italic_ `code` [text](url)
16
+ */
17
+ import { escapeHtml, sanitizeUrl } from './schema.js';
18
+ /** Inline formatting. Operates on ALREADY-ESCAPED text; only adds safe tags. */
19
+ function inline(escaped) {
20
+ let s = escaped;
21
+ // `code` first, so its contents aren't touched by the emphasis rules.
22
+ s = s.replace(/`([^`]+)`/g, (_m, c) => `<code>${c}</code>`);
23
+ // [text](url) — url is taken from escaped text (already attribute-safe) and
24
+ // scheme-sanitized; not re-escaped (that would double-encode `&`).
25
+ s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, text, url) => `<a href="${sanitizeUrl(url)}" rel="noopener">${text}</a>`);
26
+ s = s.replace(/\*\*([^*]+)\*\*/g, (_m, t) => `<strong>${t}</strong>`);
27
+ s = s.replace(/(^|[^*])\*([^*\s][^*]*?)\*/g, (_m, pre, t) => `${pre}<em>${t}</em>`);
28
+ s = s.replace(/(^|[^_\w])_([^_]+)_/g, (_m, pre, t) => `${pre}<em>${t}</em>`);
29
+ return s;
30
+ }
31
+ const esc = (raw) => inline(escapeHtml(raw));
32
+ /** Render a Markdown-subset string to safe HTML. */
33
+ export function renderMarkdown(src) {
34
+ const lines = String(src ?? '').replace(/\r\n?/g, '\n').split('\n');
35
+ const out = [];
36
+ let para = [];
37
+ const flush = () => { if (para.length) {
38
+ out.push(`<p>${esc(para.join(' '))}</p>`);
39
+ para = [];
40
+ } };
41
+ for (let i = 0; i < lines.length;) {
42
+ const t = lines[i].trim();
43
+ if (!t) {
44
+ flush();
45
+ i++;
46
+ continue;
47
+ }
48
+ if (/^(-{3,}|\*{3,}|_{3,})$/.test(t)) {
49
+ flush();
50
+ out.push('<hr>');
51
+ i++;
52
+ continue;
53
+ }
54
+ const h = /^(#{1,6})\s+(.*)$/.exec(t);
55
+ if (h) {
56
+ flush();
57
+ out.push(`<h${Math.min(h[1].length + 1, 6)}>${esc(h[2].trim())}</h${Math.min(h[1].length + 1, 6)}>`);
58
+ i++;
59
+ continue;
60
+ }
61
+ if (/^>\s?/.test(t)) {
62
+ flush();
63
+ const q = [];
64
+ for (; i < lines.length && /^>\s?/.test(lines[i].trim()); i++)
65
+ q.push(lines[i].trim().replace(/^>\s?/, ''));
66
+ out.push(`<blockquote>${esc(q.join(' '))}</blockquote>`);
67
+ continue;
68
+ }
69
+ if (/^[-*]\s+/.test(t)) {
70
+ flush();
71
+ const items = [];
72
+ for (; i < lines.length && /^[-*]\s+/.test(lines[i].trim()); i++)
73
+ items.push(lines[i].trim().replace(/^[-*]\s+/, ''));
74
+ out.push(`<ul>${items.map((it) => `<li>${esc(it)}</li>`).join('')}</ul>`);
75
+ continue;
76
+ }
77
+ if (/^\d+\.\s+/.test(t)) {
78
+ flush();
79
+ const items = [];
80
+ for (; i < lines.length && /^\d+\.\s+/.test(lines[i].trim()); i++)
81
+ items.push(lines[i].trim().replace(/^\d+\.\s+/, ''));
82
+ out.push(`<ol>${items.map((it) => `<li>${esc(it)}</li>`).join('')}</ol>`);
83
+ continue;
84
+ }
85
+ para.push(t);
86
+ i++;
87
+ }
88
+ flush();
89
+ return out.join('\n');
90
+ }
package/lib/registry.js CHANGED
@@ -26,10 +26,13 @@ import { blogPost } from './blocks/blogPost.js';
26
26
  import { feed } from './blocks/feed.js';
27
27
  import { contactForm } from './blocks/contactForm.js';
28
28
  import { newsletter } from './blocks/newsletter.js';
29
+ import { search } from './blocks/search.js';
29
30
  import { auth } from './blocks/auth.js';
30
31
  import { cta } from './blocks/cta.js';
31
32
  import { socialLinks } from './blocks/socialLinks.js';
32
33
  import { contactDetails } from './blocks/contactDetails.js';
34
+ import { directions } from './blocks/directions.js';
35
+ import { legal } from './blocks/legal.js';
33
36
  import { divider } from './blocks/divider.js';
34
37
  import { spacer } from './blocks/spacer.js';
35
38
  import { appShell } from './blocks/appShell.js';
@@ -52,9 +55,9 @@ const SPECS = [
52
55
  // collections
53
56
  blogList, blogPost, feed,
54
57
  // dynamic / powered
55
- contactForm, newsletter, auth,
58
+ contactForm, newsletter, search, auth,
56
59
  // conversion / contact
57
- cta, socialLinks, contactDetails,
60
+ cta, socialLinks, contactDetails, directions, legal,
58
61
  // rhythm
59
62
  divider, spacer,
60
63
  footer,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytesbrains/weblocks",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Block engine for AI-composable web apps — the AI composes a SiteManifest from a fixed block catalog; the engine validates and renders it to static HTML. Snap-together \"Lego\" web building blocks, shareable across repos.",
5
5
  "keywords": [
6
6
  "ai",
@@ -18,7 +18,9 @@
18
18
  "license": "MIT",
19
19
  "author": "bytesbrains",
20
20
  "type": "module",
21
- "engines": { "node": ">=20" },
21
+ "engines": {
22
+ "node": ">=20"
23
+ },
22
24
  "sideEffects": false,
23
25
  "main": "./lib/index.js",
24
26
  "module": "./lib/index.js",
@@ -47,8 +49,12 @@
47
49
  "url": "git+https://github.com/bytesbrains/weblocks.git"
48
50
  },
49
51
  "homepage": "https://github.com/bytesbrains/weblocks#readme",
50
- "bugs": { "url": "https://github.com/bytesbrains/weblocks/issues" },
51
- "publishConfig": { "access": "public" },
52
+ "bugs": {
53
+ "url": "https://github.com/bytesbrains/weblocks/issues"
54
+ },
55
+ "publishConfig": {
56
+ "access": "public"
57
+ },
52
58
  "scripts": {
53
59
  "build": "tsc -p tsconfig.json",
54
60
  "emit:catalog": "node scripts/emit-catalog.mjs",