@camox/api-contract 0.43.0 → 0.45.0

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.
@@ -0,0 +1,263 @@
1
+ import { z } from "zod";
2
+
3
+ //#region src/threads.ts
4
+ /**
5
+ * The kind of the transcript entry recording an editor's message, with its sender and the
6
+ * operation id the studio sent it with as data (`editorMessageDataSchema`).
7
+ */
8
+ const EDITOR_MESSAGE_ENTRY = "camox.editor-message";
9
+ /** The kind of the transcript entry recording that an editor stopped a run, with them as data. */
10
+ const RUN_STOPPED_ENTRY = "camox.run-stopped";
11
+ /** Who sent a message in a thread. */
12
+ const threadSenderSchema = z.object({
13
+ id: z.string(),
14
+ name: z.string(),
15
+ image: z.string().nullable()
16
+ });
17
+ /** Whether `data` names a sender. */
18
+ const isSender = (data) => threadSenderSchema.safeParse(data).success;
19
+ /** What an editor's message entry records: who sent it, and the operation id it was sent with. */
20
+ const editorMessageDataSchema = threadSenderSchema.extend({ operationId: z.string().optional() });
21
+ /**
22
+ * A draft a Live thread started with its first write, becoming its thread, and the operation ids
23
+ * of the messages of the run that made the write: the studio that sent one of them enters the
24
+ * draft with the thread.
25
+ */
26
+ const startedDraftSchema = z.object({
27
+ draftId: z.string(),
28
+ operationIds: z.array(z.string())
29
+ });
30
+ /** How a write touched what it names. */
31
+ const touchKindSchema = z.enum([
32
+ "created",
33
+ "edited",
34
+ "moved",
35
+ "deleted",
36
+ "settings"
37
+ ]);
38
+ /**
39
+ * One thing a script's successful write touched, named so the studio can locate it: a Page (and
40
+ * its Block, if any), a Synced Block's shared content, by its block definition, or a Layout (and
41
+ * its Block, if any). `settings` is a Page's settings only (nickname, path, SEO, layout). `locale`
42
+ * is the Locale the write was in, for a localized tool. Writes to Collection Records, Files and
43
+ * Locales aren't on the Canvas, so they're left out.
44
+ */
45
+ const touchBaseSchema = z.object({
46
+ kind: touchKindSchema,
47
+ locale: z.string().optional()
48
+ });
49
+ const touchSchema = z.discriminatedUnion("entity", [
50
+ touchBaseSchema.extend({
51
+ entity: z.literal("page"),
52
+ pageId: z.string(),
53
+ blockId: z.string().optional()
54
+ }),
55
+ touchBaseSchema.extend({
56
+ entity: z.literal("synced-block"),
57
+ /** The Synced Block's block definition id. */
58
+ blockType: z.string()
59
+ }),
60
+ touchBaseSchema.extend({
61
+ entity: z.literal("layout"),
62
+ layoutId: z.string(),
63
+ blockId: z.string().optional()
64
+ })
65
+ ]);
66
+ /**
67
+ * What one code-mode script's successful writes touched, in write order, and the operation ids
68
+ * of the messages of the run it belongs to: only the studio that sent one of them follows it.
69
+ */
70
+ const touchedBatchSchema = z.object({
71
+ operationIds: z.array(z.string()),
72
+ touches: z.array(touchSchema)
73
+ });
74
+ const EMPTY_THREAD_VIEW = {
75
+ messages: [],
76
+ running: false
77
+ };
78
+ const EMPTY_THREAD_STATE = {
79
+ steps: [],
80
+ running: false
81
+ };
82
+ /** Studio → thread. */
83
+ const threadClientMessageSchema = z.discriminatedUnion("type", [
84
+ z.object({
85
+ type: z.literal("send"),
86
+ operationId: z.string().min(1),
87
+ text: z.string().min(1)
88
+ }),
89
+ z.object({ type: z.literal("stop") }),
90
+ z.object({ type: z.literal("resync") })
91
+ ]);
92
+ /**
93
+ * The code a thread closes its sockets with once it ended with its draft, and for good: it's
94
+ * deleted, so reconnecting reaches nothing. A development draft, which stays open, gets a new
95
+ * thread the next time the studio connects.
96
+ */
97
+ const THREAD_ENDED_CLOSE_CODE = 4e3;
98
+ const textOf = (content) => typeof content === "string" ? content : content.flatMap((part) => part.type === "text" ? [part.text] : []).join("").trim();
99
+ /** What an assistant message is to the thread: a final answer, a tool round, or neither. */
100
+ function assistantStep(id, message) {
101
+ if (message.content.some((part) => part.type === "toolCall")) return {
102
+ id,
103
+ type: "tools"
104
+ };
105
+ const text = message.stopReason === "stop" ? textOf(message.content) : "";
106
+ return text ? {
107
+ id,
108
+ type: "answer",
109
+ text
110
+ } : {
111
+ id,
112
+ type: "unfinished"
113
+ };
114
+ }
115
+ /** The sender an entry's data names, if any, without other fields it may carry. */
116
+ function senderOf(data) {
117
+ if (!isSender(data)) return void 0;
118
+ const { id, name, image } = data;
119
+ return {
120
+ id,
121
+ name,
122
+ image
123
+ };
124
+ }
125
+ /** The step an entry is, if it's one the view depends on. */
126
+ function threadStepOf(entry) {
127
+ const id = String(entry.id);
128
+ const sender = senderOf(entry.data);
129
+ if (entry.kind === "camox.run-stopped" && sender) return {
130
+ id,
131
+ type: "stopped",
132
+ sender
133
+ };
134
+ const message = entry.model?.find((candidate) => candidate.role !== "system");
135
+ if (!message) return void 0;
136
+ if (entry.kind === "camox.editor-message" && message.role === "user" && sender) return {
137
+ id,
138
+ type: "editor",
139
+ sender,
140
+ text: textOf(message.content)
141
+ };
142
+ if (message.role === "assistant") return assistantStep(id, message);
143
+ if (message.role === "toolResult") return {
144
+ id,
145
+ type: "tools"
146
+ };
147
+ }
148
+ /**
149
+ * What the studio shows for a thread. A turn starts with an editor's message, and messages sent
150
+ * while it runs join it. It ends with the agent's final message; text written beside tool calls
151
+ * is never shown. A stopped turn shows who stopped it instead, and whatever its run wrote after
152
+ * the stop is left out. A turn that ended without either shows a failed line in its place: its
153
+ * run is over, or a later message started another run after its last model call came back
154
+ * unfinished. While Pi retries a failed call, the run goes on, so the turn shows as working.
155
+ */
156
+ function threadViewOf({ steps, running, ...drafts }) {
157
+ const messages = [];
158
+ let waiting;
159
+ let unfinished = false;
160
+ /** Ends the turn waiting for the agent's message, if any. */
161
+ const endTurn = () => {
162
+ waiting = void 0;
163
+ unfinished = false;
164
+ };
165
+ /** Ends the turn waiting for the agent's message with a failed line, if any. */
166
+ const fail = () => {
167
+ if (waiting !== void 0) messages.push({
168
+ id: `${waiting}:failed`,
169
+ from: "failed"
170
+ });
171
+ endTurn();
172
+ };
173
+ for (const step of steps) switch (step.type) {
174
+ case "editor":
175
+ if (unfinished) fail();
176
+ messages.push({
177
+ id: step.id,
178
+ from: "editor",
179
+ sender: step.sender,
180
+ text: step.text
181
+ });
182
+ waiting = step.id;
183
+ break;
184
+ case "stopped":
185
+ if (waiting === void 0) break;
186
+ messages.push({
187
+ id: step.id,
188
+ from: "stopped",
189
+ sender: step.sender
190
+ });
191
+ endTurn();
192
+ break;
193
+ case "answer":
194
+ if (waiting === void 0 && messages.at(-1)?.from === "stopped") break;
195
+ messages.push({
196
+ id: step.id,
197
+ from: "agent",
198
+ text: step.text
199
+ });
200
+ endTurn();
201
+ break;
202
+ case "tools":
203
+ unfinished = false;
204
+ break;
205
+ case "unfinished": unfinished = true;
206
+ }
207
+ if (!running) fail();
208
+ return {
209
+ ...drafts,
210
+ messages,
211
+ running
212
+ };
213
+ }
214
+ const append = (state, entry) => {
215
+ const step = entry && threadStepOf(entry);
216
+ if (!step || state.steps.some((known) => known.id === step.id)) return state;
217
+ return {
218
+ ...state,
219
+ steps: [...state.steps, step]
220
+ };
221
+ };
222
+ /** Folds one of Pi's agent events into the thread's state. */
223
+ function reduceThreadState(state, event) {
224
+ switch (event.type) {
225
+ case "snapshot": return {
226
+ ...state,
227
+ steps: event.entries.flatMap((entry) => threadStepOf(entry) ?? []),
228
+ running: event.run !== void 0
229
+ };
230
+ case "run_start": return {
231
+ ...state,
232
+ running: true
233
+ };
234
+ case "run_end": return {
235
+ ...state,
236
+ running: false
237
+ };
238
+ case "message_end":
239
+ case "entry_appended":
240
+ case "tool_execution_end": return append(state, event.entry);
241
+ default: return state;
242
+ }
243
+ }
244
+ /** Folds a batch of Pi's agent events into the thread's state. */
245
+ const reduceThreadEvents = (state, events) => events.reduce(reduceThreadState, state);
246
+ /** Folds what the thread sent over its socket into the thread's state. */
247
+ function reduceThreadFrame(state, frame) {
248
+ switch (frame.type) {
249
+ case "events": return reduceThreadEvents(state, frame.events);
250
+ case "started-draft": return {
251
+ ...state,
252
+ startedDraft: frame.startedDraft
253
+ };
254
+ case "continued-in": return {
255
+ ...state,
256
+ continuedIn: frame.continuedIn
257
+ };
258
+ default: return state;
259
+ }
260
+ }
261
+
262
+ //#endregion
263
+ export { EDITOR_MESSAGE_ENTRY, EMPTY_THREAD_STATE, EMPTY_THREAD_VIEW, RUN_STOPPED_ENTRY, THREAD_ENDED_CLOSE_CODE, editorMessageDataSchema, isSender, reduceThreadEvents, reduceThreadFrame, reduceThreadState, startedDraftSchema, threadClientMessageSchema, threadSenderSchema, threadViewOf, touchKindSchema, touchSchema, touchedBatchSchema };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camox/api-contract",
3
- "version": "0.43.0",
3
+ "version": "0.45.0",
4
4
  "license": "MIT",
