@bytesbrains/weblocks 0.4.0 → 0.6.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,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,42 @@
1
+ /**
2
+ * `resume` island — wires the `profile-header` action buttons.
3
+ *
4
+ * Shipped from `@bytesbrains/weblocks/islands/resume.js`; served at the island
5
+ * URL the renderer emits (default `/_island/resume.js`). Zero dependencies,
6
+ * self-executing, idempotent, guarded by `typeof document`.
7
+ *
8
+ * - `[data-wl-print]` → `window.print()`. The engine's `@media print` styles turn
9
+ * the page into a clean one-column résumé, so "Save as PDF" in the print dialog
10
+ * produces the downloadable CV.
11
+ * - `[data-wl-share]` → the Web Share API (native share sheet) with a
12
+ * copy-link-to-clipboard fallback.
13
+ */
14
+ if (typeof document !== 'undefined') {
15
+ const ready = (fn) => document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', fn) : fn();
16
+ ready(() => {
17
+ document.querySelectorAll('[data-wl-print]').forEach((btn) => {
18
+ btn.addEventListener('click', () => window.print());
19
+ });
20
+ document.querySelectorAll('[data-wl-share]').forEach((btn) => {
21
+ btn.addEventListener('click', async () => {
22
+ const url = location.href;
23
+ const nav = navigator;
24
+ if (typeof nav.share === 'function') {
25
+ try {
26
+ await nav.share({ title: document.title, url });
27
+ }
28
+ catch { /* dismissed */ }
29
+ return;
30
+ }
31
+ try {
32
+ await navigator.clipboard.writeText(url);
33
+ const prev = btn.textContent;
34
+ btn.textContent = 'Link copied';
35
+ setTimeout(() => { btn.textContent = prev; }, 1800);
36
+ }
37
+ catch { /* clipboard blocked */ }
38
+ });
39
+ });
40
+ });
41
+ }
42
+ 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
@@ -2,6 +2,9 @@ import { nav } from './blocks/nav.js';
2
2
  import { announcementBar } from './blocks/announcementBar.js';
3
3
  import { hero } from './blocks/hero.js';
4
4
  import { heroApp } from './blocks/heroApp.js';
5
+ import { profileHeader } from './blocks/profileHeader.js';
6
+ import { experience } from './blocks/experience.js';
7
+ import { skills } from './blocks/skills.js';
5
8
  import { features } from './blocks/features.js';
6
9
  import { about } from './blocks/about.js';
7
10
  import { richText } from './blocks/richText.js';
@@ -15,6 +18,7 @@ import { team } from './blocks/team.js';
15
18
  import { gallery } from './blocks/gallery.js';
16
19
  import { carousel } from './blocks/carousel.js';
17
20
  import { video } from './blocks/video.js';
21
+ import { videoGallery } from './blocks/videoGallery.js';
18
22
  import { map } from './blocks/map.js';
19
23
  import { timeline } from './blocks/timeline.js';
20
24
  import { tabs } from './blocks/tabs.js';
@@ -35,6 +39,7 @@ import { directions } from './blocks/directions.js';
35
39
  import { legal } from './blocks/legal.js';
36
40
  import { divider } from './blocks/divider.js';
37
41
  import { spacer } from './blocks/spacer.js';
42
+ import { copyright } from './blocks/copyright.js';
38
43
  import { appShell } from './blocks/appShell.js';
39
44
  import { sidebar } from './blocks/sidebar.js';
40
45
  import { footer } from './blocks/footer.js';
