@odori/cli 0.0.3 → 0.0.4

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.
@@ -46,14 +46,28 @@ export type ComponentContract = {
46
46
  loops?: boolean;
47
47
  };
48
48
 
49
- export type RegistryKind = "component" | "cue";
49
+ export type RegistryKind = "component" | "cue" | "asset";
50
+
51
+ /** A produced file the registry publishes: sound synthesis cannot reach. */
52
+ export type RegistryAsset = {
53
+ cue: string;
54
+ url: string;
55
+ target: string;
56
+ bytes: number;
57
+ integrity: string;
58
+ };
50
59
 
51
60
  export type RegistryComponent = {
52
61
  name: string;
53
- /** Components render pixels. Cues render samples. Both install as source. */
62
+ /**
63
+ * Components render pixels and cues render samples; both install as source.
64
+ * An asset installs as bytes into `public/` and is registered by URL.
65
+ */
54
66
  kind?: RegistryKind;
55
67
  /** Present on cues: the brand name it registers, and the factory to call. */
56
68
  cue?: {name: string; export: string};
69
+ /** Present on assets: the file to fetch and the cue name it answers to. */
70
+ asset?: RegistryAsset;
57
71
  namespaced: string;
58
72
  family: string;
59
73
  description: string;
@@ -123,6 +137,7 @@ type RegistryItemDocument = {
123
137
  namespaced?: string;
124
138
  contract?: RegistryComponent["contract"];
125
139
  cue?: {name: string; export: string};
140
+ asset?: RegistryAsset;
126
141
  integrity?: string;
127
142
  };
128
143
  };
@@ -132,6 +147,25 @@ const DEFAULT_URL = "https://odori.dev/r/v1";
132
147
  export const registryUrl = (config: ResolvedConfig): string =>
133
148
  (config.registryUrl ?? process.env.ODORI_REGISTRY ?? DEFAULT_URL).replace(/\/$/, "");
134
149
 
150
+ /**
151
+ * The site the registry is served from, without its document path.
152
+ *
153
+ * Documents live under `/r/v1`; published media lives at the site root, the
154
+ * same absolute path a project will reference it by once it is installed. So
155
+ * an asset URL resolves against the origin rather than against the registry
156
+ * directory, and a self-hosted registry still finds its own files.
157
+ */
158
+ export const registryOrigin = (config: ResolvedConfig): string => {
159
+ const url = registryUrl(config);
160
+ try {
161
+ return new URL(url).origin;
162
+ } catch {
163
+ // A relative or malformed registryUrl: keep everything before /r, which is
164
+ // where a mirror laid out like ours would put its media.
165
+ return url.replace(/\/r(\/v\d+)?$/, "");
166
+ }
167
+ };
168
+
135
169
  /** One directory per origin URL, so two registries never share a cache. */
136
170
  const cacheDir = (url: string): string =>
137
171
  resolve(cacheRoot(), "registry", createHash("sha256").update(url).digest("hex").slice(0, 16));
