@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 +6 -4
- package/CATALOG.md +41 -2
- package/CHANGELOG.md +28 -0
- package/README.md +4 -2
- package/catalog.json +159 -1
- package/lib/blocks/directions.d.ts +2 -0
- package/lib/blocks/directions.js +98 -0
- package/lib/blocks/legal.d.ts +2 -0
- package/lib/blocks/legal.js +100 -0
- package/lib/blocks/search.d.ts +2 -0
- package/lib/blocks/search.js +75 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/markdown.d.ts +2 -0
- package/lib/markdown.js +90 -0
- package/lib/registry.js +5 -2
- package/package.json +10 -4
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
|
|
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
|
|
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.
|
|
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
|
-
**
|
|
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.
|
|
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,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,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">×</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,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';
|
package/lib/markdown.js
ADDED
|
@@ -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.
|
|
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": {
|
|
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": {
|
|
51
|
-
|
|
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",
|