@extenshi/cli 0.22.0 → 0.23.1

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.
Files changed (56) hide show
  1. package/dist/auto-evidence.d.ts +59 -5
  2. package/dist/auto-evidence.d.ts.map +1 -1
  3. package/dist/auto-evidence.js +107 -7
  4. package/dist/auto-evidence.js.map +1 -1
  5. package/dist/cli.d.ts.map +1 -1
  6. package/dist/cli.js +122 -24
  7. package/dist/cli.js.map +1 -1
  8. package/dist/domain.d.ts +40 -0
  9. package/dist/domain.d.ts.map +1 -0
  10. package/dist/domain.js +64 -0
  11. package/dist/domain.js.map +1 -0
  12. package/dist/extension-localization.d.ts +6 -1
  13. package/dist/extension-localization.d.ts.map +1 -1
  14. package/dist/extension-localization.js +4 -0
  15. package/dist/extension-localization.js.map +1 -1
  16. package/dist/landing-page.d.ts +22 -4
  17. package/dist/landing-page.d.ts.map +1 -1
  18. package/dist/landing-page.js +14 -11
  19. package/dist/landing-page.js.map +1 -1
  20. package/dist/landing-sections.d.ts +332 -0
  21. package/dist/landing-sections.d.ts.map +1 -0
  22. package/dist/landing-sections.js +1071 -0
  23. package/dist/landing-sections.js.map +1 -0
  24. package/dist/listing.d.ts +15 -0
  25. package/dist/listing.d.ts.map +1 -1
  26. package/dist/listing.js +1 -1
  27. package/dist/listing.js.map +1 -1
  28. package/dist/page.d.ts +100 -21
  29. package/dist/page.d.ts.map +1 -1
  30. package/dist/page.js +217 -17
  31. package/dist/page.js.map +1 -1
  32. package/dist/pay-contract.d.ts +36 -12
  33. package/dist/pay-contract.d.ts.map +1 -1
  34. package/dist/pay-contract.js +24 -1
  35. package/dist/pay-contract.js.map +1 -1
  36. package/dist/pay-test-cycle.d.ts.map +1 -1
  37. package/dist/pay-test-cycle.js +6 -1
  38. package/dist/pay-test-cycle.js.map +1 -1
  39. package/dist/project.d.ts +30 -0
  40. package/dist/project.d.ts.map +1 -1
  41. package/dist/project.js +140 -42
  42. package/dist/project.js.map +1 -1
  43. package/dist/scan.d.ts +8 -11
  44. package/dist/scan.d.ts.map +1 -1
  45. package/dist/scan.js +8 -11
  46. package/dist/scan.js.map +1 -1
  47. package/dist/screenshots-scenes.d.ts +102 -102
  48. package/dist/screenshots.d.ts +3 -5
  49. package/dist/screenshots.d.ts.map +1 -1
  50. package/dist/screenshots.js +7 -6
  51. package/dist/screenshots.js.map +1 -1
  52. package/dist/telemetry.d.ts +7 -0
  53. package/dist/telemetry.d.ts.map +1 -1
  54. package/dist/telemetry.js +8 -0
  55. package/dist/telemetry.js.map +1 -1
  56. package/package.json +5 -5
