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.
- package/assets/skills/fimo-cli/SKILL.md +1 -1
- package/assets/skills/fimo-cli/references/analytics.md +29 -1
- package/assets/skills/fimo-cli/references/content.md +2 -2
- package/assets/skills/fimo-cli/references/hosting.md +11 -0
- package/assets/skills/fimo-cli/references/locales.md +2 -2
- package/assets/skills/fimo-cli/references/pages.md +14 -2
- package/assets/skills/fimo-code/SKILL.md +8 -0
- package/assets/skills/fimo-code/references/analytics.md +104 -0
- package/assets/skills/fimo-code/references/frameworks/astro.md +2 -0
- package/assets/skills/fimo-code/references/frameworks/next.md +12 -9
- package/assets/skills/fimo-code/references/ui.md +6 -1
- package/assets/skills/fimo-migration/SKILL.md +9 -6
- package/assets/skills/fimo-migration/references/frameworks/nextjs.md +24 -27
- package/assets/skills/fimo-migration/references/media.md +98 -62
- package/assets/skills/fimo-migration/references/qa.md +111 -11
- package/assets/skills/fimo-migration/references/workers/content-execution.md +2 -1
- package/assets/skills/fimo-migration/references/workers/validator.md +22 -6
- package/assets/skills/fimo-migration/references/workers.md +5 -3
- package/assets/skills/fimo-migration/scripts/check-rendered-guardrails.mjs +500 -0
- package/assets/skills/fimo-studio/SKILL.md +1 -0
- package/assets/skills/fimo-studio/references/analytics.md +28 -0
- package/assets/skills/fimo-studio/references/publish-and-deploy.md +1 -0
- package/dist/cli/bundle.json +1 -1
- package/dist/cli/index.js +27 -27
- package/dist/runtime/paths/get-fimo-paths.d.ts +3 -1
- package/dist/runtime/paths/get-fimo-paths.js +59 -5
- package/dist/runtime/primitives/lib/primitives.d.ts +20 -1
- package/dist/runtime/primitives/lib/primitives.js +24 -1
- package/dist/runtime/routes/index.d.ts +19 -3
- package/dist/runtime/routes/index.js +27 -2
- package/dist/runtime/templates.d.ts +1 -1
- package/dist/runtime/templates.js +1 -0
- package/dist/scripts/validate-page-registry.js +14 -3
- package/package.json +10 -9
- package/release.json +2 -2
- package/templates/astro/package.json +2 -2
- package/templates/nextjs/package.json +3 -3
- package/templates/react-router/package.json +4 -4
- package/templates/sveltekit/package.json +2 -2
- 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
|
|