@bytesbrains/weblocks 0.4.0 → 0.5.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,20 +46,26 @@ 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 40 block types, by group:
49
+ The 42 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
- - **Media:** `gallery`, `carousel`, `video`, `map`
55
+ - **Media:** `gallery`, `carousel`, `video`, `video-gallery`, `map`
56
56
  - **Location:** `directions` (deep links to open the visitor’s map app)
57
57
  - **Structured:** `timeline`, `tabs`, `accordion`, `testimonials`, `faq`
58
58
  - **Collections:** `blog-list`, `blog-post`, `feed`
59
59
  - **Dynamic (powered):** `contact-form`, `newsletter`, `search`, `auth`
60
- - **Conversion/rhythm:** `cta`, `social-links`, `contact-details`, `divider`, `spacer`
60
+ - **Conversion/rhythm:** `cta`, `social-links`, `contact-details`, `divider`, `spacer`, `copyright`
61
61
  - **Legal:** `legal` (terms/privacy links that open safe-Markdown dialogs; content is Markdown, never raw HTML)
62
62
 
63
+ Interactivity is safe to enable: `gallery` with `lightbox: true`, `carousel` with
64
+ `autoplay: true`, `tabs`/`accordion`, etc. work out of the box — CSS-only where
65
+ possible, and the host serves small shipped islands (`gallery` lightbox,
66
+ `carousel` controls) for the rest. You just set the typed config; you never write
67
+ the JavaScript.
68
+
63
69
  ## Composing a manifest
64
70
 
65
71
  Emit exactly this shape (a block’s `id` is optional — the engine assigns one):
package/CATALOG.md CHANGED
@@ -1,8 +1,8 @@
1
- # @bytesbrains/weblocks — Block Catalog (v0.4.0)
1
+ # @bytesbrains/weblocks — Block Catalog (v0.5.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` · `search` · `auth` · `cta` · `social-links` · `contact-details` · `directions` · `legal` · `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` · `video-gallery` · `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` · `copyright` · `footer`
6
6
 
7
7
  ## `app-shell`
8
8
 
@@ -203,6 +203,17 @@ A responsive embedded video from YouTube or Vimeo (by id or URL) or a self-hoste
203
203
  | `poster` | string | | |
204
204
  | `caption` | string | | |
205
205
 
206
+ ## `video-gallery`
207
+
208
+ A grid or carousel of click-to-play video cards (YouTube/Vimeo/file); each loads its player inline on click (a lightweight facade — no heavy iframes up front).
209
+
210
+ | field | type | required | notes |
211
+ |---|---|---|---|
212
+ | `title` | string | | |
213
+ | `layout` | undefined (grid\|carousel) | | |
214
+ | `columns` | integer (2\|3\|4) | | |
215
+ | `items` | array | | |
216
+
206
217
  ## `map`
207
218
 
208
219
  An embedded location map for a place or address query, with configurable zoom and height and a link to the full map.
@@ -352,11 +363,14 @@ A full-width call-to-action band: a headline, optional subheading, and one butto
352
363
 
353
364
  ## `social-links`
354
365
 
355
- A centered row of links to social or external profiles, each an optional icon plus a label.
366
+ A row or grid of links to social/contact profiles; each link picks a built-in brand icon by platform (or a custom emoji), shown labeled or icon-only.
356
367
 
357
368
  | field | type | required | notes |
358
369
  |---|---|---|---|
359
370
  | `title` | string | | |
371
+ | `layout` | undefined (row\|grid) | | |
372
+ | `variant` | undefined (labeled\|icon) | | |
373
+ | `align` | undefined (start\|center\|end) | | |
360
374
  | `links` | array | | |
361
375
 
362
376
  ## `contact-details`
