@bytesbrains/weblocks 0.3.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.
@@ -0,0 +1,100 @@
1
+ /**
2
+ * `legal` — policy links (Terms, Privacy, Cookies, …) that open as **scrollable,
3
+ * dismissible dialogs**. Each document's body is authored in a SAFE Markdown
4
+ * subset (see markdown.ts) — rich formatting, but never raw HTML (any literal
5
+ * HTML is escaped, not executed). Static brick: the dialog is pure CSS
6
+ * (`:target`), so it works with zero JavaScript — click a link to open, click
7
+ * the ✕ or the backdrop to dismiss.
8
+ *
9
+ * Drop it near the `footer` for the familiar "Terms · Privacy" footer row.
10
+ */
11
+ import { escapeAttr, escapeHtml } from '../schema.js';
12
+ import { renderMarkdown } from '../markdown.js';
13
+ const schema = {
14
+ title: { kind: 'string', default: '', max: 120 },
15
+ align: { kind: 'enum', values: ['start', 'center', 'end'], default: 'center' },
16
+ separator: { kind: 'string', default: '·', max: 4 },
17
+ documents: {
18
+ kind: 'array', max: 8,
19
+ of: {
20
+ kind: 'object',
21
+ fields: {
22
+ label: { kind: 'string', required: true, default: '', max: 60 }, // link text
23
+ title: { kind: 'string', default: '', max: 120 }, // dialog heading (defaults to label)
24
+ content: { kind: 'string', default: '', max: 40000 }, // Markdown body
25
+ },
26
+ },
27
+ },
28
+ };
29
+ const css = `
30
+ .blk-legal{padding:var(--space) var(--space);background:var(--bg);color:var(--text)}
31
+ .blk-legal .links{display:flex;flex-wrap:wrap;align-items:center;gap:.35em .8em;max-width:1080px;margin:0 auto}
32
+ .blk-legal.align-center .links{justify-content:center}
33
+ .blk-legal.align-end .links{justify-content:flex-end}
34
+ .blk-legal .lead{color:var(--muted);font-size:var(--fs-base)}
35
+ .blk-legal .links a.open{color:var(--muted);text-decoration:none;font-size:var(--fs-base);border-bottom:1px solid transparent}
36
+ .blk-legal .links a.open:hover,.blk-legal .links a.open:focus-visible{color:var(--primary);border-bottom-color:currentColor}
37
+ .blk-legal .sep{color:var(--muted);opacity:.6;user-select:none}
38
+ /* Dialog — hidden until its id is the URL :target. */
39
+ .blk-legal .modal{position:fixed;inset:0;z-index:1000;display:none;padding:calc(var(--space)*1.2)}
40
+ .blk-legal .modal:target{display:grid;place-items:center}
41
+ .blk-legal .backdrop{position:absolute;inset:0;background:color-mix(in srgb,#000 55%,transparent);border:0;display:block}
42
+ .blk-legal .panel{position:relative;display:flex;flex-direction:column;width:min(680px,100%);max-height:85vh;background:var(--surface);color:var(--text);border-radius:var(--radius);box-shadow:0 24px 60px -12px rgba(0,0,0,.5);overflow:hidden}
43
+ .blk-legal .pbar{display:flex;align-items:center;justify-content:space-between;gap:1em;padding:calc(var(--space)*1.1) calc(var(--space)*1.3);border-bottom:1px solid color-mix(in srgb,var(--text) 12%,transparent)}
44
+ .blk-legal .pbar h2{margin:0;font-size:var(--fs-lg);font-weight:800}
45
+ .blk-legal .close{flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;width:2em;height:2em;border-radius:999px;text-decoration:none;color:var(--muted);font-size:1.3em;line-height:1;background:color-mix(in srgb,var(--text) 6%,transparent)}
46
+ .blk-legal .close:hover,.blk-legal .close:focus-visible{color:var(--text);background:color-mix(in srgb,var(--text) 12%,transparent)}
47
+ .blk-legal .body{overflow:auto;padding:calc(var(--space)*1.3);line-height:1.65;overscroll-behavior:contain}
48
+ .blk-legal .body>:first-child{margin-top:0}
49
+ .blk-legal .body>:last-child{margin-bottom:0}
50
+ .blk-legal .body h2{font-size:var(--fs-lg);font-weight:800;margin:1.4em 0 .4em}
51
+ .blk-legal .body h3{font-size:var(--fs-lg);font-weight:700;margin:1.2em 0 .3em}
52
+ .blk-legal .body h4{font-size:var(--fs-base);font-weight:700;margin:1em 0 .3em;text-transform:uppercase;letter-spacing:.03em;color:var(--muted)}
53
+ .blk-legal .body p{margin:0 0 1em}
54
+ .blk-legal .body ul,.blk-legal .body ol{margin:0 0 1em;padding-left:1.4em}
55
+ .blk-legal .body li{margin:.3em 0}
56
+ .blk-legal .body a{color:var(--primary);text-decoration:underline;text-underline-offset:2px}
57
+ .blk-legal .body blockquote{margin:1em 0;padding:.2em 0 .2em 1em;border-left:3px solid var(--accent);color:var(--muted)}
58
+ .blk-legal .body code{background:color-mix(in srgb,var(--text) 8%,transparent);padding:.1em .35em;border-radius:4px;font-size:.9em}
59
+ .blk-legal .body hr{border:0;border-top:1px solid color-mix(in srgb,var(--text) 15%,transparent);margin:1.4em 0}
60
+ /* Lock background scroll while a dialog is open (progressive; no-op if unsupported). */
61
+ html:has(.blk-legal .modal:target){overflow:hidden}
62
+ `.trim();
63
+ function render(config, _tokens, ctx) {
64
+ const bid = String(ctx?.id ?? 'legal').replace(/[^A-Za-z0-9_-]/g, '') || 'legal';
65
+ const title = config.title;
66
+ const align = ['start', 'center', 'end'].includes(config.align) ? config.align : 'center';
67
+ const sep = escapeHtml(config.separator || '·');
68
+ const docs = (config.documents ?? []).filter((d) => d && d.label);
69
+ const links = docs
70
+ .map((d, i) => `<a class="open" id="open-${bid}-${i}" href="#${bid}-${i}">${escapeHtml(d.label)}</a>`)
71
+ .join(`<span class="sep" aria-hidden="true">${sep}</span>`);
72
+ const dialogs = docs.map((d, i) => {
73
+ const did = `${bid}-${i}`;
74
+ const heading = d.title || d.label;
75
+ return `<div class="modal" id="${did}" role="dialog" aria-modal="true" aria-labelledby="h-${did}">
76
+ <a class="backdrop" href="#open-${did}" aria-label="Close" tabindex="-1"></a>
77
+ <div class="panel">
78
+ <div class="pbar">
79
+ <h2 id="h-${did}">${escapeHtml(heading)}</h2>
80
+ <a class="close" href="#open-${did}" aria-label="Close dialog">&times;</a>
81
+ </div>
82
+ <div class="body">${renderMarkdown(d.content)}</div>
83
+ </div>
84
+ </div>`;
85
+ }).join('\n ');
86
+ return `<section class="blk-legal align-${align}" aria-label="${escapeAttr(title || 'Legal')}">
87
+ <div class="links">
88
+ ${title ? `<span class="lead">${escapeHtml(title)}</span>` : ''}
89
+ ${links}
90
+ </div>
91
+ ${dialogs}
92
+ </section>`;
93
+ }
94
+ export const legal = {
95
+ type: 'legal',
96
+ description: 'Policy links (terms, privacy, cookies, …) that open as scrollable, dismissible dialogs; each body is safe Markdown (never raw HTML). No JavaScript.',
97
+ schema,
98
+ css,
99
+ render,
100
+ };
@@ -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>';
package/lib/index.d.ts CHANGED
@@ -14,6 +14,7 @@ export { NOOP_RUNTIME, pathRuntime, runtimeNeeds, type RuntimeAdapter, type Runt
14
14
  export { buildWebManifest, buildWebManifestJson, buildServiceWorker, emitPwa, type WebAppManifest, } from './pwa.js';
15
15
  export { validateBlock, validateManifest, type Validation } from './validate.js';
16
16
  export { parse, escapeHtml, escapeAttr, sanitizeUrl, type Field, type Schema, type ParseResult } from './schema.js';
17
+ export { renderMarkdown } from './markdown.js';
17
18
  export { catalog, catalogPrompt, type BlockCatalogEntry, type JsonSchema } from './catalog.js';
18
19
  export { applyOp, applyOps, type EditOp, type OpResult, type BatchResult } from './ops.js';
19
20
  export { generateSite, editSite, buildGenerationPrompt, buildEditPrompt, parseManifestResponse, parseOpsResponse, type ModelCall, type ComposeResult, type EditResult, } from './generate.js';
package/lib/index.js CHANGED
@@ -6,6 +6,7 @@ export { NOOP_RUNTIME, pathRuntime, runtimeNeeds, } from './runtime.js';
6
6
  export { buildWebManifest, buildWebManifestJson, buildServiceWorker, emitPwa, } from './pwa.js';
7
7
  export { validateBlock, validateManifest } from './validate.js';
8
8
  export { parse, escapeHtml, escapeAttr, sanitizeUrl } from './schema.js';
9
+ export { renderMarkdown } from './markdown.js';
9
10
  export { catalog, catalogPrompt } from './catalog.js';
10
11
  export { applyOp, applyOps } from './ops.js';
11
12
  export { generateSite, editSite, buildGenerationPrompt, buildEditPrompt, parseManifestResponse, parseOpsResponse, } from './generate.js';
@@ -0,0 +1 @@
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 {};