@drawnagency/primitives 0.1.95 → 0.1.96
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/{chunk-QQBBYQYD.js → chunk-CSVGCAIH.js} +4 -4
- package/dist/chunk-EUSWEVOA.js +29 -0
- package/dist/components/primitives/index.js +1 -1
- package/dist/components/sections/brand-guide.js +2 -2
- package/dist/components/sections/register-schemas.js +3 -3
- package/dist/components/sections/register.js +3 -3
- package/dist/index.js +41 -35
- package/dist/lib/index-ops.d.ts +77 -0
- package/dist/lib/index-ops.d.ts.map +1 -0
- package/dist/lib/index-ops.js +223 -0
- package/dist/lib/index.js +3 -3
- package/dist/schemas/index.js +1 -1
- package/dist/storage/index.d.ts +1 -1
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +9 -3
- package/dist/storage/types.d.ts +44 -0
- package/dist/storage/types.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/lib/index-ops.ts +309 -0
- package/src/storage/index.ts +1 -1
- package/src/storage/types.ts +55 -0
- package/dist/chunk-VAOW4WXQ.js +0 -15
- package/dist/{chunk-ABV7OZB3.js → chunk-CS2ECR3U.js} +3 -3
- package/dist/{chunk-ZKGMLLUQ.js → chunk-IFCQWC3G.js} +3 -3
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
pluralize,
|
|
4
4
|
stripHtmlToPlainText,
|
|
5
5
|
truncate
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-IFCQWC3G.js";
|
|
7
7
|
import {
|
|
8
8
|
DeleteButton,
|
|
9
9
|
DragHandle,
|
|
@@ -52,13 +52,13 @@ import {
|
|
|
52
52
|
import {
|
|
53
53
|
getSectionSchema
|
|
54
54
|
} from "./chunk-TZQPOR5A.js";
|
|
55
|
-
import {
|
|
56
|
-
SingleMediaReferenceSchema
|
|
57
|
-
} from "./chunk-RHWWQV3J.js";
|
|
58
55
|
import {
|
|
59
56
|
defineSection,
|
|
60
57
|
getSection
|
|
61
58
|
} from "./chunk-VKAGMEKE.js";
|
|
59
|
+
import {
|
|
60
|
+
SingleMediaReferenceSchema
|
|
61
|
+
} from "./chunk-RHWWQV3J.js";
|
|
62
62
|
|
|
63
63
|
// src/components/sections/LinkHeading/index.tsx
|
|
64
64
|
import { Heading1 } from "lucide-react";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// src/storage/types.ts
|
|
2
|
+
var StorageConflictError = class extends Error {
|
|
3
|
+
constructor(currentVersion) {
|
|
4
|
+
super("Draft has been modified since it was loaded");
|
|
5
|
+
this.currentVersion = currentVersion;
|
|
6
|
+
this.name = "StorageConflictError";
|
|
7
|
+
}
|
|
8
|
+
currentVersion;
|
|
9
|
+
};
|
|
10
|
+
var IDEMPOTENCY_TRAILER = "Idempotency-Key";
|
|
11
|
+
var IDEMPOTENCY_KEY_RE = /^[A-Za-z0-9._:-]{8,200}$/;
|
|
12
|
+
function withIdempotencyTrailer(subject, key) {
|
|
13
|
+
if (!key) return subject;
|
|
14
|
+
if (!IDEMPOTENCY_KEY_RE.test(key)) {
|
|
15
|
+
throw new Error("Invalid idempotency key: expected 8-200 chars of A-Za-z0-9._:-");
|
|
16
|
+
}
|
|
17
|
+
return `${subject}
|
|
18
|
+
|
|
19
|
+
${IDEMPOTENCY_TRAILER}: ${key}`;
|
|
20
|
+
}
|
|
21
|
+
var ASSET_CACHE_CONTROL = "max-age=31536000, immutable";
|
|
22
|
+
|
|
23
|
+
export {
|
|
24
|
+
StorageConflictError,
|
|
25
|
+
IDEMPOTENCY_TRAILER,
|
|
26
|
+
IDEMPOTENCY_KEY_RE,
|
|
27
|
+
withIdempotencyTrailer,
|
|
28
|
+
ASSET_CACHE_CONTROL
|
|
29
|
+
};
|
|
@@ -23,8 +23,8 @@ import {
|
|
|
23
23
|
cn
|
|
24
24
|
} from "../../chunk-7EOSDFS3.js";
|
|
25
25
|
import "../../chunk-YBVK254P.js";
|
|
26
|
-
import "../../chunk-RHWWQV3J.js";
|
|
27
26
|
import "../../chunk-VKAGMEKE.js";
|
|
27
|
+
import "../../chunk-RHWWQV3J.js";
|
|
28
28
|
import "../../chunk-FM4M4Q2W.js";
|
|
29
29
|
import "../../chunk-JSBRDJBE.js";
|
|
30
30
|
|
|
@@ -2,7 +2,7 @@ import {
|
|
|
2
2
|
ColorItemSchema,
|
|
3
3
|
ColorSpaceSchema,
|
|
4
4
|
brandGuideSectionDefs
|
|
5
|
-
} from "../../chunk-
|
|
5
|
+
} from "../../chunk-IFCQWC3G.js";
|
|
6
6
|
import "../../chunk-5THS4K5D.js";
|
|
7
7
|
import "../../chunk-DJAJRBPD.js";
|
|
8
8
|
import "../../chunk-CI624HRS.js";
|
|
@@ -11,8 +11,8 @@ import "../../chunk-PZJL2S3Z.js";
|
|
|
11
11
|
import "../../chunk-WDSVHKAH.js";
|
|
12
12
|
import "../../chunk-7EOSDFS3.js";
|
|
13
13
|
import "../../chunk-YBVK254P.js";
|
|
14
|
-
import "../../chunk-RHWWQV3J.js";
|
|
15
14
|
import "../../chunk-VKAGMEKE.js";
|
|
15
|
+
import "../../chunk-RHWWQV3J.js";
|
|
16
16
|
import "../../chunk-FM4M4Q2W.js";
|
|
17
17
|
import "../../chunk-JSBRDJBE.js";
|
|
18
18
|
export {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
allSectionDefs
|
|
3
|
-
} from "../../chunk-
|
|
4
|
-
import "../../chunk-
|
|
3
|
+
} from "../../chunk-CSVGCAIH.js";
|
|
4
|
+
import "../../chunk-IFCQWC3G.js";
|
|
5
5
|
import "../../chunk-YUUUCDXN.js";
|
|
6
6
|
import "../../chunk-5THS4K5D.js";
|
|
7
7
|
import "../../chunk-RQPHPAT2.js";
|
|
@@ -16,11 +16,11 @@ import "../../chunk-53DKVAB6.js";
|
|
|
16
16
|
import "../../chunk-7EOSDFS3.js";
|
|
17
17
|
import "../../chunk-YBVK254P.js";
|
|
18
18
|
import "../../chunk-TZQPOR5A.js";
|
|
19
|
-
import "../../chunk-RHWWQV3J.js";
|
|
20
19
|
import {
|
|
21
20
|
registerRichText,
|
|
22
21
|
registerSchema
|
|
23
22
|
} from "../../chunk-VKAGMEKE.js";
|
|
23
|
+
import "../../chunk-RHWWQV3J.js";
|
|
24
24
|
import "../../chunk-FM4M4Q2W.js";
|
|
25
25
|
import "../../chunk-NSCT3AMV.js";
|
|
26
26
|
import "../../chunk-JSBRDJBE.js";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
allSectionDefs
|
|
3
|
-
} from "../../chunk-
|
|
4
|
-
import "../../chunk-
|
|
3
|
+
} from "../../chunk-CSVGCAIH.js";
|
|
4
|
+
import "../../chunk-IFCQWC3G.js";
|
|
5
5
|
import "../../chunk-YUUUCDXN.js";
|
|
6
6
|
import "../../chunk-5THS4K5D.js";
|
|
7
7
|
import "../../chunk-RQPHPAT2.js";
|
|
@@ -16,10 +16,10 @@ import "../../chunk-53DKVAB6.js";
|
|
|
16
16
|
import "../../chunk-7EOSDFS3.js";
|
|
17
17
|
import "../../chunk-YBVK254P.js";
|
|
18
18
|
import "../../chunk-TZQPOR5A.js";
|
|
19
|
-
import "../../chunk-RHWWQV3J.js";
|
|
20
19
|
import {
|
|
21
20
|
registerSection
|
|
22
21
|
} from "../../chunk-VKAGMEKE.js";
|
|
22
|
+
import "../../chunk-RHWWQV3J.js";
|
|
23
23
|
import "../../chunk-FM4M4Q2W.js";
|
|
24
24
|
import "../../chunk-NSCT3AMV.js";
|
|
25
25
|
import "../../chunk-JSBRDJBE.js";
|
package/dist/index.js
CHANGED
|
@@ -1,21 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ASSET_CACHE_CONTROL,
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
IDEMPOTENCY_KEY_RE,
|
|
4
|
+
IDEMPOTENCY_TRAILER,
|
|
5
|
+
StorageConflictError,
|
|
6
|
+
withIdempotencyTrailer
|
|
7
|
+
} from "./chunk-EUSWEVOA.js";
|
|
5
8
|
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
SESSION_COOKIE,
|
|
10
|
-
SESSION_MAX_AGE_SECONDS,
|
|
11
|
-
SessionExpiredError,
|
|
12
|
-
deriveUiCapabilities,
|
|
13
|
-
setSessionCookie,
|
|
14
|
-
signAudienceToken,
|
|
15
|
-
signSessionToken,
|
|
16
|
-
verifyAudienceToken,
|
|
17
|
-
verifySessionToken
|
|
18
|
-
} from "./chunk-EM5DFHDZ.js";
|
|
9
|
+
getCollabProvider,
|
|
10
|
+
setCollabProvider
|
|
11
|
+
} from "./chunk-2G2S36SY.js";
|
|
19
12
|
import {
|
|
20
13
|
BAKED_MEDIA_KEYS,
|
|
21
14
|
EXT_TO_MIME,
|
|
@@ -37,6 +30,20 @@ import {
|
|
|
37
30
|
sanitizeMediaName,
|
|
38
31
|
setMediaProvider
|
|
39
32
|
} from "./chunk-SOILPZ7U.js";
|
|
33
|
+
import {
|
|
34
|
+
AUDIENCE_COOKIE,
|
|
35
|
+
AUDIENCE_MAX_AGE_SECONDS,
|
|
36
|
+
LastOwnerError,
|
|
37
|
+
SESSION_COOKIE,
|
|
38
|
+
SESSION_MAX_AGE_SECONDS,
|
|
39
|
+
SessionExpiredError,
|
|
40
|
+
deriveUiCapabilities,
|
|
41
|
+
setSessionCookie,
|
|
42
|
+
signAudienceToken,
|
|
43
|
+
signSessionToken,
|
|
44
|
+
verifyAudienceToken,
|
|
45
|
+
verifySessionToken
|
|
46
|
+
} from "./chunk-EM5DFHDZ.js";
|
|
40
47
|
import {
|
|
41
48
|
CollabPeerSchema,
|
|
42
49
|
HEARTBEAT_INTERVAL_MS,
|
|
@@ -46,10 +53,6 @@ import {
|
|
|
46
53
|
SectionUpdateMessageSchema,
|
|
47
54
|
TakeoverMessageSchema
|
|
48
55
|
} from "./chunk-REM2NH7K.js";
|
|
49
|
-
import {
|
|
50
|
-
getCollabProvider,
|
|
51
|
-
setCollabProvider
|
|
52
|
-
} from "./chunk-2G2S36SY.js";
|
|
53
56
|
import {
|
|
54
57
|
AudienceColorSchema,
|
|
55
58
|
AudienceNameSchema,
|
|
@@ -88,7 +91,7 @@ import {
|
|
|
88
91
|
safeRedirect,
|
|
89
92
|
suggestAccessibleOn,
|
|
90
93
|
wcagLevel
|
|
91
|
-
} from "./chunk-
|
|
94
|
+
} from "./chunk-CS2ECR3U.js";
|
|
92
95
|
import {
|
|
93
96
|
curatedIcons,
|
|
94
97
|
getIcon
|
|
@@ -108,19 +111,30 @@ import {
|
|
|
108
111
|
import {
|
|
109
112
|
cn
|
|
110
113
|
} from "./chunk-7EOSDFS3.js";
|
|
114
|
+
import {
|
|
115
|
+
audiencePasses
|
|
116
|
+
} from "./chunk-YBVK254P.js";
|
|
111
117
|
import {
|
|
112
118
|
isSameOriginRequest,
|
|
113
119
|
requireSessionSecret,
|
|
114
120
|
safeNextPath
|
|
115
121
|
} from "./chunk-S2L3BPLS.js";
|
|
116
|
-
import {
|
|
117
|
-
audiencePasses
|
|
118
|
-
} from "./chunk-YBVK254P.js";
|
|
119
122
|
import "./chunk-5BM2Y3NJ.js";
|
|
120
123
|
import {
|
|
121
124
|
getSectionContentSchema,
|
|
122
125
|
getSectionSchema
|
|
123
126
|
} from "./chunk-TZQPOR5A.js";
|
|
127
|
+
import {
|
|
128
|
+
clearRegistry,
|
|
129
|
+
createRegistry,
|
|
130
|
+
defineSection,
|
|
131
|
+
getAllSchemas,
|
|
132
|
+
getAllSections,
|
|
133
|
+
getSchema,
|
|
134
|
+
getSection,
|
|
135
|
+
registerSchema,
|
|
136
|
+
registerSection
|
|
137
|
+
} from "./chunk-VKAGMEKE.js";
|
|
124
138
|
import {
|
|
125
139
|
ChatbotConfigSchema,
|
|
126
140
|
ChatbotTaskSchema,
|
|
@@ -143,17 +157,6 @@ import {
|
|
|
143
157
|
VariantSchema,
|
|
144
158
|
normalizeSiteIndex
|
|
145
159
|
} from "./chunk-RHWWQV3J.js";
|
|
146
|
-
import {
|
|
147
|
-
clearRegistry,
|
|
148
|
-
createRegistry,
|
|
149
|
-
defineSection,
|
|
150
|
-
getAllSchemas,
|
|
151
|
-
getAllSections,
|
|
152
|
-
getSchema,
|
|
153
|
-
getSection,
|
|
154
|
-
registerSchema,
|
|
155
|
-
registerSection
|
|
156
|
-
} from "./chunk-VKAGMEKE.js";
|
|
157
160
|
import "./chunk-FM4M4Q2W.js";
|
|
158
161
|
import "./chunk-NSCT3AMV.js";
|
|
159
162
|
import {
|
|
@@ -177,6 +180,8 @@ export {
|
|
|
177
180
|
FontNameSchema,
|
|
178
181
|
HEARTBEAT_INTERVAL_MS,
|
|
179
182
|
HexColorSchema,
|
|
183
|
+
IDEMPOTENCY_KEY_RE,
|
|
184
|
+
IDEMPOTENCY_TRAILER,
|
|
180
185
|
ImageManifestSchema,
|
|
181
186
|
IndexSchema,
|
|
182
187
|
IndexUpdateMessageSchema,
|
|
@@ -280,5 +285,6 @@ export {
|
|
|
280
285
|
toSectionId,
|
|
281
286
|
verifyAudienceToken,
|
|
282
287
|
verifySessionToken,
|
|
283
|
-
wcagLevel
|
|
288
|
+
wcagLevel,
|
|
289
|
+
withIdempotencyTrailer
|
|
284
290
|
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type SiteIndex } from "../schemas/site-config";
|
|
3
|
+
export declare const IndexOpSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
4
|
+
op: z.ZodLiteral<"insertAfter">;
|
|
5
|
+
pageId: z.ZodString;
|
|
6
|
+
afterId: z.ZodNullable<z.ZodString>;
|
|
7
|
+
sections: z.ZodArray<z.ZodObject<{
|
|
8
|
+
id: z.ZodString;
|
|
9
|
+
status: z.ZodOptional<z.ZodEnum<{
|
|
10
|
+
draft: "draft";
|
|
11
|
+
live: "live";
|
|
12
|
+
archived: "archived";
|
|
13
|
+
}>>;
|
|
14
|
+
access: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
15
|
+
}, z.core.$strip>>;
|
|
16
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
17
|
+
op: z.ZodLiteral<"insertAt">;
|
|
18
|
+
pageId: z.ZodString;
|
|
19
|
+
position: z.ZodNumber;
|
|
20
|
+
sections: z.ZodArray<z.ZodObject<{
|
|
21
|
+
id: z.ZodString;
|
|
22
|
+
status: z.ZodOptional<z.ZodEnum<{
|
|
23
|
+
draft: "draft";
|
|
24
|
+
live: "live";
|
|
25
|
+
archived: "archived";
|
|
26
|
+
}>>;
|
|
27
|
+
access: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
28
|
+
}, z.core.$strip>>;
|
|
29
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
30
|
+
op: z.ZodLiteral<"move">;
|
|
31
|
+
pageId: z.ZodString;
|
|
32
|
+
id: z.ZodString;
|
|
33
|
+
afterId: z.ZodNullable<z.ZodString>;
|
|
34
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
35
|
+
op: z.ZodLiteral<"remove">;
|
|
36
|
+
ids: z.ZodArray<z.ZodString>;
|
|
37
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
38
|
+
op: z.ZodLiteral<"setMeta">;
|
|
39
|
+
id: z.ZodString;
|
|
40
|
+
status: z.ZodOptional<z.ZodEnum<{
|
|
41
|
+
draft: "draft";
|
|
42
|
+
live: "live";
|
|
43
|
+
archived: "archived";
|
|
44
|
+
}>>;
|
|
45
|
+
access: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
46
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
47
|
+
op: z.ZodLiteral<"setPageMeta">;
|
|
48
|
+
pageId: z.ZodString;
|
|
49
|
+
title: z.ZodOptional<z.ZodString>;
|
|
50
|
+
slug: z.ZodOptional<z.ZodString>;
|
|
51
|
+
showInNav: z.ZodOptional<z.ZodBoolean>;
|
|
52
|
+
access: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
53
|
+
}, z.core.$strip>], "op">;
|
|
54
|
+
export type IndexOp = z.infer<typeof IndexOpSchema>;
|
|
55
|
+
export type ApplyIndexOpsResult = {
|
|
56
|
+
ok: true;
|
|
57
|
+
index: SiteIndex;
|
|
58
|
+
removedIds: string[];
|
|
59
|
+
warnings: string[];
|
|
60
|
+
} | {
|
|
61
|
+
ok: false;
|
|
62
|
+
error: string;
|
|
63
|
+
};
|
|
64
|
+
/** Section-body types keyed by id, for the ids an insert op creates. */
|
|
65
|
+
export declare function collectPostedTypes(sections: {
|
|
66
|
+
id: string;
|
|
67
|
+
content: unknown;
|
|
68
|
+
}[] | undefined): Record<string, string>;
|
|
69
|
+
/**
|
|
70
|
+
* Every section id the ops name, derivable from the ops ALONE (no index read).
|
|
71
|
+
* Callers need this before applying anything: the collab broadcast has to tell
|
|
72
|
+
* browser editors which rows moved, and the audit trail has to record that an
|
|
73
|
+
* ops-only save touched anything at all. `setPageMeta` names no section.
|
|
74
|
+
*/
|
|
75
|
+
export declare function opTouchedSectionIds(ops: IndexOp[]): string[];
|
|
76
|
+
export declare function applyIndexOps(rawIndex: unknown, ops: IndexOp[], newSectionTypes: Record<string, string>): ApplyIndexOpsResult;
|
|
77
|
+
//# sourceMappingURL=index-ops.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index-ops.d.ts","sourceRoot":"","sources":["../../src/lib/index-ops.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAA+B,KAAK,SAAS,EAAoB,MAAM,wBAAwB,CAAC;AAgCvG,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;yBAqCxB,CAAC;AAEH,MAAM,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAEpD,MAAM,MAAM,mBAAmB,GAC3B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,SAAS,CAAC;IAAC,UAAU,EAAE,MAAM,EAAE,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,GACxE;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAEjC,wEAAwE;AACxE,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,EAAE,GAAG,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAOnH;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,MAAM,EAAE,CAQ5D;AAID,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,OAAO,EACjB,GAAG,EAAE,OAAO,EAAE,EACd,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GACtC,mBAAmB,CAsMrB"}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import {
|
|
2
|
+
IndexSchema,
|
|
3
|
+
RESERVED_SLUGS
|
|
4
|
+
} from "../chunk-RHWWQV3J.js";
|
|
5
|
+
import "../chunk-FM4M4Q2W.js";
|
|
6
|
+
import "../chunk-JSBRDJBE.js";
|
|
7
|
+
|
|
8
|
+
// src/lib/index-ops.ts
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
var OpStatusSchema = z.enum(["draft", "live", "archived"]);
|
|
11
|
+
var SAFE_SECTION_ID = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
12
|
+
var SectionInsertSchema = z.object({
|
|
13
|
+
id: z.string().regex(SAFE_SECTION_ID, "Section ids may only contain letters, numbers, dashes and underscores (max 64 chars) \u2014 they become filenames.").describe("The new section's id: letters, numbers, dashes, underscores; max 64 chars. Must not already exist in the index."),
|
|
14
|
+
status: OpStatusSchema.optional().describe('Defaults to "draft" \u2014 invisible to viewers until promoted to "live".'),
|
|
15
|
+
access: z.array(z.string()).optional().describe("Audience slugs from list_audiences. Defaults to [] (everyone).")
|
|
16
|
+
});
|
|
17
|
+
var IndexOpSchema = z.discriminatedUnion("op", [
|
|
18
|
+
z.object({
|
|
19
|
+
op: z.literal("insertAfter"),
|
|
20
|
+
pageId: z.string().describe("The page whose order receives the sections."),
|
|
21
|
+
afterId: z.string().nullable().describe("Insert directly after this section id, or null for the start of the page."),
|
|
22
|
+
sections: z.array(SectionInsertSchema).min(1).describe("Sections to create, in the order they should appear. Each needs its body in this call's `sections`.")
|
|
23
|
+
}),
|
|
24
|
+
z.object({
|
|
25
|
+
op: z.literal("insertAt"),
|
|
26
|
+
pageId: z.string().describe("The page whose order receives the sections."),
|
|
27
|
+
position: z.number().int().min(0).describe("0-based index in the page's order; equal to the order's length appends."),
|
|
28
|
+
sections: z.array(SectionInsertSchema).min(1).describe("Sections to create, in the order they should appear. Each needs its body in this call's `sections`.")
|
|
29
|
+
}),
|
|
30
|
+
z.object({
|
|
31
|
+
op: z.literal("move"),
|
|
32
|
+
pageId: z.string().describe("Destination page id (may be the section's current page)."),
|
|
33
|
+
id: z.string().describe("The existing section to move."),
|
|
34
|
+
afterId: z.string().nullable().describe("Place it directly after this section id in the destination page, or null for the start.")
|
|
35
|
+
}),
|
|
36
|
+
z.object({
|
|
37
|
+
op: z.literal("remove"),
|
|
38
|
+
ids: z.array(z.string()).min(1).describe("Sections to delete: removed from the page order AND from sections{}, and their content files are deleted.")
|
|
39
|
+
}),
|
|
40
|
+
z.object({
|
|
41
|
+
op: z.literal("setMeta"),
|
|
42
|
+
id: z.string().describe("An existing section id."),
|
|
43
|
+
status: OpStatusSchema.optional().describe('New status. "live" is what makes a section visible to viewers.'),
|
|
44
|
+
access: z.array(z.string()).optional().describe("New audience slugs from list_audiences; [] means everyone.")
|
|
45
|
+
}),
|
|
46
|
+
z.object({
|
|
47
|
+
op: z.literal("setPageMeta"),
|
|
48
|
+
pageId: z.string().describe("An existing page id."),
|
|
49
|
+
title: z.string().optional().describe("New page title."),
|
|
50
|
+
slug: z.string().optional().describe("New URL slug (lowercase words joined by dashes). Not settable on the home page, whose slug is always empty."),
|
|
51
|
+
showInNav: z.boolean().optional().describe("Whether the page appears in site navigation."),
|
|
52
|
+
access: z.array(z.string()).optional().describe("New audience slugs from list_audiences; [] means everyone.")
|
|
53
|
+
})
|
|
54
|
+
]);
|
|
55
|
+
function collectPostedTypes(sections) {
|
|
56
|
+
const types = {};
|
|
57
|
+
for (const s of sections ?? []) {
|
|
58
|
+
const type = s.content?.type;
|
|
59
|
+
if (typeof type === "string") types[s.id] = type;
|
|
60
|
+
}
|
|
61
|
+
return types;
|
|
62
|
+
}
|
|
63
|
+
function opTouchedSectionIds(ops) {
|
|
64
|
+
const ids = /* @__PURE__ */ new Set();
|
|
65
|
+
for (const op of ops) {
|
|
66
|
+
if (op.op === "insertAfter" || op.op === "insertAt") for (const s of op.sections) ids.add(s.id);
|
|
67
|
+
else if (op.op === "move" || op.op === "setMeta") ids.add(op.id);
|
|
68
|
+
else if (op.op === "remove") for (const id of op.ids) ids.add(id);
|
|
69
|
+
}
|
|
70
|
+
return [...ids];
|
|
71
|
+
}
|
|
72
|
+
var fmt = (ids) => ids.map((id) => `"${id}"`).join(", ");
|
|
73
|
+
function applyIndexOps(rawIndex, ops, newSectionTypes) {
|
|
74
|
+
const base = IndexSchema.safeParse(rawIndex);
|
|
75
|
+
if (!base.success) {
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
error: "the site's current index could not be parsed, so indexOps cannot be applied \u2014 read it with get_site_index and post a repaired full `index` instead. " + base.error.issues.map((i) => i.message).join("; ")
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const index = {
|
|
82
|
+
...base.data,
|
|
83
|
+
pages: base.data.pages.map((p) => ({ ...p, order: [...p.order] })),
|
|
84
|
+
sections: Object.fromEntries(Object.entries(base.data.sections).map(([k, v]) => [k, { ...v }]))
|
|
85
|
+
};
|
|
86
|
+
const removed = /* @__PURE__ */ new Set();
|
|
87
|
+
const warnings = [];
|
|
88
|
+
const fail = (error) => ({ ok: false, error });
|
|
89
|
+
for (const [i, op] of ops.entries()) {
|
|
90
|
+
const at = `indexOps[${i}] ${op.op}`;
|
|
91
|
+
if (op.op === "insertAfter" || op.op === "insertAt") {
|
|
92
|
+
const page2 = index.pages.find((p) => p.id === op.pageId);
|
|
93
|
+
if (!page2) return fail(`${at}: no page with id "${op.pageId}" \u2014 page ids come from get_site_index.`);
|
|
94
|
+
let position;
|
|
95
|
+
if (op.op === "insertAfter") {
|
|
96
|
+
if (op.afterId === null) position = 0;
|
|
97
|
+
else {
|
|
98
|
+
const idx = page2.order.indexOf(op.afterId);
|
|
99
|
+
if (idx === -1) return fail(`${at}: afterId "${op.afterId}" is not in page "${op.pageId}"'s order.`);
|
|
100
|
+
position = idx + 1;
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
if (op.position > page2.order.length) {
|
|
104
|
+
return fail(`${at}: position ${op.position} is out of range for page "${op.pageId}" (valid: 0..${page2.order.length}).`);
|
|
105
|
+
}
|
|
106
|
+
position = op.position;
|
|
107
|
+
}
|
|
108
|
+
const metas = [];
|
|
109
|
+
const seen = /* @__PURE__ */ new Set();
|
|
110
|
+
for (const s of op.sections) {
|
|
111
|
+
if (seen.has(s.id)) return fail(`${at}: section "${s.id}" is listed twice in this op's \`sections\`.`);
|
|
112
|
+
seen.add(s.id);
|
|
113
|
+
if (index.sections[s.id]) {
|
|
114
|
+
return fail(`${at}: section "${s.id}" already exists in the index \u2014 use move to relocate it, or setMeta to change its status/access.`);
|
|
115
|
+
}
|
|
116
|
+
if (!SAFE_SECTION_ID.test(s.id)) {
|
|
117
|
+
return fail(`${at}: section id "${s.id}" is not usable \u2014 ids become filenames, so they may only contain letters, numbers, dashes and underscores (max 64 chars).`);
|
|
118
|
+
}
|
|
119
|
+
const type = newSectionTypes[s.id];
|
|
120
|
+
if (!type) {
|
|
121
|
+
return fail(`${at}: section "${s.id}" has no usable body in this call \u2014 post its full body ({ "type": "...", "content": {...} }) in \`sections\` in the same save; an inserted section's type comes from the body.`);
|
|
122
|
+
}
|
|
123
|
+
const meta = { type, status: s.status ?? "draft", access: s.access ?? [] };
|
|
124
|
+
const stranded = page2.access.length > 0 ? meta.access.filter((a) => !page2.access.includes(a)) : [];
|
|
125
|
+
if (stranded.length > 0) {
|
|
126
|
+
warnings.push(
|
|
127
|
+
`${at}: section "${s.id}" carries audience(s) ${fmt(stranded)} that page "${op.pageId}" does not allow, so no audienced viewer will see it \u2014 clear it with setMeta, or widen the page's access.`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
metas.push([s.id, meta]);
|
|
131
|
+
}
|
|
132
|
+
page2.order.splice(position, 0, ...metas.map(([id]) => id));
|
|
133
|
+
for (const [id, meta] of metas) index.sections[id] = meta;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (op.op === "move") {
|
|
137
|
+
const meta = index.sections[op.id];
|
|
138
|
+
if (!meta) return fail(`${at}: section "${op.id}" is not in the index.`);
|
|
139
|
+
const dest = index.pages.find((p) => p.id === op.pageId);
|
|
140
|
+
if (!dest) return fail(`${at}: no page with id "${op.pageId}" \u2014 page ids come from get_site_index.`);
|
|
141
|
+
if (op.afterId === op.id) return fail(`${at}: cannot move section "${op.id}" after itself.`);
|
|
142
|
+
for (const p of index.pages) {
|
|
143
|
+
const idx = p.order.indexOf(op.id);
|
|
144
|
+
if (idx !== -1) p.order.splice(idx, 1);
|
|
145
|
+
}
|
|
146
|
+
let position;
|
|
147
|
+
if (op.afterId === null) position = 0;
|
|
148
|
+
else {
|
|
149
|
+
const idx = dest.order.indexOf(op.afterId);
|
|
150
|
+
if (idx === -1) return fail(`${at}: afterId "${op.afterId}" is not in destination page "${op.pageId}"'s order.`);
|
|
151
|
+
position = idx + 1;
|
|
152
|
+
}
|
|
153
|
+
dest.order.splice(position, 0, op.id);
|
|
154
|
+
const stranded = dest.access.length > 0 ? meta.access.filter((a) => !dest.access.includes(a)) : [];
|
|
155
|
+
if (stranded.length > 0) {
|
|
156
|
+
warnings.push(
|
|
157
|
+
`${at}: section "${op.id}" carries audience(s) ${fmt(stranded)} that page "${op.pageId}" does not allow, so no audienced viewer will see it \u2014 clear it with setMeta, or widen the page's access.`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (op.op === "remove") {
|
|
163
|
+
for (const id of op.ids) {
|
|
164
|
+
if (!index.sections[id]) return fail(`${at}: section "${id}" is not in the index.`);
|
|
165
|
+
delete index.sections[id];
|
|
166
|
+
for (const p of index.pages) {
|
|
167
|
+
const idx = p.order.indexOf(id);
|
|
168
|
+
if (idx !== -1) p.order.splice(idx, 1);
|
|
169
|
+
}
|
|
170
|
+
removed.add(id);
|
|
171
|
+
}
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (op.op === "setMeta") {
|
|
175
|
+
if (op.status === void 0 && op.access === void 0) {
|
|
176
|
+
return fail(`${at}: nothing to set \u2014 provide status and/or access.`);
|
|
177
|
+
}
|
|
178
|
+
const meta = index.sections[op.id];
|
|
179
|
+
if (!meta) return fail(`${at}: section "${op.id}" is not in the index.`);
|
|
180
|
+
if (op.status !== void 0) meta.status = op.status;
|
|
181
|
+
if (op.access !== void 0) meta.access = op.access;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (op.title === void 0 && op.slug === void 0 && op.showInNav === void 0 && op.access === void 0) {
|
|
185
|
+
return fail(`${at}: nothing to set \u2014 provide title, slug, showInNav and/or access.`);
|
|
186
|
+
}
|
|
187
|
+
const page = index.pages.find((p) => p.id === op.pageId);
|
|
188
|
+
if (!page) return fail(`${at}: no page with id "${op.pageId}" \u2014 page ids come from get_site_index.`);
|
|
189
|
+
if (op.slug !== void 0 && page.isHome) {
|
|
190
|
+
return fail(`${at}: the home page's slug is always empty and cannot be set. Promote a different page to home in the editor if you need to change which page is the root.`);
|
|
191
|
+
}
|
|
192
|
+
if (op.slug !== void 0 && RESERVED_SLUGS.includes(op.slug)) {
|
|
193
|
+
return fail(`${at}: slug "${op.slug}" is reserved by a platform route and would shadow this page. Pick another slug.`);
|
|
194
|
+
}
|
|
195
|
+
if (op.title !== void 0) page.title = op.title;
|
|
196
|
+
if (op.slug !== void 0) page.slug = op.slug;
|
|
197
|
+
if (op.showInNav !== void 0) page.showInNav = op.showInNav;
|
|
198
|
+
if (op.access !== void 0) {
|
|
199
|
+
const dropped = page.access.filter((a) => !op.access.includes(a));
|
|
200
|
+
page.access = op.access;
|
|
201
|
+
if (dropped.length > 0 && op.access.length > 0) {
|
|
202
|
+
const stranded = page.order.filter((id) => index.sections[id]?.access.some((a) => dropped.includes(a)));
|
|
203
|
+
if (stranded.length > 0) {
|
|
204
|
+
warnings.push(
|
|
205
|
+
`${at}: section(s) ${fmt(stranded)} still carry audience(s) ${fmt(dropped)} that page "${op.pageId}" no longer allows, so no audienced viewer will see them \u2014 clear them with setMeta.`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const removedIds = [...removed].filter((id) => !index.sections[id]);
|
|
212
|
+
const validated = IndexSchema.safeParse(index);
|
|
213
|
+
if (!validated.success) {
|
|
214
|
+
return fail(`applying indexOps produced an invalid index: ${validated.error.issues.map((issue) => issue.message).join("; ")}`);
|
|
215
|
+
}
|
|
216
|
+
return { ok: true, index: validated.data, removedIds, warnings };
|
|
217
|
+
}
|
|
218
|
+
export {
|
|
219
|
+
IndexOpSchema,
|
|
220
|
+
applyIndexOps,
|
|
221
|
+
collectPostedTypes,
|
|
222
|
+
opTouchedSectionIds
|
|
223
|
+
};
|
package/dist/lib/index.js
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
safeRedirect,
|
|
19
19
|
suggestAccessibleOn,
|
|
20
20
|
wcagLevel
|
|
21
|
-
} from "../chunk-
|
|
21
|
+
} from "../chunk-CS2ECR3U.js";
|
|
22
22
|
import {
|
|
23
23
|
curatedIcons,
|
|
24
24
|
getIcon
|
|
@@ -38,13 +38,12 @@ import {
|
|
|
38
38
|
import {
|
|
39
39
|
cn
|
|
40
40
|
} from "../chunk-7EOSDFS3.js";
|
|
41
|
-
import "../chunk-S2L3BPLS.js";
|
|
42
41
|
import {
|
|
43
42
|
audiencePasses
|
|
44
43
|
} from "../chunk-YBVK254P.js";
|
|
44
|
+
import "../chunk-S2L3BPLS.js";
|
|
45
45
|
import "../chunk-5BM2Y3NJ.js";
|
|
46
46
|
import "../chunk-TZQPOR5A.js";
|
|
47
|
-
import "../chunk-RHWWQV3J.js";
|
|
48
47
|
import {
|
|
49
48
|
clearRegistry,
|
|
50
49
|
createRegistry,
|
|
@@ -56,6 +55,7 @@ import {
|
|
|
56
55
|
registerSchema,
|
|
57
56
|
registerSection
|
|
58
57
|
} from "../chunk-VKAGMEKE.js";
|
|
58
|
+
import "../chunk-RHWWQV3J.js";
|
|
59
59
|
import "../chunk-FM4M4Q2W.js";
|
|
60
60
|
import "../chunk-NSCT3AMV.js";
|
|
61
61
|
import {
|
package/dist/schemas/index.js
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
getSectionContentSchema,
|
|
21
21
|
getSectionSchema
|
|
22
22
|
} from "../chunk-TZQPOR5A.js";
|
|
23
|
+
import "../chunk-VKAGMEKE.js";
|
|
23
24
|
import {
|
|
24
25
|
ChatbotConfigSchema,
|
|
25
26
|
ChatbotTaskSchema,
|
|
@@ -42,7 +43,6 @@ import {
|
|
|
42
43
|
VariantSchema,
|
|
43
44
|
normalizeSiteIndex
|
|
44
45
|
} from "../chunk-RHWWQV3J.js";
|
|
45
|
-
import "../chunk-VKAGMEKE.js";
|
|
46
46
|
import "../chunk-FM4M4Q2W.js";
|
|
47
47
|
import "../chunk-NSCT3AMV.js";
|
|
48
48
|
import "../chunk-JSBRDJBE.js";
|
package/dist/storage/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export type { StorageProvider, FileWrite, AssetStore } from "./types";
|
|
2
|
-
export { StorageConflictError, ASSET_CACHE_CONTROL } from "./types";
|
|
2
|
+
export { StorageConflictError, ASSET_CACHE_CONTROL, IDEMPOTENCY_TRAILER, IDEMPOTENCY_KEY_RE, withIdempotencyTrailer } from "./types";
|
|
3
3
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/storage/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,eAAe,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACtE,OAAO,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/storage/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,eAAe,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACtE,OAAO,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,sBAAsB,EAAE,MAAM,SAAS,CAAC"}
|
package/dist/storage/index.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ASSET_CACHE_CONTROL,
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
IDEMPOTENCY_KEY_RE,
|
|
4
|
+
IDEMPOTENCY_TRAILER,
|
|
5
|
+
StorageConflictError,
|
|
6
|
+
withIdempotencyTrailer
|
|
7
|
+
} from "../chunk-EUSWEVOA.js";
|
|
5
8
|
import "../chunk-JSBRDJBE.js";
|
|
6
9
|
export {
|
|
7
10
|
ASSET_CACHE_CONTROL,
|
|
8
|
-
|
|
11
|
+
IDEMPOTENCY_KEY_RE,
|
|
12
|
+
IDEMPOTENCY_TRAILER,
|
|
13
|
+
StorageConflictError,
|
|
14
|
+
withIdempotencyTrailer
|
|
9
15
|
};
|
package/dist/storage/types.d.ts
CHANGED
|
@@ -24,7 +24,51 @@ export interface StorageProvider {
|
|
|
24
24
|
listDirectory(path: string, options?: {
|
|
25
25
|
draft?: boolean;
|
|
26
26
|
}): Promise<string[]>;
|
|
27
|
+
/**
|
|
28
|
+
* Find a recent commit on the draft branch whose message carries the given
|
|
29
|
+
* `Idempotency-Key:` trailer, returning its sha (null if not found).
|
|
30
|
+
*
|
|
31
|
+
* This exists to make a retried write LEGIBLE, not to make it safe — safety is
|
|
32
|
+
* already `writeFiles`' baseVersion check, which a retry cannot get past once
|
|
33
|
+
* the original landed (the head moved, so the retry's baseVersion is stale).
|
|
34
|
+
* What the check cannot do is tell "your write failed" from "your write
|
|
35
|
+
* succeeded and you didn't hear back", and it reports both as a conflict. A
|
|
36
|
+
* caller that tagged its write can ask that question here.
|
|
37
|
+
*
|
|
38
|
+
* OPTIONAL: providers that cannot search history omit it, and callers fall
|
|
39
|
+
* back to surfacing the plain conflict.
|
|
40
|
+
*
|
|
41
|
+
* Best-effort by contract. It searches a BOUNDED window, so an old key reads
|
|
42
|
+
* as absent, and an implementation may answer "not found" when the history
|
|
43
|
+
* read itself fails — failing closed keeps the conflict rather than inventing
|
|
44
|
+
* a replay. So `null` means "could not confirm", NOT "your write did not
|
|
45
|
+
* land": a caller must not tell the user the earlier attempt failed.
|
|
46
|
+
*/
|
|
47
|
+
findCommitByIdempotencyKey?(key: string, options?: {
|
|
48
|
+
limit?: number;
|
|
49
|
+
}): Promise<string | null>;
|
|
27
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Commit-message trailer carrying a write's idempotency key. The commit itself is
|
|
53
|
+
* the store — no table, and no TTL to tune: keys live exactly as long as the
|
|
54
|
+
* draft branch, and `promoteDraft` deletes it, which is the correct lifetime
|
|
55
|
+
* (after a publish there is no draft, so an old write must not replay).
|
|
56
|
+
*/
|
|
57
|
+
export declare const IDEMPOTENCY_TRAILER = "Idempotency-Key";
|
|
58
|
+
/**
|
|
59
|
+
* Keys are interpolated into a commit message, so the charset is enforced HERE,
|
|
60
|
+
* at the interpolation point, and not only by the caller that happens to have a
|
|
61
|
+
* schema. A key containing a newline would let a caller append arbitrary further
|
|
62
|
+
* trailer lines to the commit.
|
|
63
|
+
*/
|
|
64
|
+
export declare const IDEMPOTENCY_KEY_RE: RegExp;
|
|
65
|
+
/**
|
|
66
|
+
* Build a commit message carrying `key` as a trailer, or return `subject`
|
|
67
|
+
* unchanged when no key is given. Throws on a malformed key: every caller
|
|
68
|
+
* reaches this through a write path that should have validated first, so an
|
|
69
|
+
* invalid key here is a bug, not user input to be tolerated.
|
|
70
|
+
*/
|
|
71
|
+
export declare function withIdempotencyTrailer(subject: string, key?: string): string;
|
|
28
72
|
export type FileWrite = {
|
|
29
73
|
path: string;
|
|
30
74
|
content: string | Uint8Array | null;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/storage/types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,qBAAa,oBAAqB,SAAQ,KAAK;aACjB,cAAc,EAAE,MAAM,GAAG,IAAI;gBAA7B,cAAc,EAAE,MAAM,GAAG,IAAI;CAI1D;AAED,MAAM,WAAW,eAAe;IAC9B,UAAU,CACR,KAAK,EAAE,SAAS,EAAE,EAClB,OAAO,EAAE,MAAM,EAIf,OAAO,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GACxC,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAEhC,QAAQ,CACN,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,GAC5B,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAAC;IAE9B,YAAY,IAAI,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAE7C,UAAU,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC;IAE9B,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/storage/types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,qBAAa,oBAAqB,SAAQ,KAAK;aACjB,cAAc,EAAE,MAAM,GAAG,IAAI;gBAA7B,cAAc,EAAE,MAAM,GAAG,IAAI;CAI1D;AAED,MAAM,WAAW,eAAe;IAC9B,UAAU,CACR,KAAK,EAAE,SAAS,EAAE,EAClB,OAAO,EAAE,MAAM,EAIf,OAAO,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GACxC,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAEhC,QAAQ,CACN,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,GAC5B,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAAC;IAE9B,YAAY,IAAI,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAE7C,UAAU,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC;IAE9B,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAE9E;;;;;;;;;;;;;;;;;;;OAmBG;IACH,0BAA0B,CAAC,CACzB,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GAC3B,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;CAC3B;AAED;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,oBAAoB,CAAC;AAErD;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,QAA6B,CAAC;AAE7D;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,CAM5E;AAED,MAAM,MAAM,SAAS,GAAG;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,GAAG,UAAU,GAAG,IAAI,CAAC;IACpC,QAAQ,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAC;CAC/B,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,mBAAmB,gCAAgC,CAAC;AAEjE;;;;;;GAMG;AACH,MAAM,WAAW,UAAU;IACzB,6DAA6D;IAC7D,eAAe,CACb,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE;QAAE,WAAW,EAAE,MAAM,CAAA;KAAE,GAC5B,OAAO,CAAC;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAEnE,oEAAoE;IACpE,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IAEnC,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEvC,sDAAsD;IACtD,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC,CAAC;CACpF"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drawnagency/primitives",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.96",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./package.json": "./package.json",
|
|
@@ -60,6 +60,10 @@
|
|
|
60
60
|
"types": "./dist/lib/registry.d.ts",
|
|
61
61
|
"default": "./dist/lib/registry.js"
|
|
62
62
|
},
|
|
63
|
+
"./lib/index-ops": {
|
|
64
|
+
"types": "./dist/lib/index-ops.d.ts",
|
|
65
|
+
"default": "./dist/lib/index-ops.js"
|
|
66
|
+
},
|
|
63
67
|
"./lib/media-folders": {
|
|
64
68
|
"types": "./dist/lib/media-folders.d.ts",
|
|
65
69
|
"default": "./dist/lib/media-folders.js"
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { IndexSchema, RESERVED_SLUGS, type SiteIndex, type SectionMeta } from "../schemas/site-config";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Server-applied index deltas for the MCP connector's save path. An agent
|
|
6
|
+
* cannot reliably hand-transcribe a real site's 40–51 KB index into a tool
|
|
7
|
+
* parameter (two consecutive attempts each introduced an id-level slip), so
|
|
8
|
+
* structural edits are expressed as ops and applied here against the index the
|
|
9
|
+
* write is concurrency-checked against.
|
|
10
|
+
*
|
|
11
|
+
* Pure by contract: no I/O, no storage, no octokit. The caller owns reading the
|
|
12
|
+
* base index at the right commit and committing the result.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// The three canonical statuses. Deliberately a plain enum rather than the
|
|
16
|
+
// schemas' StatusSchema (a ZodPipe with a "published" -> "live" transform):
|
|
17
|
+
// this shape is converted to JSON Schema for MCP clients, so it stays a flat
|
|
18
|
+
// enum, and reads never surface "published" anyway (they return normalized
|
|
19
|
+
// values).
|
|
20
|
+
const OpStatusSchema = z.enum(["draft", "live", "archived"]);
|
|
21
|
+
|
|
22
|
+
// Must match content-ops' SAFE_SECTION_ID: the id becomes a FILENAME
|
|
23
|
+
// (src/content/sections/<id>.json). Enforced here so a bad id is a named op
|
|
24
|
+
// error rather than a bare "[input] Invalid section ID" from deep in the write
|
|
25
|
+
// path, which names neither the op nor the id.
|
|
26
|
+
const SAFE_SECTION_ID = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
27
|
+
|
|
28
|
+
const SectionInsertSchema = z.object({
|
|
29
|
+
id: z.string().regex(SAFE_SECTION_ID, "Section ids may only contain letters, numbers, dashes and underscores (max 64 chars) — they become filenames.").describe("The new section's id: letters, numbers, dashes, underscores; max 64 chars. Must not already exist in the index."),
|
|
30
|
+
status: OpStatusSchema.optional().describe('Defaults to "draft" — invisible to viewers until promoted to "live".'),
|
|
31
|
+
access: z.array(z.string()).optional().describe("Audience slugs from list_audiences. Defaults to [] (everyone)."),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const IndexOpSchema = z.discriminatedUnion("op", [
|
|
35
|
+
z.object({
|
|
36
|
+
op: z.literal("insertAfter"),
|
|
37
|
+
pageId: z.string().describe("The page whose order receives the sections."),
|
|
38
|
+
afterId: z.string().nullable().describe("Insert directly after this section id, or null for the start of the page."),
|
|
39
|
+
sections: z.array(SectionInsertSchema).min(1).describe("Sections to create, in the order they should appear. Each needs its body in this call's `sections`."),
|
|
40
|
+
}),
|
|
41
|
+
z.object({
|
|
42
|
+
op: z.literal("insertAt"),
|
|
43
|
+
pageId: z.string().describe("The page whose order receives the sections."),
|
|
44
|
+
position: z.number().int().min(0).describe("0-based index in the page's order; equal to the order's length appends."),
|
|
45
|
+
sections: z.array(SectionInsertSchema).min(1).describe("Sections to create, in the order they should appear. Each needs its body in this call's `sections`."),
|
|
46
|
+
}),
|
|
47
|
+
z.object({
|
|
48
|
+
op: z.literal("move"),
|
|
49
|
+
pageId: z.string().describe("Destination page id (may be the section's current page)."),
|
|
50
|
+
id: z.string().describe("The existing section to move."),
|
|
51
|
+
afterId: z.string().nullable().describe("Place it directly after this section id in the destination page, or null for the start."),
|
|
52
|
+
}),
|
|
53
|
+
z.object({
|
|
54
|
+
op: z.literal("remove"),
|
|
55
|
+
ids: z.array(z.string()).min(1).describe("Sections to delete: removed from the page order AND from sections{}, and their content files are deleted."),
|
|
56
|
+
}),
|
|
57
|
+
z.object({
|
|
58
|
+
op: z.literal("setMeta"),
|
|
59
|
+
id: z.string().describe("An existing section id."),
|
|
60
|
+
status: OpStatusSchema.optional().describe('New status. "live" is what makes a section visible to viewers.'),
|
|
61
|
+
access: z.array(z.string()).optional().describe("New audience slugs from list_audiences; [] means everyone."),
|
|
62
|
+
}),
|
|
63
|
+
z.object({
|
|
64
|
+
op: z.literal("setPageMeta"),
|
|
65
|
+
pageId: z.string().describe("An existing page id."),
|
|
66
|
+
title: z.string().optional().describe("New page title."),
|
|
67
|
+
slug: z.string().optional().describe("New URL slug (lowercase words joined by dashes). Not settable on the home page, whose slug is always empty."),
|
|
68
|
+
showInNav: z.boolean().optional().describe("Whether the page appears in site navigation."),
|
|
69
|
+
access: z.array(z.string()).optional().describe("New audience slugs from list_audiences; [] means everyone."),
|
|
70
|
+
}),
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
export type IndexOp = z.infer<typeof IndexOpSchema>;
|
|
74
|
+
|
|
75
|
+
export type ApplyIndexOpsResult =
|
|
76
|
+
| { ok: true; index: SiteIndex; removedIds: string[]; warnings: string[] }
|
|
77
|
+
| { ok: false; error: string };
|
|
78
|
+
|
|
79
|
+
/** Section-body types keyed by id, for the ids an insert op creates. */
|
|
80
|
+
export function collectPostedTypes(sections: { id: string; content: unknown }[] | undefined): Record<string, string> {
|
|
81
|
+
const types: Record<string, string> = {};
|
|
82
|
+
for (const s of sections ?? []) {
|
|
83
|
+
const type = (s.content as { type?: unknown } | null | undefined)?.type;
|
|
84
|
+
if (typeof type === "string") types[s.id] = type;
|
|
85
|
+
}
|
|
86
|
+
return types;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Every section id the ops name, derivable from the ops ALONE (no index read).
|
|
91
|
+
* Callers need this before applying anything: the collab broadcast has to tell
|
|
92
|
+
* browser editors which rows moved, and the audit trail has to record that an
|
|
93
|
+
* ops-only save touched anything at all. `setPageMeta` names no section.
|
|
94
|
+
*/
|
|
95
|
+
export function opTouchedSectionIds(ops: IndexOp[]): string[] {
|
|
96
|
+
const ids = new Set<string>();
|
|
97
|
+
for (const op of ops) {
|
|
98
|
+
if (op.op === "insertAfter" || op.op === "insertAt") for (const s of op.sections) ids.add(s.id);
|
|
99
|
+
else if (op.op === "move" || op.op === "setMeta") ids.add(op.id);
|
|
100
|
+
else if (op.op === "remove") for (const id of op.ids) ids.add(id);
|
|
101
|
+
}
|
|
102
|
+
return [...ids];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const fmt = (ids: string[]) => ids.map((id) => `"${id}"`).join(", ");
|
|
106
|
+
|
|
107
|
+
export function applyIndexOps(
|
|
108
|
+
rawIndex: unknown,
|
|
109
|
+
ops: IndexOp[],
|
|
110
|
+
newSectionTypes: Record<string, string>,
|
|
111
|
+
): ApplyIndexOpsResult {
|
|
112
|
+
// Apply to the CANONICAL shape, not the raw one: a legacy index
|
|
113
|
+
// (`order`/`sections` with no `pages`) normalizes into a synthetic home page
|
|
114
|
+
// carrying those ids, and ops against the raw shape would find no pages at
|
|
115
|
+
// all and silently miss every id.
|
|
116
|
+
const base = IndexSchema.safeParse(rawIndex);
|
|
117
|
+
if (!base.success) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
error:
|
|
121
|
+
"the site's current index could not be parsed, so indexOps cannot be applied — read it with get_site_index and post a repaired full `index` instead. " +
|
|
122
|
+
base.error.issues.map((i) => i.message).join("; "),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Own copy: the caller's index is also its knownSiteTypes source and may be
|
|
127
|
+
// logged, so nothing here mutates it.
|
|
128
|
+
const index: SiteIndex = {
|
|
129
|
+
...base.data,
|
|
130
|
+
pages: base.data.pages.map((p) => ({ ...p, order: [...p.order] })),
|
|
131
|
+
sections: Object.fromEntries(Object.entries(base.data.sections).map(([k, v]) => [k, { ...v }])),
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const removed = new Set<string>();
|
|
135
|
+
const warnings: string[] = [];
|
|
136
|
+
const fail = (error: string): ApplyIndexOpsResult => ({ ok: false, error });
|
|
137
|
+
|
|
138
|
+
for (const [i, op] of ops.entries()) {
|
|
139
|
+
const at = `indexOps[${i}] ${op.op}`;
|
|
140
|
+
|
|
141
|
+
if (op.op === "insertAfter" || op.op === "insertAt") {
|
|
142
|
+
const page = index.pages.find((p) => p.id === op.pageId);
|
|
143
|
+
if (!page) return fail(`${at}: no page with id "${op.pageId}" — page ids come from get_site_index.`);
|
|
144
|
+
|
|
145
|
+
let position: number;
|
|
146
|
+
if (op.op === "insertAfter") {
|
|
147
|
+
if (op.afterId === null) position = 0;
|
|
148
|
+
else {
|
|
149
|
+
const idx = page.order.indexOf(op.afterId);
|
|
150
|
+
if (idx === -1) return fail(`${at}: afterId "${op.afterId}" is not in page "${op.pageId}"'s order.`);
|
|
151
|
+
position = idx + 1;
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
if (op.position > page.order.length) {
|
|
155
|
+
return fail(`${at}: position ${op.position} is out of range for page "${op.pageId}" (valid: 0..${page.order.length}).`);
|
|
156
|
+
}
|
|
157
|
+
position = op.position;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const metas: [string, SectionMeta][] = [];
|
|
161
|
+
// Ids in THIS op are not in `index.sections` yet, so the existence check
|
|
162
|
+
// below cannot see them — without a local seen-set a repeated id splices
|
|
163
|
+
// twice and only fails at the final re-validation, as "appears in more
|
|
164
|
+
// than one page order" with no op index and a misleading description.
|
|
165
|
+
const seen = new Set<string>();
|
|
166
|
+
for (const s of op.sections) {
|
|
167
|
+
if (seen.has(s.id)) return fail(`${at}: section "${s.id}" is listed twice in this op's \`sections\`.`);
|
|
168
|
+
seen.add(s.id);
|
|
169
|
+
if (index.sections[s.id]) {
|
|
170
|
+
return fail(`${at}: section "${s.id}" already exists in the index — use move to relocate it, or setMeta to change its status/access.`);
|
|
171
|
+
}
|
|
172
|
+
if (!SAFE_SECTION_ID.test(s.id)) {
|
|
173
|
+
return fail(`${at}: section id "${s.id}" is not usable — ids become filenames, so they may only contain letters, numbers, dashes and underscores (max 64 chars).`);
|
|
174
|
+
}
|
|
175
|
+
const type = newSectionTypes[s.id];
|
|
176
|
+
if (!type) {
|
|
177
|
+
// Covers both "no body posted" and "body posted but its `type` is
|
|
178
|
+
// missing or not a string" — collectPostedTypes only records usable
|
|
179
|
+
// string types, so this cannot tell them apart, and the old wording
|
|
180
|
+
// ("has no body") sent an agent hunting for a body that was right there.
|
|
181
|
+
return fail(`${at}: section "${s.id}" has no usable body in this call — post its full body ({ "type": "...", "content": {...} }) in \`sections\` in the same save; an inserted section's type comes from the body.`);
|
|
182
|
+
}
|
|
183
|
+
const meta: SectionMeta = { type, status: s.status ?? "draft", access: s.access ?? [] };
|
|
184
|
+
// Same advisory as `move`: an inserted section whose audiences the
|
|
185
|
+
// destination page does not allow is invisible to every audienced
|
|
186
|
+
// viewer, and saying nothing would make that look like a successful save.
|
|
187
|
+
const stranded = page.access.length > 0 ? meta.access.filter((a) => !page.access.includes(a)) : [];
|
|
188
|
+
if (stranded.length > 0) {
|
|
189
|
+
warnings.push(
|
|
190
|
+
`${at}: section "${s.id}" carries audience(s) ${fmt(stranded)} that page "${op.pageId}" does not allow, so no audienced viewer will see it — clear it with setMeta, or widen the page's access.`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
metas.push([s.id, meta]);
|
|
194
|
+
}
|
|
195
|
+
page.order.splice(position, 0, ...metas.map(([id]) => id));
|
|
196
|
+
for (const [id, meta] of metas) index.sections[id] = meta;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (op.op === "move") {
|
|
201
|
+
const meta = index.sections[op.id];
|
|
202
|
+
if (!meta) return fail(`${at}: section "${op.id}" is not in the index.`);
|
|
203
|
+
const dest = index.pages.find((p) => p.id === op.pageId);
|
|
204
|
+
if (!dest) return fail(`${at}: no page with id "${op.pageId}" — page ids come from get_site_index.`);
|
|
205
|
+
if (op.afterId === op.id) return fail(`${at}: cannot move section "${op.id}" after itself.`);
|
|
206
|
+
|
|
207
|
+
for (const p of index.pages) {
|
|
208
|
+
const idx = p.order.indexOf(op.id);
|
|
209
|
+
if (idx !== -1) p.order.splice(idx, 1);
|
|
210
|
+
}
|
|
211
|
+
let position: number;
|
|
212
|
+
if (op.afterId === null) position = 0;
|
|
213
|
+
else {
|
|
214
|
+
const idx = dest.order.indexOf(op.afterId);
|
|
215
|
+
if (idx === -1) return fail(`${at}: afterId "${op.afterId}" is not in destination page "${op.pageId}"'s order.`);
|
|
216
|
+
position = idx + 1;
|
|
217
|
+
}
|
|
218
|
+
dest.order.splice(position, 0, op.id);
|
|
219
|
+
|
|
220
|
+
// Advisory, never a silent rewrite: the editor's moveSection resets an
|
|
221
|
+
// incompatible section access, but an API doing that invisibly would
|
|
222
|
+
// change who can see content without saying so. "no audienced viewer",
|
|
223
|
+
// not "everyone" — audiencePasses returns true for a viewer with no
|
|
224
|
+
// audience at all (lib/pages.ts).
|
|
225
|
+
const stranded = dest.access.length > 0 ? meta.access.filter((a) => !dest.access.includes(a)) : [];
|
|
226
|
+
if (stranded.length > 0) {
|
|
227
|
+
warnings.push(
|
|
228
|
+
`${at}: section "${op.id}" carries audience(s) ${fmt(stranded)} that page "${op.pageId}" does not allow, so no audienced viewer will see it — clear it with setMeta, or widen the page's access.`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (op.op === "remove") {
|
|
235
|
+
for (const id of op.ids) {
|
|
236
|
+
if (!index.sections[id]) return fail(`${at}: section "${id}" is not in the index.`);
|
|
237
|
+
delete index.sections[id];
|
|
238
|
+
for (const p of index.pages) {
|
|
239
|
+
const idx = p.order.indexOf(id);
|
|
240
|
+
if (idx !== -1) p.order.splice(idx, 1);
|
|
241
|
+
}
|
|
242
|
+
removed.add(id);
|
|
243
|
+
}
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (op.op === "setMeta") {
|
|
248
|
+
if (op.status === undefined && op.access === undefined) {
|
|
249
|
+
return fail(`${at}: nothing to set — provide status and/or access.`);
|
|
250
|
+
}
|
|
251
|
+
const meta = index.sections[op.id];
|
|
252
|
+
if (!meta) return fail(`${at}: section "${op.id}" is not in the index.`);
|
|
253
|
+
if (op.status !== undefined) meta.status = op.status;
|
|
254
|
+
if (op.access !== undefined) meta.access = op.access;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// setPageMeta
|
|
259
|
+
if (op.title === undefined && op.slug === undefined && op.showInNav === undefined && op.access === undefined) {
|
|
260
|
+
return fail(`${at}: nothing to set — provide title, slug, showInNav and/or access.`);
|
|
261
|
+
}
|
|
262
|
+
const page = index.pages.find((p) => p.id === op.pageId);
|
|
263
|
+
if (!page) return fail(`${at}: no page with id "${op.pageId}" — page ids come from get_site_index.`);
|
|
264
|
+
if (op.slug !== undefined && page.isHome) {
|
|
265
|
+
return fail(`${at}: the home page's slug is always empty and cannot be set. Promote a different page to home in the editor if you need to change which page is the root.`);
|
|
266
|
+
}
|
|
267
|
+
// IndexSchema hard-rejects only the five slugs reserved before client sites
|
|
268
|
+
// existed (PARSE_REJECTED_SLUGS) — the newer ones ("present", "print") are
|
|
269
|
+
// enforced at AUTHORING time on purpose, so a grandfathered index still
|
|
270
|
+
// loads (see schemas/site-config.ts). This is an authoring surface, so it
|
|
271
|
+
// owns that enforcement; without it a page silently disappears behind an
|
|
272
|
+
// injected platform route with no error anywhere.
|
|
273
|
+
if (op.slug !== undefined && (RESERVED_SLUGS as readonly string[]).includes(op.slug)) {
|
|
274
|
+
return fail(`${at}: slug "${op.slug}" is reserved by a platform route and would shadow this page. Pick another slug.`);
|
|
275
|
+
}
|
|
276
|
+
if (op.title !== undefined) page.title = op.title;
|
|
277
|
+
if (op.slug !== undefined) page.slug = op.slug;
|
|
278
|
+
if (op.showInNav !== undefined) page.showInNav = op.showInNav;
|
|
279
|
+
if (op.access !== undefined) {
|
|
280
|
+
const dropped = page.access.filter((a) => !op.access!.includes(a));
|
|
281
|
+
page.access = op.access;
|
|
282
|
+
if (dropped.length > 0 && op.access.length > 0) {
|
|
283
|
+
const stranded = page.order.filter((id) => index.sections[id]?.access.some((a) => dropped.includes(a)));
|
|
284
|
+
if (stranded.length > 0) {
|
|
285
|
+
warnings.push(
|
|
286
|
+
`${at}: section(s) ${fmt(stranded)} still carry audience(s) ${fmt(dropped)} that page "${op.pageId}" no longer allows, so no audienced viewer will see them — clear them with setMeta.`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// A remove followed by a re-insert of the same id is a net no-op for files:
|
|
294
|
+
// reporting it for deletion would delete the file the resulting index
|
|
295
|
+
// references, and content-ops' stillReferenced guard would reject the write.
|
|
296
|
+
const removedIds = [...removed].filter((id) => !index.sections[id]);
|
|
297
|
+
|
|
298
|
+
// Ops cannot break the order/sections partition by construction, but they can
|
|
299
|
+
// break page-level invariants the ops above do not check themselves: a
|
|
300
|
+
// duplicate slug, an invalid slug shape, one of the five parse-rejected legacy
|
|
301
|
+
// slugs. Re-run the canonical validation so the caller gets those named here
|
|
302
|
+
// rather than as an opaque ZodError from the write path. (The NEWER reserved
|
|
303
|
+
// slugs are not in that set — setPageMeta checks them itself, above.)
|
|
304
|
+
const validated = IndexSchema.safeParse(index);
|
|
305
|
+
if (!validated.success) {
|
|
306
|
+
return fail(`applying indexOps produced an invalid index: ${validated.error.issues.map((issue) => issue.message).join("; ")}`);
|
|
307
|
+
}
|
|
308
|
+
return { ok: true, index: validated.data, removedIds, warnings };
|
|
309
|
+
}
|
package/src/storage/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export type { StorageProvider, FileWrite, AssetStore } from "./types";
|
|
2
|
-
export { StorageConflictError, ASSET_CACHE_CONTROL } from "./types";
|
|
2
|
+
export { StorageConflictError, ASSET_CACHE_CONTROL, IDEMPOTENCY_TRAILER, IDEMPOTENCY_KEY_RE, withIdempotencyTrailer } from "./types";
|
package/src/storage/types.ts
CHANGED
|
@@ -31,6 +31,61 @@ export interface StorageProvider {
|
|
|
31
31
|
getVersion(): Promise<string>;
|
|
32
32
|
|
|
33
33
|
listDirectory(path: string, options?: { draft?: boolean }): Promise<string[]>;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Find a recent commit on the draft branch whose message carries the given
|
|
37
|
+
* `Idempotency-Key:` trailer, returning its sha (null if not found).
|
|
38
|
+
*
|
|
39
|
+
* This exists to make a retried write LEGIBLE, not to make it safe — safety is
|
|
40
|
+
* already `writeFiles`' baseVersion check, which a retry cannot get past once
|
|
41
|
+
* the original landed (the head moved, so the retry's baseVersion is stale).
|
|
42
|
+
* What the check cannot do is tell "your write failed" from "your write
|
|
43
|
+
* succeeded and you didn't hear back", and it reports both as a conflict. A
|
|
44
|
+
* caller that tagged its write can ask that question here.
|
|
45
|
+
*
|
|
46
|
+
* OPTIONAL: providers that cannot search history omit it, and callers fall
|
|
47
|
+
* back to surfacing the plain conflict.
|
|
48
|
+
*
|
|
49
|
+
* Best-effort by contract. It searches a BOUNDED window, so an old key reads
|
|
50
|
+
* as absent, and an implementation may answer "not found" when the history
|
|
51
|
+
* read itself fails — failing closed keeps the conflict rather than inventing
|
|
52
|
+
* a replay. So `null` means "could not confirm", NOT "your write did not
|
|
53
|
+
* land": a caller must not tell the user the earlier attempt failed.
|
|
54
|
+
*/
|
|
55
|
+
findCommitByIdempotencyKey?(
|
|
56
|
+
key: string,
|
|
57
|
+
options?: { limit?: number },
|
|
58
|
+
): Promise<string | null>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Commit-message trailer carrying a write's idempotency key. The commit itself is
|
|
63
|
+
* the store — no table, and no TTL to tune: keys live exactly as long as the
|
|
64
|
+
* draft branch, and `promoteDraft` deletes it, which is the correct lifetime
|
|
65
|
+
* (after a publish there is no draft, so an old write must not replay).
|
|
66
|
+
*/
|
|
67
|
+
export const IDEMPOTENCY_TRAILER = "Idempotency-Key";
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Keys are interpolated into a commit message, so the charset is enforced HERE,
|
|
71
|
+
* at the interpolation point, and not only by the caller that happens to have a
|
|
72
|
+
* schema. A key containing a newline would let a caller append arbitrary further
|
|
73
|
+
* trailer lines to the commit.
|
|
74
|
+
*/
|
|
75
|
+
export const IDEMPOTENCY_KEY_RE = /^[A-Za-z0-9._:-]{8,200}$/;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build a commit message carrying `key` as a trailer, or return `subject`
|
|
79
|
+
* unchanged when no key is given. Throws on a malformed key: every caller
|
|
80
|
+
* reaches this through a write path that should have validated first, so an
|
|
81
|
+
* invalid key here is a bug, not user input to be tolerated.
|
|
82
|
+
*/
|
|
83
|
+
export function withIdempotencyTrailer(subject: string, key?: string): string {
|
|
84
|
+
if (!key) return subject;
|
|
85
|
+
if (!IDEMPOTENCY_KEY_RE.test(key)) {
|
|
86
|
+
throw new Error("Invalid idempotency key: expected 8-200 chars of A-Za-z0-9._:-");
|
|
87
|
+
}
|
|
88
|
+
return `${subject}\n\n${IDEMPOTENCY_TRAILER}: ${key}`;
|
|
34
89
|
}
|
|
35
90
|
|
|
36
91
|
export type FileWrite = {
|
package/dist/chunk-VAOW4WXQ.js
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
// src/storage/types.ts
|
|
2
|
-
var StorageConflictError = class extends Error {
|
|
3
|
-
constructor(currentVersion) {
|
|
4
|
-
super("Draft has been modified since it was loaded");
|
|
5
|
-
this.currentVersion = currentVersion;
|
|
6
|
-
this.name = "StorageConflictError";
|
|
7
|
-
}
|
|
8
|
-
currentVersion;
|
|
9
|
-
};
|
|
10
|
-
var ASSET_CACHE_CONTROL = "max-age=31536000, immutable";
|
|
11
|
-
|
|
12
|
-
export {
|
|
13
|
-
StorageConflictError,
|
|
14
|
-
ASSET_CACHE_CONTROL
|
|
15
|
-
};
|
|
@@ -7,12 +7,12 @@ import {
|
|
|
7
7
|
import {
|
|
8
8
|
getSectionSchema
|
|
9
9
|
} from "./chunk-TZQPOR5A.js";
|
|
10
|
-
import {
|
|
11
|
-
IndexSchema
|
|
12
|
-
} from "./chunk-RHWWQV3J.js";
|
|
13
10
|
import {
|
|
14
11
|
getAllSchemas
|
|
15
12
|
} from "./chunk-VKAGMEKE.js";
|
|
13
|
+
import {
|
|
14
|
+
IndexSchema
|
|
15
|
+
} from "./chunk-RHWWQV3J.js";
|
|
16
16
|
import {
|
|
17
17
|
MAX_BLOCK_DEPTH,
|
|
18
18
|
blockDepth
|
|
@@ -27,13 +27,13 @@ import {
|
|
|
27
27
|
import {
|
|
28
28
|
cn
|
|
29
29
|
} from "./chunk-7EOSDFS3.js";
|
|
30
|
+
import {
|
|
31
|
+
defineSection
|
|
32
|
+
} from "./chunk-VKAGMEKE.js";
|
|
30
33
|
import {
|
|
31
34
|
HexColorSchema,
|
|
32
35
|
SingleMediaReferenceSchema
|
|
33
36
|
} from "./chunk-RHWWQV3J.js";
|
|
34
|
-
import {
|
|
35
|
-
defineSection
|
|
36
|
-
} from "./chunk-VKAGMEKE.js";
|
|
37
37
|
|
|
38
38
|
// src/components/sections/Colors/index.tsx
|
|
39
39
|
import { z as z2 } from "zod";
|