@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 +9 -3
- package/CATALOG.md +29 -3
- package/CHANGELOG.md +35 -0
- package/README.md +27 -3
- package/catalog.json +157 -8
- package/lib/blocks/brandIcons.d.ts +5 -0
- package/lib/blocks/brandIcons.js +18 -0
- package/lib/blocks/copyright.d.ts +2 -0
- package/lib/blocks/copyright.js +48 -0
- package/lib/blocks/gallery.js +2 -1
- package/lib/blocks/socialLinks.js +71 -21
- package/lib/blocks/videoGallery.d.ts +2 -0
- package/lib/blocks/videoGallery.js +110 -0
- package/lib/blocks/videoUtil.d.ts +14 -0
- package/lib/blocks/videoUtil.js +30 -0
- package/lib/islands/carousel.d.ts +1 -0
- package/lib/islands/carousel.js +122 -0
- package/lib/islands/lightbox.d.ts +1 -0
- package/lib/islands/lightbox.js +139 -0
- package/lib/islands/video.d.ts +1 -0
- package/lib/islands/video.js +48 -0
- package/lib/registry.js +4 -2
- package/lib/render.d.ts +6 -0
- package/lib/render.js +2 -1
- package/package.json +5 -3
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
|
|
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.
|
|
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
|
|
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
|
-
**
|
|
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
|
+
"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
|
|
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
|
-
"
|
|
1755
|
-
"
|
|
1756
|
-
|
|
1757
|
-
|
|
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":
|
|
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,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,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
|
+
};
|
package/lib/blocks/gallery.js
CHANGED
|
@@ -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
|
-
/**
|
|
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:
|
|
36
|
+
kind: 'array', max: 20,
|
|
7
37
|
of: {
|
|
8
38
|
kind: 'object',
|
|
9
39
|
fields: {
|
|
10
|
-
|
|
40
|
+
platform: { kind: 'enum', values: PLATFORMS, default: 'custom' },
|
|
11
41
|
href: { kind: 'string', required: true, default: '', max: 500 },
|
|
12
|
-
|
|
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)
|
|
19
|
-
.blk-social-links .wrap{max-width:
|
|
20
|
-
.blk-social-links h2{font-size:var(--fs-lg);font-weight:700;margin:0 0 var(--space)}
|
|
21
|
-
.blk-social-links .
|
|
22
|
-
.blk-social-links
|
|
23
|
-
.blk-social-links
|
|
24
|
-
.blk-social-links .
|
|
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
|
|
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 (
|
|
29
|
-
return '';
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
38
|
-
|
|
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="
|
|
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
|
|
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,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="
|
|
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.
|
|
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": "
|
|
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
|
}
|