fimo 2.6.0 → 2.7.0-experimental.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 (40) hide show
  1. package/assets/skills/fimo-cli/SKILL.md +1 -1
  2. package/assets/skills/fimo-cli/references/analytics.md +29 -1
  3. package/assets/skills/fimo-cli/references/content.md +2 -2
  4. package/assets/skills/fimo-cli/references/hosting.md +11 -0
  5. package/assets/skills/fimo-cli/references/locales.md +2 -2
  6. package/assets/skills/fimo-cli/references/pages.md +14 -2
  7. package/assets/skills/fimo-code/SKILL.md +8 -0
  8. package/assets/skills/fimo-code/references/analytics.md +104 -0
  9. package/assets/skills/fimo-code/references/frameworks/astro.md +2 -0
  10. package/assets/skills/fimo-code/references/frameworks/next.md +12 -9
  11. package/assets/skills/fimo-code/references/ui.md +6 -1
  12. package/assets/skills/fimo-migration/SKILL.md +9 -6
  13. package/assets/skills/fimo-migration/references/frameworks/nextjs.md +24 -27
  14. package/assets/skills/fimo-migration/references/media.md +98 -62
  15. package/assets/skills/fimo-migration/references/qa.md +111 -11
  16. package/assets/skills/fimo-migration/references/workers/content-execution.md +2 -1
  17. package/assets/skills/fimo-migration/references/workers/validator.md +22 -6
  18. package/assets/skills/fimo-migration/references/workers.md +5 -3
  19. package/assets/skills/fimo-migration/scripts/check-rendered-guardrails.mjs +500 -0
  20. package/assets/skills/fimo-studio/SKILL.md +1 -0
  21. package/assets/skills/fimo-studio/references/analytics.md +28 -0
  22. package/assets/skills/fimo-studio/references/publish-and-deploy.md +1 -0
  23. package/dist/cli/bundle.json +1 -1
  24. package/dist/cli/index.js +27 -27
  25. package/dist/runtime/paths/get-fimo-paths.d.ts +3 -1
  26. package/dist/runtime/paths/get-fimo-paths.js +59 -5
  27. package/dist/runtime/primitives/lib/primitives.d.ts +20 -1
  28. package/dist/runtime/primitives/lib/primitives.js +24 -1
  29. package/dist/runtime/routes/index.d.ts +19 -3
  30. package/dist/runtime/routes/index.js +27 -2
  31. package/dist/runtime/templates.d.ts +1 -1
  32. package/dist/runtime/templates.js +1 -0
  33. package/dist/scripts/validate-page-registry.js +14 -3
  34. package/package.json +10 -9
  35. package/release.json +2 -2
  36. package/templates/astro/package.json +2 -2
  37. package/templates/nextjs/package.json +3 -3
  38. package/templates/react-router/package.json +4 -4
  39. package/templates/sveltekit/package.json +2 -2
  40. package/templates/vite/package.json +4 -4
