@bytesbrains/weblocks 0.6.2 → 0.7.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,97 @@
1
+ /**
2
+ * `hours` — structured weekly opening hours with a live "open now / closed"
3
+ * badge. Static-first: the renderer emits the full week as an accessible table
4
+ * (always correct, no JS), with each row carrying `data-*` hooks; the shipped
5
+ * `hours` island reads them at view time to fill the badge and highlight today,
6
+ * so the "open now" state is live rather than frozen at generation time.
7
+ *
8
+ * Today it's a freeform string on `contact-details`; this gives it structure.
9
+ * Times are 24h "HH:MM". A day with no ranges (or `closed:true`) shows "Closed".
10
+ */
11
+ import { escapeAttr, escapeHtml } from '../schema.js';
12
+ const DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
13
+ const LABELS = { mon: 'Monday', tue: 'Tuesday', wed: 'Wednesday', thu: 'Thursday', fri: 'Friday', sat: 'Saturday', sun: 'Sunday' };
14
+ const schema = {
15
+ title: { kind: 'string', default: 'Opening hours', max: 120 },
16
+ timezone: { kind: 'string', default: '', max: 60 },
17
+ note: { kind: 'string', default: '', max: 200 },
18
+ days: {
19
+ kind: 'array', max: 21,
20
+ of: {
21
+ kind: 'object',
22
+ fields: {
23
+ day: { kind: 'enum', values: DAYS, required: true, default: 'mon' },
24
+ open: { kind: 'string', default: '', max: 5 },
25
+ close: { kind: 'string', default: '', max: 5 },
26
+ closed: { kind: 'boolean', default: false },
27
+ },
28
+ },
29
+ },
30
+ };
31
+ const css = `
32
+ .blk-hours{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
33
+ .blk-hours .wrap{max-width:560px;margin:0 auto}
34
+ .blk-hours .head{display:flex;flex-wrap:wrap;align-items:baseline;gap:.6em;margin-bottom:var(--space)}
35
+ .blk-hours h2{font-size:var(--fs-xl);margin:0;font-weight:800}
36
+ .blk-hours .badge{font-size:var(--fs-base);font-weight:700;border-radius:999px;padding:.15em .7em;background:color-mix(in srgb,var(--text) 10%,transparent);color:var(--muted)}
37
+ .blk-hours .badge[data-state="open"]{background:color-mix(in srgb,#2f9e44 20%,transparent);color:#2b8a3e}
38
+ .blk-hours .badge[data-state="closed"]{background:color-mix(in srgb,#e03131 18%,transparent);color:#c92a2a}
39
+ .blk-hours table{width:100%;border-collapse:collapse}
40
+ .blk-hours th,.blk-hours td{text-align:left;padding:.55em 0;border-bottom:1px solid color-mix(in srgb,var(--text) 10%,transparent)}
41
+ .blk-hours td.time{text-align:right;font-variant-numeric:tabular-nums;color:var(--muted)}
42
+ .blk-hours tr[data-today] th,.blk-hours tr[data-today] td{font-weight:800;color:var(--text)}
43
+ .blk-hours .closed{color:color-mix(in srgb,var(--text) 45%,transparent)}
44
+ .blk-hours .note{color:var(--muted);font-size:var(--fs-base);margin:var(--space) 0 0}
45
+ .blk-hours .tz{color:var(--muted);font-weight:400;font-size:var(--fs-base)}
46
+ `.trim();
47
+ /** Group configured ranges by weekday, preserving order (supports split shifts). */
48
+ function byDay(days) {
49
+ const out = {};
50
+ for (const d of days) {
51
+ if (!d || !DAYS.includes(d.day))
52
+ continue;
53
+ (out[d.day] ??= []).push(d);
54
+ }
55
+ return out;
56
+ }
57
+ function rangeText(ranges) {
58
+ const open = ranges.filter((r) => !r.closed && r.open && r.close);
59
+ if (!open.length)
60
+ return { text: 'Closed', closed: true, data: '' };
61
+ const text = open.map((r) => `${escapeHtml(r.open)}–${escapeHtml(r.close)}`).join(', ');
62
+ const data = open.map((r) => `${r.open}-${r.close}`).join(',');
63
+ return { text, closed: false, data };
64
+ }
65
+ function render(config) {
66
+ const title = config.title;
67
+ const timezone = String(config.timezone ?? '').trim();
68
+ const note = config.note;
69
+ const grouped = byDay(config.days ?? []);
70
+ const rows = DAYS.map((day) => {
71
+ const { text, closed, data } = rangeText(grouped[day] ?? []);
72
+ return `<tr data-day="${day}"${data ? ` data-hours="${escapeAttr(data)}"` : ''}>
73
+ <th scope="row">${LABELS[day]}</th>
74
+ <td class="time${closed ? ' closed' : ''}">${text}</td>
75
+ </tr>`;
76
+ }).join('\n ');
77
+ return `<section class="blk-hours" aria-label="${escapeAttr(title || 'Opening hours')}" data-wl-hours="true">
78
+ <div class="wrap">
79
+ <div class="head">
80
+ ${title ? `<h2>${escapeHtml(title)}${timezone ? ` <span class="tz">(${escapeHtml(timezone)})</span>` : ''}</h2>` : ''}
81
+ <span class="badge" data-hours-badge hidden></span>
82
+ </div>
83
+ <table>
84
+ <tbody>
85
+ ${rows}
86
+ </tbody>
87
+ </table>
88
+ ${note ? `<p class="note">${escapeHtml(note)}</p>` : ''}
89
+ </div>
90
+ </section>`;
91
+ }
92
+ export const hours = {
93
+ type: 'hours',
94
+ description: 'Structured weekly opening hours (24h times per day, split shifts allowed) rendered as an accessible table with a live "open now / closed" badge. Use for shops, cafés, salons, and clinics.',
95
+ schema, css, render,
96
+ island: 'hours',
97
+ };
@@ -0,0 +1,2 @@
1
+ import type { BlockSpec } from '../registry.js';
2
+ export declare const menu: BlockSpec;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * `menu` — a food/drink menu: named sections, each a list of items with an
3
+ * optional price, dietary/allergen tags, and a 0–3 spice level. Static brick.
4
+ * `services-catalogue` approximates a flat item+price grid but has no menu
5
+ * semantics (sections, dietary tags, spice) — this block does.
6
+ */
7
+ import { escapeAttr, escapeHtml } from '../schema.js';
8
+ const schema = {
9
+ title: { kind: 'string', default: 'Menu', max: 120 },
10
+ subtitle: { kind: 'string', default: '', max: 240 },
11
+ sections: {
12
+ kind: 'array', max: 20,
13
+ of: {
14
+ kind: 'object',
15
+ fields: {
16
+ name: { kind: 'string', required: true, default: 'Section', max: 80 },
17
+ note: { kind: 'string', default: '', max: 200 },
18
+ items: {
19
+ kind: 'array', max: 40,
20
+ of: {
21
+ kind: 'object',
22
+ fields: {
23
+ name: { kind: 'string', required: true, default: 'Item', max: 100 },
24
+ description: { kind: 'string', default: '', max: 300 },
25
+ price: { kind: 'string', default: '', max: 40 },
26
+ tags: { kind: 'array', max: 8, of: { kind: 'string', default: '', max: 12 } },
27
+ spice: { kind: 'int', default: 0, min: 0, max: 3 },
28
+ },
29
+ },
30
+ },
31
+ },
32
+ },
33
+ },
34
+ };
35
+ const css = `
36
+ .blk-menu{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
37
+ .blk-menu .wrap{max-width:820px;margin:0 auto}
38
+ .blk-menu .head{text-align:center;margin-bottom:var(--space-lg)}
39
+ .blk-menu h2{font-size:var(--fs-xl);margin:0 0 .3em;font-weight:800}
40
+ .blk-menu .sub{color:var(--muted);font-size:var(--fs-lg);margin:0}
41
+ .blk-menu .section{margin-top:var(--space-lg)}
42
+ .blk-menu .section:first-of-type{margin-top:0}
43
+ .blk-menu h3{font-size:var(--fs-lg);margin:0 0 .1em;font-weight:700;border-bottom:2px solid color-mix(in srgb,var(--text) 12%,transparent);padding-bottom:.3em}
44
+ .blk-menu .section-note{color:var(--muted);font-size:var(--fs-base);margin:.3em 0 0}
45
+ .blk-menu .item{display:grid;grid-template-columns:1fr auto;gap:.2em .8em;padding:calc(var(--space)*.7) 0;border-bottom:1px dashed color-mix(in srgb,var(--text) 10%,transparent)}
46
+ .blk-menu .item .name{font-weight:700}
47
+ .blk-menu .item .price{color:var(--accent);font-weight:700;white-space:nowrap}
48
+ .blk-menu .item .desc{grid-column:1/-1;color:var(--muted);margin:.15em 0 0}
49
+ .blk-menu .tags{grid-column:1/-1;display:flex;flex-wrap:wrap;gap:.35em;margin-top:.35em}
50
+ .blk-menu .tag{font-size:var(--fs-base);font-weight:600;color:var(--primary);background:color-mix(in srgb,var(--primary) 12%,transparent);border-radius:999px;padding:.1em .6em}
51
+ .blk-menu .spice{color:#d9480f}
52
+ `.trim();
53
+ function itemRow(it) {
54
+ const spice = it.spice > 0 ? ` <span class="spice" aria-label="spice level ${Math.min(3, it.spice)}">${'🌶️'.repeat(Math.min(3, it.spice))}</span>` : '';
55
+ const tags = (it.tags ?? []).filter(Boolean);
56
+ const tagRow = tags.length ? `<div class="tags">${tags.map((t) => `<span class="tag">${escapeHtml(t)}</span>`).join('')}</div>` : '';
57
+ return `<div class="item">
58
+ <span class="name">${escapeHtml(it.name)}${spice}</span>
59
+ ${it.price ? `<span class="price">${escapeHtml(it.price)}</span>` : '<span></span>'}
60
+ ${it.description ? `<p class="desc">${escapeHtml(it.description)}</p>` : ''}
61
+ ${tagRow}
62
+ </div>`;
63
+ }
64
+ function section(s) {
65
+ const items = (s.items ?? []).map(itemRow).join('\n ');
66
+ return `<div class="section">
67
+ <h3>${escapeHtml(s.name)}</h3>
68
+ ${s.note ? `<p class="section-note">${escapeHtml(s.note)}</p>` : ''}
69
+ ${items}
70
+ </div>`;
71
+ }
72
+ function render(config) {
73
+ const title = config.title;
74
+ const subtitle = config.subtitle;
75
+ const sections = (config.sections ?? []).filter((s) => s && s.name);
76
+ const head = (title || subtitle)
77
+ ? `<div class="head">
78
+ ${title ? `<h2>${escapeHtml(title)}</h2>` : ''}
79
+ ${subtitle ? `<p class="sub">${escapeHtml(subtitle)}</p>` : ''}
80
+ </div>`
81
+ : '';
82
+ return `<section class="blk-menu" aria-label="${escapeAttr(title || 'Menu')}">
83
+ <div class="wrap">
84
+ ${head}
85
+ ${sections.map(section).join('\n ')}
86
+ </div>
87
+ </section>`;
88
+ }
89
+ export const menu = {
90
+ type: 'menu',
91
+ description: 'A food/drink menu: named sections, each with items that have an optional price, dietary/allergen tags (e.g. V, VG, GF), and a 0–3 spice level. Use for restaurants and cafés.',
92
+ schema, css, render,
93
+ };
@@ -0,0 +1,2 @@
1
+ import type { BlockSpec } from '../registry.js';
2
+ export declare const product: BlockSpec;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * `product` — a shoppable product grid: each item has an image, price, optional
3
+ * badge, and a buy/enquire link. Static brick. `pricing` is subscription tiers
4
+ * and `gallery` is images-only; neither is a per-item shop grid. The actual
5
+ * cart/checkout is the host's job — each card links out (or to a host page).
6
+ */
7
+ import { escapeAttr, escapeHtml, sanitizeUrl } from '../schema.js';
8
+ const schema = {
9
+ title: { kind: 'string', default: '', max: 120 },
10
+ subtitle: { kind: 'string', default: '', max: 240 },
11
+ columns: { kind: 'int', oneOf: [2, 3, 4], default: 3 },
12
+ items: {
13
+ kind: 'array', max: 48,
14
+ of: {
15
+ kind: 'object',
16
+ fields: {
17
+ name: { kind: 'string', required: true, default: 'Product', max: 100 },
18
+ description: { kind: 'string', default: '', max: 240 },
19
+ price: { kind: 'string', default: '', max: 40 },
20
+ was: { kind: 'string', default: '', max: 40 },
21
+ image: { kind: 'string', default: '', max: 500 },
22
+ badge: { kind: 'string', default: '', max: 24 },
23
+ ctaLabel: { kind: 'string', default: 'Buy', max: 40 },
24
+ ctaHref: { kind: 'string', default: '#', max: 500 },
25
+ },
26
+ },
27
+ },
28
+ };
29
+ const css = `
30
+ .blk-product{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
31
+ .blk-product .wrap{max-width:1120px;margin:0 auto}
32
+ .blk-product .head{text-align:center;margin-bottom:var(--space-lg)}
33
+ .blk-product h2{font-size:var(--fs-xl);margin:0 0 .3em;font-weight:800}
34
+ .blk-product .sub{color:var(--muted);font-size:var(--fs-lg);margin:0}
35
+ .blk-product .grid{display:grid;gap:calc(var(--space)*1.1);grid-template-columns:repeat(var(--cols,3),1fr)}
36
+ @media (max-width:900px){.blk-product .grid{grid-template-columns:repeat(2,1fr)}}
37
+ @media (max-width:560px){.blk-product .grid{grid-template-columns:1fr}}
38
+ .blk-product .card{background:var(--surface);border:1px solid color-mix(in srgb,var(--text) 12%,transparent);border-radius:var(--radius);overflow:hidden;display:flex;flex-direction:column}
39
+ .blk-product .media{position:relative;aspect-ratio:4/3;background:color-mix(in srgb,var(--text) 6%,transparent)}
40
+ .blk-product .media img{width:100%;height:100%;object-fit:cover;display:block}
41
+ .blk-product .badge{position:absolute;top:.6em;left:.6em;background:var(--accent);color:var(--on-accent);font-weight:700;font-size:var(--fs-base);border-radius:999px;padding:.15em .7em}
42
+ .blk-product .body{padding:calc(var(--space)*1.1);display:flex;flex-direction:column;gap:.4em;flex:1}
43
+ .blk-product .name{font-weight:700;font-size:var(--fs-lg)}
44
+ .blk-product .desc{color:var(--muted);margin:0}
45
+ .blk-product .prices{display:flex;align-items:baseline;gap:.5em;margin-top:auto}
46
+ .blk-product .price{color:var(--accent);font-weight:800;font-size:var(--fs-lg)}
47
+ .blk-product .was{color:var(--muted);text-decoration:line-through;font-size:var(--fs-base)}
48
+ .blk-product .cta{margin-top:.6em;text-align:center;font:inherit;font-weight:700;border-radius:var(--radius);padding:.6em 1em;background:var(--primary);color:var(--on-primary);text-decoration:none;display:block}
49
+ `.trim();
50
+ function card(it) {
51
+ const media = it.image
52
+ ? `<div class="media">${it.badge ? `<span class="badge">${escapeHtml(it.badge)}</span>` : ''}<img src="${escapeAttr(sanitizeUrl(it.image))}" alt="${escapeAttr(it.name)}" loading="lazy"></div>`
53
+ : (it.badge ? `<div class="media"><span class="badge">${escapeHtml(it.badge)}</span></div>` : '');
54
+ const prices = (it.price || it.was)
55
+ ? `<div class="prices">${it.price ? `<span class="price">${escapeHtml(it.price)}</span>` : ''}${it.was ? `<span class="was">${escapeHtml(it.was)}</span>` : ''}</div>`
56
+ : '';
57
+ const cta = it.ctaLabel ? `<a class="cta" href="${escapeAttr(sanitizeUrl(it.ctaHref))}">${escapeHtml(it.ctaLabel)}</a>` : '';
58
+ return `<article class="card">
59
+ ${media}
60
+ <div class="body">
61
+ <span class="name">${escapeHtml(it.name)}</span>
62
+ ${it.description ? `<p class="desc">${escapeHtml(it.description)}</p>` : ''}
63
+ ${prices}
64
+ ${cta}
65
+ </div>
66
+ </article>`;
67
+ }
68
+ function render(config) {
69
+ const title = config.title;
70
+ const subtitle = config.subtitle;
71
+ const cols = config.columns || 3;
72
+ const items = (config.items ?? []).filter((i) => i && i.name);
73
+ const head = (title || subtitle)
74
+ ? `<div class="head">
75
+ ${title ? `<h2>${escapeHtml(title)}</h2>` : ''}
76
+ ${subtitle ? `<p class="sub">${escapeHtml(subtitle)}</p>` : ''}
77
+ </div>`
78
+ : '';
79
+ return `<section class="blk-product" aria-label="${escapeAttr(title || 'Products')}">
80
+ <div class="wrap">
81
+ ${head}
82
+ <div class="grid" style="--cols:${cols}">
83
+ ${items.map(card).join('\n ')}
84
+ </div>
85
+ </div>
86
+ </section>`;
87
+ }
88
+ export const product = {
89
+ type: 'product',
90
+ description: 'A shoppable product grid: each item has an image, price (with optional "was" price and badge), and a buy/enquire link. Use for retail and e-commerce; the cart/checkout is the host\'s.',
91
+ schema, css, render,
92
+ };
@@ -0,0 +1,2 @@
1
+ import type { BlockSpec } from '../registry.js';
2
+ export declare const reviews: BlockSpec;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * `reviews` — star-rated customer reviews with an optional source (Google, Yelp,
3
+ * …) and an aggregate summary. Richer than `testimonials` quotes: each review
4
+ * carries a 1–5 star rating and where it came from. Static brick.
5
+ */
6
+ import { escapeAttr, escapeHtml } from '../schema.js';
7
+ const SOURCES = ['google', 'yelp', 'facebook', 'trustpilot', 'tripadvisor', 'app-store', 'other'];
8
+ const schema = {
9
+ title: { kind: 'string', default: '', max: 120 },
10
+ subtitle: { kind: 'string', default: '', max: 240 },
11
+ average: { kind: 'string', default: '', max: 8 },
12
+ count: { kind: 'string', default: '', max: 24 },
13
+ source: { kind: 'string', default: '', max: 40 },
14
+ items: {
15
+ kind: 'array', max: 24,
16
+ of: {
17
+ kind: 'object',
18
+ fields: {
19
+ rating: { kind: 'int', default: 5, min: 1, max: 5 },
20
+ quote: { kind: 'string', required: true, default: '', max: 500 },
21
+ author: { kind: 'string', default: '', max: 80 },
22
+ date: { kind: 'string', default: '', max: 40 },
23
+ source: { kind: 'enum', values: SOURCES, default: 'other' },
24
+ },
25
+ },
26
+ },
27
+ };
28
+ const css = `
29
+ .blk-reviews{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
30
+ .blk-reviews .wrap{max-width:1080px;margin:0 auto}
31
+ .blk-reviews .head{text-align:center;margin-bottom:var(--space-lg)}
32
+ .blk-reviews h2{font-size:var(--fs-xl);margin:0 0 .3em;font-weight:800}
33
+ .blk-reviews .sub{color:var(--muted);font-size:var(--fs-lg);margin:0}
34
+ .blk-reviews .agg{display:inline-flex;align-items:baseline;gap:.5em;margin-top:.6em;font-weight:700}
35
+ .blk-reviews .agg .score{font-size:var(--fs-lg);color:var(--accent)}
36
+ .blk-reviews .agg .count{color:var(--muted);font-weight:400}
37
+ .blk-reviews .stars{color:var(--accent);letter-spacing:.05em;font-size:1.05em;white-space:nowrap}
38
+ .blk-reviews .stars .off{color:color-mix(in srgb,var(--text) 22%,transparent)}
39
+ .blk-reviews .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:calc(var(--space)*1.2)}
40
+ .blk-reviews .card{background:var(--surface);border:1px solid color-mix(in srgb,var(--text) 12%,transparent);border-radius:var(--radius);padding:calc(var(--space)*1.3);display:flex;flex-direction:column;gap:.6em}
41
+ .blk-reviews blockquote{margin:0;color:var(--text);line-height:1.5}
42
+ .blk-reviews .by{color:var(--muted);font-weight:600;font-size:var(--fs-base)}
43
+ .blk-reviews .by .src{font-weight:400;text-transform:capitalize}
44
+ `.trim();
45
+ /** A 1–5 star row with an accessible label. */
46
+ function stars(rating) {
47
+ const n = Math.max(1, Math.min(5, Math.round(rating || 0)));
48
+ const filled = '★'.repeat(n);
49
+ const empty = n < 5 ? `<span class="off">${'★'.repeat(5 - n)}</span>` : '';
50
+ return `<span class="stars" role="img" aria-label="${n} out of 5 stars">${filled}${empty}</span>`;
51
+ }
52
+ function card(it) {
53
+ if (!it.quote)
54
+ return '';
55
+ const src = it.source && it.source !== 'other' ? it.source.replace(/-/g, ' ') : '';
56
+ const meta = [it.author, it.date].filter(Boolean).join(' · ');
57
+ const by = (meta || src)
58
+ ? `<div class="by">${escapeHtml(meta)}${src ? ` <span class="src">· via ${escapeHtml(src)}</span>` : ''}</div>`
59
+ : '';
60
+ return `<article class="card">
61
+ ${stars(it.rating)}
62
+ <blockquote>${escapeHtml(it.quote)}</blockquote>
63
+ ${by}
64
+ </article>`;
65
+ }
66
+ function render(config) {
67
+ const title = config.title;
68
+ const subtitle = config.subtitle;
69
+ const average = String(config.average ?? '').trim();
70
+ const count = String(config.count ?? '').trim();
71
+ const source = String(config.source ?? '').trim();
72
+ const items = (config.items ?? []).map(card).filter(Boolean);
73
+ const agg = average
74
+ ? `<div class="agg">${stars(parseFloat(average))} <span class="score">${escapeHtml(average)}</span>${count ? ` <span class="count">from ${escapeHtml(count)} reviews${source ? ` on ${escapeHtml(source)}` : ''}</span>` : (source ? ` <span class="count">on ${escapeHtml(source)}</span>` : '')}</div>`
75
+ : '';
76
+ const head = (title || subtitle || agg)
77
+ ? `<div class="head">
78
+ ${title ? `<h2>${escapeHtml(title)}</h2>` : ''}
79
+ ${subtitle ? `<p class="sub">${escapeHtml(subtitle)}</p>` : ''}
80
+ ${agg}
81
+ </div>`
82
+ : '';
83
+ return `<section class="blk-reviews" aria-label="${escapeAttr(title || 'Reviews')}">
84
+ <div class="wrap">
85
+ ${head}
86
+ <div class="grid">
87
+ ${items.join('\n ')}
88
+ </div>
89
+ </div>
90
+ </section>`;
91
+ }
92
+ export const reviews = {
93
+ type: 'reviews',
94
+ description: 'Star-rated customer reviews (1–5 stars) each with an optional author, date, and source (Google, Yelp, …), plus an optional aggregate score. Richer than plain testimonial quotes.',
95
+ schema, css, render,
96
+ };
package/lib/generate.d.ts CHANGED
@@ -4,7 +4,24 @@ export type ModelCall = (args: {
4
4
  system: string;
5
5
  user: string;
6
6
  }) => Promise<string>;
