@odori/cli 0.0.11 → 0.0.12
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/LICENSE +22 -0
- package/dist/{chunk-77R3UC27.js → chunk-FS2BJU5Q.js} +741 -72
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +95 -1
- package/dist/index.js +5 -1
- package/dist/{registry-snapshot-TKH2KAC3.js → registry-snapshot-TSTAA6NT.js} +1815 -245
- package/package.json +17 -20
- package/src/cli.ts +29 -0
- package/src/commands/blocks.ts +188 -0
- package/src/commands/docs.ts +88 -0
- package/src/commands/doctor.ts +38 -0
- package/src/commands/graph.ts +141 -0
- package/src/commands/test.ts +10 -1
- package/src/discovery.ts +8 -2
- package/src/docs-snapshot.json +130 -0
- package/src/docs.ts +54 -0
- package/src/index.ts +2 -0
- package/src/registry-snapshot.json +1647 -206
- package/src/registry-source.ts +139 -0
- package/src/structure.ts +177 -0
package/src/registry-source.ts
CHANGED
|
@@ -348,4 +348,143 @@ export const verifyIntegrity = (item: RegistryItemDocument, origin: RegistryOrig
|
|
|
348
348
|
);
|
|
349
349
|
};
|
|
350
350
|
|
|
351
|
+
/**
|
|
352
|
+
* Blocks: whole videos, published the same way components are.
|
|
353
|
+
*
|
|
354
|
+
* A block is the larger unit — an entry and the files beside it, plus the
|
|
355
|
+
* components it installs — so it reuses every guarantee this module already
|
|
356
|
+
* makes rather than growing a second way in: the same index-then-document
|
|
357
|
+
* split, the same cache, the same integrity hash over the exact bytes a
|
|
358
|
+
* consumer writes, and the same refusal to write outside the project.
|
|
359
|
+
*
|
|
360
|
+
* There is no bundled floor for blocks. The snapshot exists so a locked-down
|
|
361
|
+
* machine can still install a component and so `odori test` never touches the
|
|
362
|
+
* network; taking a whole video is a deliberate act on a machine that has one.
|
|
363
|
+
* A block therefore resolves network → cache and says so when neither answers.
|
|
364
|
+
*/
|
|
365
|
+
export type BlockDocument = {
|
|
366
|
+
id: string;
|
|
367
|
+
title: string;
|
|
368
|
+
description: string;
|
|
369
|
+
tags: string[];
|
|
370
|
+
format: string;
|
|
371
|
+
/** The file to render, relative to the project root. */
|
|
372
|
+
entry: string;
|
|
373
|
+
/** Registry components this block imports, by namespaced name. */
|
|
374
|
+
components: string[];
|
|
375
|
+
files: Array<{path: string; content: string; target: string}>;
|
|
376
|
+
meta?: {integrity?: string};
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
/** The index: every block, without the file contents. */
|
|
380
|
+
export type BlockSummary = Omit<BlockDocument, "files"> & {
|
|
381
|
+
files: Array<{path: string; target: string}>;
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
export type BlockIndex = {items: BlockSummary[]; origin: RegistryOrigin; detail: string};
|
|
385
|
+
|
|
386
|
+
const blocksUnavailable = (url: string): Error =>
|
|
387
|
+
new Error(
|
|
388
|
+
`No blocks available: ${url}/blocks.json could not be fetched and nothing is cached on this machine. ` +
|
|
389
|
+
"Blocks are published rather than bundled, so this needs a network once. " +
|
|
390
|
+
"Components still install offline.",
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
export const resolveBlocks = async (
|
|
394
|
+
config: ResolvedConfig,
|
|
395
|
+
options: {allowNetwork?: boolean} = {},
|
|
396
|
+
): Promise<BlockIndex> => {
|
|
397
|
+
const url = registryUrl(config);
|
|
398
|
+
const cache = resolve(cacheDir(url), "blocks.json");
|
|
399
|
+
|
|
400
|
+
if (options.allowNetwork !== false) {
|
|
401
|
+
try {
|
|
402
|
+
const index = (await fetchJson(`${url}/blocks.json`)) as {items: BlockSummary[]};
|
|
403
|
+
if (!Array.isArray(index.items)) throw new Error("the index has no items array");
|
|
404
|
+
await mkdir(dirname(cache), {recursive: true});
|
|
405
|
+
await writeFile(cache, JSON.stringify(index), "utf8");
|
|
406
|
+
return {items: index.items, origin: "network", detail: url};
|
|
407
|
+
} catch {
|
|
408
|
+
// As with components: unreachable is not an error until the disk is
|
|
409
|
+
// empty too.
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (existsSync(cache)) {
|
|
414
|
+
try {
|
|
415
|
+
const index = JSON.parse(await readFile(cache, "utf8")) as {items: BlockSummary[]};
|
|
416
|
+
return {items: index.items, origin: "cache", detail: cache};
|
|
417
|
+
} catch {
|
|
418
|
+
// A corrupt cache is a cache miss.
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
throw blocksUnavailable(url);
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
/** One block, with the source of every file it ships. */
|
|
426
|
+
export const resolveBlock = async (
|
|
427
|
+
config: ResolvedConfig,
|
|
428
|
+
id: string,
|
|
429
|
+
options: {allowNetwork?: boolean} = {},
|
|
430
|
+
): Promise<{block: BlockDocument; origin: RegistryOrigin}> => {
|
|
431
|
+
assertSafeName(id);
|
|
432
|
+
const url = registryUrl(config);
|
|
433
|
+
const cache = resolve(cacheDir(url), `block-${id}.json`);
|
|
434
|
+
|
|
435
|
+
if (options.allowNetwork !== false) {
|
|
436
|
+
try {
|
|
437
|
+
const block = (await fetchJson(`${url}/blocks/${id}.json`)) as BlockDocument;
|
|
438
|
+
if (block?.id !== id) throw new Error(`the document at ${url}/blocks/${id}.json is for "${block?.id}"`);
|
|
439
|
+
await mkdir(dirname(cache), {recursive: true});
|
|
440
|
+
await writeFile(cache, JSON.stringify(block), "utf8");
|
|
441
|
+
return {block, origin: "network"};
|
|
442
|
+
} catch {
|
|
443
|
+
// Try the disk before giving up.
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (existsSync(cache)) {
|
|
448
|
+
try {
|
|
449
|
+
return {block: JSON.parse(await readFile(cache, "utf8")) as BlockDocument, origin: "cache"};
|
|
450
|
+
} catch {
|
|
451
|
+
// Corrupt: fall through to the error, which names the whole situation.
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
throw blocksUnavailable(url);
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* The same hash components use, over a block's files.
|
|
460
|
+
*
|
|
461
|
+
* Integrity proves the bytes and not the intent, which is why every target is
|
|
462
|
+
* still resolved against the project root before anything is written.
|
|
463
|
+
*/
|
|
464
|
+
export const verifyBlockIntegrity = (block: BlockDocument): void => {
|
|
465
|
+
const expected = block.meta?.integrity;
|
|
466
|
+
if (!expected) {
|
|
467
|
+
throw new Error(
|
|
468
|
+
`The document for block "${block.id}" carries no integrity hash. ` +
|
|
469
|
+
"A fetched block must be verifiable; refusing to write it. Nothing was written.",
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const hash = createHash("sha256");
|
|
474
|
+
for (const file of [...block.files].sort((left, right) => left.path.localeCompare(right.path))) {
|
|
475
|
+
hash.update(file.path);
|
|
476
|
+
hash.update("\0");
|
|
477
|
+
hash.update(file.content);
|
|
478
|
+
hash.update("\0");
|
|
479
|
+
}
|
|
480
|
+
const actual = `sha256-${hash.digest("base64")}`;
|
|
481
|
+
if (actual === expected) return;
|
|
482
|
+
|
|
483
|
+
throw new Error(
|
|
484
|
+
`The files for block "${block.id}" do not match the hash the registry published.\n` +
|
|
485
|
+
` expected ${expected}\n received ${actual}\n` +
|
|
486
|
+
"Nothing was written. This is a truncated download, a stale proxy, or a tampered document.",
|
|
487
|
+
);
|
|
488
|
+
};
|
|
489
|
+
|
|
351
490
|
export type {RegistryItemDocument};
|
package/src/structure.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import {readFile} from "node:fs/promises";
|
|
2
|
+
import {existsSync} from "node:fs";
|
|
3
|
+
import {relative, resolve, sep} from "node:path";
|
|
4
|
+
import {resolveEntryLayout} from "odori";
|
|
5
|
+
import type {ResolvedConfig} from "./config";
|
|
6
|
+
import {walkSource, type ProjectGraph} from "./discovery";
|
|
7
|
+
import type {LoadedVideo} from "./project";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A fact about the project's shape that discovery itself cannot say.
|
|
11
|
+
*
|
|
12
|
+
* Discovery answers "what exists": it walks the tree and takes what matches.
|
|
13
|
+
* That is the right behaviour for a build — an unrecognized file must never
|
|
14
|
+
* take the project down — and exactly the wrong behaviour for an author,
|
|
15
|
+
* because everything discovery quietly steps over is a file someone wrote on
|
|
16
|
+
* purpose. The filesystem is Odori's contract: `video.tsx` is a video,
|
|
17
|
+
* `*.preview.tsx` is a fixture, a directory under components/ names a
|
|
18
|
+
* component. This module checks the places where that contract can be almost
|
|
19
|
+
* met, which is the one distance discovery cannot see.
|
|
20
|
+
*
|
|
21
|
+
* An `error` is a promise the project makes and breaks at render or play
|
|
22
|
+
* time: a sound that will be silence, an override that will never be heard.
|
|
23
|
+
* A `warn` is a file that works today and will surprise someone later.
|
|
24
|
+
*/
|
|
25
|
+
export type StructureFinding = {
|
|
26
|
+
level: "error" | "warn";
|
|
27
|
+
/** The file an author would open to fix it, relative to the project root. */
|
|
28
|
+
file: string;
|
|
29
|
+
message: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Entry basenames one keystroke away from meaning something. */
|
|
33
|
+
const NEAR_MISSES: Array<{test: (base: string) => boolean; message: string}> = [
|
|
34
|
+
{
|
|
35
|
+
test: (base) => base === "video.ts" || base === "video.jsx",
|
|
36
|
+
message: "Only video.tsx is a video entry. This file is invisible to discovery: rename it to video.tsx.",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
test: (base) => base.toLowerCase() === "video.tsx" && base !== "video.tsx",
|
|
40
|
+
message: "Entry names are exact and lower case. This file is invisible to discovery: rename it to video.tsx.",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
test: (base) => /\.preview\.(ts|jsx)$/.test(base),
|
|
44
|
+
message:
|
|
45
|
+
"Only *.preview.tsx is a component fixture. This file is invisible to discovery: rename it to end in .preview.tsx.",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
test: (base) => base.toLowerCase().endsWith(".preview.tsx") && !base.endsWith(".preview.tsx"),
|
|
49
|
+
message:
|
|
50
|
+
"Fixture names are exact and lower case. This file is invisible to discovery: rename it to end in .preview.tsx.",
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
/** String literals that name a file under the served audio library. */
|
|
55
|
+
const AUDIO_REFERENCE = /["'`](\/audio\/[^"'`\s]+\.[a-z0-9]{2,4})["'`]/gi;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Check the filesystem contract and report every place it is almost met.
|
|
59
|
+
*
|
|
60
|
+
* Everything here is answerable in Node from the tree and the loaded
|
|
61
|
+
* metadata: no browser, no network, no render. That is what makes it cheap
|
|
62
|
+
* enough to run inside `odori doctor` and first inside `odori test`, before
|
|
63
|
+
* a browser ever starts.
|
|
64
|
+
*/
|
|
65
|
+
export const checkStructure = async (
|
|
66
|
+
config: ResolvedConfig,
|
|
67
|
+
graph: ProjectGraph,
|
|
68
|
+
videos: LoadedVideo[],
|
|
69
|
+
): Promise<StructureFinding[]> => {
|
|
70
|
+
const findings: StructureFinding[] = [];
|
|
71
|
+
const videosRoot = resolve(config.root, config.videosDir);
|
|
72
|
+
const componentsRoot = resolve(config.root, config.componentsDir);
|
|
73
|
+
const files = existsSync(videosRoot) ? await walkSource(videosRoot) : [];
|
|
74
|
+
|
|
75
|
+
// Files one keystroke away from being entries. Discovery is filename
|
|
76
|
+
// driven, so `video.ts` is not a video with a small problem — it is not a
|
|
77
|
+
// video at all, and nothing else in the pipeline will ever say so.
|
|
78
|
+
for (const file of files) {
|
|
79
|
+
const base = file.split(sep).pop() ?? "";
|
|
80
|
+
const miss = NEAR_MISSES.find((candidate) => candidate.test(base));
|
|
81
|
+
if (miss) findings.push({level: "warn", file: relative(config.root, file), message: miss.message});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// A fixture named after something other than its directory. The catalog
|
|
85
|
+
// attributes usage by matching the fixture's name to the directory videos
|
|
86
|
+
// import from, so a mismatched name previews fine and is credited to
|
|
87
|
+
// nothing: its "used by" list stays empty forever.
|
|
88
|
+
for (const preview of graph.previews) {
|
|
89
|
+
if (!preview.file.startsWith(componentsRoot + sep)) continue;
|
|
90
|
+
const directory = resolve(preview.file, "..").split(sep).pop() ?? "";
|
|
91
|
+
if (directory && preview.name !== directory) {
|
|
92
|
+
findings.push({
|
|
93
|
+
level: "warn",
|
|
94
|
+
file: preview.relativeFile,
|
|
95
|
+
message:
|
|
96
|
+
`Fixture "${preview.name}" sits in components/${directory}/, so usage is never attributed to it. ` +
|
|
97
|
+
`Name the fixture after its directory: ${directory}.preview.tsx.`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// A literal audio path with no file behind it. The dev server answers 404
|
|
103
|
+
// and plays on; the export hasher shrugs and warns late. The author finds
|
|
104
|
+
// out when the cut is silent where the sound was supposed to be.
|
|
105
|
+
const audioUrls = new Set(graph.audio.map((entry) => entry.url));
|
|
106
|
+
for (const file of files) {
|
|
107
|
+
if (!/\.(tsx|ts)$/.test(file)) continue;
|
|
108
|
+
const contents = await readFile(file, "utf8");
|
|
109
|
+
const reported = new Set<string>();
|
|
110
|
+
for (const match of contents.matchAll(AUDIO_REFERENCE)) {
|
|
111
|
+
const url = match[1];
|
|
112
|
+
// A template literal with an interpolation is a path decided at
|
|
113
|
+
// runtime. Only what is written in full can be answered for here.
|
|
114
|
+
if (url.includes("${") || audioUrls.has(url) || reported.has(url)) continue;
|
|
115
|
+
reported.add(url);
|
|
116
|
+
findings.push({
|
|
117
|
+
level: "error",
|
|
118
|
+
file: relative(config.root, file),
|
|
119
|
+
message: `References ${url}, and no file answers it under ${config.audioDir}/. It will play as silence.`,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Variant overrides that answer to nothing. A variant replaces brand cues
|
|
125
|
+
// by name, so a name the brand never defines is an override nothing asks
|
|
126
|
+
// for, and a file path with no file is a sound that cannot load. Both are
|
|
127
|
+
// authored intent that the player will quietly ignore.
|
|
128
|
+
for (const video of videos) {
|
|
129
|
+
const layout = resolveEntryLayout(video.entry);
|
|
130
|
+
const brandCues = new Set(Object.keys(layout.brand.audio.cues));
|
|
131
|
+
for (const [variant, overrides] of Object.entries(video.entry.metadata.audio?.variants ?? {})) {
|
|
132
|
+
for (const [cue, value] of Object.entries(overrides)) {
|
|
133
|
+
if (!brandCues.has(cue)) {
|
|
134
|
+
findings.push({
|
|
135
|
+
level: "warn",
|
|
136
|
+
file: video.relativeFile,
|
|
137
|
+
message:
|
|
138
|
+
`Variant "${variant}" overrides a cue named "${cue}" that the brand "${layout.brand.name}" does not define. ` +
|
|
139
|
+
`Nothing plays that name, so the override is never heard.`,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
if (typeof value === "string" && value.startsWith("/") && !audioUrls.has(value)) {
|
|
143
|
+
findings.push({
|
|
144
|
+
level: "error",
|
|
145
|
+
file: video.relativeFile,
|
|
146
|
+
message: `Variant "${variant}" points cue "${cue}" at ${value}, and no file answers it under ${config.audioDir}/.`,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// A compiled catalog that no longer describes the tree. Anything that
|
|
154
|
+
// reads the artifact instead of running discovery — an agent, a CI step,
|
|
155
|
+
// another tool — would be reading yesterday's project as if it were today's.
|
|
156
|
+
const catalogFile = resolve(config.root, config.outDir, "catalog.json");
|
|
157
|
+
if (existsSync(catalogFile)) {
|
|
158
|
+
try {
|
|
159
|
+
const catalog = JSON.parse(await readFile(catalogFile, "utf8")) as {sourceHash?: string};
|
|
160
|
+
if (catalog.sourceHash && catalog.sourceHash !== graph.sourceHash) {
|
|
161
|
+
findings.push({
|
|
162
|
+
level: "warn",
|
|
163
|
+
file: relative(config.root, catalogFile),
|
|
164
|
+
message: "The compiled catalog is older than the source tree. Run odori graph (or odori dev) to refresh it.",
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
} catch {
|
|
168
|
+
findings.push({
|
|
169
|
+
level: "warn",
|
|
170
|
+
file: relative(config.root, catalogFile),
|
|
171
|
+
message: "The compiled catalog is not valid JSON. Run odori graph (or odori dev) to rewrite it.",
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return findings;
|
|
177
|
+
};
|