@@ -45,11 +50,13 @@ const SPECS = [
45
50
  appShell, nav, announcementBar, sidebar,
46
51
  // heroes
47
52
  hero, heroApp,
53
+ // résumé / profile
54
+ profileHeader, experience, skills,
48
55
  // content
49
56
  features, about, richText, split, steps, stats,
50
57
  services, pricing, logos, team,
51
58
  // media
52
- gallery, carousel, video, map,
59
+ gallery, carousel, video, videoGallery, map,
53
60
  // structured content
54
61
  timeline, tabs, accordion, testimonials, faq,
55
62
  // collections
@@ -60,7 +67,7 @@ const SPECS = [
60
67
  cta, socialLinks, contactDetails, directions, legal,
61
68
  // rhythm
62
69
  divider, spacer,
63
- footer,
70
+ copyright, footer,
64
71
  ];
65
72
  export const REGISTRY = new Map(SPECS.map((s) => [s.type, s]));
66
73
  export function getSpec(type) {
@@ -80,5 +87,8 @@ export function needsIsland(spec, config) {
80
87
  return false;
81
88
  if (spec.type === 'gallery')
82
89
  return config.lightbox === true;
90
+ // profile-header only needs the resume island when an action button is shown.
91
+ if (spec.type === 'profile-header')
92
+ return config.showDownload === true || config.showShare === true;
83
93
  return true;
84
94
  }
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
@@ -9,7 +9,7 @@
9
9
  * options and speak the §6 contract; with the default no-op runtime they render
10
10
  * inert-but-valid. PWA/SEO head tags are emitted only when the manifest opts in.
11
11
  */
12
- import { escapeAttr, escapeHtml, parse } from './schema.js';
12
+ import { escapeAttr, escapeHtml, parse, sanitizeUrl } from './schema.js';
13
13
  import { getSpec, needsIsland, REGISTRY } from './registry.js';
14
14
  import { normalizeTokens, sectionOverrideCss, tokensToCss } from './tokens.js';
15
15
  import { NOOP_RUNTIME } from './runtime.js';
@@ -20,6 +20,10 @@ img{max-width:100%}
20
20
  .blk-scope{display:block}
21
21
  @media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
22
22
  `.trim().replace(/\n/g, '');
23
+ // Print styles — makes any page (especially a résumé) export cleanly to PDF via
24
+ // the browser's print dialog: keep colours, hide `data-wl-noprint` controls,
25
+ // un-stick fixed chrome, and avoid splitting entries across pages.
26
+ const PRINT_CSS = `@media print{*{-webkit-print-color-adjust:exact;print-color-adjust:exact}[data-wl-noprint]{display:none!important}.blk-nav,.blk-app-shell,.blk-announcement-bar,.blk-sidebar{position:static!important}.blk-profile-header,.blk-experience .entry,.blk-skills .group,.blk-timeline li{break-inside:avoid}a[href]{text-decoration:none}@page{margin:1.4cm}}`;
23
27
  /** Render one block: normalize its config, then hand markup to the brick. */
24
28
  function renderBlock(block, manifest, runtime) {
25
29
  const spec = getSpec(block.type);
@@ -57,8 +61,9 @@ export function renderSite(manifest, options = {}) {
57
61
  if (needsIsland(spec, value))
58
62
  islands.add(spec.island);
59
63
  }
64
+ const islandBase = (options.islandBase ?? '/_island').replace(/\/+$/, '');
60
65
  const islandTags = [...islands]
61
- .map((name) => `<script type="module" src="/_island/${escapeAttr(name)}.js" data-island="${escapeAttr(name)}"></script>`)
66
+ .map((name) => `<script type="module" src="${escapeAttr(islandBase)}/${escapeAttr(name)}.js" data-island="${escapeAttr(name)}"></script>`)
62
67
  .join('\n');
63
68
  return `<!doctype html>
64
69
  <html lang="${escapeAttr(meta.lang || 'en')}">
@@ -66,13 +71,26 @@ export function renderSite(manifest, options = {}) {
66
71
  <meta charset="utf-8">
67
72
  <meta name="viewport" content="width=device-width, initial-scale=1">
68
73
  <title>${escapeHtml(meta.title || 'Untitled site')}</title>${meta.description ? `\n<meta name="description" content="${escapeAttr(meta.description)}">` : ''}${headExtras(manifest, tokens.palette.primary)}
69
- <style>${tokensToCss(tokens)}\n${RESET_CSS}\n${blockCss}</style>
74
+ <style>${tokensToCss(tokens)}\n${RESET_CSS}\n${blockCss}\n${PRINT_CSS}</style>
70
75
  </head>
71
76
  <body>
72
77
  ${body}${islandTags ? '\n' + islandTags : ''}
73
78
  </body>
74
79
  </html>`;
75
80
  }
81
+ /** A browser-tab favicon link from meta.favicon (emoji → data URI, else a URL). */
82
+ function faviconTag(fav) {
83
+ const s = String(fav ?? '').trim();
84
+ if (!s)
85
+ return '';
86
+ // A short glyph with no scheme/slash/dot → treat as an emoji favicon.
87
+ if ([...s].length <= 2 && !/[/:.]/.test(s)) {
88
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">${escapeHtml(s)}</text></svg>`;
89
+ return `<link rel="icon" href="data:image/svg+xml,${encodeURIComponent(svg)}">`;
90
+ }
91
+ const url = sanitizeUrl(s);
92
+ return url === '#' ? '' : `<link rel="icon" href="${escapeAttr(url)}">`;
93
+ }
76
94
  /** SEO + PWA <head> tags, emitted only when the manifest opts in. */
77
95
  function headExtras(manifest, primary) {
78
96
  const out = [];
@@ -80,6 +98,9 @@ function headExtras(manifest, primary) {
80
98
  const pwa = manifest?.pwa;
81
99
  const title = manifest?.meta?.title ?? '';
82
100
  const desc = manifest?.meta?.description ?? '';
101
+ const favicon = faviconTag(manifest?.meta?.favicon);
102
+ if (favicon)
103
+ out.push(favicon);
83
104
  if (seo) {
84
105
  if (seo.canonical)
85
106
  out.push(`<link rel="canonical" href="${escapeAttr(seo.canonical)}">`);
package/lib/types.d.ts CHANGED
@@ -62,6 +62,11 @@ export interface SiteMeta {
62
62
  title: string;
63
63
  description: string;
64
64
  lang: string;
65
+ /**
66
+ * Optional browser-tab favicon: a URL to an icon (svg/png/ico), OR a single
67
+ * emoji/glyph (rendered as an inline SVG data URI). Absent = no icon link.
68
+ */
69
+ favicon?: string;
65
70
  }
66
71
  /**
67
72
  * Optional PWA descriptor. When present the engine can emit a Web App Manifest
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytesbrains/weblocks",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Block engine for AI-composable web apps — the AI composes a SiteManifest from a fixed block catalog; the engine validates and renders it to static HTML. Snap-together \"Lego\" web building blocks, shareable across repos.",
5
5
  "keywords": [
6
6
  "ai",
@@ -30,6 +30,7 @@
30
30
  "types": "./lib/index.d.ts",
31
31
  "import": "./lib/index.js"
32
32
  },
33
+ "./islands/*.js": "./lib/islands/*.js",
33
34
  "./catalog.json": "./catalog.json"
34
35
  },
35
36
  "files": [
@@ -56,9 +57,9 @@
56
57
  "access": "public"
57
58
  },
58
59
  "scripts": {
59
- "build": "tsc -p tsconfig.json",
60
+ "build": "tsc -p tsconfig.json && tsc -p tsconfig.islands.json",
60
61
  "emit:catalog": "node scripts/emit-catalog.mjs",
61
- "test": "tsc -p tsconfig.json && node --test lib/*.test.js",
62
+ "test": "npm run build && node --test lib/*.test.js test/*.mjs",
62
63
  "example": "tsc -p tsconfig.json && node lib/example.js",
63
64
  "ai": "tsc -p tsconfig.json && node scripts/ai-run.mjs",
64
65
  "prepare": "npm run build",
@@ -66,6 +67,7 @@
66
67
  },
67
68
  "devDependencies": {
68
69
  "@types/node": "^22.0.0",
70
+ "jsdom": "^29.1.1",
69
71
  "typescript": "^5.6.0"
70
72
  }
71
73
  }