7
- export declare function buildGenerationPrompt(brief: string): {
7
+ /** Optional levers for compose. All advisory — the model may still deviate. */
8
+ export interface GenerateOptions {
9
+ /**
10
+ * A business-vertical id (`verticalNames()`). When set, its recommended
11
+ * section set + fitting preset are injected as defaults ("prefer these unless
12
+ * the brief says otherwise"). Unknown ids are ignored. See `verticals.ts`.
13
+ */
14
+ vertical?: string;
15
+ /**
16
+ * A starter **scaffold** to adapt: a template id (`templateNames()`) or a
17
+ * complete `SiteManifest`. When set, its structure + design are seeded into
18
+ * the prompt ("start from this, keep the section set unless the brief
19
+ * conflicts, rewrite the copy for THIS business"). Unknown ids are ignored;
20
+ * absent = blank-slate compose. See `templates.ts`.
21
+ */
22
+ template?: string | SiteManifest;
23
+ }
24
+ export declare function buildGenerationPrompt(brief: string, opts?: GenerateOptions): {
8
25
  system: string;
9
26
  user: string;
10
27
  };
@@ -15,7 +32,7 @@ export interface ComposeResult {
15
32
  warnings: string[];
16
33
  }
17
34
  /** Compose a manifest from a brief using the injected model. */
18
- export declare function generateSite(brief: string, callModel: ModelCall): Promise<ComposeResult>;
35
+ export declare function generateSite(brief: string, callModel: ModelCall, opts?: GenerateOptions): Promise<ComposeResult>;
19
36
  /** Extract + coerce + validate a manifest from a model reply. Never throws. */
20
37
  export declare function parseManifestResponse(text: string): ComposeResult;
21
38
  export declare function buildEditPrompt(manifest: SiteManifest, message: string): {
package/lib/generate.js CHANGED
@@ -11,11 +11,49 @@
11
11
  import { catalogPrompt } from './catalog.js';
12
12
  import { blockTypes } from './registry.js';
13
13
  import { presetNames } from './presets.js';
14
+ import { getVertical } from './verticals.js';
15
+ import { getTemplate } from './templates.js';
14
16
  import { normalizeTokens, DEFAULT_TOKENS } from './tokens.js';
15
17
  import { validateManifest } from './validate.js';
16
18
  import { applyOps } from './ops.js';
19
+ /** Resolve the `template` option to a manifest (by id, or passed through). */
20
+ function resolveTemplate(t) {
21
+ if (!t)
22
+ return undefined;
23
+ return typeof t === 'string' ? getTemplate(t)?.manifest : t;
24
+ }
17
25
  // ── Compose: brief → manifest ──────────────────────────────────────────────────
18
- export function buildGenerationPrompt(brief) {
26
+ /** Advisory guidance lines seeded from a chosen vertical (empty if none/unknown). */
27
+ function verticalGuidance(v) {
28
+ if (!v)
29
+ return [];
30
+ return [
31
+ '',
32
+ `Business vertical: ${v.label} — aim for a ${v.tone} tone.`,
33
+ `Prefer these sections, in this order, unless the brief clearly calls for a different set: ${v.blocks.join(', ')}.`,
34
+ `Base the look on the "${v.preset}" preset unless the brief implies another mood.`,
35
+ ...(v.booking
36
+ ? ['This is an appointment/booking-driven business — the primary call-to-action should read like "Book" or "Reserve" and lead to a booking or contact section.']
37
+ : []),
38
+ ];
39
+ }
40
+ /** Advisory scaffold lines seeded from a template manifest (empty if none). */
41
+ function templateGuidance(scaffold) {
42
+ if (!scaffold)
43
+ return [];
44
+ const structure = scaffold.blocks
45
+ .filter((b) => b.visible !== false)
46
+ .map((b) => ({ type: b.type, config: b.config }));
47
+ return [
48
+ '',
49
+ 'Starter scaffold — START FROM THIS structure and adapt it (do NOT keep the placeholder copy):',
50
+ JSON.stringify({ design: scaffold.design, blocks: structure }),
51
+ 'Keep this section set and order unless the brief clearly calls for a change; REWRITE every headline, label, and body for the business described in the brief so no placeholder text remains; keep the design coherent (retune the palette if the brief implies a different mood).',
52
+ ];
53
+ }
54
+ export function buildGenerationPrompt(brief, opts = {}) {
55
+ const vertical = opts.vertical ? getVertical(opts.vertical) : undefined;
56
+ const scaffold = resolveTemplate(opts.template);
19
57
  const system = [
20
58
  'You are a web designer that composes a single-page site by ARRANGING pre-built blocks.',
21
59
  'You do NOT write HTML, CSS, or JavaScript. You output ONLY a JSON site manifest.',
@@ -29,6 +67,8 @@ export function buildGenerationPrompt(brief) {
29
67
  ' mode:auto follows the viewer\'s OS light/dark; supply an optional darkPalette:{...same keys...} for the dark side.',
30
68
  ' (Text on primary/accent fills is auto-contrasted — never hand-set button text colours.)',
31
69
  ` (For a quick coherent look, base it on a named preset: ${presetNames().join(', ')}.)`,
70
+ ...verticalGuidance(vertical),
71
+ ...templateGuidance(scaffold),
32
72
  '',
33
73
  'Rules:',
34
74
  '- Use ONLY the block types listed above; never invent a type or a config key.',
@@ -40,8 +80,8 @@ export function buildGenerationPrompt(brief) {
40
80
  return { system, user: `Brief:\n${brief}` };
41
81
  }
42
82
  /** Compose a manifest from a brief using the injected model. */
43
- export async function generateSite(brief, callModel) {
44
- const { system, user } = buildGenerationPrompt(brief);
83
+ export async function generateSite(brief, callModel, opts = {}) {
84
+ const { system, user } = buildGenerationPrompt(brief, opts);
45
85
  const raw = await callModel({ system, user });
46
86
  return parseManifestResponse(raw);
47
87
  }
package/lib/index.d.ts CHANGED
@@ -8,6 +8,8 @@
8
8
  export type { SiteManifest, Block, DesignTokens, Palette, SiteMeta, SectionOverrides, PwaConfig, SeoConfig, Mode, Scale, Radius, Spacing, Motion, } from './types.js';
9
9
  export { DEFAULT_TOKENS, normalizeTokens, tokensToCss, sectionOverrideCss, readableOn } from './tokens.js';
10
10
  export { PRESETS, presetNames, getPreset } from './presets.js';
11
+ export { VERTICALS, verticalNames, getVertical, type Vertical } from './verticals.js';
12
+ export { TEMPLATES, templateNames, templatesForVertical, getTemplate, type Template } from './templates.js';
11
13
  export { REGISTRY, getSpec, blockTypes, needsIsland, type BlockSpec, type RenderContext } from './registry.js';
12
14
  export { renderSite, type RenderOptions } from './render.js';
13
15
  export { NOOP_RUNTIME, pathRuntime, runtimeNeeds, type RuntimeAdapter, type RuntimeAction, type BlockRuntimeNeeds, } from './runtime.js';
@@ -17,4 +19,4 @@ export { parse, escapeHtml, escapeAttr, sanitizeUrl, slugify, type Field, type S
17
19
  export { renderMarkdown } from './markdown.js';
18
20
  export { catalog, catalogPrompt, type BlockCatalogEntry, type JsonSchema } from './catalog.js';
19
21
  export { applyOp, applyOps, type EditOp, type OpResult, type BatchResult } from './ops.js';
20
- export { generateSite, editSite, buildGenerationPrompt, buildEditPrompt, parseManifestResponse, parseOpsResponse, type ModelCall, type ComposeResult, type EditResult, } from './generate.js';
22
+ export { generateSite, editSite, buildGenerationPrompt, buildEditPrompt, parseManifestResponse, parseOpsResponse, type ModelCall, type ComposeResult, type EditResult, type GenerateOptions, } from './generate.js';
package/lib/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  export { DEFAULT_TOKENS, normalizeTokens, tokensToCss, sectionOverrideCss, readableOn } from './tokens.js';
2
2
  export { PRESETS, presetNames, getPreset } from './presets.js';
3
+ export { VERTICALS, verticalNames, getVertical } from './verticals.js';
4
+ export { TEMPLATES, templateNames, templatesForVertical, getTemplate } from './templates.js';
3
5
  export { REGISTRY, getSpec, blockTypes, needsIsland } from './registry.js';
4
6
  export { renderSite } from './render.js';
5
7
  export { NOOP_RUNTIME, pathRuntime, runtimeNeeds, } from './runtime.js';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,69 @@
1
+ /**
2
+ * `hours` island — makes the `hours` block's "open now / closed" badge live and
3
+ * highlights today, at VIEW time (so it's never frozen at generation time).
4
+ *
5
+ * Shipped from `@bytesbrains/weblocks/islands/hours.js`; served at the island URL
6
+ * the renderer emits (default `/_island/hours.js`). Zero dependencies,
7
+ * self-executing, idempotent, guarded by `typeof document`. Pure progressive
8
+ * enhancement: with no JS the full weekly table still renders correctly.
9
+ *
10
+ * Each `<tr data-day="mon" data-hours="09:00-17:00,18:00-21:00">` carries that
11
+ * day's ranges; a `[data-hours-badge]` in the block header is filled in.
12
+ */
13
+ if (typeof document !== 'undefined') {
14
+ const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; // JS getDay() order
15
+ const toMin = (hhmm) => {
16
+ const parts = (hhmm || '').split(':');
17
+ const h = parseInt(parts[0] ?? '', 10), m = parseInt(parts[1] ?? '', 10);
18
+ return Number.isFinite(h) && Number.isFinite(m) ? h * 60 + m : NaN;
19
+ };
20
+ const fmt = (min) => {
21
+ const h = Math.floor(min / 60) % 24, m = min % 60;
22
+ return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
23
+ };
24
+ const ready = (fn) => document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', fn) : fn();
25
+ ready(() => {
26
+ document.querySelectorAll('[data-wl-hours]').forEach((block) => {
27
+ const now = new Date();
28
+ const todayKey = DAYS[now.getDay()];
29
+ const nowMin = now.getHours() * 60 + now.getMinutes();
30
+ let openNow = false;
31
+ let nextOpenMin = Infinity;
32
+ block.querySelectorAll('tr[data-day]').forEach((row) => {
33
+ const isToday = row.getAttribute('data-day') === todayKey;
34
+ if (isToday)
35
+ row.setAttribute('data-today', 'true');
36
+ if (!isToday)
37
+ return;
38
+ const ranges = (row.getAttribute('data-hours') || '').split(',').filter(Boolean);
39
+ for (const r of ranges) {
40
+ const [o, c] = r.split('-');
41
+ const open = toMin(o ?? ''), close = toMin(c ?? '');
42
+ if (!Number.isFinite(open) || !Number.isFinite(close))
43
+ continue;
44
+ if (nowMin >= open && nowMin < close)
45
+ openNow = true;
46
+ if (open > nowMin && open < nextOpenMin)
47
+ nextOpenMin = open;
48
+ }
49
+ });
50
+ const badge = block.querySelector('[data-hours-badge]');
51
+ if (!badge)
52
+ return;
53
+ if (openNow) {
54
+ badge.textContent = 'Open now';
55
+ badge.setAttribute('data-state', 'open');
56
+ }
57
+ else if (Number.isFinite(nextOpenMin)) {
58
+ badge.textContent = `Closed · opens ${fmt(nextOpenMin)}`;
59
+ badge.setAttribute('data-state', 'closed');
60
+ }
61
+ else {
62
+ badge.textContent = 'Closed';
63
+ badge.setAttribute('data-state', 'closed');
64
+ }
65
+ badge.hidden = false;
66
+ });
67
+ });
68
+ }
69
+ export {};
package/lib/registry.js CHANGED
@@ -12,6 +12,8 @@ import { split } from './blocks/split.js';
12
12
  import { steps } from './blocks/steps.js';
13
13
  import { stats } from './blocks/stats.js';
14
14
  import { services } from './blocks/services.js';
15
+ import { menu } from './blocks/menu.js';
16
+ import { product } from './blocks/product.js';
15
17
  import { pricing } from './blocks/pricing.js';
16
18
  import { logos } from './blocks/logos.js';
17
19
  import { team } from './blocks/team.js';
@@ -24,10 +26,12 @@ import { timeline } from './blocks/timeline.js';
24
26
  import { tabs } from './blocks/tabs.js';
25
27
  import { accordion } from './blocks/accordion.js';
26
28
  import { testimonials } from './blocks/testimonials.js';
29
+ import { reviews } from './blocks/reviews.js';
27
30
  import { faq } from './blocks/faq.js';
28
31
  import { blogList } from './blocks/blogList.js';
29
32
  import { blogPost } from './blocks/blogPost.js';
30
33
  import { feed } from './blocks/feed.js';
34
+ import { booking } from './blocks/booking.js';
31
35
  import { contactForm } from './blocks/contactForm.js';
32
36
  import { newsletter } from './blocks/newsletter.js';
33
37
  import { search } from './blocks/search.js';
@@ -35,6 +39,7 @@ import { auth } from './blocks/auth.js';
35
39
  import { cta } from './blocks/cta.js';
36
40
  import { socialLinks } from './blocks/socialLinks.js';
37
41
  import { contactDetails } from './blocks/contactDetails.js';
42
+ import { hours } from './blocks/hours.js';
38
43
  import { directions } from './blocks/directions.js';
39
44
  import { legal } from './blocks/legal.js';
40
45
  import { divider } from './blocks/divider.js';
@@ -54,17 +59,17 @@ const SPECS = [
54
59
  profileHeader, experience, skills,
55
60
  // content
56
61
  features, about, richText, split, steps, stats,
57
- services, pricing, logos, team,
62
+ services, menu, product, pricing, logos, team,
58
63
  // media
59
64
  gallery, carousel, video, videoGallery, map,
60
65
  // structured content
61
- timeline, tabs, accordion, testimonials, faq,
66
+ timeline, tabs, accordion, testimonials, reviews, faq,
62
67
  // collections
63
68
  blogList, blogPost, feed,
64
69
  // dynamic / powered
65
- contactForm, newsletter, search, auth,
70
+ booking, contactForm, newsletter, search, auth,
66
71
  // conversion / contact
67
- cta, socialLinks, contactDetails, directions, legal,
72
+ cta, socialLinks, contactDetails, hours, directions, legal,
68
73
  // rhythm
69
74
  divider, spacer,
70
75
  copyright, footer,