@@ -149,6 +183,7 @@ const toComponent = (item: RegistryItemDocument): RegistryComponent => ({
149
183
  namespaced: item.meta?.namespaced ?? `@odori/${item.name}`,
150
184
  kind: (item.meta?.kind as RegistryComponent["kind"]) ?? "component",
151
185
  ...(item.meta?.cue ? {cue: item.meta.cue} : {}),
186
+ ...(item.meta?.asset ? {asset: item.meta.asset} : {}),
152
187
  family: item.meta?.family ?? "Uncategorized",
153
188
  description: item.description ?? "",
154
189
  files: item.files.map((file) => file.path.split("/").pop() ?? file.path),
package/src/server.ts CHANGED
@@ -93,6 +93,8 @@ const odoriProjectPlugin = (config: ResolvedConfig, getGraph: () => ProjectGraph
93
93
  `export const project = ${JSON.stringify({
94
94
  root: config.root,
95
95
  videosDir: config.videosDir,
96
+ componentsDir: config.componentsDir,
97
+ categories: graph.categories,
96
98
  exportDir: config.exportDir,
97
99
  audioDir: config.audioDir,
98
100
  docsUrl: config.docsUrl,
@@ -101,7 +103,11 @@ const odoriProjectPlugin = (config: ResolvedConfig, getGraph: () => ProjectGraph
101
103
  assets: config.assets ?? [],
102
104
  files: {
103
105
  videos: graph.videos.map((video) => ({id: video.slug, file: video.relativeFile})),
104
- previews: graph.previews.map((preview) => ({id: preview.name, file: preview.relativeFile})),
106
+ previews: graph.previews.map((preview) => ({
107
+ id: preview.name,
108
+ file: preview.relativeFile,
109
+ usedBy: preview.usedBy ?? [],
110
+ })),
105
111
  brands: graph.brands.map((brand) => ({id: brand.name, file: brand.relativeFile})),
106
112
  },
107
113
  })};`,
@@ -1,6 +1,7 @@
1
1
  import {useEffect, useState} from "react";
2
2
  import type {ReactNode} from "react";
3
3
  import {Icon} from "./ui";
4
+ import {tokenize} from "../lib/highlight";
4
5
 
5
6
  /**
6
7
  * The right-hand pane, with its width and its presence under the reader's
@@ -29,14 +30,27 @@ const applyWidth = (value: number) => {
29
30
  export const Inspector = ({
30
31
  title,
31
32
  hint,
33
+ source,
32
34
  children,
33
35
  }: {
34
36
  /** What this pane is about, as a person names it. */
35
37
  title?: string;
36
38
  /** The longer form, one hover away - usually the source path. */
37
39
  hint?: string;
40
+ /**
41
+ * The files that produced what is on the stage, relative to the project.
42
+ * Given any, the pane offers to show them: the source is the truth here, so
43
+ * it should be one click from the frame rather than a path to go and find.
44
+ * More than one because a component is two files - what it draws and the
45
+ * fixture that plays it - and reading only the fixture answers the wrong
46
+ * question.
47
+ */
48
+ source?: string | string[];
38
49
  children: ReactNode;
39
50
  }) => {
51
+ const files = source === undefined ? [] : Array.isArray(source) ? source : [source];
52
+ const [showing, setShowing] = useState<"inspect" | "code">("inspect");
53
+ const [reading, setReading] = useState(0);
40
54
  const [collapsed, setCollapsed] = useState(() => window.localStorage.getItem(COLLAPSED_KEY) === "true");
41
55
 
42
56
  useEffect(() => {
@@ -104,6 +118,22 @@ export const Inspector = ({
104
118
  <span className="inspector-title" title={hint ?? title}>
105
119
  {title}
106
120
  </span>
121
+ {files.length > 0 ? (
122
+ <div className="inspector-views" role="tablist">
123
+ {(["inspect", "code"] as const).map((view) => (
124
+ <button
125
+ key={view}
126
+ type="button"
127
+ role="tab"
128
+ aria-selected={showing === view}
129
+ data-active={showing === view ? "true" : undefined}
130
+ onClick={() => setShowing(view)}
131
+ >
132
+ {view === "inspect" ? "Inspect" : "Code"}
133
+ </button>
134
+ ))}
135
+ </div>
136
+ ) : null}
107
137
  <button
108
138
  type="button"
109
139
  className="inspector-toggle"
@@ -114,8 +144,78 @@ export const Inspector = ({
114
144
  <Icon name="sidebar" />
115
145
  </button>
116
146
  </div>
117
- {children}
147
+ {files.length > 0 && showing === "code" ? (
148
+ <>
149
+ {/* One file is the file; several want naming, so the pane says
150
+ which of a component's two halves is on screen. */}
151
+ {files.length > 1 ? (
152
+ <div className="source-files" role="tablist">
153
+ {files.map((file, index) => (
154
+ <button
155
+ key={file}
156
+ type="button"
157
+ role="tab"
158
+ aria-selected={index === reading}
159
+ data-active={index === reading ? "true" : undefined}
160
+ title={file}
161
+ onClick={() => setReading(index)}
162
+ >
163
+ {file.split("/").pop()}
164
+ </button>
165
+ ))}
166
+ </div>
167
+ ) : null}
168
+ <SourceView file={files[Math.min(reading, files.length - 1)]} />
169
+ </>
170
+ ) : (
171
+ children
172
+ )}
118
173
  </div>
119
174
  </aside>
120
175
  );
121
176
  };
177
+
178
+ /**
179
+ * The file, as it is on disk. Read only on purpose: the editor is the editor,
180
+ * and what this answers is "what wrote this frame", which is a reading
181
+ * question. It refetches when the path changes, and the dev server reloads the
182
+ * page on every source edit, so what is shown is never stale.
183
+ */
184
+ const SourceView = ({file}: {file: string}) => {
185
+ const [text, setText] = useState<string | null>(null);
186
+ const [failed, setFailed] = useState<string | null>(null);
187
+
188
+ useEffect(() => {
189
+ let live = true;
190
+ setText(null);
191
+ setFailed(null);
192
+ void fetch(`/__odori/source?file=${encodeURIComponent(file)}`)
193
+ .then(async (response) => {
194
+ if (!response.ok) throw new Error(await response.text());
195
+ return response.text();
196
+ })
197
+ .then((body) => live && setText(body))
198
+ .catch((error) => live && setFailed(error instanceof Error ? error.message : String(error)));
199
+ return () => {
200
+ live = false;
201
+ };
202
+ }, [file]);
203
+
204
+ if (failed) return <p className="hint">{failed}</p>;
205
+ if (text === null) return <p className="hint">Reading {file}</p>;
206
+ return (
207
+ <pre className="source">
208
+ <code>
209
+ {tokenize(text).map((token, index) =>
210
+ token.kind === "plain" ? (
211
+ token.text
212
+ ) : (
213
+ <span key={index} data-token={token.kind}>
214
+ {token.text}
215
+ </span>
216
+ ),
217
+ )}
218
+ </code>
219
+ </pre>
220
+ );
221
+ };
@@ -0,0 +1,145 @@
1
+ import {useEffect, useMemo, useRef, useState} from "react";
2
+ import {Icon} from "./ui";
3
+
4
+ /**
5
+ * The list of everything in a view, beside the thing you are looking at.
6
+ *
7
+ * Studio moved to gallery-then-detail so a narrow pane could give the stage
8
+ * its whole width, which is right when Studio sits beside an agent and wrong
9
+ * when you are working through a library: reaching the next component meant
10
+ * going back to the gallery, finding it, and clicking in. A session of that
11
+ * is a lot of round trips.
12
+ *
13
+ * So the list comes back as something you can put away. It collapses the way
14
+ * the inspector does, remembers that choice, and filters as you type, which
15
+ * is the part a gallery cannot do at all.
16
+ */
17
+ const COLLAPSED_KEY = "odori-navigator-collapsed";
18
+
19
+ export type NavigatorItem = {
20
+ id: string;
21
+ title: string;
22
+ /** The quieter second line: an id, a duration, a file. */
23
+ detail?: string;
24
+ /** A heading to sit under. Items with none come first, ungrouped. */
25
+ group?: string;
26
+ };
27
+
28
+ export const Navigator = ({
29
+ label,
30
+ items,
31
+ selected,
32
+ onSelect,
33
+ }: {
34
+ /** What this list is of, for the header and the screen reader. */
35
+ label: string;
36
+ items: NavigatorItem[];
37
+ selected: string | null;
38
+ onSelect: (id: string) => void;
39
+ }) => {
40
+ const [collapsed, setCollapsed] = useState(() => window.localStorage.getItem(COLLAPSED_KEY) === "true");
41
+ const [query, setQuery] = useState("");
42
+ const field = useRef<HTMLInputElement>(null);
43
+
44
+ // The grid in .main reads this, so opening the list costs no React layout.
45
+ useEffect(() => {
46
+ document.documentElement.dataset.navigator = collapsed ? "collapsed" : "open";
47
+ window.localStorage.setItem(COLLAPSED_KEY, String(collapsed));
48
+ return () => {
49
+ delete document.documentElement.dataset.navigator;
50
+ };
51
+ }, [collapsed]);
52
+
53
+ const matches = useMemo(() => {
54
+ const needle = query.trim().toLowerCase();
55
+ if (!needle) return items;
56
+ return items.filter((item) =>
57
+ [item.title, item.id, item.group].some((value) => value?.toLowerCase().includes(needle)),
58
+ );
59
+ }, [items, query]);
60
+
61
+ /* Headings come from the order the caller sorted, so a group is a run of
62
+ items rather than a bucket this has to invent an order for. */
63
+ const runs = useMemo(() => {
64
+ const out: Array<{group?: string; items: NavigatorItem[]}> = [];
65
+ for (const item of matches) {
66
+ const last = out.at(-1);
67
+ if (last && last.group === item.group) last.items.push(item);
68
+ else out.push({group: item.group, items: [item]});
69
+ }
70
+ return out;
71
+ }, [matches]);
72
+
73
+ if (collapsed) {
74
+ return (
75
+ <button
76
+ type="button"
77
+ className="navigator-reopen"
78
+ aria-label={`Show ${label.toLowerCase()}`}
79
+ title={`Show ${label.toLowerCase()}`}
80
+ onClick={() => setCollapsed(false)}
81
+ >
82
+ <Icon name="sidebar" />
83
+ </button>
84
+ );
85
+ }
86
+
87
+ return (
88
+ <aside className="navigator" aria-label={label}>
89
+ <div className="navigator-head">
90
+ <button
91
+ type="button"
92
+ className="navigator-toggle"
93
+ aria-label={`Hide ${label.toLowerCase()}`}
94
+ title={`Hide ${label.toLowerCase()}`}
95
+ onClick={() => setCollapsed(true)}
96
+ >
97
+ <Icon name="sidebar" />
98
+ </button>
99
+ <input
100
+ ref={field}
101
+ className="input navigator-search"
102
+ type="search"
103
+ placeholder={`Filter ${label.toLowerCase()}`}
104
+ value={query}
105
+ onChange={(event) => setQuery(event.currentTarget.value)}
106
+ onKeyDown={(event) => {
107
+ if (event.key === "Escape") {
108
+ // The first press clears, the second gives the keys back to the
109
+ // transport: typing in here must not eat the space bar.
110
+ if (query) setQuery("");
111
+ else event.currentTarget.blur();
112
+ }
113
+ }}
114
+ />
115
+ </div>
116
+
117
+ <div className="navigator-scroll">
118
+ {matches.length === 0 ? (
119
+ <p className="hint navigator-empty">Nothing matches {query}.</p>
120
+ ) : (
121
+ runs.map((run) => (
122
+ <div key={run.group ?? "."}>
123
+ {run.group ? <h3 className="navigator-group">{run.group}</h3> : null}
124
+ <ul className="list">
125
+ {run.items.map((item) => (
126
+ <li key={item.id}>
127
+ <button
128
+ type="button"
129
+ className="list-item"
130
+ data-active={item.id === selected ? "true" : undefined}
131
+ onClick={() => onSelect(item.id)}
132
+ >
133
+ <strong>{item.title}</strong>
134
+ {item.detail ? <span>{item.detail}</span> : null}
135
+ </button>
136
+ </li>
137
+ ))}
138
+ </ul>
139
+ </div>
140
+ ))
141
+ )}
142
+ </div>
143
+ </aside>
144
+ );
145
+ };
@@ -0,0 +1,85 @@
1
+ /**
2
+ * A small TypeScript and TSX tokenizer, for reading source in the inspector.
3
+ *
4
+ * Studio ships inside `@odori/cli`, so anything it imports is something every
5
+ * consumer downloads. A real highlighter is megabytes of grammars to colour a
6
+ * read-only panel, which is a bad trade for a dev tool; this is one pass of
7
+ * one regex over files we already know the shape of. It is deliberately not a
8
+ * parser: it does not resolve types, it will not know a word is a variable
9
+ * rather than a call, and it does not need to. What it has to get right is
10
+ * that a keyword inside a string stays a string, and a slash that opens a
11
+ * comment is not division, which ordering the alternation handles.
12
+ */
13
+ export type Token = {text: string; kind: TokenKind};
14
+
15
+ export type TokenKind =
16
+ | "plain"
17
+ | "comment"
18
+ | "string"
19
+ | "keyword"
20
+ | "number"
21
+ | "tag"
22
+ | "attr"
23
+ | "fn"
24
+ | "punct";
25
+
26
+ const KEYWORDS = new Set([
27
+ "as", "async", "await", "break", "case", "catch", "class", "const", "continue", "declare", "default", "delete",
28
+ "do", "else", "enum", "export", "extends", "false", "finally", "for", "from", "function", "if", "implements",
29
+ "import", "in", "instanceof", "interface", "keyof", "let", "new", "null", "of", "readonly", "return", "satisfies",
30
+ "static", "super", "switch", "this", "throw", "true", "try", "type", "typeof", "undefined", "var", "void", "while",
31
+ "yield",
32
+ ]);
33
+
34
+ /*
35
+ * Order is the whole design. Comments and strings come first so their contents
36
+ * are never read as code; the JSX tag rule follows, because `<Scene` is a tag
37
+ * and `a < b` is not; words and numbers come last.
38
+ */
39
+ const PATTERN = new RegExp(
40
+ [
41
+ "(?<comment>//[^\\n]*|/\\*[\\s\\S]*?\\*/)",
42
+ "(?<string>`(?:\\\\.|[^`\\\\])*`|\"(?:\\\\.|[^\"\\\\\\n])*\"|'(?:\\\\.|[^'\\\\\\n])*')",
43
+ "(?<tag></?[A-Za-z][\\w.]*(?=[\\s/>]))",
44
+ "(?<number>\\b\\d[\\w.]*\\b)",
45
+ "(?<word>[A-Za-z_$][\\w$]*)",
46
+ "(?<punct>[{}()[\\].,;:=+\\-*/<>!?&|%^~]+)",
47
+ ].join("|"),
48
+ "g",
49
+ );
50
+
51
+ export const tokenize = (source: string): Token[] => {
52
+ const tokens: Token[] = [];
53
+ let last = 0;
54
+
55
+ const push = (text: string, kind: TokenKind) => {
56
+ if (text) tokens.push({text, kind});
57
+ };
58
+
59
+ for (const match of source.matchAll(PATTERN)) {
60
+ const groups = match.groups ?? {};
61
+ const index = match.index ?? 0;
62
+ push(source.slice(last, index), "plain");
63
+ last = index + match[0].length;
64
+
65
+ if (groups.comment) push(match[0], "comment");
66
+ else if (groups.string) push(match[0], "string");
67
+ else if (groups.tag) push(match[0], "tag");
68
+ else if (groups.number) push(match[0], "number");
69
+ else if (groups.punct) push(match[0], "punct");
70
+ else if (groups.word) {
71
+ const word = match[0];
72
+ // A word followed by `(` is being called; one followed by `=` inside a
73
+ // tag is an attribute. Both are guesses from the next character, which
74
+ // is as far as a tokenizer can honestly go.
75
+ const next = source[last];
76
+ if (KEYWORDS.has(word)) push(word, "keyword");
77
+ else if (next === "(") push(word, "fn");
78
+ else if (next === "=" && source[last + 1] !== "=") push(word, "attr");
79
+ else push(word, "plain");
80
+ }
81
+ }
82
+
83
+ push(source.slice(last), "plain");
84
+ return tokens;
85
+ };