@@ -413,6 +427,18 @@ Deliberate vertical whitespace between sections, in one of four sizes (sm, md, l
413
427
  |---|---|---|---|
414
428
  | `size` | undefined (sm\|md\|lg\|xl) | | |
415
429
 
430
+ ## `copyright`
431
+
432
+ A small copyright bar (© year holder + rights text) for the bottom of a page; the year auto-fills to the current year when left blank.
433
+
434
+ | field | type | required | notes |
435
+ |---|---|---|---|
436
+ | `holder` | string | | |
437
+ | `year` | string | | |
438
+ | `text` | string | | |
439
+ | `showSymbol` | boolean | | |
440
+ | `align` | undefined (start\|center\|end) | | |
441
+
416
442
  ## `footer`
417
443
 
418
444
  Page footer: brand, tagline, a row of links, and a copyright line. Place last.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,41 @@ 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.5.0 — 2026-07-16
9
+
10
+ More media & footer blocks, brand icons, and shipped interactivity. Additive and
11
+ **non-breaking** — every `0.4.0` manifest still validates and renders identically.
12
+ Catalog 40 → 42.
13
+
14
+ ### Added
15
+ - **`video-gallery` block** — a grid or carousel of **click-to-play video cards**
16
+ (YouTube / Vimeo / self-hosted). Each card is a lightweight *facade* (thumbnail
17
+ + play button); the new **`video.js` island** loads the real player inline on
18
+ click, so no heavy iframes load up front. YouTube thumbnails auto-derive from
19
+ the id; no-JS clicks open the platform.
20
+ - **`copyright` block** — a small "© year holder · rights" bar for the bottom of
21
+ a page (a standalone alternative to the `footer` copyright line). The year
22
+ auto-fills to the current year when blank; configurable holder, note, symbol,
23
+ and alignment.
24
+ - **`social-links` — built-in brand icons & layouts.** Each link takes a typed
25
+ `platform` (x/twitter, instagram, facebook, linkedin, youtube, github, tiktok,
26
+ whatsapp, telegram, discord, mastodon, rss, email, website, phone) that supplies
27
+ a monochrome brand icon (simple-icons, CC0; themed via `currentColor`) and a
28
+ default label — `custom` still takes your own emoji/glyph. New `layout`
29
+ (row/grid), `variant` (labeled/icon-only), and `align` options. Non-breaking.
30
+ - **Shipped interactive islands** — the engine now ships the client scripts for
31
+ its interactive blocks (previously host-provided), as zero-dependency browser
32
+ modules under the `./islands/*.js` subpath export:
33
+ - `lightbox.js` — `gallery` click-to-zoom: prev/next, keyboard, swipe, Esc,
34
+ caption, scroll-lock, focus restore.
35
+ - `carousel.js` — `carousel` arrows, dot indicators, keyboard nav, and optional
36
+ autoplay (honours `prefers-reduced-motion`, pauses on hover/focus).
37
+ - `renderSite(manifest, { islandBase })` to configure where island scripts are
38
+ served (default `/_island`).
39
+ - Islands are built by a separate `tsconfig.islands.json` (DOM lib); the engine's
40
+ Node code stays DOM-free. `gallery` now marks `data-wl-lightbox` when its
41
+ lightbox is enabled.
42
+
8
43
  ## 0.4.0 — 2026-07-16
9
44
 
10
45
  Additive and non-breaking — every `0.3.0` manifest still validates and renders
package/README.md CHANGED
@@ -139,19 +139,19 @@ import catalogJson from '@bytesbrains/weblocks/catalog.json' with { type: 'json'
139
139
 
140
140
  ## Block catalog
141
141
 
142
- **40 typed blocks.** Full field reference in [`CATALOG.md`](./CATALOG.md).
142
+ **42 typed blocks.** Full field reference in [`CATALOG.md`](./CATALOG.md).
143
143
 
144
144
  | Group | Blocks |
145
145
  |---|---|
146
146
  | Chrome / app-shell | `nav` · `app-shell` · `sidebar` · `announcement-bar` · `footer` |
147
147
  | Heroes | `hero` · `hero-app` |
148
148
  | Content | `features` · `about` · `rich-text` · `split` · `steps` · `stats` · `services-catalogue` · `pricing` · `logos` · `team` |
149
- | Media | `gallery` · `carousel` · `video` · `map` |
149
+ | Media | `gallery` · `carousel` · `video` · `video-gallery` · `map` |
150
150
  | Location | `directions` (deep links to the visitor’s map app) |
151
151
  | Structured | `timeline` · `tabs` · `accordion` · `testimonials` · `faq` |
152
152
  | Collections | `blog-list` · `blog-post` · `feed` |
153
153
  | Dynamic (powered) | `contact-form` · `newsletter` · `search` · `auth` |
154
- | Conversion / rhythm | `cta` · `social-links` · `contact-details` · `divider` · `spacer` |
154
+ | Conversion / rhythm | `cta` · `social-links` · `contact-details` · `divider` · `spacer` · `copyright` |
155
155
  | Legal | `legal` (terms/privacy links → safe-Markdown dialogs) |
156
156
 
157
157
  `rich-text` and `blog-post` carry **typed** content nodes (headings, paragraphs,
@@ -234,6 +234,30 @@ for (const [file, body] of Object.entries(emitPwa(manifest) ?? {})) writeFileSyn
234
234
  // → manifest.webmanifest + sw.js
235
235
  ```
236
236
 
237
+ ## Interactivity (islands)
238
+
239
+ Pages are static-first: JavaScript ships only for the interactive blocks that need
240
+ it, and only when their behaviour is on. `renderSite` emits a marker for each —
241
+ `<script type="module" src="/_island/<name>.js">` — and the engine **ships the
242
+ island scripts** (zero-dependency, ~6 KB each) under a subpath:
243
+
244
+ | Block | Island | Behaviour |
245
+ |---|---|---|
246
+ | `gallery` (`lightbox: true`) | `lightbox.js` | click-to-zoom, prev/next, keyboard, swipe, Esc |
247
+ | `carousel` | `carousel.js` | arrows, dots, keyboard, optional autoplay |
248
+ | `video-gallery` | `video.js` | click-to-play cards (load the real player inline on press) |
249
+
250
+ Serve them at the island URL. Copy from the package's `./islands/*.js` export, e.g.:
251
+
252
+ ```ts
253
+ import lightbox from '@bytesbrains/weblocks/islands/lightbox.js?url'; // bundler
254
+ // or, for a static host, copy node_modules/@bytesbrains/weblocks/lib/islands/*.js
255
+ // to /_island/ (change the base with renderSite(m, { islandBase: '/assets/js' }))
256
+ ```
257
+
258
+ Blocks whose behaviour is off (e.g. `tabs`, `accordion` — CSS-only) ship no JS at
259
+ all.
260
+
237
261
  ## API reference
238
262
 
239
263
  All exports are named; types are shipped (`lib/index.d.ts`).
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.0",
4
+ "version": "0.5.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",
@@ -23,6 +23,7 @@
23
23
  "gallery",
24
24
  "carousel",
25
25
  "video",
26
+ "video-gallery",
26
27
  "map",
27
28
  "timeline",
28
29
  "tabs",
@@ -43,6 +44,7 @@
43
44
  "legal",
44
45
  "divider",
45
46
  "spacer",
47
+ "copyright",
46
48
  "footer"
47
49
  ],
48
50
  "blocks": [
@@ -1030,6 +1032,73 @@
1030
1032
  "additionalProperties": false
1031
1033
  }
1032
1034
  },
1035
+ {
1036
+ "type": "video-gallery",
1037
+ "description": "A grid or carousel of click-to-play video cards (YouTube/Vimeo/file); each loads its player inline on click (a lightweight facade — no heavy iframes up front).",
1038
+ "schema": {
1039
+ "type": "object",
1040
+ "properties": {
1041
+ "title": {
1042
+ "type": "string",
1043
+ "maxLength": 120,
1044
+ "default": ""
1045
+ },
1046
+ "layout": {
1047
+ "enum": [
1048
+ "grid",
1049
+ "carousel"
1050
+ ],
1051
+ "default": "grid"
1052
+ },
1053
+ "columns": {
1054
+ "type": "integer",
1055
+ "enum": [
1056
+ 2,
1057
+ 3,
1058
+ 4
1059
+ ],
1060
+ "default": 3
1061
+ },
1062
+ "items": {
1063
+ "type": "array",
1064
+ "items": {
1065
+ "type": "object",
1066
+ "properties": {
1067
+ "provider": {
1068
+ "enum": [
1069
+ "youtube",
1070
+ "vimeo",
1071
+ "file"
1072
+ ],
1073
+ "default": "youtube"
1074
+ },
1075
+ "src": {
1076
+ "type": "string",
1077
+ "maxLength": 500,
1078
+ "default": ""
1079
+ },
1080
+ "title": {
1081
+ "type": "string",
1082
+ "maxLength": 160,
1083
+ "default": ""
1084
+ },
1085
+ "poster": {
1086
+ "type": "string",
1087
+ "maxLength": 500,
1088
+ "default": ""
1089
+ }
1090
+ },
1091
+ "required": [
1092
+ "src"
1093
+ ],
1094
+ "additionalProperties": false
1095
+ },
1096
+ "maxItems": 24
1097
+ }
1098
+ },
1099
+ "additionalProperties": false
1100
+ }
1101
+ },
1033
1102
  {
1034
1103
  "type": "map",
1035
1104
  "description": "An embedded location map for a place or address query, with configurable zoom and height and a link to the full map.",
@@ -1737,7 +1806,7 @@
1737
1806
  },
1738
1807
  {
1739
1808
  "type": "social-links",
1740
- "description": "A centered row of links to social or external profiles, each an optional icon plus a label.",
1809
+ "description": "A row or grid of links to social/contact profiles; each link picks a built-in brand icon by platform (or a custom emoji), shown labeled or icon-only.",
1741
1810
  "schema": {
1742
1811
  "type": "object",
1743
1812
  "properties": {
@@ -1746,21 +1815,65 @@
1746
1815
  "maxLength": 120,
1747
1816
  "default": ""
1748
1817
  },
1818
+ "layout": {
1819
+ "enum": [
1820
+ "row",
1821
+ "grid"
1822
+ ],
1823
+ "default": "row"
1824
+ },
1825
+ "variant": {
1826
+ "enum": [
1827
+ "labeled",
1828
+ "icon"
1829
+ ],
1830
+ "default": "labeled"
1831
+ },
1832
+ "align": {
1833
+ "enum": [
1834
+ "start",
1835
+ "center",
1836
+ "end"
1837
+ ],
1838
+ "default": "center"
1839
+ },
1749
1840
  "links": {
1750
1841
  "type": "array",
1751
1842
  "items": {
1752
1843
  "type": "object",
1753
1844
  "properties": {
1754
- "label": {
1755
- "type": "string",
1756
- "maxLength": 40,
1757
- "default": ""
1845
+ "platform": {
1846
+ "enum": [
1847
+ "x",
1848
+ "twitter",
1849
+ "instagram",
1850
+ "facebook",
1851
+ "linkedin",
1852
+ "youtube",
1853
+ "github",
1854
+ "tiktok",
1855
+ "whatsapp",
1856
+ "telegram",
1857
+ "discord",
1858
+ "mastodon",
1859
+ "rss",
1860
+ "email",
1861
+ "website",
1862
+ "phone",
1863
+ "custom"
1864
+ ],
1865
+ "default": "custom"
1758
1866
  },
1759
1867
  "href": {
1760
1868
  "type": "string",
1761
1869
  "maxLength": 500,
1762
1870
  "default": ""
1763
1871
  },
1872
+ "label": {
1873
+ "type": "string",
1874
+ "maxLength": 60,
1875
+ "default": ""
1876
+ },
1764
1877
  "icon": {
1765
1878
  "type": "string",
1766
1879
  "maxLength": 8,
@@ -1768,12 +1881,11 @@
1768
1881
  }
1769
1882
  },
1770
1883
  "required": [
1771
- "label",
1772
1884
  "href"
1773
1885
  ],
1774
1886
  "additionalProperties": false
1775
1887
  },
1776
- "maxItems": 12
1888
+ "maxItems": 20
1777
1889
  }
1778
1890
  },
1779
1891
  "additionalProperties": false
@@ -1956,6 +2068,43 @@
1956
2068
  "additionalProperties": false
1957
2069
  }
1958
2070
  },
2071
+ {
2072
+ "type": "copyright",
2073
+ "description": "A small copyright bar (© year holder + rights text) for the bottom of a page; the year auto-fills to the current year when left blank.",
2074
+ "schema": {
2075
+ "type": "object",
2076
+ "properties": {
2077
+ "holder": {
2078
+ "type": "string",
2079
+ "maxLength": 120,
2080
+ "default": ""
2081
+ },
2082
+ "year": {
2083
+ "type": "string",
2084
+ "maxLength": 24,
2085
+ "default": ""
2086
+ },
2087
+ "text": {
2088
+ "type": "string",
2089
+ "maxLength": 160,
2090
+ "default": "All rights reserved."
2091
+ },
2092
+ "showSymbol": {
2093
+ "type": "boolean",
2094
+ "default": true
2095
+ },
2096
+ "align": {
2097
+ "enum": [
2098
+ "start",
2099
+ "center",
2100
+ "end"
2101
+ ],
2102
+ "default": "center"
2103
+ }
2104
+ },
2105
+ "additionalProperties": false
2106
+ }
2107
+ },
1959
2108
  {
1960
2109
  "type": "footer",
1961
2110
  "description": "Page footer: brand, tagline, a row of links, and a copyright line. Place last.",
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Brand icon path data (24×24 viewBox, fill=currentColor). Sourced from
3
+ * simple-icons (CC0 / public domain). Generated from source — do not edit by hand.
4
+ */
5
+ export declare const BRAND_ICON_PATHS: Record<string, string>;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Brand icon path data (24×24 viewBox, fill=currentColor). Sourced from
3
+ * simple-icons (CC0 / public domain). Generated from source — do not edit by hand.
4
+ */
5
+ export const BRAND_ICON_PATHS = {
6
+ discord: "M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z",
7
+ facebook: "M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z",
8
+ github: "M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12",
9
+ instagram: "M7.0301.084c-1.2768.0602-2.1487.264-2.911.5634-.7888.3075-1.4575.72-2.1228 1.3877-.6652.6677-1.075 1.3368-1.3802 2.127-.2954.7638-.4956 1.6365-.552 2.914-.0564 1.2775-.0689 1.6882-.0626 4.947.0062 3.2586.0206 3.6671.0825 4.9473.061 1.2765.264 2.1482.5635 2.9107.308.7889.72 1.4573 1.388 2.1228.6679.6655 1.3365 1.0743 2.1285 1.38.7632.295 1.6361.4961 2.9134.552 1.2773.056 1.6884.069 4.9462.0627 3.2578-.0062 3.668-.0207 4.9478-.0814 1.28-.0607 2.147-.2652 2.9098-.5633.7889-.3086 1.4578-.72 2.1228-1.3881.665-.6682 1.0745-1.3378 1.3795-2.1284.2957-.7632.4966-1.636.552-2.9124.056-1.2809.0692-1.6898.063-4.948-.0063-3.2583-.021-3.6668-.0817-4.9465-.0607-1.2797-.264-2.1487-.5633-2.9117-.3084-.7889-.72-1.4568-1.3876-2.1228C21.2982 1.33 20.628.9208 19.8378.6165 19.074.321 18.2017.1197 16.9244.0645 15.6471.0093 15.236-.005 11.977.0014 8.718.0076 8.31.0215 7.0301.0839m.1402 21.6932c-1.17-.0509-1.8053-.2453-2.2287-.408-.5606-.216-.96-.4771-1.3819-.895-.422-.4178-.6811-.8186-.9-1.378-.1644-.4234-.3624-1.058-.4171-2.228-.0595-1.2645-.072-1.6442-.079-4.848-.007-3.2037.0053-3.583.0607-4.848.05-1.169.2456-1.805.408-2.2282.216-.5613.4762-.96.895-1.3816.4188-.4217.8184-.6814 1.3783-.9003.423-.1651 1.0575-.3614 2.227-.4171 1.2655-.06 1.6447-.072 4.848-.079 3.2033-.007 3.5835.005 4.8495.0608 1.169.0508 1.8053.2445 2.228.408.5608.216.96.4754 1.3816.895.4217.4194.6816.8176.9005 1.3787.1653.4217.3617 1.056.4169 2.2263.0602 1.2655.0739 1.645.0796 4.848.0058 3.203-.0055 3.5834-.061 4.848-.051 1.17-.245 1.8055-.408 2.2294-.216.5604-.4763.96-.8954 1.3814-.419.4215-.8181.6811-1.3783.9-.4224.1649-1.0577.3617-2.2262.4174-1.2656.0595-1.6448.072-4.8493.079-3.2045.007-3.5825-.006-4.848-.0608M16.953 5.5864A1.44 1.44 0 1 0 18.39 4.144a1.44 1.44 0 0 0-1.437 1.4424M5.8385 12.012c.0067 3.4032 2.7706 6.1557 6.173 6.1493 3.4026-.0065 6.157-2.7701 6.1506-6.1733-.0065-3.4032-2.771-6.1565-6.174-6.1498-3.403.0067-6.156 2.771-6.1496 6.1738M8 12.0077a4 4 0 1 1 4.008 3.9921A3.9996 3.9996 0 0 1 8 12.0077",
10
+ linkedin: "M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z",
11
+ mastodon: "M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z",
12
+ rss: "M19.199 24C19.199 13.467 10.533 4.8 0 4.8V0c13.165 0 24 10.835 24 24h-4.801zM3.291 17.415c1.814 0 3.293 1.479 3.293 3.295 0 1.813-1.485 3.29-3.301 3.29C1.47 24 0 22.526 0 20.71s1.475-3.294 3.291-3.295zM15.909 24h-4.665c0-6.169-5.075-11.245-11.244-11.245V8.09c8.727 0 15.909 7.184 15.909 15.91z",
13
+ telegram: "M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z",
14
+ tiktok: "M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z",
15
+ whatsapp: "M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z",
16
+ x: "M14.234 10.162 22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299-.929-1.329L3.076 1.56h3.182l5.965 8.532.929 1.329 7.754 11.09h-3.182z",
17
+ youtube: "M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z",
18
+ };
@@ -0,0 +1,2 @@
1
+ import type { BlockSpec } from '../registry.js';
2
+ export declare const copyright: BlockSpec;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `copyright` — a small "© year holder · rights" bar for the very bottom of a
3
+ * page. A standalone alternative to the `footer` block's copyright line: pair it
4
+ * with `legal` and `social-links` for a lightweight footer, or use it under the
5
+ * full `footer`. The year auto-fills to the current year when left blank (or give
6
+ * an explicit year / range like "2020–2026"). Static brick.
7
+ */
8
+ import { escapeHtml } from '../schema.js';
9
+ const schema = {
10
+ holder: { kind: 'string', default: '', max: 120 }, // e.g. "Acme Inc."
11
+ year: { kind: 'string', default: '', max: 24 }, // blank → current year
12
+ text: { kind: 'string', default: 'All rights reserved.', max: 160 }, // trailing note
13
+ showSymbol: { kind: 'boolean', default: true }, // the © symbol
14
+ align: { kind: 'enum', values: ['start', 'center', 'end'], default: 'center' },
15
+ };
16
+ const css = `
17
+ .blk-copyright{padding:var(--space) var(--space);background:var(--bg);color:var(--muted);border-top:1px solid color-mix(in srgb,var(--text) 10%,transparent)}
18
+ .blk-copyright .wrap{max-width:1080px;margin:0 auto;font-size:var(--fs-base)}
19
+ .blk-copyright.align-center .wrap{text-align:center}
20
+ .blk-copyright.align-end .wrap{text-align:right}
21
+ .blk-copyright p{margin:0}
22
+ `.trim();
23
+ function render(config) {
24
+ const holder = config.holder;
25
+ const yearIn = String(config.year ?? '').trim();
26
+ const year = yearIn || String(new Date().getFullYear()); // blank → current year
27
+ const text = config.text;
28
+ const symbol = config.showSymbol !== false;
29
+ const align = ['start', 'center', 'end'].includes(config.align) ? config.align : 'center';
30
+ let line = `${symbol ? '© ' : ''}${escapeHtml(year)}`;
31
+ if (holder)
32
+ line += ` ${escapeHtml(holder)}`;
33
+ // Add the note, but don't double punctuation when the holder ends in "." (Inc./Co./Ltd.).
34
+ if (text)
35
+ line += `${/[.!?]$/.test(line) ? ' ' : '. '}${escapeHtml(text)}`;
36
+ return `<footer class="blk-copyright align-${align}" aria-label="Copyright">
37
+ <div class="wrap">
38
+ <p>${line}</p>
39
+ </div>
40
+ </footer>`;
41
+ }
42
+ export const copyright = {
43
+ type: 'copyright',
44
+ description: 'A small copyright bar (© year holder + rights text) for the bottom of a page; the year auto-fills to the current year when left blank.',
45
+ schema,
46
+ css,
47
+ render,
48
+ };
@@ -44,8 +44,9 @@ function figure(it) {
44
44
  function render(config) {
45
45
  const columns = Number.isInteger(config.columns) ? config.columns : 3;
46
46
  const gap = GAP_REM[config.gap] ?? GAP_REM.md;
47
+ const lightbox = config.lightbox === true; // flag for the `lightbox` island
47
48
  const items = (config.items ?? []).map(figure).filter(Boolean);
48
- return `<section class="blk-gallery" aria-label="Gallery">
49
+ return `<section class="blk-gallery" aria-label="Gallery"${lightbox ? ' data-wl-lightbox="true"' : ''}>
49
50
  <div class="wrap">
50
51
  <div class="grid" style="--cols:${columns};--gap:${gap}">
51
52
  ${items.join('\n ')}
@@ -1,44 +1,94 @@
1
- /** `social-links` — a centered icon row linking out to profiles. Static brick. */
1
+ /**
2
+ * `social-links` — one row/grid of links to social or contact profiles.
3
+ *
4
+ * A single generic block (not one per network): you add only the accounts you
5
+ * have, so unset ones are simply absent. Each link's `platform` selects a
6
+ * built-in monochrome brand icon (from simple-icons, themed via `currentColor`)
7
+ * and a default accessible label; `custom` (or an unknown platform) falls back to
8
+ * your own emoji/glyph `icon`. Layout adapts via `layout` (row/grid) and
9
+ * `variant` (labeled/icon-only). Static brick.
10
+ */
2
11
  import { escapeAttr, escapeHtml, sanitizeUrl } from '../schema.js';
12
+ import { BRAND_ICON_PATHS } from './brandIcons.js';
13
+ const PLATFORMS = [
14
+ 'x', 'twitter', 'instagram', 'facebook', 'linkedin', 'youtube', 'github', 'tiktok',
15
+ 'whatsapp', 'telegram', 'discord', 'mastodon', 'rss', 'email', 'website', 'phone', 'custom',
16
+ ];
17
+ /** Default accessible label per platform (author `label` overrides). */
18
+ const LABELS = {
19
+ x: 'X', twitter: 'X', instagram: 'Instagram', facebook: 'Facebook', linkedin: 'LinkedIn',
20
+ youtube: 'YouTube', github: 'GitHub', tiktok: 'TikTok', whatsapp: 'WhatsApp',
21
+ telegram: 'Telegram', discord: 'Discord', mastodon: 'Mastodon', rss: 'RSS',
22
+ email: 'Email', website: 'Website', phone: 'Phone',
23
+ };
24
+ /** Simple monochrome (non-brand) contact icons — filled, 24×24. */
25
+ const GENERIC_ICONS = {
26
+ email: 'M2 6a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v.5l-10 6.25L2 6.5V6zm0 2.85V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8.85l-9.47 5.92a1 1 0 0 1-1.06 0L2 8.85z',
27
+ website: 'M14 3h7v7h-2V6.41l-8.29 8.3-1.42-1.42L17.59 5H14V3zM5 5h5v2H5v12h12v-5h2v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z',
28
+ phone: 'M6.62 2.53c.5-.2 1.08.02 1.36.49l1.7 3.01c.24.42.18.95-.15 1.3L8.06 9.03a12 12 0 0 0 6.9 6.9l1.7-1.47c.35-.33.88-.39 1.3-.15l3.01 1.7c.47.28.69.86.49 1.36l-.98 2.4c-.22.55-.79.9-1.4.83C10.7 20.1 3.9 13.3 3.1 5.9c-.07-.6.28-1.18.83-1.4l2.69-1.97z',
29
+ };
3
30
  const schema = {
4
31
  title: { kind: 'string', default: '', max: 120 },
32
+ layout: { kind: 'enum', values: ['row', 'grid'], default: 'row' },
33
+ variant: { kind: 'enum', values: ['labeled', 'icon'], default: 'labeled' },
34
+ align: { kind: 'enum', values: ['start', 'center', 'end'], default: 'center' },
5
35
  links: {
6
- kind: 'array', max: 12,
36
+ kind: 'array', max: 20,
7
37
  of: {
8
38
  kind: 'object',
9
39
  fields: {
10
- label: { kind: 'string', required: true, default: '', max: 40 },
40
+ platform: { kind: 'enum', values: PLATFORMS, default: 'custom' },
11
41
  href: { kind: 'string', required: true, default: '', max: 500 },
12
- icon: { kind: 'string', default: '', max: 8 }, // emoji or a single glyph
42
+ label: { kind: 'string', default: '', max: 60 }, // overrides the platform default
43
+ icon: { kind: 'string', default: '', max: 8 }, // emoji/glyph for `custom`
13
44
  },
14
45
  },
15
46
  },
16
47
  };
17
48
  const css = `
18
- .blk-social-links{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text);text-align:center}
19
- .blk-social-links .wrap{max-width:800px;margin:0 auto}
20
- .blk-social-links h2{font-size:var(--fs-lg);font-weight:700;margin:0 0 var(--space)}
21
- .blk-social-links .row{display:flex;flex-wrap:wrap;justify-content:center;gap:.8em}
22
- .blk-social-links a{display:inline-flex;align-items:center;gap:.4em;padding:.5em .9em;border-radius:var(--radius);text-decoration:none;color:var(--text);background:var(--surface);border:1px solid color-mix(in srgb,var(--text) 15%,transparent);font-size:var(--fs-base);font-weight:600;transition:color var(--motion),border-color var(--motion)}
23
- .blk-social-links a:hover,.blk-social-links a:focus-visible{color:var(--primary);border-color:var(--primary)}
24
- .blk-social-links .ico{font-size:1.15em;line-height:1}
49
+ .blk-social-links{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
50
+ .blk-social-links .wrap{max-width:900px;margin:0 auto}
51
+ .blk-social-links h2{text-align:center;font-size:var(--fs-lg);font-weight:700;margin:0 0 var(--space)}
52
+ .blk-social-links .items{display:flex;flex-wrap:wrap;gap:.7em}
53
+ .blk-social-links.layout-grid .items{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr))}
54
+ .blk-social-links.align-center .items{justify-content:center}
55
+ .blk-social-links.align-end .items{justify-content:flex-end}
56
+ .blk-social-links .s-item{display:inline-flex;align-items:center;justify-content:center;gap:.5em;padding:.55em .95em;border-radius:var(--radius);text-decoration:none;color:var(--text);background:var(--surface);border:1px solid color-mix(in srgb,var(--text) 14%,transparent);font-size:var(--fs-base);font-weight:600;transition:color var(--motion),border-color var(--motion),transform var(--motion)}
57
+ .blk-social-links .s-item:hover,.blk-social-links .s-item:focus-visible{color:var(--primary);border-color:var(--primary);transform:translateY(-1px)}
58
+ .blk-social-links .s-item.icon-only{padding:0;width:2.9em;height:2.9em;border-radius:999px}
59
+ .blk-social-links .ico{flex:0 0 auto;width:1.2em;height:1.2em;display:block}
60
+ .blk-social-links .ico.emoji{font-size:1.15em;line-height:1;width:auto;height:auto}
25
61
  `.trim();
26
- function link(l) {
62
+ function iconMarkup(platform, emoji) {
63
+ const path = platform === 'twitter' ? BRAND_ICON_PATHS.x : (BRAND_ICON_PATHS[platform] ?? GENERIC_ICONS[platform]);
64
+ if (path)
65
+ return `<svg class="ico" viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="${path}"></path></svg>`;
66
+ return emoji ? `<span class="ico emoji" aria-hidden="true">${escapeHtml(emoji)}</span>` : '';
67
+ }
68
+ function item(l, iconOnly) {
27
69
  const href = sanitizeUrl(l.href);
28
- if (!l.label || href === '#' || !l.href)
29
- return '';
30
- return `<a href="${escapeAttr(href)}" rel="noopener">
31
- ${l.icon ? `<span class="ico" aria-hidden="true">${escapeHtml(l.icon)}</span>` : ''}
32
- <span>${escapeHtml(l.label)}</span>
70
+ if (href === '#' || !l.href)
71
+ return ''; // no destination → drop (unset stays hidden)
72
+ const platform = String(l.platform || 'custom');
73
+ const label = l.label || LABELS[platform] || 'Link';
74
+ const icon = iconMarkup(platform, l.icon);
75
+ // icon-only mode needs the label as the accessible name; labeled mode shows it.
76
+ return `<a class="s-item${iconOnly ? ' icon-only' : ''}" href="${escapeAttr(href)}" rel="noopener"${iconOnly ? ` aria-label="${escapeAttr(label)}"` : ''}>
77
+ ${icon}
78
+ ${iconOnly ? '' : `<span class="lbl">${escapeHtml(label)}</span>`}
33
79
  </a>`;
34
80
  }
35
81
  function render(config) {
36
82
  const title = config.title;
37
- const links = (config.links ?? []).map(link).filter(Boolean);
38
- return `<section class="blk-social-links" aria-label="${escapeAttr(title || 'Social links')}">
83
+ const layout = config.layout === 'grid' ? 'grid' : 'row';
84
+ const variant = config.variant === 'icon' ? 'icon' : 'labeled';
85
+ const align = ['start', 'center', 'end'].includes(config.align) ? config.align : 'center';
86
+ const iconOnly = variant === 'icon';
87
+ const links = (config.links ?? []).map((l) => item(l, iconOnly)).filter(Boolean);
88
+ return `<section class="blk-social-links layout-${layout} align-${align}" aria-label="${escapeAttr(title || 'Social links')}">
39
89
  <div class="wrap">
40
90
  ${title ? `<h2>${escapeHtml(title)}</h2>` : ''}
41
- <div class="row">
91
+ <div class="items">
42
92
  ${links.join('\n ')}
43
93
  </div>
44
94
  </div>
@@ -46,7 +96,7 @@ function render(config) {
46
96
  }
47
97
  export const socialLinks = {
48
98
  type: 'social-links',
49
- description: 'A centered row of links to social or external profiles, each an optional icon plus a label.',
99
+ description: 'A row or grid of links to social/contact profiles; each link picks a built-in brand icon by platform (or a custom emoji), shown labeled or icon-only.',
50
100
  schema,
51
101
  css,
52
102
  render,
@@ -0,0 +1,2 @@
1
+ import type { BlockSpec } from '../registry.js';
2
+ export declare const videoGallery: BlockSpec;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * `video-gallery` — a grid or carousel of **click-to-play video cards**
3
+ * (YouTube / Vimeo / self-hosted). Each card is a lightweight *facade*: a
4
+ * thumbnail + play button, so no heavy player iframe loads until the visitor
5
+ * presses play — then the `video` island swaps in the real player inline.
6
+ *
7
+ * Progressive enhancement: with no JavaScript each card is a link that opens the
8
+ * video on its platform; with the island, it plays inline. YouTube thumbnails are
9
+ * derived from the id automatically (give a `poster` for Vimeo/file). Static-first
10
+ * brick — the island only adds inline playback.
11
+ */
12
+ import { escapeAttr, escapeHtml, sanitizeUrl } from '../schema.js';
13
+ import { youtubeId, vimeoId, youtubeThumb, youtubeEmbed, youtubeWatch, vimeoEmbed, vimeoWatch, PLAY_ICON, } from './videoUtil.js';
14
+ const schema = {
15
+ title: { kind: 'string', default: '', max: 120 },
16
+ layout: { kind: 'enum', values: ['grid', 'carousel'], default: 'grid' },
17
+ columns: { kind: 'int', oneOf: [2, 3, 4], default: 3 },
18
+ items: {
19
+ kind: 'array', max: 24,
20
+ of: {
21
+ kind: 'object',
22
+ fields: {
23
+ provider: { kind: 'enum', values: ['youtube', 'vimeo', 'file'], default: 'youtube' },
24
+ src: { kind: 'string', required: true, default: '', max: 500 }, // id or url
25
+ title: { kind: 'string', default: '', max: 160 },
26
+ poster: { kind: 'string', default: '', max: 500 }, // thumbnail (auto for youtube)
27
+ },
28
+ },
29
+ },
30
+ };
31
+ const css = `
32
+ .blk-video-gallery{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
33
+ .blk-video-gallery .wrap{max-width:1120px;margin:0 auto}
34
+ .blk-video-gallery h2{font-size:var(--fs-xl);margin:0 0 var(--space-lg);font-weight:800;text-align:center}
35
+ .blk-video-gallery .grid{display:grid;grid-template-columns:repeat(var(--cols,3),1fr);gap:calc(var(--space)*1.2)}
36
+ .blk-video-gallery.layout-carousel .grid{display:flex;grid-template-columns:none;overflow-x:auto;scroll-snap-type:x mandatory;padding-bottom:.5em}
37
+ .blk-video-gallery.layout-carousel .v-card{flex:0 0 min(88%,440px);scroll-snap-align:center}
38
+ .blk-video-gallery .v-card{display:block;text-decoration:none;color:inherit;background:var(--surface);border-radius:var(--radius);overflow:hidden;transition:transform var(--motion)}
39
+ .blk-video-gallery .v-card:hover{transform:translateY(-3px)}
40
+ .blk-video-gallery .v-media{position:relative;display:block;aspect-ratio:16/9;background:#000}
41
+ .blk-video-gallery .v-media img{width:100%;height:100%;object-fit:cover;display:block}
42
+ .blk-video-gallery .v-play{position:absolute;inset:0;margin:auto;width:62px;height:62px;display:flex;align-items:center;justify-content:center;border-radius:999px;background:rgba(0,0,0,.55);color:#fff;transition:background var(--motion),transform var(--motion)}
43
+ .blk-video-gallery .v-card:hover .v-play{background:var(--primary);color:var(--on-primary);transform:scale(1.06)}
44
+ .blk-video-gallery .v-play svg{margin-left:3px}
45
+ .blk-video-gallery .v-title{display:block;padding:.7em .9em;font-weight:600;font-size:var(--fs-base)}
46
+ .blk-video-gallery .v-frame{position:absolute;inset:0;width:100%;height:100%;border:0}
47
+ @media(max-width:720px){.blk-video-gallery .grid{grid-template-columns:1fr 1fr}}
48
+ @media(max-width:440px){.blk-video-gallery .grid{grid-template-columns:1fr}}
49
+ `.trim();
50
+ function card(it) {
51
+ const provider = ['youtube', 'vimeo', 'file'].includes(it.provider) ? it.provider : 'youtube';
52
+ const title = it.title;
53
+ const posterUrl = sanitizeUrl(it.poster);
54
+ const poster = (posterUrl !== '#' && it.poster) ? posterUrl : '';
55
+ let thumb = '', embed = '', watch = '';
56
+ if (provider === 'youtube') {
57
+ const id = youtubeId(it.src);
58
+ if (!id)
59
+ return '';
60
+ thumb = poster || youtubeThumb(id);
61
+ embed = youtubeEmbed(id, true);
62
+ watch = youtubeWatch(id);
63
+ }
64
+ else if (provider === 'vimeo') {
65
+ const id = vimeoId(it.src);
66
+ if (!id)
67
+ return '';
68
+ thumb = poster;
69
+ embed = vimeoEmbed(id, true);
70
+ watch = vimeoWatch(id);
71
+ }
72
+ else {
73
+ const url = sanitizeUrl(it.src);
74
+ if (url === '#' || !it.src)
75
+ return '';
76
+ thumb = poster;
77
+ embed = url;
78
+ watch = url;
79
+ }
80
+ const media = `<span class="v-media">
81
+ ${thumb ? `<img src="${escapeAttr(thumb)}" alt="${escapeAttr(title)}" loading="lazy">` : ''}
82
+ <span class="v-play" aria-hidden="true">${PLAY_ICON}</span>
83
+ </span>`;
84
+ return `<a class="v-card" href="${escapeAttr(watch)}" target="_blank" rel="noopener" data-wl-video data-wl-provider="${provider}" data-wl-embed="${escapeAttr(embed)}" data-wl-title="${escapeAttr(title || 'Video')}" aria-label="Play video: ${escapeAttr(title || 'video')}">
85
+ ${media}
86
+ ${title ? `<span class="v-title">${escapeHtml(title)}</span>` : ''}
87
+ </a>`;
88
+ }
89
+ function render(config) {
90
+ const title = config.title;
91
+ const layout = config.layout === 'carousel' ? 'carousel' : 'grid';
92
+ const columns = Number.isInteger(config.columns) ? config.columns : 3;
93
+ const items = (config.items ?? []).map(card).filter(Boolean);
94
+ return `<section class="blk-video-gallery layout-${layout}" aria-label="${escapeAttr(title || 'Videos')}">
95
+ <div class="wrap">
96
+ ${title ? `<h2>${escapeHtml(title)}</h2>` : ''}
97
+ <div class="grid" style="--cols:${columns}">
98
+ ${items.join('\n ')}
99
+ </div>
100
+ </div>
101
+ </section>`;
102
+ }
103
+ export const videoGallery = {
104
+ type: 'video-gallery',
105
+ description: 'A grid or carousel of click-to-play video cards (YouTube/Vimeo/file); each loads its player inline on click (a lightweight facade — no heavy iframes up front).',
106
+ schema,
107
+ css,
108
+ render,
109
+ island: 'video',
110
+ };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Shared helpers for the video blocks — extract provider ids from an id-or-URL
3
+ * and build SAFE provider URLs. Ids are reduced to their allowed charset first,
4
+ * so the returned URLs are built from trusted fragments (no injection).
5
+ */
6
+ export declare function youtubeId(src: unknown): string;
7
+ export declare function vimeoId(src: unknown): string;
8
+ export declare function youtubeThumb(id: string): string;
9
+ export declare function youtubeEmbed(id: string, autoplay?: boolean): string;
10
+ export declare function youtubeWatch(id: string): string;
11
+ export declare function vimeoEmbed(id: string, autoplay?: boolean): string;
12
+ export declare function vimeoWatch(id: string): string;
13
+ /** A filled play-triangle glyph (24×24, fill=currentColor). */
14
+ export declare const PLAY_ICON = "<svg viewBox=\"0 0 24 24\" width=\"30\" height=\"30\" fill=\"currentColor\" aria-hidden=\"true\"><path d=\"M8 5v14l11-7z\"></path></svg>";
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Shared helpers for the video blocks — extract provider ids from an id-or-URL
3
+ * and build SAFE provider URLs. Ids are reduced to their allowed charset first,
4
+ * so the returned URLs are built from trusted fragments (no injection).
5
+ */
6
+ export function youtubeId(src) {
7
+ const s = String(src ?? '');
8
+ const m = /(?:v=|youtu\.be\/|embed\/|shorts\/)([A-Za-z0-9_-]{6,})/.exec(s);
9
+ return (m ? m[1] : s).replace(/[^A-Za-z0-9_-]/g, '');
10
+ }
11
+ export function vimeoId(src) {
12
+ return String(src ?? '').replace(/[^0-9]/g, '');
13
+ }
14
+ export function youtubeThumb(id) {
15
+ return `https://i.ytimg.com/vi/${id}/hqdefault.jpg`;
16
+ }
17
+ export function youtubeEmbed(id, autoplay = false) {
18
+ return `https://www.youtube-nocookie.com/embed/${id}${autoplay ? '?autoplay=1' : ''}`;
19
+ }
20
+ export function youtubeWatch(id) {
21
+ return `https://www.youtube.com/watch?v=${id}`;
22
+ }
23
+ export function vimeoEmbed(id, autoplay = false) {
24
+ return `https://player.vimeo.com/video/${id}${autoplay ? '?autoplay=1' : ''}`;
25
+ }
26
+ export function vimeoWatch(id) {
27
+ return `https://vimeo.com/${id}`;
28
+ }
29
+ /** A filled play-triangle glyph (24×24, fill=currentColor). */
30
+ export const PLAY_ICON = '<svg viewBox="0 0 24 24" width="30" height="30" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7z"></path></svg>';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,122 @@
1
+ /**
2
+ * `carousel` island — arrows, dot indicators, keyboard nav, and optional
3
+ * autoplay for the `carousel` block.
4
+ *
5
+ * Shipped from `@bytesbrains/weblocks/islands/carousel.js`; served at the island
6
+ * URL the renderer emits (default `/_island/carousel.js`). Zero dependencies,
7
+ * self-executing, idempotent. Enhances the native scroll-snap track (the block
8
+ * already works without JS); a no-op without a DOM or with fewer than 2 slides.
9
+ * Autoplay honours `prefers-reduced-motion` and pauses on hover/focus.
10
+ */
11
+ if (typeof document !== 'undefined') {
12
+ const mkBtn = (cls, glyph, label) => {
13
+ const b = document.createElement('button');
14
+ b.type = 'button';
15
+ b.className = 'wl-car-btn ' + cls;
16
+ b.textContent = glyph;
17
+ b.setAttribute('aria-label', label);
18
+ return b;
19
+ };
20
+ const setup = (root) => {
21
+ if (root.dataset.wlReady)
22
+ return;
23
+ const track = root.querySelector('.track');
24
+ if (!track)
25
+ return;
26
+ const slides = Array.from(track.children);
27
+ if (slides.length < 2)
28
+ return;
29
+ root.dataset.wlReady = '1';
30
+ const prev = mkBtn('wl-car-prev', '‹', 'Previous slide');
31
+ const next = mkBtn('wl-car-next', '›', 'Next slide');
32
+ root.append(prev, next);
33
+ const dotsWrap = document.createElement('div');
34
+ dotsWrap.className = 'wl-car-dots';
35
+ const dots = slides.map((_, i) => {
36
+ const d = document.createElement('button');
37
+ d.type = 'button';
38
+ d.setAttribute('aria-label', 'Go to slide ' + (i + 1));
39
+ d.addEventListener('click', () => go(i));
40
+ dotsWrap.appendChild(d);
41
+ return d;
42
+ });
43
+ root.appendChild(dotsWrap);
44
+ let current = 0;
45
+ const go = (i) => {
46
+ const target = slides[Math.max(0, Math.min(slides.length - 1, i))];
47
+ const r = target.getBoundingClientRect();
48
+ const t = track.getBoundingClientRect();
49
+ track.scrollBy({ left: r.left - t.left - (t.width - r.width) / 2, behavior: 'smooth' });
50
+ };
51
+ const update = () => {
52
+ dots.forEach((d, i) => d.setAttribute('aria-current', i === current ? 'true' : 'false'));
53
+ prev.toggleAttribute('disabled', current === 0);
54
+ next.toggleAttribute('disabled', current === slides.length - 1);
55
+ };
56
+ prev.addEventListener('click', () => go(current - 1));
57
+ next.addEventListener('click', () => go(current + 1));
58
+ root.setAttribute('tabindex', '0');
59
+ root.addEventListener('keydown', (e) => {
60
+ if (e.key === 'ArrowLeft') {
61
+ e.preventDefault();
62
+ go(current - 1);
63
+ }
64
+ else if (e.key === 'ArrowRight') {
65
+ e.preventDefault();
66
+ go(current + 1);
67
+ }
68
+ });
69
+ const io = new IntersectionObserver((entries) => {
70
+ for (const en of entries) {
71
+ if (en.isIntersecting) {
72
+ const i = slides.indexOf(en.target);
73
+ if (i >= 0) {
74
+ current = i;
75
+ update();
76
+ }
77
+ }
78
+ }
79
+ }, { root: track, threshold: 0.6 });
80
+ slides.forEach((s) => io.observe(s));
81
+ update();
82
+ const reduce = typeof window !== 'undefined' && window.matchMedia
83
+ && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
84
+ if (root.dataset.wlAutoplay === 'true' && !reduce) {
85
+ let timer = 0;
86
+ const tick = () => go((current + 1) % slides.length);
87
+ const start = () => { timer = window.setInterval(tick, 5000); };
88
+ const stop = () => { window.clearInterval(timer); };
89
+ start();
90
+ root.addEventListener('mouseenter', stop);
91
+ root.addEventListener('mouseleave', start);
92
+ root.addEventListener('focusin', stop);
93
+ root.addEventListener('focusout', start);
94
+ }
95
+ };
96
+ const ready = (fn) => document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', fn) : fn();
97
+ ready(() => {
98
+ const roots = Array.from(document.querySelectorAll('.blk-carousel'));
99
+ if (!roots.length)
100
+ return;
101
+ if (!document.getElementById('wl-carousel-css')) {
102
+ const s = document.createElement('style');
103
+ s.id = 'wl-carousel-css';
104
+ s.textContent = [
105
+ '.blk-carousel{position:relative}',
106
+ '.blk-carousel .track{scroll-behavior:smooth}',
107
+ '.wl-car-btn{position:absolute;top:calc(50% - 22px);z-index:2;width:44px;height:44px;border-radius:999px;border:0;cursor:pointer;background:var(--surface,#fff);color:var(--text,#111);box-shadow:0 2px 12px rgba(0,0,0,.22);font-size:24px;line-height:1;display:flex;align-items:center;justify-content:center;opacity:.94;transition:opacity .15s}',
108
+ '.wl-car-btn:hover{opacity:1}',
109
+ '.wl-car-btn[disabled]{opacity:.35;cursor:default}',
110
+ '.wl-car-prev{left:8px}',
111
+ '.wl-car-next{right:8px}',
112
+ '.wl-car-dots{display:flex;justify-content:center;gap:8px;margin-top:12px}',
113
+ '.wl-car-dots button{width:9px;height:9px;padding:0;border:0;border-radius:999px;cursor:pointer;background:color-mix(in srgb,var(--text,#111) 30%,transparent);transition:width .2s,background .2s}',
114
+ '.wl-car-dots button[aria-current="true"]{background:var(--primary,#333);width:22px}',
115
+ '@media(prefers-reduced-motion:reduce){.blk-carousel .track{scroll-behavior:auto}}',
116
+ ].join('');
117
+ document.head.appendChild(s);
118
+ }
119
+ roots.forEach(setup);
120
+ });
121
+ }
122
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,139 @@
1
+ /**
2
+ * `lightbox` island — click-to-zoom viewer for `gallery` images.
3
+ *
4
+ * Shipped from `@bytesbrains/weblocks/islands/lightbox.js`; the host serves it at
5
+ * the island URL the renderer emits (default `/_island/lightbox.js`). Zero
6
+ * dependencies, self-executing on load, idempotent. Enhances only galleries the
7
+ * renderer flagged with `data-wl-lightbox`; a no-op without a DOM (safe to
8
+ * import in Node/SSR).
9
+ *
10
+ * Features: open on click / Enter, prev–next within the gallery, arrow-key nav,
11
+ * Escape + backdrop to close, touch swipe, caption, background scroll lock, and
12
+ * focus returned to the opener.
13
+ */
14
+ if (typeof document !== 'undefined') {
15
+ const ready = (fn) => document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', fn) : fn();
16
+ ready(() => {
17
+ const galleries = Array.from(document.querySelectorAll('.blk-gallery[data-wl-lightbox]'));
18
+ if (!galleries.length)
19
+ return;
20
+ if (!document.getElementById('wl-lightbox-css')) {
21
+ const s = document.createElement('style');
22
+ s.id = 'wl-lightbox-css';
23
+ s.textContent = [
24
+ '.wl-lb{position:fixed;inset:0;z-index:2147483000;display:none;align-items:center;justify-content:center;background:rgba(0,0,0,.9)}',
25
+ '.wl-lb[data-open]{display:flex}',
26
+ '.wl-lb figure{margin:0;display:flex;flex-direction:column;align-items:center;gap:.7rem}',
27
+ '.wl-lb img{max-width:92vw;max-height:82vh;object-fit:contain;border-radius:6px;user-select:none}',
28
+ '.wl-lb figcaption{color:#eee;font:500 14px system-ui,sans-serif;text-align:center;max-width:60ch;padding:0 1rem}',
29
+ '.wl-lb button{position:absolute;background:rgba(255,255,255,.14);color:#fff;border:0;cursor:pointer;border-radius:999px;width:48px;height:48px;font-size:26px;line-height:1;display:flex;align-items:center;justify-content:center;transition:background .15s}',
30
+ '.wl-lb button:hover,.wl-lb button:focus-visible{background:rgba(255,255,255,.28);outline:2px solid #fff}',
31
+ '.wl-lb .wl-close{top:14px;right:14px}',
32
+ '.wl-lb .wl-prev{left:14px;top:50%;transform:translateY(-50%)}',
33
+ '.wl-lb .wl-next{right:14px;top:50%;transform:translateY(-50%)}',
34
+ 'html.wl-lb-lock{overflow:hidden}',
35
+ '@media(max-width:520px){.wl-lb button{width:40px;height:40px;font-size:22px}}',
36
+ ].join('');
37
+ document.head.appendChild(s);
38
+ }
39
+ // Built with createElement (no innerHTML) — the overlay chrome is static, but
40
+ // constructing nodes keeps the "no HTML strings into the DOM" rule absolute.
41
+ const mkBtn = (cls, glyph, label) => {
42
+ const b = document.createElement('button');
43
+ b.type = 'button';
44
+ b.className = cls;
45
+ b.textContent = glyph;
46
+ b.setAttribute('aria-label', label);
47
+ return b;
48
+ };
49
+ const ov = document.createElement('div');
50
+ ov.className = 'wl-lb';
51
+ ov.setAttribute('role', 'dialog');
52
+ ov.setAttribute('aria-modal', 'true');
53
+ ov.setAttribute('aria-label', 'Image viewer');
54
+ const btnClose = mkBtn('wl-close', '×', 'Close');
55
+ const btnPrev = mkBtn('wl-prev', '‹', 'Previous image');
56
+ const btnNext = mkBtn('wl-next', '›', 'Next image');
57
+ const fig = document.createElement('figure');
58
+ const bigImg = document.createElement('img');
59
+ bigImg.alt = '';
60
+ const cap = document.createElement('figcaption');
61
+ fig.append(bigImg, cap);
62
+ ov.append(btnClose, btnPrev, btnNext, fig);
63
+ document.body.appendChild(ov);
64
+ let items = [];
65
+ let idx = 0;
66
+ let opener = null;
67
+ const show = () => {
68
+ const img = items[idx];
69
+ if (!img)
70
+ return;
71
+ bigImg.src = img.currentSrc || img.src;
72
+ bigImg.alt = img.alt || '';
73
+ const c = img.closest('figure')?.querySelector('figcaption')?.textContent || '';
74
+ cap.textContent = c;
75
+ cap.style.display = c ? '' : 'none';
76
+ const multi = items.length > 1;
77
+ btnPrev.style.display = multi ? '' : 'none';
78
+ btnNext.style.display = multi ? '' : 'none';
79
+ };
80
+ const open = (list, i, from) => {
81
+ items = list;
82
+ idx = i;
83
+ opener = from;
84
+ show();
85
+ ov.setAttribute('data-open', '');
86
+ document.documentElement.classList.add('wl-lb-lock');
87
+ btnClose.focus();
88
+ };
89
+ const close = () => {
90
+ ov.removeAttribute('data-open');
91
+ document.documentElement.classList.remove('wl-lb-lock');
92
+ bigImg.removeAttribute('src');
93
+ opener?.focus();
94
+ };
95
+ const nav = (d) => { if (items.length) {
96
+ idx = (idx + d + items.length) % items.length;
97
+ show();
98
+ } };
99
+ btnClose.addEventListener('click', close);
100
+ btnPrev.addEventListener('click', () => nav(-1));
101
+ btnNext.addEventListener('click', () => nav(1));
102
+ ov.addEventListener('click', (e) => { if (e.target === ov)
103
+ close(); });
104
+ document.addEventListener('keydown', (e) => {
105
+ if (!ov.hasAttribute('data-open'))
106
+ return;
107
+ if (e.key === 'Escape')
108
+ close();
109
+ else if (e.key === 'ArrowLeft')
110
+ nav(-1);
111
+ else if (e.key === 'ArrowRight')
112
+ nav(1);
113
+ });
114
+ let sx = 0;
115
+ ov.addEventListener('touchstart', (e) => { sx = e.changedTouches[0].clientX; }, { passive: true });
116
+ ov.addEventListener('touchend', (e) => {
117
+ const dx = e.changedTouches[0].clientX - sx;
118
+ if (Math.abs(dx) > 40)
119
+ nav(dx < 0 ? 1 : -1);
120
+ });
121
+ galleries.forEach((g) => {
122
+ const imgs = Array.from(g.querySelectorAll('figure img'));
123
+ imgs.forEach((img, i) => {
124
+ img.style.cursor = 'zoom-in';
125
+ img.setAttribute('role', 'button');
126
+ img.setAttribute('tabindex', '0');
127
+ const go = () => open(imgs, i, img);
128
+ img.addEventListener('click', go);
129
+ img.addEventListener('keydown', (e) => {
130
+ if (e.key === 'Enter' || e.key === ' ') {
131
+ e.preventDefault();
132
+ go();
133
+ }
134
+ });
135
+ });
136
+ });
137
+ });
138
+ }
139
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `video` island — click-to-play for `video-gallery` cards.
3
+ *
4
+ * Shipped from `@bytesbrains/weblocks/islands/video.js`; served at the island URL
5
+ * the renderer emits (default `/_island/video.js`). Zero dependencies,
6
+ * self-executing, idempotent, guarded by `typeof document`. Each card is an
7
+ * `<a>` facade (thumbnail + play button) that links to the video on its platform;
8
+ * this island intercepts the click and swaps the thumbnail for the real,
9
+ * autoplaying player inline. Built with `createElement`/`replaceChildren` — no
10
+ * HTML strings into the DOM.
11
+ */
12
+ if (typeof document !== 'undefined') {
13
+ const play = (card) => {
14
+ if (card.dataset.wlPlaying)
15
+ return;
16
+ const embed = card.dataset.wlEmbed;
17
+ const media = card.querySelector('.v-media');
18
+ if (!embed || !media)
19
+ return;
20
+ card.dataset.wlPlaying = '1';
21
+ let el;
22
+ if (card.dataset.wlProvider === 'file') {
23
+ const v = document.createElement('video');
24
+ v.src = embed;
25
+ v.controls = true;
26
+ v.autoplay = true;
27
+ v.setAttribute('playsinline', '');
28
+ el = v;
29
+ }
30
+ else {
31
+ const f = document.createElement('iframe');
32
+ f.src = embed;
33
+ f.title = card.dataset.wlTitle || 'Video';
34
+ f.setAttribute('allow', 'autoplay; encrypted-media; picture-in-picture; fullscreen');
35
+ f.setAttribute('allowfullscreen', '');
36
+ el = f;
37
+ }
38
+ el.className = 'v-frame';
39
+ media.replaceChildren(el);
40
+ };
41
+ const ready = (fn) => document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', fn) : fn();
42
+ ready(() => {
43
+ document.querySelectorAll('.blk-video-gallery [data-wl-video]').forEach((card) => {
44
+ card.addEventListener('click', (e) => { e.preventDefault(); play(card); });
45
+ });
46
+ });
47
+ }
48
+ export {};
package/lib/registry.js CHANGED
@@ -15,6 +15,7 @@ import { team } from './blocks/team.js';
15
15
  import { gallery } from './blocks/gallery.js';
16
16
  import { carousel } from './blocks/carousel.js';
17
17
  import { video } from './blocks/video.js';
18
+ import { videoGallery } from './blocks/videoGallery.js';
18
19
  import { map } from './blocks/map.js';
19
20
  import { timeline } from './blocks/timeline.js';
20
21
  import { tabs } from './blocks/tabs.js';
@@ -35,6 +36,7 @@ import { directions } from './blocks/directions.js';
35
36
  import { legal } from './blocks/legal.js';
36
37
  import { divider } from './blocks/divider.js';
37
38
  import { spacer } from './blocks/spacer.js';
39
+ import { copyright } from './blocks/copyright.js';
38
40
  import { appShell } from './blocks/appShell.js';
39
41
  import { sidebar } from './blocks/sidebar.js';
40
42
  import { footer } from './blocks/footer.js';
@@ -49,7 +51,7 @@ const SPECS = [
49
51
  features, about, richText, split, steps, stats,
50
52
  services, pricing, logos, team,
51
53
  // media
52
- gallery, carousel, video, map,
54
+ gallery, carousel, video, videoGallery, map,
53
55
  // structured content
54
56
  timeline, tabs, accordion, testimonials, faq,
55
57
  // collections
@@ -60,7 +62,7 @@ const SPECS = [
60
62
  cta, socialLinks, contactDetails, directions, legal,
61
63
  // rhythm
62
64
  divider, spacer,
63
- footer,
65
+ copyright, footer,
64
66
  ];
65
67
  export const REGISTRY = new Map(SPECS.map((s) => [s.type, s]));
66
68
  export function getSpec(type) {
package/lib/render.d.ts CHANGED
@@ -3,6 +3,12 @@ import type { SiteManifest } from './types.js';
3
3
  export interface RenderOptions {
4
4
  /** Host runtime for powered bricks (§6). Defaults to the inert no-op adapter. */
5
5
  runtime?: RuntimeAdapter;
6
+ /**
7
+ * URL prefix where the host serves island scripts (from the package's
8
+ * `./islands/*.js` subpath). Defaults to `/_island`, so a `lightbox` island is
9
+ * emitted as `<script src="/_island/lightbox.js">`.
10
+ */
11
+ islandBase?: string;
6
12
  }
7
13
  /** The full document. */
8
14
  export declare function renderSite(manifest: SiteManifest, options?: RenderOptions): string;
package/lib/render.js CHANGED
@@ -57,8 +57,9 @@ export function renderSite(manifest, options = {}) {
57
57
  if (needsIsland(spec, value))
58
58
  islands.add(spec.island);
59
59
  }
60
+ const islandBase = (options.islandBase ?? '/_island').replace(/\/+$/, '');
60
61
  const islandTags = [...islands]
61
- .map((name) => `<script type="module" src="/_island/${escapeAttr(name)}.js" data-island="${escapeAttr(name)}"></script>`)
62
+ .map((name) => `<script type="module" src="${escapeAttr(islandBase)}/${escapeAttr(name)}.js" data-island="${escapeAttr(name)}"></script>`)
62
63
  .join('\n');
63
64
  return `<!doctype html>
64
65
  <html lang="${escapeAttr(meta.lang || 'en')}">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytesbrains/weblocks",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",
@@ -30,6 +30,7 @@
30
30
  "types": "./lib/index.d.ts",
31
31
  "import": "./lib/index.js"
32
32
  },
33
+ "./islands/*.js": "./lib/islands/*.js",
33
34
  "./catalog.json": "./catalog.json"
34
35
  },
35
36
  "files": [
@@ -56,9 +57,9 @@
56
57
  "access": "public"
57
58
  },
58
59
  "scripts": {
59
- "build": "tsc -p tsconfig.json",
60
+ "build": "tsc -p tsconfig.json && tsc -p tsconfig.islands.json",
60
61
  "emit:catalog": "node scripts/emit-catalog.mjs",
61
- "test": "tsc -p tsconfig.json && node --test lib/*.test.js",
62
+ "test": "npm run build && node --test lib/*.test.js test/*.mjs",
62
63
  "example": "tsc -p tsconfig.json && node lib/example.js",
63
64
  "ai": "tsc -p tsconfig.json && node scripts/ai-run.mjs",
64
65
  "prepare": "npm run build",
@@ -66,6 +67,7 @@
66
67
  },
67
68
  "devDependencies": {
68
69
  "@types/node": "^22.0.0",
70
+ "jsdom": "^29.1.1",
69
71
  "typescript": "^5.6.0"
70
72
  }
71
73
  }