5
5
  "files": [
6
6
  "src",
@@ -15,18 +15,70 @@
15
15
  "./query-keys": {
16
16
  "types": "./dist/query-keys.d.ts",
17
17
  "default": "./dist/query-keys.js"
18
+ },
19
+ "./ids": {
20
+ "types": "./dist/ids.d.ts",
21
+ "default": "./dist/ids.js"
22
+ },
23
+ "./drafts": {
24
+ "types": "./dist/drafts.d.ts",
25
+ "default": "./dist/drafts.js"
26
+ },
27
+ "./icon-catalog": {
28
+ "types": "./dist/icon-catalog.d.ts",
29
+ "default": "./dist/icon-catalog.js"
30
+ },
31
+ "./analytics": {
32
+ "types": "./dist/analytics.d.ts",
33
+ "default": "./dist/analytics.js"
34
+ },
35
+ "./threads": {
36
+ "types": "./dist/threads.d.ts",
37
+ "default": "./dist/threads.js"
38
+ },
39
+ "./projects": {
40
+ "types": "./dist/projects.d.ts",
41
+ "default": "./dist/projects.js"
18
42
  }
19
43
  },
44
+ "dependencies": {
45
+ "@iconify/types": "^2.0.0",
46
+ "@iconify/utils": "^3.1.7",
47
+ "zod": "^4.6.7"
48
+ },
20
49
  "devDependencies": {
21
- "dts-bundle-generator": "^9.5.1",
22
- "esbuild": "^0.25.12"
50
+ "@earendil-works/pi-ai": "1.3.0",
51
+ "@earendil-works/pi-durable": "1.3.0"
52
+ },
53
+ "peerDependencies": {
54
+ "@earendil-works/pi-ai": "1.3.0",
55
+ "@earendil-works/pi-durable": "1.3.0"
56
+ },
57
+ "peerDependenciesMeta": {
58
+ "@earendil-works/pi-ai": {
59
+ "optional": true
60
+ },
61
+ "@earendil-works/pi-durable": {
62
+ "optional": true
63
+ }
23
64
  },
