@bytesbrains/weblocks 0.2.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 +214 -0
- package/CATALOG.md +387 -0
- package/CHANGELOG.md +63 -0
- package/LICENSE +21 -0
- package/README.md +294 -0
- package/catalog.json +1850 -0
- package/lib/blocks/about.d.ts +2 -0
- package/lib/blocks/about.js +50 -0
- package/lib/blocks/accordion.d.ts +2 -0
- package/lib/blocks/accordion.js +51 -0
- package/lib/blocks/announcementBar.d.ts +2 -0
- package/lib/blocks/announcementBar.js +56 -0
- package/lib/blocks/appShell.d.ts +2 -0
- package/lib/blocks/appShell.js +54 -0
- package/lib/blocks/auth.d.ts +2 -0
- package/lib/blocks/auth.js +82 -0
- package/lib/blocks/blogList.d.ts +2 -0
- package/lib/blocks/blogList.js +81 -0
- package/lib/blocks/blogPost.d.ts +2 -0
- package/lib/blocks/blogPost.js +60 -0
- package/lib/blocks/carousel.d.ts +2 -0
- package/lib/blocks/carousel.js +56 -0
- package/lib/blocks/contactDetails.d.ts +2 -0
- package/lib/blocks/contactDetails.js +50 -0
- package/lib/blocks/contactForm.d.ts +2 -0
- package/lib/blocks/contactForm.js +100 -0
- package/lib/blocks/cta.d.ts +2 -0
- package/lib/blocks/cta.js +40 -0
- package/lib/blocks/divider.d.ts +2 -0
- package/lib/blocks/divider.js +28 -0
- package/lib/blocks/faq.d.ts +2 -0
- package/lib/blocks/faq.js +52 -0
- package/lib/blocks/features.d.ts +2 -0
- package/lib/blocks/features.js +58 -0
- package/lib/blocks/feed.d.ts +2 -0
- package/lib/blocks/feed.js +81 -0
- package/lib/blocks/footer.d.ts +2 -0
- package/lib/blocks/footer.js +40 -0
- package/lib/blocks/gallery.d.ts +2 -0
- package/lib/blocks/gallery.js +64 -0
- package/lib/blocks/hero.d.ts +2 -0
- package/lib/blocks/hero.js +48 -0
- package/lib/blocks/heroApp.d.ts +2 -0
- package/lib/blocks/heroApp.js +65 -0
- package/lib/blocks/logos.d.ts +2 -0
- package/lib/blocks/logos.js +59 -0
- package/lib/blocks/map.d.ts +2 -0
- package/lib/blocks/map.js +43 -0
- package/lib/blocks/nav.d.ts +2 -0
- package/lib/blocks/nav.js +39 -0
- package/lib/blocks/newsletter.d.ts +2 -0
- package/lib/blocks/newsletter.js +56 -0
- package/lib/blocks/pricing.d.ts +2 -0
- package/lib/blocks/pricing.js +89 -0
- package/lib/blocks/prose.d.ts +5 -0
- package/lib/blocks/prose.js +46 -0
- package/lib/blocks/richText.d.ts +2 -0
- package/lib/blocks/richText.js +46 -0
- package/lib/blocks/services.d.ts +2 -0
- package/lib/blocks/services.js +65 -0
- package/lib/blocks/sidebar.d.ts +2 -0
- package/lib/blocks/sidebar.js +58 -0
- package/lib/blocks/socialLinks.d.ts +2 -0
- package/lib/blocks/socialLinks.js +53 -0
- package/lib/blocks/spacer.d.ts +2 -0
- package/lib/blocks/spacer.js +25 -0
- package/lib/blocks/split.d.ts +2 -0
- package/lib/blocks/split.js +67 -0
- package/lib/blocks/stats.d.ts +2 -0
- package/lib/blocks/stats.js +64 -0
- package/lib/blocks/steps.d.ts +2 -0
- package/lib/blocks/steps.js +63 -0
- package/lib/blocks/tabs.d.ts +2 -0
- package/lib/blocks/tabs.js +57 -0
- package/lib/blocks/team.d.ts +2 -0
- package/lib/blocks/team.js +94 -0
- package/lib/blocks/testimonials.d.ts +2 -0
- package/lib/blocks/testimonials.js +54 -0
- package/lib/blocks/timeline.d.ts +2 -0
- package/lib/blocks/timeline.js +53 -0
- package/lib/blocks/video.d.ts +2 -0
- package/lib/blocks/video.js +61 -0
- package/lib/catalog.d.ts +26 -0
- package/lib/catalog.js +62 -0
- package/lib/generate.d.ts +35 -0
- package/lib/generate.js +160 -0
- package/lib/index.d.ts +19 -0
- package/lib/index.js +11 -0
- package/lib/ops.d.ts +85 -0
- package/lib/ops.js +248 -0
- package/lib/presets.d.ts +16 -0
- package/lib/presets.js +50 -0
- package/lib/pwa.d.ts +30 -0
- package/lib/pwa.js +79 -0
- package/lib/registry.d.ts +53 -0
- package/lib/registry.js +81 -0
- package/lib/render.d.ts +8 -0
- package/lib/render.js +105 -0
- package/lib/runtime.d.ts +68 -0
- package/lib/runtime.js +46 -0
- package/lib/schema.d.ts +67 -0
- package/lib/schema.js +150 -0
- package/lib/tokens.d.ts +37 -0
- package/lib/tokens.js +136 -0
- package/lib/types.d.ts +108 -0
- package/lib/types.js +10 -0
- package/lib/validate.d.ts +10 -0
- package/lib/validate.js +48 -0
- package/package.json +65 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type BatchResult, type EditOp } from './ops.js';
|
|
2
|
+
import type { SiteManifest } from './types.js';
|
|
3
|
+
export type ModelCall = (args: {
|
|
4
|
+
system: string;
|
|
5
|
+
user: string;
|
|
6
|
+
}) => Promise<string>;
|
|
7
|
+
export declare function buildGenerationPrompt(brief: string): {
|
|
8
|
+
system: string;
|
|
9
|
+
user: string;
|
|
10
|
+
};
|
|
11
|
+
export interface ComposeResult {
|
|
12
|
+
ok: boolean;
|
|
13
|
+
manifest: SiteManifest;
|
|
14
|
+
errors: string[];
|
|
15
|
+
warnings: string[];
|
|
16
|
+
}
|
|
17
|
+
/** Compose a manifest from a brief using the injected model. */
|
|
18
|
+
export declare function generateSite(brief: string, callModel: ModelCall): Promise<ComposeResult>;
|
|
19
|
+
/** Extract + coerce + validate a manifest from a model reply. Never throws. */
|
|
20
|
+
export declare function parseManifestResponse(text: string): ComposeResult;
|
|
21
|
+
export declare function buildEditPrompt(manifest: SiteManifest, message: string): {
|
|
22
|
+
system: string;
|
|
23
|
+
user: string;
|
|
24
|
+
};
|
|
25
|
+
export interface EditResult extends BatchResult {
|
|
26
|
+
parsedOps: EditOp[];
|
|
27
|
+
parseError?: string;
|
|
28
|
+
}
|
|
29
|
+
/** Interpret a natural-language edit against the manifest via the injected model. */
|
|
30
|
+
export declare function editSite(manifest: SiteManifest, message: string, callModel: ModelCall): Promise<EditResult>;
|
|
31
|
+
/** Extract a JSON array of ops from a model reply. */
|
|
32
|
+
export declare function parseOpsResponse(text: string): {
|
|
33
|
+
ops: EditOp[];
|
|
34
|
+
error?: string;
|
|
35
|
+
};
|
package/lib/generate.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI compose + edit orchestration.
|
|
3
|
+
*
|
|
4
|
+
* The deterministic parts live here — building the prompt from the catalog,
|
|
5
|
+
* extracting/validating the model's JSON, and turning an edit reply into
|
|
6
|
+
* validated ops. The actual model call is INJECTED (`ModelCall`) — bring your
|
|
7
|
+
* own provider — so this stays provider-agnostic and unit-testable with a fake.
|
|
8
|
+
* Validity comes from the schema + ops, never from the model being right
|
|
9
|
+
* (invariant: validity ⟂ AI quality).
|
|
10
|
+
*/
|
|
11
|
+
import { catalogPrompt } from './catalog.js';
|
|
12
|
+
import { blockTypes } from './registry.js';
|
|
13
|
+
import { presetNames } from './presets.js';
|
|
14
|
+
import { normalizeTokens, DEFAULT_TOKENS } from './tokens.js';
|
|
15
|
+
import { validateManifest } from './validate.js';
|
|
16
|
+
import { applyOps } from './ops.js';
|
|
17
|
+
// ── Compose: brief → manifest ──────────────────────────────────────────────────
|
|
18
|
+
export function buildGenerationPrompt(brief) {
|
|
19
|
+
const system = [
|
|
20
|
+
'You are a web designer that composes a single-page site by ARRANGING pre-built blocks.',
|
|
21
|
+
'You do NOT write HTML, CSS, or JavaScript. You output ONLY a JSON site manifest.',
|
|
22
|
+
'',
|
|
23
|
+
'Available blocks (config keys; `*` = should be provided):',
|
|
24
|
+
catalogPrompt(),
|
|
25
|
+
'',
|
|
26
|
+
'Also set `design` tokens: { mode: light|dark|auto, palette:{bg,surface,text,muted,primary,accent (hex)},',
|
|
27
|
+
' typography:{fontStack,scale: compact|default|expressive}, radius: sharp|soft|round,',
|
|
28
|
+
' spacing: tight|default|airy, motion: none|subtle|lively }.',
|
|
29
|
+
' mode:auto follows the viewer\'s OS light/dark; supply an optional darkPalette:{...same keys...} for the dark side.',
|
|
30
|
+
' (Text on primary/accent fills is auto-contrasted — never hand-set button text colours.)',
|
|
31
|
+
` (For a quick coherent look, base it on a named preset: ${presetNames().join(', ')}.)`,
|
|
32
|
+
'',
|
|
33
|
+
'Rules:',
|
|
34
|
+
'- Use ONLY the block types listed above; never invent a type or a config key.',
|
|
35
|
+
'- Write real, specific copy for the described business — no lorem ipsum, no placeholders.',
|
|
36
|
+
'- Order blocks top-to-bottom as a visitor would read them.',
|
|
37
|
+
'Respond with ONLY the JSON object, no prose, no code fences:',
|
|
38
|
+
'{ "meta": { "title", "description", "lang" }, "design": {...}, "blocks": [ { "type", "config": {...} } ] }',
|
|
39
|
+
].join('\n');
|
|
40
|
+
return { system, user: `Brief:\n${brief}` };
|
|
41
|
+
}
|
|
42
|
+
/** Compose a manifest from a brief using the injected model. */
|
|
43
|
+
export async function generateSite(brief, callModel) {
|
|
44
|
+
const { system, user } = buildGenerationPrompt(brief);
|
|
45
|
+
const raw = await callModel({ system, user });
|
|
46
|
+
return parseManifestResponse(raw);
|
|
47
|
+
}
|
|
48
|
+
/** Extract + coerce + validate a manifest from a model reply. Never throws. */
|
|
49
|
+
export function parseManifestResponse(text) {
|
|
50
|
+
const json = extractJson(text);
|
|
51
|
+
if (json === undefined)
|
|
52
|
+
return { ok: false, manifest: emptyManifest(), errors: ['no JSON object in model reply'], warnings: [] };
|
|
53
|
+
const manifest = coerceManifest(json);
|
|
54
|
+
const v = validateManifest(manifest);
|
|
55
|
+
return { ok: v.ok, manifest, errors: v.errors, warnings: v.warnings };
|
|
56
|
+
}
|
|
57
|
+
// ── Edit: message + manifest → ops → new manifest ──────────────────────────────
|
|
58
|
+
export function buildEditPrompt(manifest, message) {
|
|
59
|
+
const outline = manifest.blocks.map((b, i) => ` ${i}. ${b.id} (${b.type})`).join('\n') || ' (empty)';
|
|
60
|
+
const system = [
|
|
61
|
+
'You edit a single-page site by emitting JSON EDIT OPERATIONS over its blocks. You never write HTML/CSS/JS.',
|
|
62
|
+
'',
|
|
63
|
+
'Ops (emit a JSON array):',
|
|
64
|
+
'- { "op":"addBlock", "type":..., "config":{...}, "at":<index?> }',
|
|
65
|
+
'- { "op":"updateBlock", "id":..., "config":{ ...only changed keys... } }',
|
|
66
|
+
'- { "op":"removeBlock", "id":... }',
|
|
67
|
+
'- { "op":"moveBlock", "id":..., "to":<index> }',
|
|
68
|
+
'- { "op":"setVisible", "id":..., "visible":true|false }',
|
|
69
|
+
'- { "op":"setDesignTokens", "patch":{ ...design keys... } }',
|
|
70
|
+
'- { "op":"setMeta", "patch":{ title?, description?, lang? } }',
|
|
71
|
+
'// Surgical array-item edits — change ONE item without resending the block:',
|
|
72
|
+
'- { "op":"addItem", "id":..., "field":"items", "item":{...}, "at":<index?> }',
|
|
73
|
+
'- { "op":"updateItem", "id":..., "field":"items", "index":<n>, "patch":{...} }',
|
|
74
|
+
'- { "op":"removeItem", "id":..., "field":"items", "index":<n> }',
|
|
75
|
+
'- { "op":"moveItem", "id":..., "field":"items", "from":<n>, "to":<n> }',
|
|
76
|
+
'// Theming:',
|
|
77
|
+
`- { "op":"applyPreset", "name":"<one of: ${presetNames().join(' | ')}>" }`,
|
|
78
|
+
'- { "op":"setOverrides", "id":..., "overrides":{ primary?, accent?, bg?, surface?, text?, muted?, radius?, spacing? } | null }',
|
|
79
|
+
'',
|
|
80
|
+
`Block types available: ${blockTypes().join(', ')}.`,
|
|
81
|
+
'Block configs (for add/update):',
|
|
82
|
+
catalogPrompt(),
|
|
83
|
+
'',
|
|
84
|
+
'Current page blocks (top→bottom):',
|
|
85
|
+
outline,
|
|
86
|
+
'',
|
|
87
|
+
'Emit ONLY the JSON array of ops needed for the request. Empty array [] if nothing to do.',
|
|
88
|
+
].join('\n');
|
|
89
|
+
return { system, user: message };
|
|
90
|
+
}
|
|
91
|
+
/** Interpret a natural-language edit against the manifest via the injected model. */
|
|
92
|
+
export async function editSite(manifest, message, callModel) {
|
|
93
|
+
const { system, user } = buildEditPrompt(manifest, message);
|
|
94
|
+
const raw = await callModel({ system, user });
|
|
95
|
+
const { ops, error } = parseOpsResponse(raw);
|
|
96
|
+
const batch = applyOps(manifest, ops);
|
|
97
|
+
return { ...batch, parsedOps: ops, parseError: error };
|
|
98
|
+
}
|
|
99
|
+
/** Extract a JSON array of ops from a model reply. */
|
|
100
|
+
export function parseOpsResponse(text) {
|
|
101
|
+
const json = extractJson(text, 'array');
|
|
102
|
+
if (json === undefined)
|
|
103
|
+
return { ops: [], error: 'no JSON array in model reply' };
|
|
104
|
+
if (!Array.isArray(json))
|
|
105
|
+
return { ops: [], error: 'expected a JSON array of ops' };
|
|
106
|
+
const ops = json.filter((o) => !!o && typeof o === 'object' && typeof o.op === 'string');
|
|
107
|
+
return { ops };
|
|
108
|
+
}
|
|
109
|
+
// ── helpers ────────────────────────────────────────────────────────────────────
|
|
110
|
+
function extractJson(text, want = 'object') {
|
|
111
|
+
const t = (text ?? '').trim().replace(/^```[a-zA-Z]*\r?\n?/, '').replace(/\r?\n?```$/, '').trim();
|
|
112
|
+
const open = want === 'array' ? '[' : '{';
|
|
113
|
+
const close = want === 'array' ? ']' : '}';
|
|
114
|
+
const start = t.indexOf(open);
|
|
115
|
+
const end = t.lastIndexOf(close);
|
|
116
|
+
if (start < 0 || end <= start)
|
|
117
|
+
return undefined;
|
|
118
|
+
try {
|
|
119
|
+
return JSON.parse(t.slice(start, end + 1));
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function coerceManifest(raw) {
|
|
126
|
+
const r = (raw && typeof raw === 'object') ? raw : {};
|
|
127
|
+
const meta = (r.meta && typeof r.meta === 'object') ? r.meta : {};
|
|
128
|
+
const rawBlocks = Array.isArray(r.blocks) ? r.blocks : [];
|
|
129
|
+
const used = new Set();
|
|
130
|
+
const blocks = rawBlocks.map((b) => {
|
|
131
|
+
const bb = (b && typeof b === 'object') ? b : {};
|
|
132
|
+
const type = typeof bb.type === 'string' ? bb.type : '';
|
|
133
|
+
let id = typeof bb.id === 'string' && bb.id ? bb.id : '';
|
|
134
|
+
if (!id || used.has(id)) {
|
|
135
|
+
let n = 1;
|
|
136
|
+
while (used.has(`${type || 'block'}-${n}`))
|
|
137
|
+
n++;
|
|
138
|
+
id = `${type || 'block'}-${n}`;
|
|
139
|
+
}
|
|
140
|
+
used.add(id);
|
|
141
|
+
return {
|
|
142
|
+
id, type,
|
|
143
|
+
visible: bb.visible !== false,
|
|
144
|
+
config: (bb.config && typeof bb.config === 'object') ? bb.config : {},
|
|
145
|
+
};
|
|
146
|
+
});
|
|
147
|
+
return {
|
|
148
|
+
meta: {
|
|
149
|
+
title: typeof meta.title === 'string' ? meta.title : 'Untitled site',
|
|
150
|
+
description: typeof meta.description === 'string' ? meta.description : '',
|
|
151
|
+
lang: typeof meta.lang === 'string' ? meta.lang : 'en',
|
|
152
|
+
},
|
|
153
|
+
design: normalizeTokens((r.design ?? DEFAULT_TOKENS)),
|
|
154
|
+
blocks,
|
|
155
|
+
version: typeof r.version === 'number' ? r.version : 1,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
function emptyManifest() {
|
|
159
|
+
return { meta: { title: 'Untitled site', description: '', lang: 'en' }, design: DEFAULT_TOKENS, blocks: [], version: 1 };
|
|
160
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @bytesbrains/weblocks — the block engine for AI-composable Apps.
|
|
3
|
+
*
|
|
4
|
+
* Pipeline: a `SiteManifest` (design tokens + ordered typed blocks) is
|
|
5
|
+
* validated (strict, for edit ops) and rendered (total, for serving) into one
|
|
6
|
+
* self-contained static HTML document. See docs/APPS_BLOCKS_ARCHITECTURE.md.
|
|
7
|
+
*/
|
|
8
|
+
export type { SiteManifest, Block, DesignTokens, Palette, SiteMeta, SectionOverrides, PwaConfig, SeoConfig, Mode, Scale, Radius, Spacing, Motion, } from './types.js';
|
|
9
|
+
export { DEFAULT_TOKENS, normalizeTokens, tokensToCss, sectionOverrideCss, readableOn } from './tokens.js';
|
|
10
|
+
export { PRESETS, presetNames, getPreset } from './presets.js';
|
|
11
|
+
export { REGISTRY, getSpec, blockTypes, needsIsland, type BlockSpec, type RenderContext } from './registry.js';
|
|
12
|
+
export { renderSite, type RenderOptions } from './render.js';
|
|
13
|
+
export { NOOP_RUNTIME, pathRuntime, runtimeNeeds, type RuntimeAdapter, type RuntimeAction, type BlockRuntimeNeeds, } from './runtime.js';
|
|
14
|
+
export { buildWebManifest, buildWebManifestJson, buildServiceWorker, emitPwa, type WebAppManifest, } from './pwa.js';
|
|
15
|
+
export { validateBlock, validateManifest, type Validation } from './validate.js';
|
|
16
|
+
export { parse, escapeHtml, escapeAttr, sanitizeUrl, type Field, type Schema, type ParseResult } from './schema.js';
|
|
17
|
+
export { catalog, catalogPrompt, type BlockCatalogEntry, type JsonSchema } from './catalog.js';
|
|
18
|
+
export { applyOp, applyOps, type EditOp, type OpResult, type BatchResult } from './ops.js';
|
|
19
|
+
export { generateSite, editSite, buildGenerationPrompt, buildEditPrompt, parseManifestResponse, parseOpsResponse, type ModelCall, type ComposeResult, type EditResult, } from './generate.js';
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { DEFAULT_TOKENS, normalizeTokens, tokensToCss, sectionOverrideCss, readableOn } from './tokens.js';
|
|
2
|
+
export { PRESETS, presetNames, getPreset } from './presets.js';
|
|
3
|
+
export { REGISTRY, getSpec, blockTypes, needsIsland } from './registry.js';
|
|
4
|
+
export { renderSite } from './render.js';
|
|
5
|
+
export { NOOP_RUNTIME, pathRuntime, runtimeNeeds, } from './runtime.js';
|
|
6
|
+
export { buildWebManifest, buildWebManifestJson, buildServiceWorker, emitPwa, } from './pwa.js';
|
|
7
|
+
export { validateBlock, validateManifest } from './validate.js';
|
|
8
|
+
export { parse, escapeHtml, escapeAttr, sanitizeUrl } from './schema.js';
|
|
9
|
+
export { catalog, catalogPrompt } from './catalog.js';
|
|
10
|
+
export { applyOp, applyOps } from './ops.js';
|
|
11
|
+
export { generateSite, editSite, buildGenerationPrompt, buildEditPrompt, parseManifestResponse, parseOpsResponse, } from './generate.js';
|
package/lib/ops.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { DesignTokens, SectionOverrides, SiteManifest, SiteMeta } from './types.js';
|
|
2
|
+
export type EditOp = {
|
|
3
|
+
op: 'addBlock';
|
|
4
|
+
type: string;
|
|
5
|
+
config?: Record<string, unknown>;
|
|
6
|
+
at?: number;
|
|
7
|
+
id?: string;
|
|
8
|
+
} | {
|
|
9
|
+
op: 'updateBlock';
|
|
10
|
+
id: string;
|
|
11
|
+
config: Record<string, unknown>;
|
|
12
|
+
} | {
|
|
13
|
+
op: 'removeBlock';
|
|
14
|
+
id: string;
|
|
15
|
+
} | {
|
|
16
|
+
op: 'moveBlock';
|
|
17
|
+
id: string;
|
|
18
|
+
to: number;
|
|
19
|
+
} | {
|
|
20
|
+
op: 'setVisible';
|
|
21
|
+
id: string;
|
|
22
|
+
visible: boolean;
|
|
23
|
+
} | {
|
|
24
|
+
op: 'setDesignTokens';
|
|
25
|
+
patch: Partial<DesignTokens>;
|
|
26
|
+
} | {
|
|
27
|
+
op: 'setMeta';
|
|
28
|
+
patch: Partial<SiteMeta>;
|
|
29
|
+
} | {
|
|
30
|
+
op: 'addItem';
|
|
31
|
+
id: string;
|
|
32
|
+
field: string;
|
|
33
|
+
item: unknown;
|
|
34
|
+
at?: number;
|
|
35
|
+
} | {
|
|
36
|
+
op: 'updateItem';
|
|
37
|
+
id: string;
|
|
38
|
+
field: string;
|
|
39
|
+
index: number;
|
|
40
|
+
patch: unknown;
|
|
41
|
+
} | {
|
|
42
|
+
op: 'removeItem';
|
|
43
|
+
id: string;
|
|
44
|
+
field: string;
|
|
45
|
+
index: number;
|
|
46
|
+
} | {
|
|
47
|
+
op: 'moveItem';
|
|
48
|
+
id: string;
|
|
49
|
+
field: string;
|
|
50
|
+
from: number;
|
|
51
|
+
to: number;
|
|
52
|
+
} | {
|
|
53
|
+
op: 'applyPreset';
|
|
54
|
+
name: string;
|
|
55
|
+
} | {
|
|
56
|
+
op: 'setOverrides';
|
|
57
|
+
id: string;
|
|
58
|
+
overrides: SectionOverrides | null;
|
|
59
|
+
};
|
|
60
|
+
export interface OpResult {
|
|
61
|
+
ok: boolean;
|
|
62
|
+
manifest: SiteManifest;
|
|
63
|
+
errors: string[];
|
|
64
|
+
warnings: string[];
|
|
65
|
+
/** For addBlock: the id of the created block. */
|
|
66
|
+
id?: string;
|
|
67
|
+
}
|
|
68
|
+
/** Apply one op. Pure: never mutates `manifest`. */
|
|
69
|
+
export declare function applyOp(manifest: SiteManifest, op: EditOp): OpResult;
|
|
70
|
+
export interface BatchResult {
|
|
71
|
+
manifest: SiteManifest;
|
|
72
|
+
results: Array<{
|
|
73
|
+
op: EditOp;
|
|
74
|
+
ok: boolean;
|
|
75
|
+
errors: string[];
|
|
76
|
+
warnings: string[];
|
|
77
|
+
}>;
|
|
78
|
+
applied: number;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Apply a sequence of ops (a chat turn). Each is validated independently against
|
|
82
|
+
* the running manifest; a failing op is skipped (recorded), the rest proceed —
|
|
83
|
+
* so one bad tool-call doesn't discard the whole turn.
|
|
84
|
+
*/
|
|
85
|
+
export declare function applyOps(manifest: SiteManifest, ops: EditOp[]): BatchResult;
|
package/lib/ops.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edit operations — the verbs of the vocabulary, and the tools a chat-edit agent
|
|
3
|
+
* calls. Each op is validated BEFORE it is applied (illegal states
|
|
4
|
+
* unrepresentable): a malformed op is a no-op with errors, never a corrupted
|
|
5
|
+
* manifest. Applying returns a NEW manifest with `version` bumped, so
|
|
6
|
+
* undo/history/diff come for free.
|
|
7
|
+
*/
|
|
8
|
+
import { getSpec, blockTypes } from './registry.js';
|
|
9
|
+
import { normalizeTokens } from './tokens.js';
|
|
10
|
+
import { parse } from './schema.js';
|
|
11
|
+
import { getPreset, presetNames } from './presets.js';
|
|
12
|
+
const OVERRIDE_KEYS = ['bg', 'surface', 'text', 'muted', 'primary', 'accent'];
|
|
13
|
+
const clone = (v) => structuredClone(v);
|
|
14
|
+
const find = (m, id) => m.blocks.findIndex((b) => b.id === id);
|
|
15
|
+
/** A deterministic, unique id: `<type>-N` with the smallest free N. */
|
|
16
|
+
function freshId(m, type) {
|
|
17
|
+
const used = new Set(m.blocks.map((b) => b.id));
|
|
18
|
+
for (let n = 1;; n++) {
|
|
19
|
+
const id = `${type}-${n}`;
|
|
20
|
+
if (!used.has(id))
|
|
21
|
+
return id;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function fail(m, ...errors) {
|
|
25
|
+
return { ok: false, manifest: m, errors, warnings: [] };
|
|
26
|
+
}
|
|
27
|
+
/** Apply one op. Pure: never mutates `manifest`. */
|
|
28
|
+
export function applyOp(manifest, op) {
|
|
29
|
+
const next = clone(manifest);
|
|
30
|
+
next.version = (manifest.version ?? 0) + 1;
|
|
31
|
+
switch (op.op) {
|
|
32
|
+
case 'addBlock': {
|
|
33
|
+
const spec = getSpec(op.type);
|
|
34
|
+
if (!spec)
|
|
35
|
+
return fail(manifest, `addBlock: unknown type "${op.type}" (choose from: ${blockTypes().join(', ')})`);
|
|
36
|
+
const parsed = parse(spec.schema, op.config ?? {});
|
|
37
|
+
if (!parsed.ok)
|
|
38
|
+
return fail(manifest, ...parsed.errors.map((e) => `addBlock(${op.type}): ${e}`));
|
|
39
|
+
const id = op.id && !next.blocks.some((b) => b.id === op.id) ? op.id : freshId(next, op.type);
|
|
40
|
+
const block = { id, type: op.type, visible: true, config: parsed.value };
|
|
41
|
+
const at = clamp(op.at ?? next.blocks.length, 0, next.blocks.length);
|
|
42
|
+
next.blocks.splice(at, 0, block);
|
|
43
|
+
return { ok: true, manifest: next, errors: [], warnings: parsed.warnings, id };
|
|
44
|
+
}
|
|
45
|
+
case 'updateBlock': {
|
|
46
|
+
const i = find(next, op.id);
|
|
47
|
+
if (i < 0)
|
|
48
|
+
return fail(manifest, `updateBlock: no block "${op.id}"`);
|
|
49
|
+
const cur = next.blocks[i];
|
|
50
|
+
const spec = getSpec(cur.type);
|
|
51
|
+
// The block may carry an unknown/deprecated type (e.g. a hand-edited or
|
|
52
|
+
// legacy manifest); reject rather than dereferencing undefined.
|
|
53
|
+
if (!spec)
|
|
54
|
+
return fail(manifest, `updateBlock: unknown block type "${cur.type}" for "${op.id}"`);
|
|
55
|
+
// Merge patch onto current config, then re-validate the whole config.
|
|
56
|
+
const merged = { ...cur.config, ...(op.config ?? {}) };
|
|
57
|
+
const parsed = parse(spec.schema, merged);
|
|
58
|
+
if (!parsed.ok)
|
|
59
|
+
return fail(manifest, ...parsed.errors.map((e) => `updateBlock(${op.id}): ${e}`));
|
|
60
|
+
next.blocks[i] = { ...cur, config: parsed.value };
|
|
61
|
+
return { ok: true, manifest: next, errors: [], warnings: parsed.warnings };
|
|
62
|
+
}
|
|
63
|
+
case 'removeBlock': {
|
|
64
|
+
const i = find(next, op.id);
|
|
65
|
+
if (i < 0)
|
|
66
|
+
return fail(manifest, `removeBlock: no block "${op.id}"`);
|
|
67
|
+
next.blocks.splice(i, 1);
|
|
68
|
+
return { ok: true, manifest: next, errors: [], warnings: [] };
|
|
69
|
+
}
|
|
70
|
+
case 'moveBlock': {
|
|
71
|
+
const i = find(next, op.id);
|
|
72
|
+
if (i < 0)
|
|
73
|
+
return fail(manifest, `moveBlock: no block "${op.id}"`);
|
|
74
|
+
const [b] = next.blocks.splice(i, 1);
|
|
75
|
+
const to = clamp(op.to, 0, next.blocks.length);
|
|
76
|
+
next.blocks.splice(to, 0, b);
|
|
77
|
+
return { ok: true, manifest: next, errors: [], warnings: [] };
|
|
78
|
+
}
|
|
79
|
+
case 'setVisible': {
|
|
80
|
+
const i = find(next, op.id);
|
|
81
|
+
if (i < 0)
|
|
82
|
+
return fail(manifest, `setVisible: no block "${op.id}"`);
|
|
83
|
+
next.blocks[i] = { ...next.blocks[i], visible: op.visible };
|
|
84
|
+
return { ok: true, manifest: next, errors: [], warnings: [] };
|
|
85
|
+
}
|
|
86
|
+
case 'setDesignTokens': {
|
|
87
|
+
next.design = normalizeTokens({ ...next.design, ...(op.patch ?? {}) });
|
|
88
|
+
return { ok: true, manifest: next, errors: [], warnings: [] };
|
|
89
|
+
}
|
|
90
|
+
case 'setMeta': {
|
|
91
|
+
next.meta = { ...next.meta, ...(op.patch ?? {}) };
|
|
92
|
+
return { ok: true, manifest: next, errors: [], warnings: [] };
|
|
93
|
+
}
|
|
94
|
+
// ── §8 array-item ops ────────────────────────────────────────────────────
|
|
95
|
+
case 'addItem': {
|
|
96
|
+
const r = withArrayField(manifest, next, op.id, op.field);
|
|
97
|
+
if ('error' in r)
|
|
98
|
+
return r.error;
|
|
99
|
+
const at = clamp(op.at ?? r.arr.length, 0, r.arr.length);
|
|
100
|
+
r.arr.splice(at, 0, op.item);
|
|
101
|
+
return commitBlockConfig(manifest, next, r);
|
|
102
|
+
}
|
|
103
|
+
case 'updateItem': {
|
|
104
|
+
const r = withArrayField(manifest, next, op.id, op.field);
|
|
105
|
+
if ('error' in r)
|
|
106
|
+
return r.error;
|
|
107
|
+
if (!inBounds(op.index, r.arr.length))
|
|
108
|
+
return fail(manifest, `updateItem: index ${op.index} out of range for "${op.field}" (len ${r.arr.length})`);
|
|
109
|
+
const cur = r.arr[op.index];
|
|
110
|
+
// Merge patch onto object items; replace for scalar-item arrays.
|
|
111
|
+
r.arr[op.index] = (isObject(cur) && isObject(op.patch)) ? { ...cur, ...op.patch } : op.patch;
|
|
112
|
+
return commitBlockConfig(manifest, next, r);
|
|
113
|
+
}
|
|
114
|
+
case 'removeItem': {
|
|
115
|
+
const r = withArrayField(manifest, next, op.id, op.field);
|
|
116
|
+
if ('error' in r)
|
|
117
|
+
return r.error;
|
|
118
|
+
if (!inBounds(op.index, r.arr.length))
|
|
119
|
+
return fail(manifest, `removeItem: index ${op.index} out of range for "${op.field}" (len ${r.arr.length})`);
|
|
120
|
+
r.arr.splice(op.index, 1);
|
|
121
|
+
return commitBlockConfig(manifest, next, r);
|
|
122
|
+
}
|
|
123
|
+
case 'moveItem': {
|
|
124
|
+
const r = withArrayField(manifest, next, op.id, op.field);
|
|
125
|
+
if ('error' in r)
|
|
126
|
+
return r.error;
|
|
127
|
+
if (!inBounds(op.from, r.arr.length))
|
|
128
|
+
return fail(manifest, `moveItem: from ${op.from} out of range for "${op.field}" (len ${r.arr.length})`);
|
|
129
|
+
const [it] = r.arr.splice(op.from, 1);
|
|
130
|
+
r.arr.splice(clamp(op.to, 0, r.arr.length), 0, it);
|
|
131
|
+
return commitBlockConfig(manifest, next, r);
|
|
132
|
+
}
|
|
133
|
+
// ── theming ──────────────────────────────────────────────────────────────
|
|
134
|
+
case 'applyPreset': {
|
|
135
|
+
const preset = getPreset(op.name);
|
|
136
|
+
if (!preset)
|
|
137
|
+
return fail(manifest, `applyPreset: unknown preset "${op.name}" (choose from: ${presetNames().join(', ')})`);
|
|
138
|
+
next.design = normalizeTokens(preset);
|
|
139
|
+
return { ok: true, manifest: next, errors: [], warnings: [] };
|
|
140
|
+
}
|
|
141
|
+
case 'setOverrides': {
|
|
142
|
+
const i = find(next, op.id);
|
|
143
|
+
if (i < 0)
|
|
144
|
+
return fail(manifest, `setOverrides: no block "${op.id}"`);
|
|
145
|
+
const block = next.blocks[i];
|
|
146
|
+
if (op.overrides === null) {
|
|
147
|
+
delete block.overrides;
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
const clean = cleanOverrides(op.overrides);
|
|
151
|
+
if (!clean) {
|
|
152
|
+
delete block.overrides;
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
block.overrides = clean;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { ok: true, manifest: next, errors: [], warnings: [] };
|
|
159
|
+
}
|
|
160
|
+
default:
|
|
161
|
+
return fail(manifest, `unknown op: ${op.op}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Apply a sequence of ops (a chat turn). Each is validated independently against
|
|
166
|
+
* the running manifest; a failing op is skipped (recorded), the rest proceed —
|
|
167
|
+
* so one bad tool-call doesn't discard the whole turn.
|
|
168
|
+
*/
|
|
169
|
+
export function applyOps(manifest, ops) {
|
|
170
|
+
let m = manifest;
|
|
171
|
+
let applied = 0;
|
|
172
|
+
const results = ops.map((op) => {
|
|
173
|
+
const r = applyOp(m, op);
|
|
174
|
+
if (r.ok) {
|
|
175
|
+
m = r.manifest;
|
|
176
|
+
applied++;
|
|
177
|
+
}
|
|
178
|
+
return { op, ok: r.ok, errors: r.errors, warnings: r.warnings };
|
|
179
|
+
});
|
|
180
|
+
return { manifest: m, results, applied };
|
|
181
|
+
}
|
|
182
|
+
function clamp(n, lo, hi) {
|
|
183
|
+
return Math.max(lo, Math.min(hi, Math.trunc(Number.isFinite(n) ? n : hi)));
|
|
184
|
+
}
|
|
185
|
+
function inBounds(i, len) {
|
|
186
|
+
return typeof i === 'number' && Number.isInteger(i) && i >= 0 && i < len;
|
|
187
|
+
}
|
|
188
|
+
function isObject(v) {
|
|
189
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Locate a block's ARRAY field for an item op: the block must exist, its type be
|
|
193
|
+
* known, and `field` be an array field in the schema. Returns a fresh copy of
|
|
194
|
+
* the current array to mutate, or a ready-made failure (a bad op is a no-op).
|
|
195
|
+
*/
|
|
196
|
+
function withArrayField(orig, next, id, field) {
|
|
197
|
+
const i = find(next, id);
|
|
198
|
+
if (i < 0)
|
|
199
|
+
return { error: fail(orig, `item op: no block "${id}"`) };
|
|
200
|
+
const block = next.blocks[i];
|
|
201
|
+
const spec = getSpec(block.type);
|
|
202
|
+
if (!spec)
|
|
203
|
+
return { error: fail(orig, `item op: unknown block type "${block.type}" for "${id}"`) };
|
|
204
|
+
const fs = spec.schema[field];
|
|
205
|
+
if (!fs || fs.kind !== 'array')
|
|
206
|
+
return { error: fail(orig, `item op: "${field}" is not an array field of "${block.type}"`) };
|
|
207
|
+
const raw = block.config?.[field];
|
|
208
|
+
return { i, block, field, arr: Array.isArray(raw) ? [...raw] : [] };
|
|
209
|
+
}
|
|
210
|
+
/** Re-validate the whole config with the mutated array applied; reject on hard errors. */
|
|
211
|
+
function commitBlockConfig(orig, next, ctx) {
|
|
212
|
+
const merged = { ...ctx.block.config, [ctx.field]: ctx.arr };
|
|
213
|
+
const spec = getSpec(ctx.block.type);
|
|
214
|
+
const parsed = parse(spec.schema, merged);
|
|
215
|
+
if (!parsed.ok)
|
|
216
|
+
return fail(orig, ...parsed.errors.map((e) => `item op(${ctx.block.id}.${ctx.field}): ${e}`));
|
|
217
|
+
next.blocks[ctx.i] = { ...ctx.block, config: parsed.value };
|
|
218
|
+
return { ok: true, manifest: next, errors: [], warnings: parsed.warnings };
|
|
219
|
+
}
|
|
220
|
+
const OVERRIDE_RADII = ['sharp', 'soft', 'round'];
|
|
221
|
+
const OVERRIDE_SPACINGS = ['tight', 'default', 'airy'];
|
|
222
|
+
/**
|
|
223
|
+
* Keep only known override keys with valid values: palette roles (strings) plus
|
|
224
|
+
* an optional radius / spacing enum. Null when nothing usable — so a junk
|
|
225
|
+
* override clears rather than corrupts.
|
|
226
|
+
*/
|
|
227
|
+
function cleanOverrides(input) {
|
|
228
|
+
if (!isObject(input))
|
|
229
|
+
return null;
|
|
230
|
+
const out = {};
|
|
231
|
+
let any = false;
|
|
232
|
+
for (const k of OVERRIDE_KEYS) {
|
|
233
|
+
const v = input[k];
|
|
234
|
+
if (typeof v === 'string' && v) {
|
|
235
|
+
out[k] = v;
|
|
236
|
+
any = true;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (typeof input.radius === 'string' && OVERRIDE_RADII.includes(input.radius)) {
|
|
240
|
+
out.radius = input.radius;
|
|
241
|
+
any = true;
|
|
242
|
+
}
|
|
243
|
+
if (typeof input.spacing === 'string' && OVERRIDE_SPACINGS.includes(input.spacing)) {
|
|
244
|
+
out.spacing = input.spacing;
|
|
245
|
+
any = true;
|
|
246
|
+
}
|
|
247
|
+
return any ? out : null;
|
|
248
|
+
}
|
package/lib/presets.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named `DesignTokens` presets (§9) — coherent palettes + scale/radius/spacing/
|
|
3
|
+
* motion the AI (or a human picker) selects by NAME instead of hand-mixing hexes.
|
|
4
|
+
* "Make it feel like 'midnight'" becomes one op. Each preset is a complete,
|
|
5
|
+
* valid token set, so applying one is total and never leaves a hole.
|
|
6
|
+
*
|
|
7
|
+
* Presets are additive and stable: add new ones freely, but keep existing names
|
|
8
|
+
* and their intent so old manifests that reference a preset stay reproducible.
|
|
9
|
+
*/
|
|
10
|
+
import type { DesignTokens } from './types.js';
|
|
11
|
+
/** The built-in presets, keyed by name. Values are complete token sets. */
|
|
12
|
+
export declare const PRESETS: Readonly<Record<string, DesignTokens>>;
|
|
13
|
+
/** Preset names the AI/picker may choose from. */
|
|
14
|
+
export declare function presetNames(): string[];
|
|
15
|
+
/** A named preset as a fresh, complete token set (deep copy — never aliased). */
|
|
16
|
+
export declare function getPreset(name: string): DesignTokens | undefined;
|
package/lib/presets.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const SANS = "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
|
|
2
|
+
const SERIF = "'Iowan Old Style', Georgia, Cambria, 'Times New Roman', serif";
|
|
3
|
+
/** The built-in presets, keyed by name. Values are complete token sets. */
|
|
4
|
+
export const PRESETS = {
|
|
5
|
+
// Warm, editorial default (matches DEFAULT_TOKENS' spirit).
|
|
6
|
+
sand: {
|
|
7
|
+
mode: 'light',
|
|
8
|
+
palette: { bg: '#f7f4ee', surface: '#ffffff', text: '#2a2622', muted: '#6b6459', primary: '#3a5a40', accent: '#c2703d' },
|
|
9
|
+
typography: { fontStack: SANS, scale: 'default' }, radius: 'soft', spacing: 'default', motion: 'subtle',
|
|
10
|
+
},
|
|
11
|
+
// Dark, high-contrast, product-y.
|
|
12
|
+
midnight: {
|
|
13
|
+
mode: 'dark',
|
|
14
|
+
palette: { bg: '#0d1117', surface: '#161b22', text: '#e6edf3', muted: '#8b949e', primary: '#4c8dff', accent: '#f778ba' },
|
|
15
|
+
typography: { fontStack: SANS, scale: 'default' }, radius: 'soft', spacing: 'default', motion: 'subtle',
|
|
16
|
+
},
|
|
17
|
+
// Calm, natural, spacious.
|
|
18
|
+
forest: {
|
|
19
|
+
mode: 'light',
|
|
20
|
+
palette: { bg: '#f3f6f2', surface: '#ffffff', text: '#1f2a24', muted: '#5c6b60', primary: '#2f6f4e', accent: '#b5832f' },
|
|
21
|
+
typography: { fontStack: SANS, scale: 'default' }, radius: 'round', spacing: 'airy', motion: 'subtle',
|
|
22
|
+
},
|
|
23
|
+
// Minimal black-on-white, sharp, editorial serif.
|
|
24
|
+
mono: {
|
|
25
|
+
mode: 'light',
|
|
26
|
+
palette: { bg: '#ffffff', surface: '#fafafa', text: '#111111', muted: '#666666', primary: '#111111', accent: '#555555' },
|
|
27
|
+
typography: { fontStack: SERIF, scale: 'default' }, radius: 'sharp', spacing: 'default', motion: 'none',
|
|
28
|
+
},
|
|
29
|
+
// Playful, bright, rounded.
|
|
30
|
+
candy: {
|
|
31
|
+
mode: 'light',
|
|
32
|
+
palette: { bg: '#fff7fb', surface: '#ffffff', text: '#2b1f2b', muted: '#7a6b78', primary: '#e0468b', accent: '#7c5cff' },
|
|
33
|
+
typography: { fontStack: SANS, scale: 'expressive' }, radius: 'round', spacing: 'airy', motion: 'lively',
|
|
34
|
+
},
|
|
35
|
+
// Cool, corporate, trustworthy.
|
|
36
|
+
ocean: {
|
|
37
|
+
mode: 'light',
|
|
38
|
+
palette: { bg: '#f4f8fb', surface: '#ffffff', text: '#0f2233', muted: '#5a6b78', primary: '#1668b0', accent: '#0aa5a5' },
|
|
39
|
+
typography: { fontStack: SANS, scale: 'default' }, radius: 'soft', spacing: 'default', motion: 'subtle',
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
/** Preset names the AI/picker may choose from. */
|
|
43
|
+
export function presetNames() {
|
|
44
|
+
return Object.keys(PRESETS);
|
|
45
|
+
}
|
|
46
|
+
/** A named preset as a fresh, complete token set (deep copy — never aliased). */
|
|
47
|
+
export function getPreset(name) {
|
|
48
|
+
const p = PRESETS[name];
|
|
49
|
+
return p ? structuredClone(p) : undefined;
|
|
50
|
+
}
|
package/lib/pwa.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { SiteManifest } from './types.js';
|
|
2
|
+
export interface WebAppManifest {
|
|
3
|
+
name: string;
|
|
4
|
+
short_name: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
start_url: string;
|
|
7
|
+
scope: string;
|
|
8
|
+
display: string;
|
|
9
|
+
theme_color: string;
|
|
10
|
+
background_color: string;
|
|
11
|
+
icons: Array<{
|
|
12
|
+
src: string;
|
|
13
|
+
sizes: string;
|
|
14
|
+
type?: string;
|
|
15
|
+
purpose?: string;
|
|
16
|
+
}>;
|
|
17
|
+
}
|
|
18
|
+
/** Build the Web App Manifest object from meta + design tokens + `pwa` opts. */
|
|
19
|
+
export declare function buildWebManifest(manifest: SiteManifest): WebAppManifest;
|
|
20
|
+
/** Serialized `manifest.webmanifest` content. */
|
|
21
|
+
export declare function buildWebManifestJson(manifest: SiteManifest): string;
|
|
22
|
+
/**
|
|
23
|
+
* A minimal, dependency-free service worker: precache the app shell, serve
|
|
24
|
+
* cache-first for same-origin GETs, and fall back to the cached shell offline.
|
|
25
|
+
* The cache name is derived from the manifest `version` so a new publish
|
|
26
|
+
* invalidates the old cache deterministically (no timestamps — reproducible).
|
|
27
|
+
*/
|
|
28
|
+
export declare function buildServiceWorker(manifest: SiteManifest): string;
|
|
29
|
+
/** Files to serve for an installable PWA, or null when not opted in. */
|
|
30
|
+
export declare function emitPwa(manifest: SiteManifest): Record<string, string> | null;
|