@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.
- package/dist/analytics.d.ts +19 -0
- package/dist/analytics.js +18 -0
- package/dist/drafts.d.ts +82 -0
- package/dist/drafts.js +145 -0
- package/dist/icon-catalog.d.ts +7 -0
- package/dist/icon-catalog.js +48 -0
- package/dist/icons-BAxwOBBX.d.ts +19 -0
- package/dist/icons-Bk4igP2j.js +15 -0
- package/dist/ids.d.ts +26 -0
- package/dist/ids.js +40 -0
- package/dist/index.d.ts +12359 -7641
- package/dist/index.js +70 -0
- package/dist/projects.d.ts +28 -0
- package/dist/projects.js +50 -0
- package/dist/query-keys.d.ts +58 -38
- package/dist/query-keys.js +118 -28
- package/dist/threads.d.ts +277 -0
- package/dist/threads.js +263 -0
- package/package.json +57 -5
- package/src/analytics.ts +22 -0
- package/src/artifact.ts +38 -0
- package/src/drafts.ts +197 -0
- package/src/icon-catalog.ts +60 -0
- package/src/icons.ts +27 -0
- package/src/ids.ts +43 -0
- package/src/index.ts +6 -0
- package/src/projects.ts +67 -0
- package/src/query-keys.ts +65 -27
- package/src/reference-query.ts +48 -0
- package/src/threads.ts +327 -0
package/dist/threads.js
ADDED
|
@@ -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.
|
|
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
|
-
"
|
|
22
|
-
"
|
|
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": "
|
|
82
|
+
"build": "vp pack"
|
|
31
83
|
}
|
|
32
84
|
}
|
package/src/analytics.ts
ADDED
|
@@ -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>;
|
package/src/artifact.ts
ADDED
|
@@ -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
|
+
}
|