24
65
  "nx": {
25
66
  "tags": [
26
67
  "type:pkg"
27
- ]
68
+ ],
69
+ "targets": {
70
+ "build": {
71
+ "inputs": [
72
+ "production",
73
+ "^production",
74
+ "{workspaceRoot}/apps/api/src/**/*",
75
+ "{workspaceRoot}/packages/ai-tools/src/**/*",
76
+ "!{workspaceRoot}/**/*.test.ts"
77
+ ]
78
+ }
79
+ }
28
80
  },
29
81
  "scripts": {
30
- "build": "node build.mjs"
82
+ "build": "vp pack"
31
83
  }
32
84
  }
@@ -0,0 +1,22 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * A GA4 measurement ID: `G-` followed by uppercase letters and digits. Shared so the dashboard
5
+ * checks an ID by the API's own rule before saving it.
6
+ */
7
+ export const ga4MeasurementIdSchema = /* @__PURE__ */ z
8
+ .string()
9
+ .regex(/^G-[A-Z0-9]+$/, "Not a GA4 measurement ID");
10
+
11
+ /**
12
+ * A PostHog project API key: `phc_` followed by letters and digits. The prefix tells it apart from
13
+ * a personal API key, which is secret and must never reach a public page.
14
+ */
15
+ export const posthogProjectApiKeySchema = /* @__PURE__ */ z
16
+ .string()
17
+ .regex(/^phc_[A-Za-z0-9]+$/, "Not a PostHog project API key");
18
+
19
+ /** The PostHog Cloud region a project lives in. */
20
+ export const posthogRegionSchema = /* @__PURE__ */ z.enum(["us", "eu"]);
21
+
22
+ export type PosthogRegion = z.output<typeof posthogRegionSchema>;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Artifact titles, shared by the SDK (frame titles, studio sidebar) and the API (Markdown
3
+ * Representation, write warnings), so every surface names an artifact the same way.
4
+ */
5
+
6
+ export const UNTITLED_ARTIFACT = "Untitled artifact";
7
+
8
+ const entities: Record<string, string> = {
9
+ amp: "&",
10
+ lt: "<",
11
+ gt: ">",
12
+ quot: '"',
13
+ apos: "'",
14
+ nbsp: " ",
15
+ };
16
+
17
+ function decodeEntities(text: string) {
18
+ return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, entity: string) => {
19
+ if (entity[0] !== "#") return entities[entity.toLowerCase()] ?? match;
20
+ const code =
21
+ entity[1] === "x" || entity[1] === "X"
22
+ ? Number.parseInt(entity.slice(2), 16)
23
+ : Number(entity.slice(1));
24
+ return Number.isNaN(code) ? match : String.fromCodePoint(code);
25
+ });
26
+ }
27
+
28
+ /** The non-empty `<title>` of an artifact source, if it has one. */
29
+ export function artifactSourceTitle(source: string): string | undefined {
30
+ const match = /<title\b[^>]*>([\s\S]*?)<\/title\s*>/i.exec(source);
31
+ const title = match ? decodeEntities(match[1]).replace(/\s+/g, " ").trim() : "";
32
+ return title || undefined;
33
+ }
34
+
35
+ /** The artifact's name: its source's `<title>`, or "Untitled artifact". */
36
+ export function artifactTitle(source: string): string {
37
+ return artifactSourceTitle(source) ?? UNTITLED_ARTIFACT;
38
+ }
package/src/drafts.ts ADDED
@@ -0,0 +1,197 @@
1
+ import type { Router } from "./index.ts";
2
+
3
+ /** Request header naming the draft a request reads and writes (ADR 0011). Absent means Live. */
4
+ export const DRAFT_HEADER = "x-camox-draft";
5
+
6
+ /**
7
+ * Request header naming, by its tag, the locale a request reads and writes content in (ADR
8
+ * 0015). Absent means the project's default locale.
9
+ */
10
+ export const LOCALE_HEADER = "x-camox-locale";
11
+
12
+ /**
13
+ * Request header asking the API to create the draft named by `DRAFT_HEADER` if it doesn't
14
+ * exist yet. Its value is `"1"`.
15
+ */
16
+ export const DRAFT_CREATE_HEADER = "x-camox-draft-create";
17
+
18
+ /**
19
+ * The `x-camox-draft` value naming the caller's own development draft in the project a request
20
+ * acts on (ADR 0006, ADR 0011). Dev mode and the CLI always send it.
21
+ */
22
+ export const DEVELOPMENT_DRAFT = "development";
23
+
24
+ /**
25
+ * oRPC error code for a request naming the development draft before dev mode created it: the
26
+ * first dev-mode sync after login does, so the site has to run locally once first.
27
+ */
28
+ export const NO_DEVELOPMENT_DRAFT_ERROR = "NO_DEVELOPMENT_DRAFT";
29
+
30
+ /** URL search param carrying the current draft's id in the studio and the rendered site. */
31
+ export const DRAFT_SEARCH_PARAM = "camox-draft";
32
+
33
+ /** A regular draft is a named set of edits; a development draft backs a dev server. */
34
+ export type DraftKind = "regular" | "development";
35
+
36
+ /** How a regular draft ended. */
37
+ export type DraftOutcome = "published" | "discarded";
38
+
39
+ /** Who ended a draft, how and when, from its ended-drafts record (ADR 0009, ADR 0011). */
40
+ export type EndedDraft = {
41
+ draftId: string;
42
+ outcome: DraftOutcome;
43
+ endedBy: { id: string; name: string };
44
+ endedAt: number;
45
+ };
46
+
47
+ /**
48
+ * The oRPC error code a request naming an ended draft fails with. Its `data` is an `EndedDraft`,
49
+ * so a page can render Live and say who published or discarded the draft.
50
+ */
51
+ export const DRAFT_ENDED_ERROR_CODE = "DRAFT_ENDED";
52
+
53
+ /**
54
+ * oRPC error code for a publish whose token no longer matches Live: Live changed since the
55
+ * preview, so nothing was applied and the caller reviews the new preview.
56
+ */
57
+ export const CONFLICTS_CHANGED_ERROR = "CONFLICTS_CHANGED";
58
+
59
+ /** oRPC error code for a publish refused because the draft still has blockers. */
60
+ export const PUBLISH_BLOCKED_ERROR = "PUBLISH_BLOCKED";
61
+
62
+ /**
63
+ * oRPC error code for a publish refused because the draft still has pending translations: every
64
+ * locale goes live together, unless the caller publishes anyway (ADR 0017).
65
+ */
66
+ export const TRANSLATIONS_PENDING_ERROR = "TRANSLATIONS_PENDING";
67
+
68
+ /**
69
+ * Why the publish tool published nothing of a draft with changes, and returned a fresh preview to
70
+ * review again, by the error code of each: Live changed since the preview, the draft has blockers,
71
+ * or translations are still running.
72
+ */
73
+ export const NOT_PUBLISHED_CODES = {
74
+ conflictsChanged: CONFLICTS_CHANGED_ERROR,
75
+ blocked: PUBLISH_BLOCKED_ERROR,
76
+ translating: TRANSLATIONS_PENDING_ERROR,
77
+ } as const;
78
+
79
+ export type NotPublishedReason = keyof typeof NOT_PUBLISHED_CODES;
80
+
81
+ /**
82
+ * Who Camox's own work is by: the creator of the drafts it opens to catch translations up, their
83
+ * publisher on Live, and who ended them. Shown as "Camox".
84
+ */
85
+ export const CAMOX_ACTOR = "camox";
86
+
87
+ /** The name Camox's own work shows under. */
88
+ export const CAMOX_NAME = "Camox";
89
+
90
+ /**
91
+ * oRPC error code for a content write that names no draft. Live is read-only (ADR 0003): content
92
+ * changes reach it only by publishing a draft, or through a release.
93
+ */
94
+ export const LIVE_READ_ONLY_ERROR = "LIVE_READ_ONLY";
95
+
96
+ // Whether each procedure of a router that follows drafts writes content. Live is read-only
97
+ // (ADR 0003), so the API rejects a content write naming no draft, and the studio starts a draft
98
+ // for one on Live. Every procedure is listed, so a new one fails the type check until it's
99
+ // classified and can't be forgotten. Procedures that only fill a generated value (ADR 0012),
100
+ // like `generateSummary`, write no content. A synced block's shared content is written through
101
+ // `blocks` and `repeatableItems`. Releases and dev-mode sync aren't listed.
102
+ const BLOCK_WRITES: Record<keyof Router["blocks"], boolean> = {
103
+ get: false,
104
+ getPageMarkdown: false,
105
+ getLayoutMarkdown: false,
106
+ getUsageCounts: false,
107
+ getPagesUsing: false,
108
+ getSyncedContent: false,
109
+ create: true,
110
+ updateContent: true,
111
+ updateSettings: true,
112
+ updatePosition: true,
113
+ delete: true,
114
+ deleteMany: true,
115
+ generateSummary: false,
116
+ duplicate: true,
117
+ };
118
+
119
+ const COLLECTION_WRITES: Record<keyof Router["collectionDefinitions"], boolean> = {
120
+ list: false,
121
+ get: false,
122
+ listRecords: false,
123
+ getRecord: false,
124
+ getRecordUsageCount: false,
125
+ findRecordsByField: false,
126
+ listRecordPageValues: false,
127
+ recordMarkdown: false,
128
+ createRecord: true,
129
+ editRecord: true,
130
+ deleteRecord: true,
131
+ };
132
+
133
+ // Uploads and replacements are content writes too, outside oRPC.
134
+ const FILE_WRITES: Record<keyof Router["files"], boolean> = {
135
+ list: false,
136
+ get: false,
137
+ getUsageCount: false,
138
+ setAlt: true,
139
+ setFilename: true,
140
+ delete: true,
141
+ deleteMany: true,
142
+ // Switching AI metadata on or off changes content (ADR 0012); generating only fills it.
143
+ setAiMetadata: true,
144
+ generateMetadata: false,
145
+ };
146
+
147
+ const PAGE_WRITES: Record<keyof Router["pages"], boolean> = {
148
+ getByPath: false,
149
+ getStructure: false,
150
+ list: false,
151
+ listBySlug: false,
152
+ get: false,
153
+ create: true,
154
+ update: true,
155
+ delete: true,
156
+ // Switching AI SEO on or off changes content (ADR 0012), like writing either field.
157
+ setSeo: true,
158
+ setLayout: true,
159
+ generateSeo: false,
160
+ };
161
+
162
+ const ITEM_WRITES: Record<keyof Router["repeatableItems"], boolean> = {
163
+ get: false,
164
+ create: true,
165
+ updateContent: true,
166
+ updateSettings: true,
167
+ updatePosition: true,
168
+ duplicate: true,
169
+ generateSummary: false,
170
+ delete: true,
171
+ };
172
+
173
+ // Correcting the default locale's tag changes no content, so it needs no draft (ADR 0015).
174
+ const LOCALE_WRITES: Record<keyof Router["locales"], boolean> = {
175
+ list: false,
176
+ add: true,
177
+ remove: true,
178
+ setDefaultTag: false,
179
+ listMissing: false,
180
+ addBack: true,
181
+ };
182
+
183
+ // `definitions` is absent: its sync is a release or a dev sync, never a studio content write.
184
+ const CONTENT_WRITES: Partial<Record<keyof Router, Record<string, boolean>>> = {
185
+ blocks: BLOCK_WRITES,
186
+ locales: LOCALE_WRITES,
187
+ collectionDefinitions: COLLECTION_WRITES,
188
+ files: FILE_WRITES,
189
+ pages: PAGE_WRITES,
190
+ repeatableItems: ITEM_WRITES,
191
+ };
192
+
193
+ /** Whether the procedure at `path` (`[router, procedure]`) writes content. */
194
+ export function isContentWrite(path: readonly string[]): boolean {
195
+ if (path.length !== 2) return false;
196
+ return CONTENT_WRITES[path[0] as keyof Router]?.[path[1]] === true;
197
+ }
@@ -0,0 +1,60 @@
1
+ import type { IconifyJSON } from "@iconify/types";
2
+ import { getIconData, iconToSVG } from "@iconify/utils";
3
+
4
+ import { ICON_CATALOG_VERSION, type IconSvgData } from "./icons.ts";
5
+
6
+ // Kept out of the root entry so `@iconify/utils` never reaches client bundles.
7
+ const collections = new Map<string, Promise<IconifyJSON>>();
8
+
9
+ export async function loadIconCollection(prefix: string): Promise<IconifyJSON> {
10
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(prefix)) throw new Error("Invalid icon collection");
11
+ const existing = collections.get(prefix);
12
+ if (existing) return existing;
13
+ const pending = (async () => {
14
+ const response = await fetch(
15
+ `https://unpkg.com/@iconify/json@${ICON_CATALOG_VERSION}/json/${prefix}.json`,
16
+ { signal: AbortSignal.timeout(30_000) },
17
+ );
18
+ if (!response.ok) throw new Error(`Unable to load icon collection ${prefix}`);
19
+ const data = (await response.json()) as IconifyJSON;
20
+ if (data.prefix !== prefix || !data.icons || !data.info?.license)
21
+ throw new Error("Invalid icon catalog or missing license");
22
+ return data;
23
+ })();
24
+ collections.set(prefix, pending);
25
+ // Bound worker memory; failed fetches must be retryable.
26
+ if (collections.size > 8) collections.delete(collections.keys().next().value!);
27
+ try {
28
+ return await pending;
29
+ } catch (error) {
30
+ collections.delete(prefix);
31
+ throw error;
32
+ }
33
+ }
34
+
35
+ export function iconCollectionIds(data: IconifyJSON): string[] {
36
+ return [...Object.keys(data.icons), ...Object.keys(data.aliases ?? {})]
37
+ .filter((name) => getIconData(data, name) !== null)
38
+ .sort()
39
+ .map((name) => `${data.prefix}:${name}`);
40
+ }
41
+
42
+ export function resolveIconSvg(data: IconifyJSON, id: string): IconSvgData {
43
+ const [prefix, name, extra] = id.split(":");
44
+ if (prefix !== data.prefix || !name || extra !== undefined) throw new Error("Invalid icon ID");
45
+ const icon = getIconData(data, name);
46
+ if (!icon) throw new Error(`Unknown icon: ${id}`);
47
+ const svg = iconToSVG(icon);
48
+ return {
49
+ body: svg.body,
50
+ attributes: svg.attributes,
51
+ attribution: data.info
52
+ ? {
53
+ collection: data.info.name,
54
+ author: data.info.author.name,
55
+ license: data.info.license.title,
56
+ url: data.info.license.url,
57
+ }
58
+ : undefined,
59
+ };
60
+ }
package/src/icons.ts ADDED
@@ -0,0 +1,27 @@
1
+ // One immutable snapshot for declarations, schemas, picker and SVG resolution.
2
+ export const ICON_CATALOG_VERSION = "2.2.532";
3
+ export type IconSvgData = {
4
+ body: string;
5
+ attributes: Record<string, string>;
6
+ attribution?: { collection: string; author: string; license: string; url?: string };
7
+ };
8
+
9
+ export function scopeIconSvgIds(body: string, prefix: string): string {
10
+ const ids = [...body.matchAll(/\sid="([^\s"]+)"/g)].map((match) => match[1]!);
11
+ if (!ids.length) return body;
12
+ const pattern = ids.map((id) => id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
13
+ return body.replace(
14
+ new RegExp(`([#;"])(${pattern})([")]|\\.[a-z])`, "g"),
15
+ (_match, before: string, id: string, after: string) => `${before}${prefix}${id}${after}`,
16
+ );
17
+ }
18
+
19
+ export function validateIconValue(
20
+ value: unknown,
21
+ schema: { fieldType?: string; enum?: unknown },
22
+ ): void {
23
+ if (schema.fieldType !== "Icon") return;
24
+ if (typeof value !== "string" || !Array.isArray(schema.enum) || !schema.enum.includes(value)) {
25
+ throw new Error(`Invalid icon ID: ${String(value)}`);
26
+ }
27
+ }