@@ -0,0 +1,1071 @@
1
+ /**
2
+ * Page generator, schema v2: a full, declarative extension homepage.
3
+ *
4
+ * A v1 form (no `schemaVersion`) is still rendered by `buildLandingPage` in
5
+ * landing-page.ts, unchanged and byte-identical — `renderLandingForm` below
6
+ * only takes the v2 path when `schemaVersion === 2`. Everything v2 adds is an
7
+ * optional field on the same LandingForm, so an old row, an old client and an
8
+ * old server all keep working (see landing-page-v2.test.ts).
9
+ *
10
+ * What v2 adds, all declarative data — never markup:
11
+ * - ordered sections with `hidden` (hero, about, features, screenshots,
12
+ * how-it-works, pricing, faq, testimonials, store-badges, changelog, links);
13
+ * - theme tokens: palette per light/dark (or `auto` = both, by
14
+ * prefers-color-scheme), fonts from LANDING_FONTS, radius, width;
15
+ * - per-locale content (`locales`) rendered as one page per locale with
16
+ * hreflang alternates;
17
+ * - SEO / Open Graph fields and JSON-LD (SoftwareApplication + FAQPage).
18
+ *
19
+ * Output rules are the v1 rules: no JavaScript, every string HTML-escaped,
20
+ * links scheme-clamped (safeHref), images clamped (safeImageSrc), CSS built
21
+ * only from allowlists and validated hex colours. The hosted CSP
22
+ * (`default-src 'none'`, inline styles only) therefore holds unchanged.
23
+ *
24
+ * Testimonials must be attributed: a quote without an author and a public
25
+ * https source is refused, never rendered. Drafting tools never write
26
+ * testimonials — only the developer supplies them.
27
+ *
28
+ * SINGLE SOURCE, THREE COPIES — same scheme as landing-page.ts:
29
+ * shared-types/landing-sections.ts (canonical — edit here)
30
+ * tools/extenshi-mcp/src/landing-sections.ts (copy)
31
+ * tools/extenshi-cli/src/landing-sections.ts (copy)
32
+ * After editing, run:
33
+ * sed "s#from 'shared-types\/#from './#" shared-types/landing-sections.ts > tools/extenshi-mcp/src/landing-sections.ts
34
+ * sed "s#from 'shared-types\/#from './#" shared-types/landing-sections.ts > tools/extenshi-cli/src/landing-sections.ts
35
+ */
36
+ import { generateJsonLd, jsonLdFromLanding } from './json-ld.js';
37
+ import { absoluteImageUrl, buildLandingPage, canonicalUrl, esc, HEX_COLOR, landingFormToInput, landingLogoSvgProblem, landingPageWarnings, safeHref, safeImageSrc, safeSupportHref, str, } from './landing-page.js';
38
+ export const LANDING_SCHEMA_VERSION = 2;
39
+ export const LANDING_SECTION_TYPES = [
40
+ 'hero',
41
+ 'about',
42
+ 'features',
43
+ 'screenshots',
44
+ 'how-it-works',
45
+ 'pricing',
46
+ 'faq',
47
+ 'testimonials',
48
+ 'store-badges',
49
+ 'changelog',
50
+ 'links',
51
+ ];
52
+ export const LANDING_V2_LIMITS = {
53
+ sections: 20,
54
+ sectionId: 40,
55
+ title: 120,
56
+ intro: 600,
57
+ body: 6000,
58
+ items: 24,
59
+ screenshots: 12,
60
+ steps: 8,
61
+ plans: 6,
62
+ planFeatures: 12,
63
+ faq: 20,
64
+ testimonials: 12,
65
+ quote: 600,
66
+ changelog: 20,
67
+ changelogNotes: 12,
68
+ links: 12,
69
+ shortText: 120,
70
+ text: 500,
71
+ locales: 10,
72
+ seoTitle: 70,
73
+ seoDescription: 300,
74
+ };
75
+ /** A section id: lowercase slug, used as the HTML id and as the locale override key. */
76
+ export const LANDING_SECTION_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
77
+ /**
78
+ * System font stacks only (after modern-font-stacks, CC0). A web font would
79
+ * need a font-src exception in the hosted CSP and a third-party request from
80
+ * every visitor; a stack renders instantly and requests nothing.
81
+ */
82
+ export const LANDING_FONTS = {
83
+ default: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
84
+ 'system-ui': 'system-ui, sans-serif',
85
+ 'neo-grotesque': 'Inter, Roboto, "Helvetica Neue", "Arial Nova", "Nimbus Sans", Arial, sans-serif',
86
+ humanist: 'Seravek, "Gill Sans Nova", Ubuntu, Calibri, "DejaVu Sans", source-sans-pro, sans-serif',
87
+ 'geometric-humanist': 'Avenir, Montserrat, Corbel, "URW Gothic", source-sans-pro, sans-serif',
88
+ 'classical-humanist': 'Optima, Candara, "Noto Sans", source-sans-pro, sans-serif',
89
+ 'rounded-sans': 'ui-rounded, "Hiragino Maru Gothic ProN", Quicksand, Comfortaa, Manjari, "Arial Rounded MT", "Arial Rounded MT Bold", Calibri, source-sans-pro, sans-serif',
90
+ hyperlegible: '"Atkinson Hyperlegible Next", "Atkinson Hyperlegible", Lexend, Verdana, Tahoma, sans-serif',
91
+ industrial: 'Bahnschrift, "DIN Alternate", "Franklin Gothic Medium", "Nimbus Sans Narrow", sans-serif-condensed, sans-serif',
92
+ transitional: 'Charter, "Bitstream Charter", "Sitka Text", Cambria, serif',
93
+ 'old-style': '"Iowan Old Style", "Palatino Linotype", "URW Palladio L", P052, serif',
94
+ 'slab-serif': 'Rockwell, "Rockwell Nova", "Roboto Slab", "DejaVu Serif", "Sitka Small", serif',
95
+ didone: 'Didot, "Bodoni MT", "Noto Serif Display", "URW Palladio L", P052, Sylfaen, serif',
96
+ monospace: 'ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, "DejaVu Sans Mono", monospace',
97
+ };
98
+ export const LANDING_UI_DEFAULTS = {
99
+ addToChrome: 'Add to Chrome',
100
+ getForFirefox: 'Get for Firefox',
101
+ getForEdge: 'Get for Edge',
102
+ screenshots: 'Screenshots',
103
+ features: 'Features',
104
+ howItWorks: 'How it works',
105
+ pricing: 'Pricing',
106
+ faq: 'Frequently asked questions',
107
+ testimonials: 'What people say',
108
+ storeBadges: 'Get it for your browser',
109
+ changelog: 'Changelog',
110
+ links: 'Links',
111
+ privacy: 'Privacy policy',
112
+ support: 'Support',
113
+ feedback: 'Share feedback',
114
+ terms: 'License & refunds',
115
+ install: 'Install',
116
+ popular: 'Most popular',
117
+ perMonth: '/ month',
118
+ perYear: '/ year',
119
+ oneTime: 'one-time',
120
+ lifetime: 'lifetime',
121
+ source: 'Source',
122
+ languages: 'Languages',
123
+ };
124
+ // ─── Locales ────────────────────────────────────────────────────────────────
125
+ const LOCALE_RE = /^([a-zA-Z]{2,3})(?:[-_]([a-zA-Z]{4}))?(?:[-_]([a-zA-Z]{2}|\d{3}))?$/;
126
+ /**
127
+ * `pt_BR`, `pt-br`, `PT-BR` → `pt-BR` (BCP 47, as hreflang and <html lang>
128
+ * want it). Accepts the Chrome `_locales` spelling used by the localization
129
+ * workflow. Null when the value is not a language tag.
130
+ */
131
+ export function normalizeLandingLocale(raw) {
132
+ const m = LOCALE_RE.exec(str(raw));
133
+ if (!m)
134
+ return null;
135
+ const [, lang, script, region] = m;
136
+ let tag = lang.toLowerCase();
137
+ if (script)
138
+ tag += `-${script.charAt(0).toUpperCase()}${script.slice(1).toLowerCase()}`;
139
+ if (region)
140
+ tag += `-${region.toUpperCase()}`;
141
+ return tag;
142
+ }
143
+ /** URL path segment for a locale: lowercase BCP 47 (`pt-br`). */
144
+ export function landingLocaleSlug(locale) {
145
+ return (normalizeLandingLocale(locale) ?? locale).toLowerCase();
146
+ }
147
+ export function landingDefaultLocale(form) {
148
+ return normalizeLandingLocale(form.defaultLocale) ?? 'en';
149
+ }
150
+ /** Default locale first, then every valid translated locale (deduplicated, capped). */
151
+ export function landingLocales(form) {
152
+ const def = landingDefaultLocale(form);
153
+ const out = [def];
154
+ if (form.schemaVersion !== 2 || !form.locales || typeof form.locales !== 'object')
155
+ return out;
156
+ for (const key of Object.keys(form.locales)) {
157
+ const tag = normalizeLandingLocale(key);
158
+ if (tag && !out.includes(tag) && out.length <= LANDING_V2_LIMITS.locales)
159
+ out.push(tag);
160
+ }
161
+ return out;
162
+ }
163
+ /** Absolute URL of a locale's page: the default locale lives at `base`, others at `base/{slug}`. */
164
+ export function landingLocaleUrl(base, locale, defaultLocale) {
165
+ if (!base)
166
+ return '';
167
+ if (landingLocaleSlug(locale) === landingLocaleSlug(defaultLocale))
168
+ return base;
169
+ return `${base.replace(/\/+$/, '')}/${landingLocaleSlug(locale)}`;
170
+ }
171
+ function localeEntry(form, locale) {
172
+ if (!form.locales)
173
+ return null;
174
+ for (const [key, value] of Object.entries(form.locales)) {
175
+ if (normalizeLandingLocale(key) === locale && value && typeof value === 'object')
176
+ return value;
177
+ }
178
+ return null;
179
+ }
180
+ /** Strings from `over` replace strings in `base`; arrays merge by index; structure comes from `base`. */
181
+ function mergeText(base, over) {
182
+ if (typeof base === 'string')
183
+ return typeof over === 'string' ? over : base;
184
+ if (Array.isArray(base))
185
+ return Array.isArray(over) ? base.map((b, i) => mergeText(b, over[i])) : base;
186
+ if (base && typeof base === 'object') {
187
+ if (!over || typeof over !== 'object' || Array.isArray(over))
188
+ return base;
189
+ const out = { ...base };
190
+ for (const [key, value] of Object.entries(over)) {
191
+ if (key === 'id' || key === 'type')
192
+ continue;
193
+ if (key in out)
194
+ out[key] = mergeText(out[key], value);
195
+ else if (typeof value === 'string')
196
+ out[key] = value;
197
+ }
198
+ return out;
199
+ }
200
+ return base;
201
+ }
202
+ /** The form as it reads in `locale` (the base form for the default locale). */
203
+ export function localizeLandingForm(form, locale) {
204
+ const tag = normalizeLandingLocale(locale);
205
+ if (!tag || tag === landingDefaultLocale(form))
206
+ return form;
207
+ const entry = localeEntry(form, tag);
208
+ if (!entry)
209
+ return form;
210
+ const out = { ...form };
211
+ for (const key of ['extensionName', 'tagline', 'description']) {
212
+ if (typeof entry[key] === 'string' && str(entry[key]))
213
+ out[key] = entry[key];
214
+ }
215
+ if (entry.seo)
216
+ out.seo = mergeText({ title: '', description: '', ogImageUrl: '', ...form.seo }, entry.seo);
217
+ if (entry.ui)
218
+ out.ui = { ...form.ui, ...pickStrings(entry.ui) };
219
+ if (entry.sections && Array.isArray(form.sections)) {
220
+ out.sections = form.sections.map((s) => s && entry.sections?.[s.id] ? mergeText(s, entry.sections[s.id]) : s);
221
+ }
222
+ return out;
223
+ }
224
+ function pickStrings(o) {
225
+ const out = {};
226
+ for (const [k, v] of Object.entries(o))
227
+ if (typeof v === 'string')
228
+ out[k] = v;
229
+ return out;
230
+ }
231
+ // ─── Colours ────────────────────────────────────────────────────────────────
232
+ const PALETTES = {
233
+ light: {
234
+ accent: '#5e5ce6',
235
+ accentText: '#ffffff',
236
+ background: '#ffffff',
237
+ surface: '#f6f7fa',
238
+ text: '#1f2430',
239
+ muted: '#5b6372',
240
+ border: '#e5e8ef',
241
+ },
242
+ dark: {
243
+ accent: '#5e5ce6',
244
+ accentText: '#ffffff',
245
+ background: '#0f1115',
246
+ surface: '#171a21',
247
+ text: '#e8eaf0',
248
+ muted: '#9aa1b2',
249
+ border: '#262b36',
250
+ },
251
+ };
252
+ function hexRgb(hex) {
253
+ if (!HEX_COLOR.test(hex))
254
+ return null;
255
+ let h = hex.slice(1);
256
+ if (h.length === 3 || h.length === 4)
257
+ h = [...h.slice(0, 3)].map((c) => c + c).join('');
258
+ else if (h.length === 8)
259
+ h = h.slice(0, 6);
260
+ else if (h.length !== 6)
261
+ return null;
262
+ return [0, 2, 4].map((i) => Number.parseInt(h.slice(i, i + 2), 16));
263
+ }
264
+ /** WCAG 2 contrast ratio of two hex colours, or null when either is not a hex colour. */
265
+ export function contrastRatio(a, b) {
266
+ const lum = (hex) => {
267
+ const rgb = hexRgb(hex);
268
+ if (!rgb)
269
+ return null;
270
+ const [r, g, bl] = rgb.map((v) => {
271
+ const c = v / 255;
272
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
273
+ });
274
+ return 0.2126 * r + 0.7152 * g + 0.0722 * bl;
275
+ };
276
+ const la = lum(a);
277
+ const lb = lum(b);
278
+ if (la === null || lb === null)
279
+ return null;
280
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
281
+ }
282
+ function resolvePalette(form, mode) {
283
+ const base = { ...PALETTES[mode] };
284
+ const accent = str(form.accentColor);
285
+ if (HEX_COLOR.test(accent))
286
+ base.accent = accent;
287
+ const custom = form.tokens?.[mode];
288
+ if (custom && typeof custom === 'object') {
289
+ for (const key of Object.keys(base)) {
290
+ const v = str(custom[key]);
291
+ if (HEX_COLOR.test(v))
292
+ base[key] = v;
293
+ }
294
+ }
295
+ return base;
296
+ }
297
+ function themeMode(form) {
298
+ const m = form.tokens?.mode;
299
+ if (m === 'light' || m === 'dark' || m === 'auto')
300
+ return m;
301
+ return form.theme === 'light' ? 'light' : 'dark';
302
+ }
303
+ /** Own-property check (Object.hasOwn needs lib es2022, which not every consumer compiles with). */
304
+ function hasOwn(o, key) {
305
+ return Object.hasOwn(o, key);
306
+ }
307
+ const RADIUS = { none: '0', small: '6px', medium: '12px', large: '20px' };
308
+ const WIDTH = { narrow: '720px', normal: '880px', wide: '1080px' };
309
+ function font(id) {
310
+ return typeof id === 'string' && hasOwn(LANDING_FONTS, id)
311
+ ? LANDING_FONTS[id]
312
+ : LANDING_FONTS.default;
313
+ }
314
+ // ─── Validation ─────────────────────────────────────────────────────────────
315
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
316
+ const TWITTER_RE = /^@?[A-Za-z0-9_]{1,15}$/;
317
+ const HTTPS_RE = /^https:\/\/[^\s"'<>\\]+$/i;
318
+ const HTTP_RE = /^https?:\/\/[^\s"'<>\\]+$/i;
319
+ const SECTION_TITLE_KEY = {
320
+ features: 'features',
321
+ screenshots: 'screenshots',
322
+ 'how-it-works': 'howItWorks',
323
+ pricing: 'pricing',
324
+ faq: 'faq',
325
+ testimonials: 'testimonials',
326
+ 'store-badges': 'storeBadges',
327
+ changelog: 'changelog',
328
+ links: 'links',
329
+ };
330
+ /**
331
+ * Everything wrong with a form, as actionable problems. `error` means the
332
+ * hosted publish refuses the form; `warning` means it renders but something
333
+ * was dropped, defaulted or is likely unintended. For a v1 form this is
334
+ * landingPageWarnings() as warnings — v1 validation is unchanged.
335
+ */
336
+ export function landingFormProblems(form) {
337
+ const out = [];
338
+ const err = (path, message, fix) => out.push({ path, severity: 'error', message, fix });
339
+ const warn = (path, message, fix) => out.push({ path, severity: 'warning', message, fix });
340
+ for (const message of landingPageWarnings(landingFormToInput(form))) {
341
+ out.push({ path: warningPath(message), severity: 'warning', message, fix: 'See the message.' });
342
+ }
343
+ const hasV2 = form.sections !== undefined ||
344
+ form.tokens !== undefined ||
345
+ form.locales !== undefined ||
346
+ form.seo !== undefined;
347
+ if (form.schemaVersion !== 2) {
348
+ if (hasV2) {
349
+ err('schemaVersion', 'sections, tokens, seo and locales are only read when schemaVersion is 2', 'Set "schemaVersion": 2.');
350
+ }
351
+ return out;
352
+ }
353
+ const sections = form.sections;
354
+ if (sections !== undefined && !Array.isArray(sections)) {
355
+ err('sections', 'sections must be an array', 'Pass an ordered array of section objects.');
356
+ }
357
+ const list = Array.isArray(sections) ? sections : [];
358
+ if (list.length === 0) {
359
+ warn('sections', 'no sections — the page renders only the hero defaults', 'Add sections, e.g. from draft_landing_page.');
360
+ }
361
+ if (list.length > LANDING_V2_LIMITS.sections) {
362
+ err('sections', `more than ${LANDING_V2_LIMITS.sections} sections`, 'Merge or remove sections.');
363
+ }
364
+ if (list.length > 0 && !list.some((s) => s && typeof s === 'object' && s.type === 'hero' && !s.hidden)) {
365
+ warn('sections', 'no visible hero section — the page has no <h1> and no top install button', 'Add { "id": "hero", "type": "hero" } first.');
366
+ }
367
+ const ids = new Set();
368
+ for (const [i, raw] of list.entries()) {
369
+ const p = `sections[${i}]`;
370
+ if (!raw || typeof raw !== 'object') {
371
+ err(p, 'a section must be an object', 'Replace it with { "id": "...", "type": "..." }.');
372
+ continue;
373
+ }
374
+ const s = raw;
375
+ if (!LANDING_SECTION_TYPES.includes(s.type)) {
376
+ err(`${p}.type`, `unknown section type "${String(s.type)}"`, `Use one of: ${LANDING_SECTION_TYPES.join(', ')}.`);
377
+ continue;
378
+ }
379
+ if (typeof s.id !== 'string' || !LANDING_SECTION_ID.test(s.id)) {
380
+ err(`${p}.id`, 'id must be a lowercase slug (a-z, 0-9, -; up to 40 chars)', `Use e.g. "${s.type}".`);
381
+ }
382
+ else if (ids.has(s.id)) {
383
+ err(`${p}.id`, `duplicate section id "${s.id}"`, 'Give every section a unique id.');
384
+ }
385
+ else
386
+ ids.add(s.id);
387
+ textLimit(out, `${p}.title`, s.title, LANDING_V2_LIMITS.title);
388
+ textLimit(out, `${p}.intro`, s.intro, LANDING_V2_LIMITS.intro);
389
+ sectionProblems(s, p, err, warn, form);
390
+ }
391
+ const tokens = form.tokens;
392
+ if (tokens && typeof tokens === 'object') {
393
+ if (tokens.mode !== undefined && !['light', 'dark', 'auto'].includes(tokens.mode)) {
394
+ err('tokens.mode', `unknown mode "${String(tokens.mode)}"`, 'Use light, dark or auto.');
395
+ }
396
+ for (const key of ['headingFont', 'bodyFont']) {
397
+ const v = tokens[key];
398
+ if (v !== undefined && !(typeof v === 'string' && hasOwn(LANDING_FONTS, v))) {
399
+ err(`tokens.${key}`, `font "${String(v)}" is not in the allowlist`, `Use one of: ${Object.keys(LANDING_FONTS).join(', ')}.`);
400
+ }
401
+ }
402
+ if (tokens.radius !== undefined && !hasOwn(RADIUS, tokens.radius)) {
403
+ err('tokens.radius', `unknown radius "${String(tokens.radius)}"`, 'Use none, small, medium or large.');
404
+ }
405
+ if (tokens.width !== undefined && !hasOwn(WIDTH, tokens.width)) {
406
+ err('tokens.width', `unknown width "${String(tokens.width)}"`, 'Use narrow, normal or wide.');
407
+ }
408
+ for (const mode of ['light', 'dark']) {
409
+ const pal = tokens[mode];
410
+ if (!pal)
411
+ continue;
412
+ for (const [key, value] of Object.entries(pal)) {
413
+ if (value !== undefined && !HEX_COLOR.test(str(value))) {
414
+ err(`tokens.${mode}.${key}`, `"${String(value)}" is not a hex colour`, 'Use #rgb or #rrggbb.');
415
+ }
416
+ }
417
+ }
418
+ }
419
+ const mode = themeMode(form);
420
+ for (const pmode of mode === 'auto' ? ['light', 'dark'] : [mode]) {
421
+ const pal = resolvePalette(form, pmode);
422
+ const body = contrastRatio(pal.text, pal.background) ?? 21;
423
+ if (body < 4.5) {
424
+ warn(`tokens.${pmode}.text`, `text on background contrast is ${body.toFixed(2)}:1 (WCAG AA needs 4.5:1)`, 'Darken the text or lighten the background (or the reverse).');
425
+ }
426
+ const button = contrastRatio(pal.accentText, pal.accent) ?? 21;
427
+ if (button < 4.5) {
428
+ warn(`tokens.${pmode}.accentText`, `button text on accent contrast is ${button.toFixed(2)}:1 (WCAG AA needs 4.5:1)`, 'Set accentText to #000000 or #ffffff, whichever contrasts with the accent.');
429
+ }
430
+ }
431
+ const seo = form.seo;
432
+ if (seo && typeof seo === 'object') {
433
+ textLimit(out, 'seo.title', seo.title, LANDING_V2_LIMITS.seoTitle);
434
+ textLimit(out, 'seo.description', seo.description, LANDING_V2_LIMITS.seoDescription);
435
+ if (str(seo.title).length > 60)
436
+ warn('seo.title', 'titles over 60 characters are usually cut in search results', 'Shorten it.');
437
+ if (str(seo.description).length > 160)
438
+ warn('seo.description', 'descriptions over 160 characters are usually cut in search results', 'Shorten it.');
439
+ if (str(seo.ogImageUrl) && !HTTPS_RE.test(str(seo.ogImageUrl))) {
440
+ err('seo.ogImageUrl', 'og:image must be an absolute https URL', 'Upload it with upload_project_media and use the returned URL.');
441
+ }
442
+ if (str(seo.twitterSite) && !TWITTER_RE.test(str(seo.twitterSite))) {
443
+ err('seo.twitterSite', 'not a Twitter/X handle', 'Use e.g. "@extenshi".');
444
+ }
445
+ }
446
+ for (const key of ['uninstallUrl', 'termsUrl']) {
447
+ const v = str(form[key]);
448
+ if (v && !HTTP_RE.test(v))
449
+ err(key, 'must be an http(s) URL', 'Use the hosted artifact URL from get_project_state.');
450
+ }
451
+ uiProblems(out, 'ui', form.ui);
452
+ const def = form.defaultLocale;
453
+ if (def !== undefined && !normalizeLandingLocale(def)) {
454
+ err('defaultLocale', `"${String(def)}" is not a language tag`, 'Use e.g. "en" or "pt-BR".');
455
+ }
456
+ const locales = form.locales;
457
+ if (locales !== undefined) {
458
+ if (!locales || typeof locales !== 'object' || Array.isArray(locales)) {
459
+ err('locales', 'locales must be an object keyed by locale', 'Use { "de": { ... }, "pt-BR": { ... } }.');
460
+ }
461
+ else {
462
+ const keys = Object.keys(locales);
463
+ if (keys.length > LANDING_V2_LIMITS.locales) {
464
+ err('locales', `more than ${LANDING_V2_LIMITS.locales} locales`, 'Host the most-used locales only.');
465
+ }
466
+ const seen = new Set([landingDefaultLocale(form)]);
467
+ for (const key of keys) {
468
+ const p = `locales.${key}`;
469
+ const tag = normalizeLandingLocale(key);
470
+ if (!tag) {
471
+ err(p, `"${key}" is not a language tag`, 'Use e.g. "de", "pt-BR" or "pt_BR".');
472
+ continue;
473
+ }
474
+ if (seen.has(tag)) {
475
+ err(p, `locale ${tag} is listed twice (or is the default locale)`, 'Keep one entry per locale; the base content is the default locale.');
476
+ continue;
477
+ }
478
+ seen.add(tag);
479
+ const entry = locales[key];
480
+ if (!entry || typeof entry !== 'object') {
481
+ err(p, 'a locale entry must be an object', 'Use { "tagline": "...", "sections": { ... } }.');
482
+ continue;
483
+ }
484
+ uiProblems(out, `${p}.ui`, entry.ui);
485
+ for (const id of Object.keys(entry.sections ?? {})) {
486
+ if (!ids.has(id))
487
+ err(`${p}.sections.${id}`, `no section with id "${id}"`, `Use one of: ${[...ids].join(', ') || '(no sections)'}.`);
488
+ }
489
+ for (const s of list) {
490
+ if (!s || s.hidden || !ids.has(s.id))
491
+ continue;
492
+ const titleKey = SECTION_TITLE_KEY[s.type];
493
+ if (!titleKey)
494
+ continue;
495
+ const translated = entry.sections?.[s.id]?.title;
496
+ if (!str(s.title) && typeof translated !== 'string' && !str(entry.ui?.[titleKey])) {
497
+ warn(`${p}.sections.${s.id}.title`, `section "${s.id}" shows the default English heading "${LANDING_UI_DEFAULTS[titleKey]}" on the ${tag} page`, `Set locales.${key}.ui.${titleKey} (or locales.${key}.sections.${s.id}.title).`);
498
+ }
499
+ }
500
+ }
501
+ }
502
+ }
503
+ return out;
504
+ }
505
+ function warningPath(message) {
506
+ const m = /^([a-z][A-Za-z]*(?:\[\d+\])?(?:\.[A-Za-z]+)?) (?:is|was) /.exec(message);
507
+ return m?.[1] ?? '(form)';
508
+ }
509
+ function textLimit(out, path, value, max) {
510
+ if (value === undefined)
511
+ return;
512
+ if (typeof value !== 'string') {
513
+ out.push({ path, severity: 'error', message: 'must be a string', fix: 'Pass plain text.' });
514
+ }
515
+ else if (value.length > max) {
516
+ out.push({ path, severity: 'error', message: `longer than ${max} characters`, fix: 'Shorten it.' });
517
+ }
518
+ }
519
+ function uiProblems(out, path, ui) {
520
+ if (ui === undefined)
521
+ return;
522
+ if (!ui || typeof ui !== 'object') {
523
+ out.push({
524
+ path,
525
+ severity: 'error',
526
+ message: 'ui must be an object',
527
+ fix: 'Use { "install": "Installer" }.',
528
+ });
529
+ return;
530
+ }
531
+ for (const [key, value] of Object.entries(ui)) {
532
+ if (!hasOwn(LANDING_UI_DEFAULTS, key)) {
533
+ out.push({
534
+ path: `${path}.${key}`,
535
+ severity: 'error',
536
+ message: `unknown ui string "${key}"`,
537
+ fix: `Use one of: ${Object.keys(LANDING_UI_DEFAULTS).join(', ')}.`,
538
+ });
539
+ }
540
+ else
541
+ textLimit(out, `${path}.${key}`, value, LANDING_V2_LIMITS.shortText);
542
+ }
543
+ }
544
+ function itemsArray(value, path, max, err, required = true) {
545
+ if (value === undefined && !required)
546
+ return [];
547
+ if (!Array.isArray(value)) {
548
+ err(path, 'must be an array', 'Pass a list (it may be empty).');
549
+ return [];
550
+ }
551
+ if (value.length > max)
552
+ err(path, `more than ${max} entries`, `Keep the first ${max}.`);
553
+ return value;
554
+ }
555
+ function sectionProblems(s, p, err, warn, form) {
556
+ const L = LANDING_V2_LIMITS;
557
+ const sub = [];
558
+ const t = (path, v, max) => textLimit(sub, path, v, max);
559
+ switch (s.type) {
560
+ case 'hero':
561
+ t(`${p}.headline`, s.headline, L.title);
562
+ t(`${p}.subheadline`, s.subheadline, L.text);
563
+ break;
564
+ case 'about':
565
+ if (!str(s.body))
566
+ err(`${p}.body`, 'about needs a body', 'Write a few sentences, or remove the section.');
567
+ t(`${p}.body`, s.body, L.body);
568
+ break;
569
+ case 'features':
570
+ for (const [i, item] of itemsArray(s.items, `${p}.items`, L.items, err).entries()) {
571
+ const it = item;
572
+ if (!str(it?.title))
573
+ err(`${p}.items[${i}].title`, 'feature needs a title', 'Add a title or remove the item.');
574
+ t(`${p}.items[${i}].title`, it?.title, L.shortText);
575
+ t(`${p}.items[${i}].description`, it?.description, L.text);
576
+ }
577
+ break;
578
+ case 'screenshots':
579
+ for (const [i, item] of itemsArray(s.items, `${p}.items`, L.screenshots, err).entries()) {
580
+ const it = item;
581
+ if (!safeImageSrc(it?.url)) {
582
+ err(`${p}.items[${i}].url`, 'not an https URL, relative path or raster data: image', 'Upload the image with upload_project_media and use the returned URL.');
583
+ }
584
+ t(`${p}.items[${i}].alt`, it?.alt, 200);
585
+ t(`${p}.items[${i}].caption`, it?.caption, L.text);
586
+ if (!str(it?.alt))
587
+ warn(`${p}.items[${i}].alt`, 'screenshot has no alt text', 'Describe what the screenshot shows.');
588
+ }
589
+ break;
590
+ case 'how-it-works':
591
+ for (const [i, item] of itemsArray(s.steps, `${p}.steps`, L.steps, err).entries()) {
592
+ const it = item;
593
+ if (!str(it?.title))
594
+ err(`${p}.steps[${i}].title`, 'step needs a title', 'Add a title or remove the step.');
595
+ t(`${p}.steps[${i}].title`, it?.title, L.shortText);
596
+ t(`${p}.steps[${i}].description`, it?.description, L.text);
597
+ }
598
+ break;
599
+ case 'pricing':
600
+ t(`${p}.note`, s.note, L.text);
601
+ for (const [i, item] of itemsArray(s.plans, `${p}.plans`, L.plans, err).entries()) {
602
+ const plan = item;
603
+ const pp = `${p}.plans[${i}]`;
604
+ if (!str(plan?.offerSku) && !str(plan?.name))
605
+ err(`${pp}.name`, 'plan needs a name', 'Set name, or offerSku to take it from the Pay offer.');
606
+ if (!str(plan?.offerSku) && !str(plan?.price))
607
+ err(`${pp}.price`, 'plan needs a price', 'Set price (e.g. "Free", "$4.99"), or offerSku to take it from the Pay offer.');
608
+ if (plan?.period !== undefined && !['one-time', 'month', 'year', 'lifetime'].includes(plan.period)) {
609
+ err(`${pp}.period`, `unknown period "${String(plan.period)}"`, 'Use one-time, month, year or lifetime.');
610
+ }
611
+ t(`${pp}.name`, plan?.name, L.shortText);
612
+ t(`${pp}.price`, plan?.price, 40);
613
+ t(`${pp}.description`, plan?.description, L.text);
614
+ t(`${pp}.ctaLabel`, plan?.ctaLabel, 60);
615
+ if (str(plan?.ctaUrl) && !HTTP_RE.test(str(plan?.ctaUrl)))
616
+ err(`${pp}.ctaUrl`, 'must be an http(s) URL', 'Remove it to link the store listing.');
617
+ for (const [j, f] of itemsArray(plan?.features, `${pp}.features`, L.planFeatures, err, false).entries()) {
618
+ t(`${pp}.features[${j}]`, f, L.shortText);
619
+ }
620
+ }
621
+ break;
622
+ case 'faq':
623
+ for (const [i, item] of itemsArray(s.items, `${p}.items`, L.faq, err).entries()) {
624
+ const it = item;
625
+ if (!str(it?.question) || !str(it?.answer))
626
+ err(`${p}.items[${i}]`, 'FAQ entry needs a question and an answer', 'Fill both or remove the entry.');
627
+ t(`${p}.items[${i}].question`, it?.question, L.title);
628
+ t(`${p}.items[${i}].answer`, it?.answer, L.body / 4);
629
+ }
630
+ break;
631
+ case 'testimonials':
632
+ for (const [i, item] of itemsArray(s.items, `${p}.items`, L.testimonials, err).entries()) {
633
+ const it = item;
634
+ const tp = `${p}.items[${i}]`;
635
+ const refuse = 'Testimonials must be real and attributed: add who said it and the public https page where they said it (a store review, a post), or remove it. Never invent one.';
636
+ if (!str(it?.quote))
637
+ err(`${tp}.quote`, 'testimonial has no quote', refuse);
638
+ if (!str(it?.author))
639
+ err(`${tp}.author`, 'testimonial has no author', refuse);
640
+ if (!HTTPS_RE.test(str(it?.sourceUrl)))
641
+ err(`${tp}.sourceUrl`, 'testimonial has no public https source', refuse);
642
+ t(`${tp}.quote`, it?.quote, L.quote);
643
+ t(`${tp}.author`, it?.author, L.shortText);
644
+ t(`${tp}.role`, it?.role, L.shortText);
645
+ t(`${tp}.sourceLabel`, it?.sourceLabel, L.shortText);
646
+ }
647
+ break;
648
+ case 'store-badges': {
649
+ const browsers = s.browsers ?? [];
650
+ if (!Array.isArray(browsers) || browsers.some((b) => !['chrome', 'firefox', 'edge'].includes(b))) {
651
+ err(`${p}.browsers`, 'browsers must list chrome, firefox or edge', 'Use e.g. ["chrome", "firefox"].');
652
+ }
653
+ const any = ['chrome', 'firefox', 'edge'].some((b) => str(form[`${b}Url`]));
654
+ if (!any)
655
+ warn(p, 'no store URL is set, so this section renders nothing', 'Set chromeUrl, firefoxUrl or edgeUrl.');
656
+ break;
657
+ }
658
+ case 'changelog':
659
+ for (const [i, item] of itemsArray(s.entries, `${p}.entries`, L.changelog, err).entries()) {
660
+ const e = item;
661
+ const ep = `${p}.entries[${i}]`;
662
+ if (!str(e?.version))
663
+ err(`${ep}.version`, 'changelog entry needs a version', 'Set e.g. "1.2.0".');
664
+ t(`${ep}.version`, e?.version, 40);
665
+ if (e?.date !== undefined && !DATE_RE.test(str(e.date)))
666
+ err(`${ep}.date`, 'date must be YYYY-MM-DD', 'Use e.g. "2026-09-28".');
667
+ for (const [j, n] of itemsArray(e?.notes, `${ep}.notes`, L.changelogNotes, err).entries())
668
+ t(`${ep}.notes[${j}]`, n, L.text);
669
+ }
670
+ break;
671
+ case 'links':
672
+ for (const [i, item] of itemsArray(s.items, `${p}.items`, L.links, err, false).entries()) {
673
+ const it = item;
674
+ if (!str(it?.label))
675
+ err(`${p}.items[${i}].label`, 'link needs a label', 'Add a label.');
676
+ if (!HTTP_RE.test(str(it?.url)))
677
+ err(`${p}.items[${i}].url`, 'link must be an http(s) URL', 'Use an absolute URL.');
678
+ t(`${p}.items[${i}].label`, it?.label, L.shortText);
679
+ }
680
+ break;
681
+ }
682
+ for (const problem of sub)
683
+ err(problem.path, problem.message, problem.fix);
684
+ }
685
+ // ─── Rendering ──────────────────────────────────────────────────────────────
686
+ const STORE_ORDER = ['chrome', 'firefox', 'edge'];
687
+ const STORE_UI = {
688
+ chrome: 'addToChrome',
689
+ firefox: 'getForFirefox',
690
+ edge: 'getForEdge',
691
+ };
692
+ const BROWSER_NAMES = {
693
+ chrome: 'Google Chrome',
694
+ firefox: 'Mozilla Firefox',
695
+ edge: 'Microsoft Edge',
696
+ };
697
+ function storeUrl(form, b) {
698
+ return str(b === 'chrome' ? form.chromeUrl : b === 'firefox' ? form.firefoxUrl : form.edgeUrl);
699
+ }
700
+ /** Paragraphs from plain text: blank lines split, single newlines become <br>. */
701
+ function paragraphs(text) {
702
+ return text
703
+ .split(/\n\s*\n/)
704
+ .map((p) => p.trim())
705
+ .filter(Boolean)
706
+ .map((p) => `<p>${esc(p).replace(/\n/g, '<br />')}</p>`)
707
+ .join('\n');
708
+ }
709
+ function arr(v, max) {
710
+ return Array.isArray(v) ? v.slice(0, max) : [];
711
+ }
712
+ /**
713
+ * The one entry point every surface calls (dojo preview, BFF hosting, MCP and
714
+ * CLI generate). A v1 form goes to buildLandingPage untouched.
715
+ */
716
+ export function renderLandingForm(form, opts = {}) {
717
+ if (form.schemaVersion !== 2)
718
+ return buildLandingPage(landingFormToInput(form));
719
+ const def = landingDefaultLocale(form);
720
+ const locales = landingLocales(form);
721
+ const requested = normalizeLandingLocale(opts.locale);
722
+ const locale = requested && locales.includes(requested) ? requested : def;
723
+ return renderV2(localizeLandingForm(form, locale), form, locale, locales, def);
724
+ }
725
+ function renderV2(form, base, locale, locales, def) {
726
+ const ui = (key) => str(form.ui?.[key]) || LANDING_UI_DEFAULTS[key];
727
+ const rawName = str(form.extensionName) || 'My Extension';
728
+ const name = esc(rawName);
729
+ const tagline = str(form.tagline);
730
+ const description = str(form.description);
731
+ const homeBase = canonicalUrl(base.homepageUrl);
732
+ const pageUrl = homeBase ? landingLocaleUrl(homeBase, locale, def) : '';
733
+ const mode = themeMode(form);
734
+ const t = form.tokens ?? {};
735
+ const radius = RADIUS[t.radius] ?? RADIUS.medium;
736
+ const width = WIDTH[t.width] ?? WIDTH.normal;
737
+ const sections = arr(form.sections, LANDING_V2_LIMITS.sections).filter((s) => s &&
738
+ typeof s === 'object' &&
739
+ !s.hidden &&
740
+ LANDING_SECTION_TYPES.includes(s.type));
741
+ const stores = STORE_ORDER.filter((b) => storeUrl(form, b));
742
+ const primaryStore = stores[0] ? safeHref(storeUrl(form, stores[0])) : '';
743
+ const badges = (browsers) => browsers
744
+ .filter((b) => storeUrl(form, b))
745
+ .map((b, i) => `<a class="cta${i === 0 ? ' primary' : ''}" href="${safeHref(storeUrl(form, b))}" rel="noopener">${esc(ui(STORE_UI[b]))}<span class="cta-sub">${esc(BROWSER_NAMES[b])}</span></a>`)
746
+ .join('\n');
747
+ const svg = str(form.logoSvg);
748
+ const logoSrc = svg && !landingLogoSvgProblem(svg)
749
+ ? `data:image/svg+xml,${encodeURIComponent(svg)}`
750
+ : safeImageSrc(form.logoUrl);
751
+ const logo = logoSrc
752
+ ? `<div class="logo"><img src="${logoSrc}" alt="${name} logo" width="64" height="64" /></div>`
753
+ : `<div class="logo monogram" aria-hidden="true">${esc(rawName.charAt(0).toUpperCase() || 'E')}</div>`;
754
+ const heading = (s, key) => {
755
+ const title = str(s.title) || (key ? ui(key) : '');
756
+ const intro = str(s.intro);
757
+ return `${title ? `<h2>${esc(title)}</h2>` : ''}${intro ? `\n<p class="intro">${esc(intro)}</p>` : ''}`;
758
+ };
759
+ const section = (s, cls, inner) => inner ? `<section id="${esc(s.id)}" class="${cls}">\n${inner}\n</section>` : '';
760
+ const shotUrls = [];
761
+ const faqs = [];
762
+ const featureTitles = [];
763
+ const blocks = sections.map((s) => {
764
+ switch (s.type) {
765
+ case 'hero': {
766
+ const headline = esc(str(s.headline) || rawName);
767
+ const sub = str(s.subheadline) || tagline;
768
+ const cta = s.showBadges === false ? '' : badges(stores);
769
+ return `<header id="${esc(s.id)}" class="hero">\n${logo}\n<h1>${headline}</h1>${sub ? `\n<p class="tagline">${esc(sub)}</p>` : ''}${cta ? `\n<div class="ctas">\n${cta}\n</div>` : ''}\n</header>`;
770
+ }
771
+ case 'about':
772
+ return section(s, 'about', str(s.body) ? `${heading(s)}\n${paragraphs(str(s.body))}` : '');
773
+ case 'features': {
774
+ const items = arr(s.items, LANDING_V2_LIMITS.items).filter((f) => str(f?.title));
775
+ for (const f of items)
776
+ featureTitles.push({ title: str(f.title), description: str(f.description) });
777
+ const cards = items.map((f) => `<div class="card"><h3>${esc(str(f.title))}</h3>${str(f.description) ? `<p>${esc(str(f.description))}</p>` : ''}</div>`);
778
+ return section(s, 'features', cards.length ? `${heading(s, 'features')}\n<div class="grid">\n${cards.join('\n')}\n</div>` : '');
779
+ }
780
+ case 'screenshots': {
781
+ const items = arr(s.items, LANDING_V2_LIMITS.screenshots)
782
+ .map((shot, i) => ({
783
+ url: str(shot?.url),
784
+ src: safeImageSrc(shot?.url),
785
+ alt: str(shot?.alt).slice(0, 200) || `${rawName} screenshot ${i + 1}`,
786
+ caption: str(shot?.caption),
787
+ }))
788
+ .filter((shot) => shot.src);
789
+ for (const shot of items) {
790
+ const abs = absoluteImageUrl(shot.url, homeBase);
791
+ if (abs)
792
+ shotUrls.push(abs);
793
+ }
794
+ const figs = items.map((shot) => `<figure class="shot"><img src="${shot.src}" alt="${esc(shot.alt)}" loading="lazy" />${shot.caption ? `<figcaption>${esc(shot.caption)}</figcaption>` : ''}</figure>`);
795
+ return section(s, 'shots', figs.length
796
+ ? `${heading(s, 'screenshots')}\n<div class="shot-grid">\n${figs.join('\n')}\n</div>`
797
+ : '');
798
+ }
799
+ case 'how-it-works': {
800
+ const steps = arr(s.steps, LANDING_V2_LIMITS.steps).filter((x) => str(x?.title));
801
+ const lis = steps.map((x) => `<li><h3>${esc(str(x.title))}</h3>${str(x.description) ? `<p>${esc(str(x.description))}</p>` : ''}</li>`);
802
+ return section(s, 'steps', lis.length ? `${heading(s, 'howItWorks')}\n<ol class="step-list">\n${lis.join('\n')}\n</ol>` : '');
803
+ }
804
+ case 'pricing': {
805
+ const plans = arr(s.plans, LANDING_V2_LIMITS.plans).filter((pl) => str(pl?.name) && str(pl?.price));
806
+ const period = (pl) => pl.period === 'month'
807
+ ? ui('perMonth')
808
+ : pl.period === 'year'
809
+ ? ui('perYear')
810
+ : pl.period === 'one-time'
811
+ ? ui('oneTime')
812
+ : pl.period === 'lifetime'
813
+ ? ui('lifetime')
814
+ : '';
815
+ const cards = plans.map((pl) => {
816
+ const href = str(pl.ctaUrl) ? safeHref(str(pl.ctaUrl)) : primaryStore;
817
+ const feats = arr(pl.features, LANDING_V2_LIMITS.planFeatures).map(str).filter(Boolean);
818
+ return `<div class="card plan${pl.highlighted ? ' highlighted' : ''}">${pl.highlighted ? `<p class="badge">${esc(ui('popular'))}</p>` : ''}<h3>${esc(str(pl.name))}</h3><p class="price">${esc(str(pl.price))}${period(pl) ? ` <span class="period">${esc(period(pl))}</span>` : ''}</p>${str(pl.description) ? `<p>${esc(str(pl.description))}</p>` : ''}${feats.length ? `<ul>${feats.map((f) => `<li>${esc(f)}</li>`).join('')}</ul>` : ''}${href ? `<a class="cta${pl.highlighted ? ' primary' : ''}" href="${href}" rel="noopener">${esc(str(pl.ctaLabel) || ui('install'))}</a>` : ''}</div>`;
819
+ });
820
+ const note = str(s.note);
821
+ return section(s, 'pricing', cards.length
822
+ ? `${heading(s, 'pricing')}\n<div class="grid plans">\n${cards.join('\n')}\n</div>${note ? `\n<p class="note">${esc(note)}</p>` : ''}`
823
+ : '');
824
+ }
825
+ case 'faq': {
826
+ const items = arr(s.items, LANDING_V2_LIMITS.faq).filter((x) => str(x?.question) && str(x?.answer));
827
+ for (const x of items)
828
+ faqs.push({ q: str(x.question), a: str(x.answer) });
829
+ const det = items.map((x) => `<details><summary>${esc(str(x.question))}</summary>\n${paragraphs(str(x.answer))}\n</details>`);
830
+ return section(s, 'faq', det.length ? `${heading(s, 'faq')}\n${det.join('\n')}` : '');
831
+ }
832
+ case 'testimonials': {
833
+ const items = arr(s.items, LANDING_V2_LIMITS.testimonials).filter((x) => str(x?.quote) && str(x?.author) && HTTPS_RE.test(str(x?.sourceUrl)));
834
+ const quotes = items.map((x) => {
835
+ let label = str(x.sourceLabel);
836
+ if (!label) {
837
+ try {
838
+ label = new URL(str(x.sourceUrl)).hostname;
839
+ }
840
+ catch {
841
+ label = ui('source');
842
+ }
843
+ }
844
+ return `<figure class="card quote"><blockquote>${paragraphs(str(x.quote))}</blockquote><figcaption><strong>${esc(str(x.author))}</strong>${str(x.role) ? `, ${esc(str(x.role))}` : ''} · <a href="${safeHref(str(x.sourceUrl))}" rel="nofollow noopener">${esc(label)}</a></figcaption></figure>`;
845
+ });
846
+ return section(s, 'testimonials', quotes.length
847
+ ? `${heading(s, 'testimonials')}\n<div class="grid">\n${quotes.join('\n')}\n</div>`
848
+ : '');
849
+ }
850
+ case 'store-badges': {
851
+ const wanted = Array.isArray(s.browsers) && s.browsers.length
852
+ ? s.browsers.filter((b) => STORE_ORDER.includes(b))
853
+ : stores;
854
+ const cta = badges(wanted);
855
+ return section(s, 'badges', cta ? `${heading(s, 'storeBadges')}\n<div class="ctas">\n${cta}\n</div>` : '');
856
+ }
857
+ case 'changelog': {
858
+ const entries = arr(s.entries, LANDING_V2_LIMITS.changelog).filter((e) => str(e?.version));
859
+ const items = entries.map((e) => {
860
+ const date = DATE_RE.test(str(e.date))
861
+ ? ` <time datetime="${esc(str(e.date))}">${esc(str(e.date))}</time>`
862
+ : '';
863
+ const notes = arr(e.notes, LANDING_V2_LIMITS.changelogNotes).map(str).filter(Boolean);
864
+ return `<li><h3>${esc(str(e.version))}${date}</h3>${notes.length ? `<ul>${notes.map((n) => `<li>${esc(n)}</li>`).join('')}</ul>` : ''}</li>`;
865
+ });
866
+ return section(s, 'changelog', items.length ? `${heading(s, 'changelog')}\n<ol class="releases">\n${items.join('\n')}\n</ol>` : '');
867
+ }
868
+ case 'links': {
869
+ const links = arr(s.items, LANDING_V2_LIMITS.links)
870
+ .filter((l) => str(l?.label) && HTTP_RE.test(str(l?.url)))
871
+ .map((l) => `<li><a href="${safeHref(str(l.url))}" rel="noopener">${esc(str(l.label))}</a></li>`);
872
+ if (s.includeProjectLinks !== false)
873
+ links.push(...projectLinks(form, ui).map((a) => `<li>${a}</li>`));
874
+ return section(s, 'links', links.length ? `${heading(s, 'links')}\n<ul class="link-list">\n${links.join('\n')}\n</ul>` : '');
875
+ }
876
+ }
877
+ return '';
878
+ });
879
+ const languageNav = locales.length > 1
880
+ ? `<nav class="langs" aria-label="${esc(ui('languages'))}">${locales
881
+ .map((l) => {
882
+ const href = homeBase
883
+ ? esc(landingLocaleUrl(homeBase, l, def))
884
+ : l === def
885
+ ? './'
886
+ : `./${landingLocaleSlug(l)}/`;
887
+ return l === locale
888
+ ? `<span lang="${esc(l)}" aria-current="true">${esc(l)}</span>`
889
+ : `<a href="${href}" hreflang="${esc(l)}" lang="${esc(l)}">${esc(l)}</a>`;
890
+ })
891
+ .join(' ')}</nav>`
892
+ : '';
893
+ // ── head ──
894
+ const seo = form.seo ?? {};
895
+ const titleText = str(seo.title) || `${rawName}${tagline ? ` — ${tagline}` : ''}`;
896
+ const metaDescription = str(seo.description) || description || tagline;
897
+ const ogImage = HTTPS_RE.test(str(seo.ogImageUrl)) ? str(seo.ogImageUrl) : (shotUrls[0] ?? '');
898
+ const twitter = TWITTER_RE.test(str(seo.twitterSite)) ? `@${str(seo.twitterSite).replace(/^@/, '')}` : '';
899
+ const alternates = homeBase && locales.length > 1
900
+ ? [
901
+ ...locales.map((l) => `<link rel="alternate" hreflang="${esc(l)}" href="${esc(landingLocaleUrl(homeBase, l, def))}" />`),
902
+ `<link rel="alternate" hreflang="x-default" href="${esc(homeBase)}" />`,
903
+ ]
904
+ : [];
905
+ const app = generateJsonLd(jsonLdFromLanding({
906
+ extensionName: rawName,
907
+ tagline,
908
+ description: metaDescription,
909
+ storeUrls: { chrome: form.chromeUrl, firefox: form.firefoxUrl, edge: form.edgeUrl },
910
+ features: featureTitles.length
911
+ ? featureTitles
912
+ : arr(form.features, 24).filter((f) => str(f?.title)),
913
+ privacyPolicyUrl: str(form.privacyPolicyUrl),
914
+ homepageUrl: pageUrl || undefined,
915
+ screenshots: shotUrls,
916
+ }));
917
+ if (str(seo.authorName))
918
+ app.author = { '@type': 'Organization', name: str(seo.authorName) };
919
+ app.inLanguage = locale;
920
+ const ld = [app];
921
+ if (faqs.length) {
922
+ ld.push({
923
+ '@context': 'https://schema.org',
924
+ '@type': 'FAQPage',
925
+ inLanguage: locale,
926
+ mainEntity: faqs.map((f) => ({
927
+ '@type': 'Question',
928
+ name: f.q,
929
+ acceptedAnswer: { '@type': 'Answer', text: f.a },
930
+ })),
931
+ });
932
+ }
933
+ const ldTags = ld.map((o) => `<script type="application/ld+json">${JSON.stringify(o).replace(/</g, '\\u003c')}</script>`);
934
+ const head = [
935
+ `<title>${esc(titleText)}</title>`,
936
+ metaDescription ? `<meta name="description" content="${esc(metaDescription)}" />` : '',
937
+ seo.noindex === true ? '<meta name="robots" content="noindex" />' : '',
938
+ `<meta name="color-scheme" content="${mode === 'auto' ? 'light dark' : mode}" />`,
939
+ pageUrl ? `<link rel="canonical" href="${esc(pageUrl)}" />` : '',
940
+ ...alternates,
941
+ '<meta property="og:type" content="website" />',
942
+ `<meta property="og:title" content="${esc(titleText)}" />`,
943
+ metaDescription ? `<meta property="og:description" content="${esc(metaDescription)}" />` : '',
944
+ pageUrl ? `<meta property="og:url" content="${esc(pageUrl)}" />` : '',
945
+ `<meta property="og:locale" content="${esc(locale.replace('-', '_'))}" />`,
946
+ ogImage ? `<meta property="og:image" content="${esc(ogImage)}" />` : '',
947
+ `<meta name="twitter:card" content="${ogImage ? 'summary_large_image' : 'summary'}" />`,
948
+ twitter ? `<meta name="twitter:site" content="${esc(twitter)}" />` : '',
949
+ ...ldTags,
950
+ ]
951
+ .filter(Boolean)
952
+ .join('\n');
953
+ const vars = (p) => `--accent: ${p.accent}; --accent-text: ${p.accentText}; --bg: ${p.background}; --surface: ${p.surface}; --fg: ${p.text}; --muted: ${p.muted}; --border: ${p.border};`;
954
+ const palette = mode === 'auto'
955
+ ? `:root { ${vars(resolvePalette(form, 'light'))} }\n\t@media (prefers-color-scheme: dark) { :root { ${vars(resolvePalette(form, 'dark'))} } }`
956
+ : `:root { ${vars(resolvePalette(form, mode))} }`;
957
+ const footer = projectLinks(form, ui);
958
+ const bodyFont = font(t.bodyFont);
959
+ const headingFont = t.headingFont ? font(t.headingFont) : bodyFont;
960
+ return `<!doctype html>
961
+ <html lang="${esc(locale)}">
962
+ <head>
963
+ <meta charset="utf-8" />
964
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
965
+ ${head}
966
+ <style>
967
+ ${palette}
968
+ :root { --radius: ${radius}; --width: ${width}; --font-body: ${bodyFont}; --font-heading: ${headingFont}; }
969
+ * { box-sizing: border-box; margin: 0; padding: 0; }
970
+ body { font-family: var(--font-body); background: var(--bg); color: var(--fg); line-height: 1.6; }
971
+ h1, h2, h3 { font-family: var(--font-heading); line-height: 1.2; }
972
+ a { color: var(--accent); }
973
+ .wrap { max-width: var(--width); margin: 0 auto; padding: 0 24px; }
974
+ .langs { padding: 16px 0 0; text-align: right; font-size: 0.85rem; color: var(--muted); }
975
+ .langs a, .langs span { margin-left: 10px; }
976
+ .hero { padding: 88px 0 56px; text-align: center; }
977
+ .logo { width: 88px; height: 88px; margin: 0 auto 28px; border-radius: calc(var(--radius) * 1.8); display: flex; align-items: center; justify-content: center; background: var(--surface); border: 1px solid var(--border); overflow: hidden; }
978
+ .logo img { width: 64px; height: 64px; object-fit: contain; }
979
+ .logo.monogram { font-size: 2.4rem; font-weight: 700; color: var(--accent); }
980
+ h1 { font-size: 2.6rem; letter-spacing: -0.02em; }
981
+ .tagline { margin-top: 14px; font-size: 1.25rem; color: var(--muted); }
982
+ .ctas { margin-top: 36px; display: flex; gap: 14px; justify-content: center; flex-wrap: wrap; }
983
+ .cta { display: inline-flex; flex-direction: column; align-items: center; gap: 2px; padding: 12px 26px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--surface); color: var(--fg); text-decoration: none; font-weight: 600; }
984
+ .cta.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
985
+ .cta-sub { font-size: 0.72rem; font-weight: 400; opacity: 0.75; }
986
+ section { padding: 40px 0; }
987
+ section h2 { font-size: 1.6rem; margin-bottom: 18px; text-align: center; }
988
+ .intro { max-width: 640px; margin: -6px auto 22px; text-align: center; color: var(--muted); }
989
+ .about p { max-width: 680px; margin: 0 auto 14px; color: var(--muted); }
990
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 18px; }
991
+ .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 22px; }
992
+ .card h3 { font-size: 1.02rem; margin-bottom: 8px; }
993
+ .card p, .card li { font-size: 0.92rem; color: var(--muted); }
994
+ .shot-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 18px; }
995
+ .shot img { display: block; width: 100%; height: auto; border-radius: var(--radius); border: 1px solid var(--border); }
996
+ .shot figcaption { margin-top: 8px; font-size: 0.85rem; color: var(--muted); text-align: center; }
997
+ .step-list { list-style: none; counter-reset: step; display: grid; gap: 14px; max-width: 680px; margin: 0 auto; }
998
+ .step-list li { counter-increment: step; position: relative; padding: 18px 18px 18px 64px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); }
999
+ .step-list li::before { content: counter(step); position: absolute; left: 18px; top: 18px; width: 30px; height: 30px; border-radius: 50%; background: var(--accent); color: var(--accent-text); font-weight: 700; display: flex; align-items: center; justify-content: center; }
1000
+ .step-list p { color: var(--muted); font-size: 0.92rem; }
1001
+ .plan { display: flex; flex-direction: column; gap: 10px; }
1002
+ .plan.highlighted { border-color: var(--accent); }
1003
+ .plan .badge { font-size: 0.75rem; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.04em; }
1004
+ .plan .price { font-size: 1.6rem; font-weight: 700; color: var(--fg); }
1005
+ .plan .period { font-size: 0.9rem; font-weight: 400; color: var(--muted); }
1006
+ .plan ul { padding-left: 18px; }
1007
+ .plan .cta { margin-top: auto; }
1008
+ .note { margin-top: 16px; text-align: center; font-size: 0.85rem; color: var(--muted); }
1009
+ .faq details { max-width: 720px; margin: 0 auto 10px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 18px; }
1010
+ .faq summary { cursor: pointer; font-weight: 600; }
1011
+ .faq details p { margin-top: 10px; color: var(--muted); }
1012
+ .quote blockquote p { font-size: 1rem; color: var(--fg); }
1013
+ .quote figcaption { margin-top: 12px; font-size: 0.85rem; color: var(--muted); }
1014
+ .releases { list-style: none; max-width: 720px; margin: 0 auto; }
1015
+ .releases > li { padding: 14px 0; border-bottom: 1px solid var(--border); }
1016
+ .releases time { font-size: 0.85rem; font-weight: 400; color: var(--muted); margin-left: 8px; }
1017
+ .releases ul { padding-left: 18px; color: var(--muted); }
1018
+ .link-list { list-style: none; display: flex; flex-wrap: wrap; gap: 10px 22px; justify-content: center; }
1019
+ footer { border-top: 1px solid var(--border); margin-top: 24px; padding: 28px 0 48px; text-align: center; font-size: 0.85rem; color: var(--muted); }
1020
+ footer a { color: var(--muted); margin: 0 10px; }
1021
+ </style>
1022
+ </head>
1023
+ <body>
1024
+ <div class="wrap">
1025
+ ${[languageNav, ...blocks].filter(Boolean).join('\n')}
1026
+ <footer>
1027
+ ${footer.join('\n')}
1028
+ <div>© ${name}</div>
1029
+ </footer>
1030
+ </div>
1031
+ </body>
1032
+ </html>
1033
+ `;
1034
+ }
1035
+ function projectLinks(form, ui) {
1036
+ const links = [
1037
+ [str(form.privacyPolicyUrl) ? safeHref(str(form.privacyPolicyUrl)) : '', ui('privacy')],
1038
+ [str(form.supportUrl) ? safeSupportHref(str(form.supportUrl)) : '', ui('support')],
1039
+ [str(form.termsUrl) ? safeHref(str(form.termsUrl)) : '', ui('terms')],
1040
+ [str(form.uninstallUrl) ? safeHref(str(form.uninstallUrl)) : '', ui('feedback')],
1041
+ ];
1042
+ // One page registered for two roles (support = the feedback form) is listed once.
1043
+ const seen = new Set();
1044
+ const out = [];
1045
+ for (const [href, label] of links) {
1046
+ if (!href || (href !== '#' && seen.has(href)))
1047
+ continue;
1048
+ seen.add(href);
1049
+ out.push(`<a href="${href}">${esc(label)}</a>`);
1050
+ }
1051
+ return out;
1052
+ }
1053
+ /** Every locale's page: `[{ locale, path, html }]`, default locale first (path ''). */
1054
+ export function renderLandingLocales(form) {
1055
+ const def = landingDefaultLocale(form);
1056
+ return landingLocales(form).map((locale) => ({
1057
+ locale,
1058
+ slug: locale === def ? '' : landingLocaleSlug(locale),
1059
+ html: renderLandingForm(form, { locale }),
1060
+ }));
1061
+ }
1062
+ /** Format a Pay offer amount for a pricing card ("$4.99", "4,99 €"). */
1063
+ export function formatLandingPrice(amountMinor, currency, locale) {
1064
+ try {
1065
+ return new Intl.NumberFormat(locale, { style: 'currency', currency: currency.toUpperCase() }).format(amountMinor / 100);
1066
+ }
1067
+ catch {
1068
+ return `${(amountMinor / 100).toFixed(2)} ${currency.toUpperCase()}`;
1069
+ }
1070
+ }
1071
+ //# sourceMappingURL=landing-sections.js.map