@@ -0,0 +1,500 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ // Evaluates the migration guardrail inventory against the final rendered
4
+ // application. Inputs are rendered HTML observations (browser/HTTP captures or
5
+ // a running local server), never the target's source files: once copy moves
6
+ // into Fimo, a source grep proves nothing about what visitors see.
7
+ //
8
+ // The check fails closed. An empty inventory, a route with no rendered text,
9
+ // or a rule that ends up evaluated against zero observations is a failure, not
10
+ // a pass.
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import process from 'node:process';
14
+
15
+ /**
16
+ * Shipped as plain JavaScript so a migration target needs only Node.
17
+ * Checked with its subprocess tests by tsconfig.guardrails.json.
18
+ * @typedef {{projectRoot?: string, inventory?: string, captures?: string, baseUrl?: string, output?: string}} Args
19
+ * @typedef {{id: string, kind: string, source?: string, text?: string | string[], href?: string, routes?: string[], allow?: string[]}} Rule
20
+ * @typedef {{routes: string[], rules: Rule[]}} Inventory
21
+ * @typedef {{route: string, source: string, url: string, baseUrl: string, html: string | null, text: string, links: string[], passed: boolean, reason: string}} Observation
22
+ * @typedef {{id: string, kind: string, passed: boolean, observed: number, failures: string[], extra: string | null}} Result
23
+ */
24
+
25
+ const USAGE =
26
+ 'Usage: node check-rendered-guardrails.mjs [project-root] [--inventory .fimo/migration/guardrails.json] ' +
27
+ '(--captures <dir> | --base-url <http://localhost:port>) [--output .fimo/migration/guardrails-report.md]';
28
+
29
+ const RULE_KINDS = ['required-text', 'forbidden-text', 'required-link', 'links-resolve'];
30
+
31
+ async function main() {
32
+ const args = parseArgs(process.argv.slice(2));
33
+ const projectRoot = path.resolve(args.projectRoot ?? process.cwd());
34
+ const inventoryPath = path.resolve(projectRoot, args.inventory ?? '.fimo/migration/guardrails.json');
35
+ const inventory = loadInventory(inventoryPath);
36
+
37
+ if (!args.captures && !args.baseUrl) fail(`Provide --captures <dir> or --base-url <url>.\n${USAGE}`);
38
+ if (args.captures && args.baseUrl) fail('Provide either --captures or --base-url, not both.');
39
+
40
+ const observations = args.baseUrl
41
+ ? await fetchObservations(inventory.routes, args.baseUrl)
42
+ : readCaptures(inventory.routes, path.resolve(projectRoot, args.captures ?? ''));
43
+ const results = evaluateRules(inventory, observations);
44
+ const passed = observations.every((item) => item.passed) && results.every((item) => item.passed);
45
+
46
+ const report = buildReport({
47
+ inventoryPath: toPosix(path.relative(projectRoot, inventoryPath)),
48
+ inventory,
49
+ source: args.baseUrl
50
+ ? `fetched from ${args.baseUrl}`
51
+ : `captures in ${toPosix(path.relative(projectRoot, path.resolve(projectRoot, args.captures ?? '')))}`,
52
+ observations,
53
+ results,
54
+ passed,
55
+ });
56
+
57
+ if (args.output) {
58
+ const outputPath = path.resolve(projectRoot, args.output);
59
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
60
+ fs.writeFileSync(outputPath, report, 'utf8');
61
+ }
62
+ console.log(report);
63
+ if (!passed) process.exitCode = 1;
64
+ }
65
+
66
+ /** @param {string[]} argv */
67
+ function parseArgs(argv) {
68
+ /** @type {Args} */
69
+ const args = {};
70
+ /** @type {Record<string, keyof Args>} */
71
+ const valueFlags = {
72
+ '--inventory': 'inventory',
73
+ '--captures': 'captures',
74
+ '--base-url': 'baseUrl',
75
+ '--output': 'output',
76
+ };
77
+ for (let i = 0; i < argv.length; i += 1) {
78
+ const arg = argv[i];
79
+ if (arg === '--help' || arg === '-h') {
80
+ console.log(USAGE);
81
+ process.exit(0);
82
+ }
83
+ const [flag, inlineValue] = arg.includes('=')
84
+ ? [arg.slice(0, arg.indexOf('=')), arg.slice(arg.indexOf('=') + 1)]
85
+ : [arg, undefined];
86
+ if (valueFlags[flag]) {
87
+ const value = inlineValue ?? argv[++i];
88
+ if (!value || value.startsWith('--')) fail(`Missing value for ${flag}.\n${USAGE}`);
89
+ args[valueFlags[flag]] = value;
90
+ continue;
91
+ }
92
+ if (!arg.startsWith('--') && !args.projectRoot) {
93
+ args.projectRoot = arg;
94
+ continue;
95
+ }
96
+ fail(`Unknown argument: ${arg}\n${USAGE}`);
97
+ }
98
+ return args;
99
+ }
100
+
101
+ // The inventory is the explicit acceptance contract. It must name the route
102
+ // surface and at least one rule; the script never infers either from the
103
+ // target, so a migration cannot pass by declaring nothing.
104
+ /** @param {string} inventoryPath @returns {Inventory} */
105
+ function loadInventory(inventoryPath) {
106
+ if (!fs.existsSync(inventoryPath)) fail(`Guardrail inventory not found: ${inventoryPath}`);
107
+ /** @type {unknown} */
108
+ let raw;
109
+ try {
110
+ raw = JSON.parse(fs.readFileSync(inventoryPath, 'utf8'));
111
+ } catch (error) {
112
+ fail(`Could not parse ${inventoryPath}: ${error instanceof Error ? error.message : String(error)}`);
113
+ }
114
+ if (!isRecord(raw) || !Array.isArray(raw.routes) || !Array.isArray(raw.rules)) {
115
+ fail('Invalid guardrail inventory: expected an object with routes and rules arrays.');
116
+ }
117
+ if (!raw.routes.every(isNonEmptyString)) fail('Invalid guardrail inventory: routes must be non-empty strings.');
118
+ // Keep trailing slashes for document-relative URL resolution during fetches.
119
+ const routes = uniqueArray(
120
+ raw.routes.map(
121
+ (route) => normalizePath(route) + (route.trim().endsWith('/') && normalizePath(route) !== '/' ? '/' : '')
122
+ )
123
+ );
124
+ const rules = raw.rules;
125
+ if (routes.length === 0)
126
+ fail(
127
+ `Empty guardrail inventory: ${inventoryPath} lists no routes. Inventory every rendered route the migration must prove.`
128
+ );
129
+ if (rules.length === 0)
130
+ fail(
131
+ `Empty guardrail inventory: ${inventoryPath} lists no rules. Add the brand, legal/compliance, and navigation rules the source imposes.`
132
+ );
133
+ rules.forEach((rule, index) => {
134
+ if (!isRecord(rule)) fail(`Rule #${index + 1} must be an object.`);
135
+ if (!isNonEmptyString(rule.id)) fail(`Rule #${index + 1} has no id.`);
136
+ if (typeof rule.kind !== 'string' || !RULE_KINDS.includes(rule.kind))
137
+ fail(`Rule ${rule.id} has unknown kind "${rule.kind}". Use one of: ${RULE_KINDS.join(', ')}.`);
138
+ if (
139
+ ['required-text', 'forbidden-text'].includes(String(rule.kind)) &&
140
+ (asArray(rule.text).length === 0 || !asArray(rule.text).every(isNonEmptyString))
141
+ ) {
142
+ fail(`Rule ${rule.id} (${rule.kind}) needs a non-empty "text".`);
143
+ }
144
+ if (rule.kind === 'required-link' && !isNonEmptyString(rule.href))
145
+ fail(`Rule ${rule.id} (required-link) needs an "href".`);
146
+ if (rule.kind === 'required-link' && /^(?!https?:)[a-z][a-z0-9+.-]*:/i.test(String(rule.href))) {
147
+ fail(`Rule ${rule.id} (required-link) supports paths and http(s) URLs; prove ${rule.href} in the browser pass.`);
148
+ }
149
+ for (const field of ['routes', 'allow']) {
150
+ if (rule[field] !== undefined && (!Array.isArray(rule[field]) || !rule[field].every(isNonEmptyString))) {
151
+ fail(`Rule ${rule.id} (${field}) needs an array of non-empty strings.`);
152
+ }
153
+ }
154
+ if (!isNonEmptyString(rule.source))
155
+ fail(`Rule ${rule.id} needs "source": cite the original page, file, or owner decision that imposes it.`);
156
+ const unknownScope = asArray(rule.routes)
157
+ .filter(isNonEmptyString)
158
+ .map(normalizePath)
159
+ .filter((route) => !routes.some((item) => normalizePath(item) === route));
160
+ if (unknownScope.length > 0) {
161
+ fail(
162
+ `Rule ${rule.id} is scoped to routes missing from the inventory: ${unknownScope.join(', ')}. A rule cannot be proven on a route that is never rendered.`
163
+ );
164
+ }
165
+ });
166
+ return { routes, rules: /** @type {Rule[]} */ (rules) };
167
+ }
168
+
169
+ /** @param {string[]} routes @param {string} capturesDir */
170
+ function readCaptures(routes, capturesDir) {
171
+ return routes.map((route) => {
172
+ const name = captureFileName(route);
173
+ const file = path.join(capturesDir, name);
174
+ if (!fs.existsSync(file)) return observation(route, `capture missing: ${name}`, null);
175
+ // Status must come from the navigation response, never from HTML content.
176
+ try {
177
+ /** @type {unknown} */
178
+ const metadata = JSON.parse(fs.readFileSync(`${file}.json`, 'utf8'));
179
+ if (!isRecord(metadata) || metadata.status !== 200 || metadata.error != null) {
180
+ return observation(route, `capture rejected: ${name}.json must record status 200 and no error`, null);
181
+ }
182
+ if (!isNonEmptyString(metadata.url) || !/^https?:\/\//i.test(metadata.url)) {
183
+ return observation(route, `capture rejected: ${name}.json needs the final HTTP(S) document URL`, null);
184
+ }
185
+ const finalUrl = new URL(metadata.url).href;
186
+ const finalPath = normalizePath(new URL(finalUrl).pathname);
187
+ if (finalPath !== normalizePath(route)) {
188
+ return observation(
189
+ route,
190
+ `capture rejected: ${name}.json records ${finalPath}, not ${normalizePath(route)}`,
191
+ null
192
+ );
193
+ }
194
+ return observation(route, `capture ${name}`, fs.readFileSync(file, 'utf8'), finalUrl);
195
+ } catch (error) {
196
+ return observation(
197
+ route,
198
+ `capture evidence unreadable: ${name}.json: ${error instanceof Error ? error.message : String(error)}`,
199
+ null
200
+ );
201
+ }
202
+ });
203
+ }
204
+
205
+ // `/` -> index.html, `/legal/privacy` -> legal/privacy.html. The validator saves
206
+ // the hydrated document under these names when it captures with a browser.
207
+ /** @param {string} route */
208
+ function captureFileName(route) {
209
+ route = normalizePath(route);
210
+ return route === '/' ? 'index.html' : `${route.replace(/^\//, '')}.html`;
211
+ }
212
+
213
+ /** @param {string[]} routes @param {string} baseUrl */
214
+ async function fetchObservations(routes, baseUrl) {
215
+ const base = baseUrl.replace(/\/$/, '');
216
+ return Promise.all(
217
+ routes.map(async (route) => {
218
+ try {
219
+ const response = await fetch(`${base}${route}`, { redirect: 'follow' });
220
+ const body = await response.text();
221
+ if (response.status !== 200) {
222
+ return observation(route, `HTTP ${response.status} from ${base}${route}`, null);
223
+ }
224
+ const finalPath = normalizePath(new URL(response.url).pathname);
225
+ if (finalPath !== normalizePath(route)) {
226
+ return observation(
227
+ route,
228
+ `redirected to ${finalPath}: the route surface is not preserved`,
229
+ null,
230
+ response.url
231
+ );
232
+ }
233
+ return observation(route, `HTTP 200 from ${base}${route}`, body, response.url);
234
+ } catch (error) {
235
+ return observation(route, `request failed: ${error instanceof Error ? error.message : String(error)}`, null);
236
+ }
237
+ })
238
+ );
239
+ }
240
+
241
+ /** @param {string} route @param {string} source @param {string | null} html @param {string} [url] @returns {Observation} */
242
+ function observation(route, source, html, url = '') {
243
+ const context = { route, source, url, baseUrl: url };
244
+ if (html === null) {
245
+ return { ...context, html: null, text: '', links: [], passed: false, reason: source };
246
+ }
247
+ const text = visibleText(html);
248
+ const links = extractLinks(html);
249
+ const baseHref = html
250
+ .replace(/<!--[^]*?-->/g, '')
251
+ .replace(/<(script|style|template)\b[^>]*>[^]*?<\/\1\s*>/gi, ' ')
252
+ .match(/<base\b[^>]*\s+href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/i);
253
+ if (baseHref) context.baseUrl = new URL(decodeEntities(baseHref[1] ?? baseHref[2] ?? baseHref[3]), url).href;
254
+ if (text.length === 0) {
255
+ return {
256
+ ...context,
257
+ html,
258
+ text,
259
+ links,
260
+ passed: false,
261
+ reason:
262
+ 'rendered HTML has no visible text (an empty client-rendered root is not a page; capture after hydration)',
263
+ };
264
+ }
265
+ return {
266
+ ...context,
267
+ html,
268
+ text,
269
+ links,
270
+ passed: true,
271
+ reason: `${text.length} visible characters, ${links.length} links`,
272
+ };
273
+ }
274
+
275
+ // Visible text is body text plus the attributes a visitor or crawler reads:
276
+ // alt, title, aria-label, placeholder, and meta content. Scripts, styles, and
277
+ // comments are excluded on purpose: a brand string inside a bundle or JSON
278
+ // payload is source presence, not a rendered observation.
279
+ /** @param {string} html */
280
+ function visibleText(html) {
281
+ const stripped = html
282
+ .replace(/<(script|style|noscript|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, ' ')
283
+ .replace(/<!--[\s\S]*?-->/g, ' ');
284
+ const attributeText = [
285
+ ...stripped.matchAll(/\b(?:alt|title|aria-label|placeholder|content)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi),
286
+ ].map((match) => match[1] ?? match[2] ?? '');
287
+ const bodyText = stripped.replace(/<[^>]+>/g, ' ');
288
+ return decodeEntities([bodyText, ...attributeText].join(' '))
289
+ .replace(/\s+/g, ' ')
290
+ .trim();
291
+ }
292
+
293
+ /** @param {string} html */
294
+ function extractLinks(html) {
295
+ const withoutHidden = html
296
+ .replace(/<(script|style|noscript|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, ' ')
297
+ .replace(/<!--[\s\S]*?-->/g, ' ');
298
+ return [...withoutHidden.matchAll(/<a\b[^>]*\s+href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/gi)].map((match) =>
299
+ decodeEntities(match[1] ?? match[2] ?? match[3] ?? '').trim()
300
+ );
301
+ }
302
+
303
+ /** @param {Inventory} inventory @param {Observation[]} observations */
304
+ function evaluateRules(inventory, observations) {
305
+ const rendered = observations.filter((item) => item.passed);
306
+ return inventory.rules.map((rule) => {
307
+ const scope = rule.routes?.length ? rule.routes.map(normalizePath) : inventory.routes.map(normalizePath);
308
+ const observed = rendered.filter((item) => scope.includes(normalizePath(item.route)));
309
+ if (observed.length === 0) {
310
+ return result(rule, false, 0, [`vacuous: evaluated against 0 rendered routes (scope: ${scope.join(', ')})`]);
311
+ }
312
+ switch (rule.kind) {
313
+ case 'required-text':
314
+ return textRule(rule, observed, (haystack, phrase) => !haystack.includes(phrase), 'missing');
315
+ case 'forbidden-text':
316
+ return textRule(rule, observed, (haystack, phrase) => haystack.includes(phrase), 'found');
317
+ case 'required-link':
318
+ return linkRule(rule, observed);
319
+ case 'links-resolve':
320
+ return linksResolveRule(rule, inventory, observed);
321
+ default:
322
+ return result(rule, false, observed.length, [`unknown rule kind ${rule.kind}`]);
323
+ }
324
+ });
325
+ }
326
+
327
+ /** @param {Rule} rule @param {Observation[]} observed @param {(haystack: string, phrase: string) => boolean} isFailure @param {string} verb */
328
+ function textRule(rule, observed, isFailure, verb) {
329
+ const failures = [];
330
+ for (const item of observed) {
331
+ const haystack = item.text.toLowerCase();
332
+ for (const phrase of phrases(rule)) {
333
+ const needle = phrase.toLowerCase();
334
+ if (isFailure(haystack, needle)) {
335
+ failures.push(
336
+ `"${phrase}" ${verb} on ${item.route}${verb === 'found' ? `: ${snippet(item.text, phrase)}` : ''}`
337
+ );
338
+ }
339
+ }
340
+ }
341
+ return result(rule, failures.length === 0, observed.length, failures);
342
+ }
343
+
344
+ /** @param {Rule} rule @param {Observation[]} observed */
345
+ function linkRule(rule, observed) {
346
+ const failures = observed
347
+ .filter((item) => {
348
+ const target = linkTarget(rule.href ?? '', item, false);
349
+ return !target || !item.links.some((href) => linkTarget(href, item, false) === target);
350
+ })
351
+ .map((item) => `no <a href="${rule.href}"> rendered on ${item.route} (${item.links.length} links collected)`);
352
+ return result(rule, failures.length === 0, observed.length, failures);
353
+ }
354
+
355
+ // Every internal link rendered on the observed routes must point at a route
356
+ // the inventory proves, or at an explicitly allowed path prefix. Collecting
357
+ // zero links is a failure: a navigation check over nothing checks nothing.
358
+ /** @param {Rule} rule @param {Inventory} inventory @param {Observation[]} observed */
359
+ function linksResolveRule(rule, inventory, observed) {
360
+ const allow = asArray(rule.allow).filter(isNonEmptyString).map(normalizePath);
361
+ const internal = [];
362
+ for (const item of observed) {
363
+ for (const href of item.links) {
364
+ const target = linkTarget(href, item);
365
+ if (target !== null) internal.push({ route: item.route, href, target });
366
+ }
367
+ }
368
+ if (internal.length === 0) {
369
+ return result(rule, false, observed.length, [
370
+ `vacuous: 0 internal links collected across ${observed.length} rendered routes`,
371
+ ]);
372
+ }
373
+ const failures = uniqueArray(
374
+ internal
375
+ .filter(
376
+ ({ target }) =>
377
+ !inventory.routes.some((route) => normalizePath(route) === target) &&
378
+ !allow.some((prefix) => target === prefix || target.startsWith(`${prefix}/`))
379
+ )
380
+ .map(({ route, href }) => `${href} on ${route} resolves to no inventoried route`)
381
+ );
382
+ return result(rule, failures.length === 0, observed.length, failures, `${internal.length} internal links`);
383
+ }
384
+
385
+ /** @param {Rule} rule @param {boolean} passed @param {number} observed @param {string[]} failures @param {string} [extra] @returns {Result} */
386
+ function result(rule, passed, observed, failures, extra) {
387
+ return { id: rule.id, kind: rule.kind, passed, observed, failures, extra: extra ?? null };
388
+ }
389
+
390
+ /** @param {{inventoryPath: string, inventory: Inventory, source: string, observations: Observation[], results: Result[], passed: boolean}} report */
391
+ function buildReport({ inventoryPath, inventory, source, observations, results, passed }) {
392
+ const lines = [
393
+ '## Guardrails',
394
+ '',
395
+ `- Inventory: ${inventoryPath} (${inventory.routes.length} routes, ${inventory.rules.length} rules)`,
396
+ `- Observations: ${source}`,
397
+ '',
398
+ '### Rendered routes',
399
+ '',
400
+ ...observations.map((item) => `- ${box(item.passed)} ${item.route}: ${item.reason}`),
401
+ '',
402
+ '### Rules',
403
+ '',
404
+ ...results.flatMap((item) => [
405
+ `- ${box(item.passed)} ${item.id} (${item.kind}) observed on ${item.observed} routes${item.extra ? `, ${item.extra}` : ''}`,
406
+ ...item.failures.map((failure) => ` - ${failure}`),
407
+ ]),
408
+ '',
409
+ `Result: ${passed ? 'passed' : 'failed'}`,
410
+ '',
411
+ ];
412
+ return lines.join('\n');
413
+ }
414
+
415
+ /** @param {Rule} rule */
416
+ function phrases(rule) {
417
+ return asArray(rule.text ?? []).filter(isNonEmptyString);
418
+ }
419
+
420
+ /** @param {string} text @param {string} phrase */
421
+ function snippet(text, phrase) {
422
+ const index = text.toLowerCase().indexOf(phrase.toLowerCase());
423
+ const start = Math.max(0, index - 40);
424
+ const end = Math.min(text.length, index + phrase.length + 40);
425
+ return `"${start > 0 ? '…' : ''}${text.slice(start, end)}${end < text.length ? '…' : ''}"`;
426
+ }
427
+
428
+ // Queries and fragments address the same route. Absolute and protocol-relative
429
+ // URLs are internal only when their origin matches the observed document.
430
+ /** @param {string} href @param {Observation} item @param {boolean} [internalOnly] @returns {string | null} */
431
+ function linkTarget(href, item, internalOnly = true) {
432
+ try {
433
+ const url = new URL(href, item.baseUrl);
434
+ if (!['http:', 'https:'].includes(url.protocol)) return null;
435
+ if (url.origin !== new URL(item.url).origin)
436
+ return internalOnly ? null : `${url.origin}${normalizePath(url.pathname)}`;
437
+ return normalizePath(url.pathname);
438
+ } catch {
439
+ return ''; // Invalid URLs cannot match an inventoried route.
440
+ }
441
+ }
442
+
443
+ /** @param {string} value */
444
+ function normalizePath(value) {
445
+ const trimmed = value.trim();
446
+ if (trimmed === '' || trimmed === '/') return '/';
447
+ return `/${trimmed.replace(/^\/+/, '').replace(/\/+$/, '')}`;
448
+ }
449
+
450
+ /** @param {string} value */
451
+ function decodeEntities(value) {
452
+ return value
453
+ .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
454
+ .replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(Number.parseInt(code, 16)))
455
+ .replace(
456
+ /&(amp|lt|gt|quot|apos|nbsp);/g,
457
+ (_, name) =>
458
+ /** @type {Record<string, string>} */ ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' })[name]
459
+ );
460
+ }
461
+
462
+ /** @param {boolean} passed */
463
+ function box(passed) {
464
+ return passed ? '[x]' : '[ ]';
465
+ }
466
+
467
+ /** @template T @param {T | T[]} value @returns {T[]} */
468
+ function asArray(value) {
469
+ if (Array.isArray(value)) return value;
470
+ if (value === undefined || value === null) return [];
471
+ return [value];
472
+ }
473
+
474
+ /** @template T @param {T[]} items */
475
+ function uniqueArray(items) {
476
+ return [...new Set(items)];
477
+ }
478
+
479
+ /** @param {unknown} value @returns {value is string} */
480
+ function isNonEmptyString(value) {
481
+ return typeof value === 'string' && value.trim().length > 0;
482
+ }
483
+
484
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
485
+ function isRecord(value) {
486
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
487
+ }
488
+
489
+ /** @param {string} value */
490
+ function toPosix(value) {
491
+ return value.split(path.sep).join('/');
492
+ }
493
+
494
+ /** @param {string} message @returns {never} */
495
+ function fail(message) {
496
+ console.error(message);
497
+ process.exit(1);
498
+ }
499
+
500
+ await main();
@@ -33,6 +33,7 @@ Read the relevant reference file when the user's question matches a topic.
33
33
  | ------------------------ | --------------------------------------- | ----------------------------------------------------- |
34
34
  | Command Palette | references/command-palette.md | "What can I open with Cmd+K?", "Quick actions" |
35
35
  | Publish & Deploy | references/publish-and-deploy.md | "How do I publish?", "How do I make my site live?" |
36
+ | Analytics | references/analytics.md | "Is tracking working?", "Why are analytics empty?" |
36
37
  | Custom Domains | references/custom-domains.md | "How do I use my own domain?", "DNS setup" |
37
38
  | Content Management (CMS) | references/cms.md | "How does the CMS work?", "How do I add blog posts?" |
38
39
  | Media Library | references/media-library.md | "How do I upload images?", "Where are my files?" |
@@ -0,0 +1,28 @@
1
+ # Analytics
2
+
3
+ Open **Analytics** in the project's workspace navigation. It may be inside
4
+ **More** at narrower desktop widths. This workspace reports the published site,
5
+ regardless of the editing branch currently selected.
6
+
7
+ Publishing alone does not confirm pageview tracking. Before publication, the
8
+ page offers setup guidance without an **Open site** link.
9
+
10
+ For a published site:
11
+
12
+ - **Waiting for the first pageview** means no pageviews are stored for it.
13
+ **Open site** lets the user visit the published address. **Set up tracking**
14
+ opens the installation guide. A visit must run a configured tracker to count.
15
+ - **Pageviews received** shows the last pageview timestamp across available
16
+ history. Changing the chart's date range or device filter does not reset it.
17
+ Earlier traffic proves tracking worked then, not that a later deployment
18
+ still sends events.
19
+ - **Tracking status unavailable** means the status could not be checked.
20
+ **Check again** retries it. Do not interpret this as an uninstalled tracker.
21
+ Charts that loaded successfully remain visible.
22
+
23
+ Studio checks every 15 seconds while waiting in a visible tab. The first
24
+ pageview refreshes the charts automatically. Processing may take longer than
25
+ one check. Do not diagnose a missing tracker from zero chart counts alone.
26
+
27
+ Users need analytics viewing permission. Date ranges and grouping apply to the
28
+ charts; available history depends on the organization's analytics access.
@@ -31,6 +31,7 @@ The header **Publish** button itself only shows the label "Publish" (or a spinne
31
31
  - **"Publishing"** — Publishing is in progress. A spinner and a **Publishing** status badge show while it builds, and the button is disabled.
32
32
  - **"Published"** — Your site is live. A **Published** status badge appears, your live URL is listed under a **Published site** heading as a clickable link, and a **Last published** date and time is shown.
33
33
  - **"Couldn't publish · N issues"** — Project validation found issues that must be fixed first. Choose **Fix with Fimo** to send the safe issue list to the project chat and ask Fimo to repair it.
34
+ - **"Latest update failed"** — The newest publication did not finish. The panel says what went wrong and, for a site that was published before, that the previous version remains the active release — so it can appear beside **Published**. It stays until a newer publication finishes. **View details** opens the reader's Inbox item about the failure, which offers **Open publishing** and **Contact support**. Studio never shows the build output; read it with `fimo publish logs <id>`, taking the id from `fimo publish list`. The person who published and the project's managers get that Inbox item, plus an email unless they turned off **Publication failures** in Account settings → Notifications.
34
35
 
35
36
  ## Key Concepts
36
37
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "bundled": true,
3
3
  "bundler": "esbuild",
4
- "bundledAt": "2026-09-09T18:25:01.506Z",
4
+ "bundledAt": "2026-09-29T12:38:55.114Z",
5
5
  "cliVersion": "2.6.0",
6
6
  "external": [
7
7
  "oxc-parser",