agent-cms 0.1.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/LICENSE +21 -0
- package/PROMPT.md +55 -0
- package/README.md +220 -0
- package/dist/handler-ClOW1ldA.mjs +5703 -0
- package/dist/http-transport-DbFCI6Cs.mjs +993 -0
- package/dist/index.d.mts +213 -0
- package/dist/index.mjs +1170 -0
- package/dist/structured-text-service-B4xSlUg_.mjs +1952 -0
- package/dist/token-service-BDjccMmz.mjs +3820 -0
- package/migrations/0000_genesis.sql +118 -0
- package/package.json +98 -0
|
@@ -0,0 +1,1952 @@
|
|
|
1
|
+
import { Data, Effect, ParseResult, Schema } from "effect";
|
|
2
|
+
import { SqlClient, SqlError } from "@effect/sql";
|
|
3
|
+
import * as Either from "effect/Either";
|
|
4
|
+
import { unified } from "unified";
|
|
5
|
+
import remarkParse from "remark-parse";
|
|
6
|
+
import "remark-stringify";
|
|
7
|
+
import remarkGfm from "remark-gfm";
|
|
8
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
9
|
+
//#region src/errors.ts
|
|
10
|
+
/** Model/field/record not found */
|
|
11
|
+
var NotFoundError = class extends Data.TaggedError("NotFoundError") {};
|
|
12
|
+
/** Validation error (field values, DAST, API input) */
|
|
13
|
+
var ValidationError = class extends Data.TaggedError("ValidationError") {};
|
|
14
|
+
/** Reference conflict — trying to delete something that's referenced */
|
|
15
|
+
var ReferenceConflictError = class extends Data.TaggedError("ReferenceConflictError") {};
|
|
16
|
+
/** Duplicate — e.g., apiKey already exists */
|
|
17
|
+
var DuplicateError = class extends Data.TaggedError("DuplicateError") {};
|
|
18
|
+
/** Schema engine error — DDL failed, migration issue */
|
|
19
|
+
var SchemaEngineError = class extends Data.TaggedError("SchemaEngineError") {};
|
|
20
|
+
/** Unauthorized access to a protected API surface */
|
|
21
|
+
var UnauthorizedError = class extends Data.TaggedError("UnauthorizedError") {};
|
|
22
|
+
/** Runtime type guard for CmsError — uses instanceof, no duck-typing */
|
|
23
|
+
function isCmsError(error) {
|
|
24
|
+
return error instanceof NotFoundError || error instanceof ValidationError || error instanceof ReferenceConflictError || error instanceof DuplicateError || error instanceof SchemaEngineError || error instanceof UnauthorizedError;
|
|
25
|
+
}
|
|
26
|
+
/** Map a CMS error to an HTTP status code and JSON body */
|
|
27
|
+
function errorToResponse(error) {
|
|
28
|
+
switch (error._tag) {
|
|
29
|
+
case "NotFoundError": return {
|
|
30
|
+
status: 404,
|
|
31
|
+
body: { error: `${error.entity} not found: ${error.id}` }
|
|
32
|
+
};
|
|
33
|
+
case "ValidationError": return {
|
|
34
|
+
status: 400,
|
|
35
|
+
body: { error: error.message }
|
|
36
|
+
};
|
|
37
|
+
case "ReferenceConflictError": return {
|
|
38
|
+
status: 409,
|
|
39
|
+
body: { error: error.message }
|
|
40
|
+
};
|
|
41
|
+
case "DuplicateError": return {
|
|
42
|
+
status: 409,
|
|
43
|
+
body: { error: error.message }
|
|
44
|
+
};
|
|
45
|
+
case "SchemaEngineError": return {
|
|
46
|
+
status: 500,
|
|
47
|
+
body: { error: error.message }
|
|
48
|
+
};
|
|
49
|
+
case "UnauthorizedError": return {
|
|
50
|
+
status: 401,
|
|
51
|
+
body: { error: error.message }
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
//#endregion
|
|
56
|
+
//#region src/field-types.ts
|
|
57
|
+
/**
|
|
58
|
+
* Typed field type registry.
|
|
59
|
+
*
|
|
60
|
+
* Each field type defines its static properties in one place:
|
|
61
|
+
* SQL storage, GraphQL type, filter type, validation schema, etc.
|
|
62
|
+
*
|
|
63
|
+
* The "type sandwich":
|
|
64
|
+
* - Top: system tables (models, fields) are statically typed
|
|
65
|
+
* - Middle: which fields exist on which models is dynamic (runtime)
|
|
66
|
+
* - Bottom: each field type's shape is statically known (this file)
|
|
67
|
+
*/
|
|
68
|
+
/** Effect Schemas for composite field type validation */
|
|
69
|
+
const ColorSchema = Schema.Struct({
|
|
70
|
+
red: Schema.Number.pipe(Schema.int(), Schema.between(0, 255)),
|
|
71
|
+
green: Schema.Number.pipe(Schema.int(), Schema.between(0, 255)),
|
|
72
|
+
blue: Schema.Number.pipe(Schema.int(), Schema.between(0, 255)),
|
|
73
|
+
alpha: Schema.optional(Schema.Number.pipe(Schema.int(), Schema.between(0, 255)))
|
|
74
|
+
});
|
|
75
|
+
const LatLonSchema = Schema.Struct({
|
|
76
|
+
latitude: Schema.Number.pipe(Schema.between(-90, 90)),
|
|
77
|
+
longitude: Schema.Number.pipe(Schema.between(-180, 180))
|
|
78
|
+
});
|
|
79
|
+
const SeoSchema = Schema.Struct({
|
|
80
|
+
title: Schema.optional(Schema.String),
|
|
81
|
+
description: Schema.optional(Schema.String),
|
|
82
|
+
image: Schema.optional(Schema.String),
|
|
83
|
+
twitterCard: Schema.optional(Schema.String)
|
|
84
|
+
});
|
|
85
|
+
const MediaFocalPointSchema = Schema.Struct({
|
|
86
|
+
x: Schema.Number.pipe(Schema.between(0, 1)),
|
|
87
|
+
y: Schema.Number.pipe(Schema.between(0, 1))
|
|
88
|
+
});
|
|
89
|
+
const MediaFieldObjectSchema = Schema.Struct({
|
|
90
|
+
upload_id: Schema.String,
|
|
91
|
+
alt: Schema.optional(Schema.NullOr(Schema.String)),
|
|
92
|
+
title: Schema.optional(Schema.NullOr(Schema.String)),
|
|
93
|
+
focal_point: Schema.optional(Schema.NullOr(MediaFocalPointSchema)),
|
|
94
|
+
custom_data: Schema.optional(Schema.NullOr(Schema.Record({
|
|
95
|
+
key: Schema.String,
|
|
96
|
+
value: Schema.Unknown
|
|
97
|
+
})))
|
|
98
|
+
});
|
|
99
|
+
const MediaFieldSchema = Schema.Union(Schema.String, MediaFieldObjectSchema);
|
|
100
|
+
const MediaGalleryFieldSchema = Schema.Array(MediaFieldSchema);
|
|
101
|
+
function isValidIsoDate(value) {
|
|
102
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
|
103
|
+
const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
|
|
104
|
+
return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
|
|
105
|
+
}
|
|
106
|
+
function isValidIsoDateTime(value) {
|
|
107
|
+
if (!/^\d{4}-\d{2}-\d{2}T/.test(value)) return false;
|
|
108
|
+
return !Number.isNaN(Date.parse(value));
|
|
109
|
+
}
|
|
110
|
+
const DateSchema = Schema.String.pipe(Schema.filter(isValidIsoDate, { message: () => "expected ISO date string YYYY-MM-DD" }));
|
|
111
|
+
const DateTimeSchema = Schema.String.pipe(Schema.filter(isValidIsoDateTime, { message: () => "expected ISO datetime string" }));
|
|
112
|
+
/**
|
|
113
|
+
* The field type registry — one entry per field type.
|
|
114
|
+
* Add a new field type here and TypeScript will enforce all properties are defined.
|
|
115
|
+
*/
|
|
116
|
+
const FIELD_TYPE_REGISTRY = {
|
|
117
|
+
string: {
|
|
118
|
+
sqliteType: "TEXT",
|
|
119
|
+
graphqlType: "String",
|
|
120
|
+
filterType: "StringFilter",
|
|
121
|
+
localizable: true,
|
|
122
|
+
multiLocaleType: "StringMultiLocaleField",
|
|
123
|
+
inputSchema: null,
|
|
124
|
+
jsonStored: false,
|
|
125
|
+
hasCustomResolver: false
|
|
126
|
+
},
|
|
127
|
+
text: {
|
|
128
|
+
sqliteType: "TEXT",
|
|
129
|
+
graphqlType: "String",
|
|
130
|
+
filterType: "StringFilter",
|
|
131
|
+
localizable: true,
|
|
132
|
+
multiLocaleType: "StringMultiLocaleField",
|
|
133
|
+
inputSchema: null,
|
|
134
|
+
jsonStored: false,
|
|
135
|
+
hasCustomResolver: false
|
|
136
|
+
},
|
|
137
|
+
boolean: {
|
|
138
|
+
sqliteType: "INTEGER",
|
|
139
|
+
graphqlType: "Boolean",
|
|
140
|
+
filterType: "BooleanFilter",
|
|
141
|
+
localizable: true,
|
|
142
|
+
multiLocaleType: "BooleanMultiLocaleField",
|
|
143
|
+
inputSchema: Schema.Boolean,
|
|
144
|
+
jsonStored: false,
|
|
145
|
+
hasCustomResolver: false
|
|
146
|
+
},
|
|
147
|
+
integer: {
|
|
148
|
+
sqliteType: "INTEGER",
|
|
149
|
+
graphqlType: "Int",
|
|
150
|
+
filterType: "IntFilter",
|
|
151
|
+
localizable: true,
|
|
152
|
+
multiLocaleType: "IntMultiLocaleField",
|
|
153
|
+
inputSchema: Schema.Number.pipe(Schema.int()),
|
|
154
|
+
jsonStored: false,
|
|
155
|
+
hasCustomResolver: false
|
|
156
|
+
},
|
|
157
|
+
float: {
|
|
158
|
+
sqliteType: "REAL",
|
|
159
|
+
graphqlType: "Float",
|
|
160
|
+
filterType: "FloatFilter",
|
|
161
|
+
localizable: true,
|
|
162
|
+
multiLocaleType: "FloatMultiLocaleField",
|
|
163
|
+
inputSchema: Schema.Number,
|
|
164
|
+
jsonStored: false,
|
|
165
|
+
hasCustomResolver: false
|
|
166
|
+
},
|
|
167
|
+
slug: {
|
|
168
|
+
sqliteType: "TEXT",
|
|
169
|
+
graphqlType: "String",
|
|
170
|
+
filterType: "StringFilter",
|
|
171
|
+
localizable: true,
|
|
172
|
+
multiLocaleType: "StringMultiLocaleField",
|
|
173
|
+
inputSchema: null,
|
|
174
|
+
jsonStored: false,
|
|
175
|
+
hasCustomResolver: false
|
|
176
|
+
},
|
|
177
|
+
date: {
|
|
178
|
+
sqliteType: "TEXT",
|
|
179
|
+
graphqlType: "String",
|
|
180
|
+
filterType: "StringFilter",
|
|
181
|
+
localizable: true,
|
|
182
|
+
multiLocaleType: "StringMultiLocaleField",
|
|
183
|
+
inputSchema: DateSchema,
|
|
184
|
+
jsonStored: false,
|
|
185
|
+
hasCustomResolver: false
|
|
186
|
+
},
|
|
187
|
+
date_time: {
|
|
188
|
+
sqliteType: "TEXT",
|
|
189
|
+
graphqlType: "String",
|
|
190
|
+
filterType: "StringFilter",
|
|
191
|
+
localizable: true,
|
|
192
|
+
multiLocaleType: "StringMultiLocaleField",
|
|
193
|
+
inputSchema: DateTimeSchema,
|
|
194
|
+
jsonStored: false,
|
|
195
|
+
hasCustomResolver: false
|
|
196
|
+
},
|
|
197
|
+
media: {
|
|
198
|
+
sqliteType: "TEXT",
|
|
199
|
+
graphqlType: "Asset",
|
|
200
|
+
filterType: "LinkFilter",
|
|
201
|
+
localizable: false,
|
|
202
|
+
multiLocaleType: "StringMultiLocaleField",
|
|
203
|
+
inputSchema: MediaFieldSchema,
|
|
204
|
+
jsonStored: false,
|
|
205
|
+
hasCustomResolver: true
|
|
206
|
+
},
|
|
207
|
+
media_gallery: {
|
|
208
|
+
sqliteType: "TEXT",
|
|
209
|
+
graphqlType: "[Asset!]",
|
|
210
|
+
filterType: "LinksFilter",
|
|
211
|
+
localizable: false,
|
|
212
|
+
multiLocaleType: "JsonMultiLocaleField",
|
|
213
|
+
inputSchema: MediaGalleryFieldSchema,
|
|
214
|
+
jsonStored: true,
|
|
215
|
+
hasCustomResolver: true
|
|
216
|
+
},
|
|
217
|
+
link: {
|
|
218
|
+
sqliteType: "TEXT",
|
|
219
|
+
graphqlType: null,
|
|
220
|
+
filterType: "LinkFilter",
|
|
221
|
+
localizable: false,
|
|
222
|
+
multiLocaleType: "StringMultiLocaleField",
|
|
223
|
+
inputSchema: null,
|
|
224
|
+
jsonStored: false,
|
|
225
|
+
hasCustomResolver: true
|
|
226
|
+
},
|
|
227
|
+
links: {
|
|
228
|
+
sqliteType: "TEXT",
|
|
229
|
+
graphqlType: null,
|
|
230
|
+
filterType: "LinksFilter",
|
|
231
|
+
localizable: false,
|
|
232
|
+
multiLocaleType: "JsonMultiLocaleField",
|
|
233
|
+
inputSchema: null,
|
|
234
|
+
jsonStored: true,
|
|
235
|
+
hasCustomResolver: true
|
|
236
|
+
},
|
|
237
|
+
structured_text: {
|
|
238
|
+
sqliteType: "TEXT",
|
|
239
|
+
graphqlType: "StructuredText",
|
|
240
|
+
filterType: "TextFilter",
|
|
241
|
+
localizable: false,
|
|
242
|
+
multiLocaleType: "JsonMultiLocaleField",
|
|
243
|
+
inputSchema: null,
|
|
244
|
+
jsonStored: true,
|
|
245
|
+
hasCustomResolver: true
|
|
246
|
+
},
|
|
247
|
+
seo: {
|
|
248
|
+
sqliteType: "TEXT",
|
|
249
|
+
graphqlType: "SeoField",
|
|
250
|
+
filterType: "ExistsFilter",
|
|
251
|
+
localizable: false,
|
|
252
|
+
multiLocaleType: "SeoMultiLocaleField",
|
|
253
|
+
inputSchema: SeoSchema,
|
|
254
|
+
jsonStored: true,
|
|
255
|
+
hasCustomResolver: true
|
|
256
|
+
},
|
|
257
|
+
json: {
|
|
258
|
+
sqliteType: "TEXT",
|
|
259
|
+
graphqlType: "JSON",
|
|
260
|
+
filterType: "ExistsFilter",
|
|
261
|
+
localizable: false,
|
|
262
|
+
multiLocaleType: "JsonMultiLocaleField",
|
|
263
|
+
inputSchema: null,
|
|
264
|
+
jsonStored: true,
|
|
265
|
+
hasCustomResolver: false
|
|
266
|
+
},
|
|
267
|
+
color: {
|
|
268
|
+
sqliteType: "TEXT",
|
|
269
|
+
graphqlType: "ColorField",
|
|
270
|
+
filterType: "ExistsFilter",
|
|
271
|
+
localizable: false,
|
|
272
|
+
multiLocaleType: "JsonMultiLocaleField",
|
|
273
|
+
inputSchema: ColorSchema,
|
|
274
|
+
jsonStored: true,
|
|
275
|
+
hasCustomResolver: true
|
|
276
|
+
},
|
|
277
|
+
lat_lon: {
|
|
278
|
+
sqliteType: "TEXT",
|
|
279
|
+
graphqlType: "LatLonField",
|
|
280
|
+
filterType: "LatLonFilter",
|
|
281
|
+
localizable: false,
|
|
282
|
+
multiLocaleType: "JsonMultiLocaleField",
|
|
283
|
+
inputSchema: LatLonSchema,
|
|
284
|
+
jsonStored: true,
|
|
285
|
+
hasCustomResolver: true
|
|
286
|
+
},
|
|
287
|
+
video: {
|
|
288
|
+
sqliteType: "TEXT",
|
|
289
|
+
graphqlType: "VideoField",
|
|
290
|
+
filterType: "ExistsFilter",
|
|
291
|
+
localizable: false,
|
|
292
|
+
multiLocaleType: "JsonMultiLocaleField",
|
|
293
|
+
inputSchema: null,
|
|
294
|
+
jsonStored: false,
|
|
295
|
+
hasCustomResolver: true
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
/** Get the registry definition for a field type */
|
|
299
|
+
function getFieldTypeDef(fieldType) {
|
|
300
|
+
return FIELD_TYPE_REGISTRY[fieldType];
|
|
301
|
+
}
|
|
302
|
+
//#endregion
|
|
303
|
+
//#region src/json.ts
|
|
304
|
+
const UnknownJson = Schema.parseJson();
|
|
305
|
+
const JsonRecord = Schema.Record({
|
|
306
|
+
key: Schema.String,
|
|
307
|
+
value: Schema.Unknown
|
|
308
|
+
});
|
|
309
|
+
const JsonRecordString = Schema.parseJson(JsonRecord);
|
|
310
|
+
function encodeJson(value) {
|
|
311
|
+
return Schema.encodeSync(UnknownJson)(value);
|
|
312
|
+
}
|
|
313
|
+
function decodeJsonString(input) {
|
|
314
|
+
return Schema.decodeUnknownSync(UnknownJson)(input);
|
|
315
|
+
}
|
|
316
|
+
function tryDecodeJsonString(input) {
|
|
317
|
+
const result = Schema.decodeUnknownEither(UnknownJson)(input);
|
|
318
|
+
return Either.isRight(result) ? {
|
|
319
|
+
ok: true,
|
|
320
|
+
value: result.right
|
|
321
|
+
} : { ok: false };
|
|
322
|
+
}
|
|
323
|
+
function decodeJsonStringOr(input, fallback) {
|
|
324
|
+
const parsed = tryDecodeJsonString(input);
|
|
325
|
+
return parsed.ok ? parsed.value : fallback;
|
|
326
|
+
}
|
|
327
|
+
function decodeJsonRecordStringOr(input, fallback) {
|
|
328
|
+
const result = Schema.decodeUnknownEither(JsonRecordString)(input);
|
|
329
|
+
return Either.isRight(result) ? result.right : fallback;
|
|
330
|
+
}
|
|
331
|
+
function decodeJsonIfString(value) {
|
|
332
|
+
if (typeof value !== "string") return value;
|
|
333
|
+
return decodeJsonStringOr(value, value);
|
|
334
|
+
}
|
|
335
|
+
//#endregion
|
|
336
|
+
//#region src/media-field.ts
|
|
337
|
+
function parseMediaFieldReference(value) {
|
|
338
|
+
const parsed = decodeJsonIfString(value);
|
|
339
|
+
if (typeof parsed === "string") return parsed.length > 0 ? { uploadId: parsed } : null;
|
|
340
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
341
|
+
const objectValue = parsed;
|
|
342
|
+
const uploadId = typeof objectValue.upload_id === "string" ? objectValue.upload_id : null;
|
|
343
|
+
if (!uploadId) return null;
|
|
344
|
+
return {
|
|
345
|
+
uploadId,
|
|
346
|
+
alt: typeof objectValue.alt === "string" || objectValue.alt === null ? objectValue.alt : void 0,
|
|
347
|
+
title: typeof objectValue.title === "string" || objectValue.title === null ? objectValue.title : void 0,
|
|
348
|
+
focalPoint: isFocalPoint(objectValue.focal_point) || objectValue.focal_point === null ? objectValue.focal_point ?? null : void 0,
|
|
349
|
+
customData: isJsonRecord(objectValue.custom_data) || objectValue.custom_data === null ? objectValue.custom_data ?? null : void 0
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
function parseMediaGalleryReferences(value) {
|
|
353
|
+
const parsed = decodeJsonIfString(value);
|
|
354
|
+
if (!Array.isArray(parsed)) return [];
|
|
355
|
+
return parsed.map((entry) => parseMediaFieldReference(entry)).filter((entry) => entry !== null);
|
|
356
|
+
}
|
|
357
|
+
function mergeAssetWithMediaReference(asset, reference, assetUrl) {
|
|
358
|
+
const defaultCustomData = asset.custom_data ? decodeJsonStringOr(asset.custom_data, null) : null;
|
|
359
|
+
const defaultFocalPoint = asset.focal_point ? decodeJsonStringOr(asset.focal_point, null) : null;
|
|
360
|
+
return {
|
|
361
|
+
id: asset.id,
|
|
362
|
+
filename: asset.filename,
|
|
363
|
+
mimeType: asset.mime_type,
|
|
364
|
+
size: asset.size,
|
|
365
|
+
width: asset.width,
|
|
366
|
+
height: asset.height,
|
|
367
|
+
alt: reference?.alt ?? asset.alt,
|
|
368
|
+
title: reference?.title ?? asset.title,
|
|
369
|
+
blurhash: asset.blurhash ?? null,
|
|
370
|
+
focalPoint: isFocalPoint(reference?.focalPoint) ? reference.focalPoint : isFocalPoint(defaultFocalPoint) ? defaultFocalPoint : null,
|
|
371
|
+
customData: isJsonRecord(reference?.customData) ? reference.customData : isJsonRecord(defaultCustomData) ? defaultCustomData : null,
|
|
372
|
+
tags: Array.isArray(decodeJsonStringOr(asset.tags, [])) ? decodeJsonStringOr(asset.tags, []).filter((value) => typeof value === "string") : [],
|
|
373
|
+
url: assetUrl(asset.r2_key),
|
|
374
|
+
_createdAt: asset.created_at,
|
|
375
|
+
_updatedAt: asset.updated_at,
|
|
376
|
+
_createdBy: asset.created_by,
|
|
377
|
+
_updatedBy: asset.updated_by
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
function isJsonRecord(value) {
|
|
381
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
382
|
+
}
|
|
383
|
+
function isFocalPoint(value) {
|
|
384
|
+
return isJsonRecord(value) && typeof value.x === "number" && typeof value.y === "number";
|
|
385
|
+
}
|
|
386
|
+
//#endregion
|
|
387
|
+
//#region src/db/validators.ts
|
|
388
|
+
/**
|
|
389
|
+
* Typed access to field validator properties.
|
|
390
|
+
* Instead of casting with `as`, these functions safely extract
|
|
391
|
+
* known validator properties with runtime checks.
|
|
392
|
+
*/
|
|
393
|
+
/** Safely get the slug source field from validators */
|
|
394
|
+
function getSlugSource(validators) {
|
|
395
|
+
const v = validators.slug_source;
|
|
396
|
+
return typeof v === "string" ? v : void 0;
|
|
397
|
+
}
|
|
398
|
+
/** Safely get the structured_text_blocks whitelist */
|
|
399
|
+
function getBlockWhitelist(validators) {
|
|
400
|
+
const v = validators.structured_text_blocks;
|
|
401
|
+
return Array.isArray(v) && v.every((x) => typeof x === "string") ? v : void 0;
|
|
402
|
+
}
|
|
403
|
+
/** Safely get the blocks_only flag */
|
|
404
|
+
function getBlocksOnly(validators) {
|
|
405
|
+
return validators.blocks_only === true;
|
|
406
|
+
}
|
|
407
|
+
/** Safely check if field is required */
|
|
408
|
+
function isRequired(validators) {
|
|
409
|
+
return validators.required === true;
|
|
410
|
+
}
|
|
411
|
+
/** Safely check if field must be unique */
|
|
412
|
+
function isUnique(validators) {
|
|
413
|
+
return validators.unique === true;
|
|
414
|
+
}
|
|
415
|
+
/** Safely get link target model types (for `link` fields) */
|
|
416
|
+
function getLinkTargets(validators) {
|
|
417
|
+
const v = validators.item_item_type;
|
|
418
|
+
return Array.isArray(v) && v.every((x) => typeof x === "string") ? v : void 0;
|
|
419
|
+
}
|
|
420
|
+
/** Safely get links target model types (for `links` fields) */
|
|
421
|
+
function getLinksTargets(validators) {
|
|
422
|
+
const v = validators.items_item_type;
|
|
423
|
+
return Array.isArray(v) && v.every((x) => typeof x === "string") ? v : void 0;
|
|
424
|
+
}
|
|
425
|
+
/** Check if field is searchable (default: true — opt out with {"searchable": false}) */
|
|
426
|
+
function isSearchable(validators) {
|
|
427
|
+
return validators.searchable !== false;
|
|
428
|
+
}
|
|
429
|
+
/** Field types where exact-value uniqueness is supported */
|
|
430
|
+
function supportsUniqueValidation(fieldType) {
|
|
431
|
+
return [
|
|
432
|
+
"string",
|
|
433
|
+
"text",
|
|
434
|
+
"slug",
|
|
435
|
+
"integer",
|
|
436
|
+
"float",
|
|
437
|
+
"boolean",
|
|
438
|
+
"date",
|
|
439
|
+
"date_time",
|
|
440
|
+
"link",
|
|
441
|
+
"media"
|
|
442
|
+
].includes(fieldType);
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Compute whether a record is valid (all required fields have values).
|
|
446
|
+
* For localized required fields, checks the default locale key in the JSON map.
|
|
447
|
+
* When allLocales is provided, checks all locale keys (for all_locales_required models).
|
|
448
|
+
* Returns { valid, missingFields } where missingFields lists api_keys that are missing.
|
|
449
|
+
*/
|
|
450
|
+
function computeIsValid(record, fields, defaultLocale, allLocales) {
|
|
451
|
+
const missingFields = [];
|
|
452
|
+
for (const field of fields) {
|
|
453
|
+
const value = record[field.api_key];
|
|
454
|
+
let fieldInvalid = false;
|
|
455
|
+
if (field.localized && defaultLocale) {
|
|
456
|
+
let localeMap = value;
|
|
457
|
+
if (typeof localeMap === "string") try {
|
|
458
|
+
localeMap = JSON.parse(localeMap);
|
|
459
|
+
} catch {
|
|
460
|
+
missingFields.push(field.api_key);
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (typeof localeMap !== "object" || localeMap === null) {
|
|
464
|
+
missingFields.push(field.api_key);
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
const map = localeMap;
|
|
468
|
+
const localesToCheck = allLocales ?? [defaultLocale];
|
|
469
|
+
for (const locale of localesToCheck) {
|
|
470
|
+
const locValue = map[locale];
|
|
471
|
+
if (!isValueValidForField(locValue, field.field_type, field.validators)) {
|
|
472
|
+
fieldInvalid = true;
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
} else fieldInvalid = !isValueValidForField(value, field.field_type, field.validators);
|
|
477
|
+
if (fieldInvalid) missingFields.push(field.api_key);
|
|
478
|
+
}
|
|
479
|
+
return {
|
|
480
|
+
valid: missingFields.length === 0,
|
|
481
|
+
missingFields
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
function isValueValidForField(value, fieldType, validators) {
|
|
485
|
+
if (isRequired(validators) && !hasMeaningfulValue(value)) return false;
|
|
486
|
+
if (!hasMeaningfulValue(value)) return true;
|
|
487
|
+
if (!passesEnumValidation(value, validators)) return false;
|
|
488
|
+
if (!passesLengthValidation(value, fieldType, validators)) return false;
|
|
489
|
+
if (!passesNumberRangeValidation(value, fieldType, validators)) return false;
|
|
490
|
+
if (!passesFormatValidation(value, fieldType, validators)) return false;
|
|
491
|
+
if (!passesDateRangeValidation(value, fieldType, validators)) return false;
|
|
492
|
+
return true;
|
|
493
|
+
}
|
|
494
|
+
function passesEnumValidation(value, validators) {
|
|
495
|
+
const enumValues = validators.enum;
|
|
496
|
+
if (!Array.isArray(enumValues) || !enumValues.every((entry) => typeof entry === "string")) return true;
|
|
497
|
+
return typeof value === "string" && enumValues.includes(value);
|
|
498
|
+
}
|
|
499
|
+
function passesLengthValidation(value, fieldType, validators) {
|
|
500
|
+
if (![
|
|
501
|
+
"string",
|
|
502
|
+
"text",
|
|
503
|
+
"slug"
|
|
504
|
+
].includes(fieldType)) return true;
|
|
505
|
+
const lengthConfig = validators.length;
|
|
506
|
+
if (typeof lengthConfig !== "object" || lengthConfig === null || Array.isArray(lengthConfig)) return true;
|
|
507
|
+
const length = lengthConfig;
|
|
508
|
+
if (typeof value !== "string") return false;
|
|
509
|
+
const min = typeof length.min === "number" ? length.min : void 0;
|
|
510
|
+
const max = typeof length.max === "number" ? length.max : void 0;
|
|
511
|
+
if (min !== void 0 && value.length < min) return false;
|
|
512
|
+
if (max !== void 0 && value.length > max) return false;
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
function passesNumberRangeValidation(value, fieldType, validators) {
|
|
516
|
+
if (!["integer", "float"].includes(fieldType)) return true;
|
|
517
|
+
const rangeConfig = validators.number_range;
|
|
518
|
+
if (typeof rangeConfig !== "object" || rangeConfig === null || Array.isArray(rangeConfig)) return true;
|
|
519
|
+
const range = rangeConfig;
|
|
520
|
+
if (typeof value !== "number") return false;
|
|
521
|
+
const min = typeof range.min === "number" ? range.min : void 0;
|
|
522
|
+
const max = typeof range.max === "number" ? range.max : void 0;
|
|
523
|
+
if (min !== void 0 && value < min) return false;
|
|
524
|
+
if (max !== void 0 && value > max) return false;
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
function passesFormatValidation(value, fieldType, validators) {
|
|
528
|
+
if (![
|
|
529
|
+
"string",
|
|
530
|
+
"text",
|
|
531
|
+
"slug"
|
|
532
|
+
].includes(fieldType) || typeof value !== "string") return true;
|
|
533
|
+
const format = validators.format;
|
|
534
|
+
if (!format) return true;
|
|
535
|
+
if (format === "email") return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
|
536
|
+
if (format === "url") try {
|
|
537
|
+
const parsed = new URL(value);
|
|
538
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
539
|
+
} catch {
|
|
540
|
+
return false;
|
|
541
|
+
}
|
|
542
|
+
if (typeof format === "object" && !Array.isArray(format) && typeof format.custom_pattern === "string") try {
|
|
543
|
+
return new RegExp(format.custom_pattern).test(value);
|
|
544
|
+
} catch {
|
|
545
|
+
return false;
|
|
546
|
+
}
|
|
547
|
+
return true;
|
|
548
|
+
}
|
|
549
|
+
function passesDateRangeValidation(value, fieldType, validators) {
|
|
550
|
+
if (!["date", "date_time"].includes(fieldType) || typeof value !== "string") return true;
|
|
551
|
+
const rangeConfig = validators.date_range;
|
|
552
|
+
if (typeof rangeConfig !== "object" || rangeConfig === null || Array.isArray(rangeConfig)) return true;
|
|
553
|
+
const range = rangeConfig;
|
|
554
|
+
const valueTime = parseDateValue(value);
|
|
555
|
+
if (valueTime === null) return false;
|
|
556
|
+
const minTime = parseDateBoundary(range.min);
|
|
557
|
+
const maxTime = parseDateBoundary(range.max);
|
|
558
|
+
if (minTime !== null && valueTime < minTime) return false;
|
|
559
|
+
if (maxTime !== null && valueTime > maxTime) return false;
|
|
560
|
+
return true;
|
|
561
|
+
}
|
|
562
|
+
function parseDateValue(value) {
|
|
563
|
+
const time = Date.parse(value);
|
|
564
|
+
return Number.isNaN(time) ? null : time;
|
|
565
|
+
}
|
|
566
|
+
function parseDateBoundary(value) {
|
|
567
|
+
if (value === void 0) return null;
|
|
568
|
+
if (value === "now") return Date.now();
|
|
569
|
+
if (typeof value !== "string") return null;
|
|
570
|
+
return parseDateValue(value);
|
|
571
|
+
}
|
|
572
|
+
function findUniqueConstraintViolations(options) {
|
|
573
|
+
return Effect.gen(function* () {
|
|
574
|
+
const sql = yield* SqlClient.SqlClient;
|
|
575
|
+
const invalidFields = /* @__PURE__ */ new Set();
|
|
576
|
+
for (const field of options.fields) {
|
|
577
|
+
if (!isUnique(field.validators) || !supportsUniqueValidation(field.field_type)) continue;
|
|
578
|
+
if (options.onlyFieldApiKeys && !options.onlyFieldApiKeys.has(field.api_key)) continue;
|
|
579
|
+
const value = options.record[field.api_key];
|
|
580
|
+
if (field.localized) {
|
|
581
|
+
const localeMap = parseLocaleMap(value);
|
|
582
|
+
for (const [localeCode, localeValue] of Object.entries(localeMap)) {
|
|
583
|
+
if (!hasMeaningfulValue(localeValue)) continue;
|
|
584
|
+
const path = `$."${localeCode.replace(/"/g, "\\\"")}"`;
|
|
585
|
+
if ((yield* sql.unsafe(`SELECT id FROM "${options.tableName}" WHERE json_extract("${field.api_key}", ?) = ?${options.excludeId ? " AND id != ?" : ""} LIMIT 1`, options.excludeId ? [
|
|
586
|
+
path,
|
|
587
|
+
serializeUniqueValue(localeValue),
|
|
588
|
+
options.excludeId
|
|
589
|
+
] : [path, serializeUniqueValue(localeValue)])).length > 0) {
|
|
590
|
+
invalidFields.add(field.api_key);
|
|
591
|
+
break;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
if (!hasMeaningfulValue(value)) continue;
|
|
597
|
+
const comparableValue = field.field_type === "media" ? parseMediaFieldReference(value)?.uploadId ?? value : value;
|
|
598
|
+
if ((yield* sql.unsafe(`SELECT id FROM "${options.tableName}" WHERE (CASE WHEN json_valid("${field.api_key}") AND json_type("${field.api_key}") = 'object' THEN json_extract("${field.api_key}", '$.upload_id') ELSE "${field.api_key}" END) = ?${options.excludeId ? " AND id != ?" : ""} LIMIT 1`, options.excludeId ? [serializeUniqueValue(comparableValue), options.excludeId] : [serializeUniqueValue(comparableValue)])).length > 0) invalidFields.add(field.api_key);
|
|
599
|
+
}
|
|
600
|
+
return [...invalidFields];
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
function parseLocaleMap(value) {
|
|
604
|
+
if (value === null || value === void 0) return {};
|
|
605
|
+
return typeof value === "string" ? decodeJsonRecordStringOr(value, {}) : value;
|
|
606
|
+
}
|
|
607
|
+
function hasMeaningfulValue(value) {
|
|
608
|
+
return value !== null && value !== void 0 && value !== "";
|
|
609
|
+
}
|
|
610
|
+
function serializeUniqueValue(value) {
|
|
611
|
+
if (typeof value === "boolean") return value ? 1 : 0;
|
|
612
|
+
return value;
|
|
613
|
+
}
|
|
614
|
+
//#endregion
|
|
615
|
+
//#region src/db/row-types.ts
|
|
616
|
+
function isContentRow(row) {
|
|
617
|
+
return typeof row === "object" && row !== null && "id" in row && "_status" in row;
|
|
618
|
+
}
|
|
619
|
+
/** Runtime check that a value is a plain object record */
|
|
620
|
+
function isRecord(value) {
|
|
621
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
622
|
+
}
|
|
623
|
+
/** Safely parse JSON to a Record, returning empty object on failure */
|
|
624
|
+
function parseJsonRecord(json) {
|
|
625
|
+
if (!json) return {};
|
|
626
|
+
try {
|
|
627
|
+
const parsed = JSON.parse(json);
|
|
628
|
+
if (isRecord(parsed)) return parsed;
|
|
629
|
+
} catch {}
|
|
630
|
+
return {};
|
|
631
|
+
}
|
|
632
|
+
/** Parse a FieldRow's validators from JSON string to object */
|
|
633
|
+
function parseFieldValidators(field) {
|
|
634
|
+
return {
|
|
635
|
+
...field,
|
|
636
|
+
validators: parseJsonRecord(field.validators)
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
//#endregion
|
|
640
|
+
//#region src/dast/schema.ts
|
|
641
|
+
/**
|
|
642
|
+
* Effect Schema definitions for DAST documents.
|
|
643
|
+
* Comprehensive recursive schemas for full validation of DAST nodes.
|
|
644
|
+
*/
|
|
645
|
+
const MarkSchema = Schema.Literal("strong", "emphasis", "underline", "strikethrough", "code", "highlight");
|
|
646
|
+
const LinkMetaEntry = Schema.Struct({
|
|
647
|
+
id: Schema.String,
|
|
648
|
+
value: Schema.String
|
|
649
|
+
});
|
|
650
|
+
const SpanNodeSchema = Schema.Struct({
|
|
651
|
+
type: Schema.Literal("span"),
|
|
652
|
+
value: Schema.String,
|
|
653
|
+
marks: Schema.optionalWith(Schema.Array(MarkSchema), { exact: true })
|
|
654
|
+
});
|
|
655
|
+
const LinkNodeSchema = Schema.Struct({
|
|
656
|
+
type: Schema.Literal("link"),
|
|
657
|
+
url: Schema.NonEmptyString,
|
|
658
|
+
meta: Schema.optionalWith(Schema.Array(LinkMetaEntry), { exact: true }),
|
|
659
|
+
children: Schema.Array(SpanNodeSchema)
|
|
660
|
+
});
|
|
661
|
+
const ItemLinkNodeSchema = Schema.Struct({
|
|
662
|
+
type: Schema.Literal("itemLink"),
|
|
663
|
+
item: Schema.NonEmptyString,
|
|
664
|
+
meta: Schema.optionalWith(Schema.Array(LinkMetaEntry), { exact: true }),
|
|
665
|
+
children: Schema.Array(SpanNodeSchema)
|
|
666
|
+
});
|
|
667
|
+
const InlineItemNodeSchema = Schema.Struct({
|
|
668
|
+
type: Schema.Literal("inlineItem"),
|
|
669
|
+
item: Schema.NonEmptyString
|
|
670
|
+
});
|
|
671
|
+
const InlineBlockNodeSchema = Schema.Struct({
|
|
672
|
+
type: Schema.Literal("inlineBlock"),
|
|
673
|
+
item: Schema.NonEmptyString
|
|
674
|
+
});
|
|
675
|
+
const InlineNodeSchema = Schema.Union(SpanNodeSchema, LinkNodeSchema, ItemLinkNodeSchema, InlineItemNodeSchema, InlineBlockNodeSchema);
|
|
676
|
+
const ParagraphNodeSchema = Schema.Struct({
|
|
677
|
+
type: Schema.Literal("paragraph"),
|
|
678
|
+
style: Schema.optionalWith(Schema.String, { exact: true }),
|
|
679
|
+
children: Schema.Array(InlineNodeSchema)
|
|
680
|
+
});
|
|
681
|
+
const HeadingLevel = Schema.Number.pipe(Schema.int(), Schema.greaterThanOrEqualTo(1), Schema.lessThanOrEqualTo(6));
|
|
682
|
+
const HeadingNodeSchema = Schema.Struct({
|
|
683
|
+
type: Schema.Literal("heading"),
|
|
684
|
+
level: HeadingLevel,
|
|
685
|
+
style: Schema.optionalWith(Schema.String, { exact: true }),
|
|
686
|
+
children: Schema.Array(InlineNodeSchema)
|
|
687
|
+
});
|
|
688
|
+
const ListNodeSchema = Schema.suspend(() => Schema.Struct({
|
|
689
|
+
type: Schema.Literal("list"),
|
|
690
|
+
style: Schema.Literal("bulleted", "numbered"),
|
|
691
|
+
children: Schema.Array(ListItemNodeSchema)
|
|
692
|
+
}));
|
|
693
|
+
const ListItemNodeSchema = Schema.suspend(() => Schema.Struct({
|
|
694
|
+
type: Schema.Literal("listItem"),
|
|
695
|
+
children: Schema.Array(Schema.Union(ParagraphNodeSchema, ListNodeSchema))
|
|
696
|
+
}));
|
|
697
|
+
const BlockquoteNodeSchema = Schema.Struct({
|
|
698
|
+
type: Schema.Literal("blockquote"),
|
|
699
|
+
attribution: Schema.optionalWith(Schema.String, { exact: true }),
|
|
700
|
+
children: Schema.Array(ParagraphNodeSchema)
|
|
701
|
+
});
|
|
702
|
+
const CodeNodeSchema = Schema.Struct({
|
|
703
|
+
type: Schema.Literal("code"),
|
|
704
|
+
code: Schema.String,
|
|
705
|
+
language: Schema.optionalWith(Schema.String, { exact: true }),
|
|
706
|
+
highlight: Schema.optionalWith(Schema.Array(Schema.Number), { exact: true })
|
|
707
|
+
});
|
|
708
|
+
const ThematicBreakNodeSchema = Schema.Struct({ type: Schema.Literal("thematicBreak") });
|
|
709
|
+
const BlockRefNodeSchema = Schema.Struct({
|
|
710
|
+
type: Schema.Literal("block"),
|
|
711
|
+
item: Schema.NonEmptyString
|
|
712
|
+
});
|
|
713
|
+
const TableCellNodeSchema = Schema.Struct({
|
|
714
|
+
type: Schema.Literal("tableCell"),
|
|
715
|
+
children: Schema.Array(Schema.Union(ParagraphNodeSchema, InlineNodeSchema))
|
|
716
|
+
});
|
|
717
|
+
const TableRowNodeSchema = Schema.Struct({
|
|
718
|
+
type: Schema.Literal("tableRow"),
|
|
719
|
+
children: Schema.NonEmptyArray(TableCellNodeSchema)
|
|
720
|
+
});
|
|
721
|
+
const TableNodeSchema = Schema.Struct({
|
|
722
|
+
type: Schema.Literal("table"),
|
|
723
|
+
children: Schema.NonEmptyArray(TableRowNodeSchema)
|
|
724
|
+
});
|
|
725
|
+
const BlockLevelNodeSchema = Schema.Union(ParagraphNodeSchema, HeadingNodeSchema, ListNodeSchema, BlockquoteNodeSchema, CodeNodeSchema, ThematicBreakNodeSchema, BlockRefNodeSchema, TableNodeSchema);
|
|
726
|
+
const RootNodeSchema = Schema.Struct({
|
|
727
|
+
type: Schema.Literal("root"),
|
|
728
|
+
children: Schema.Array(BlockLevelNodeSchema)
|
|
729
|
+
});
|
|
730
|
+
const DastDocumentSchema = Schema.Struct({
|
|
731
|
+
schema: Schema.Literal("dast"),
|
|
732
|
+
document: RootNodeSchema
|
|
733
|
+
});
|
|
734
|
+
/** Decode a StructuredText write payload */
|
|
735
|
+
const StructuredTextWriteInput = Schema.Struct({
|
|
736
|
+
value: DastDocumentSchema,
|
|
737
|
+
blocks: Schema.optionalWith(Schema.Record({
|
|
738
|
+
key: Schema.String,
|
|
739
|
+
value: Schema.Unknown
|
|
740
|
+
}), { default: () => ({}) })
|
|
741
|
+
});
|
|
742
|
+
//#endregion
|
|
743
|
+
//#region src/dast/validate.ts
|
|
744
|
+
/**
|
|
745
|
+
* Validate that a DAST document only contains block nodes at root level.
|
|
746
|
+
*/
|
|
747
|
+
function validateBlocksOnly(doc) {
|
|
748
|
+
const decoded = Schema.decodeUnknownEither(DastDocumentSchema)(doc);
|
|
749
|
+
if (decoded._tag === "Left") return [];
|
|
750
|
+
return decoded.right.document.children.flatMap((child, index) => child.type === "block" ? [] : [{
|
|
751
|
+
path: `document.children[${index}]`,
|
|
752
|
+
message: `Only block nodes are allowed at root level in a blocks-only field. Found "${child.type}" node.`
|
|
753
|
+
}]);
|
|
754
|
+
}
|
|
755
|
+
function visitDastNode(node, visit) {
|
|
756
|
+
visit(node);
|
|
757
|
+
if ("children" in node) for (const child of node.children) visitDastNode(child, visit);
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* Extract all block-level block IDs (type "block") from a DAST document.
|
|
761
|
+
*/
|
|
762
|
+
function extractBlockIds(doc) {
|
|
763
|
+
const ids = [];
|
|
764
|
+
visitDastNode({
|
|
765
|
+
type: "root",
|
|
766
|
+
children: doc.document.children
|
|
767
|
+
}, (node) => {
|
|
768
|
+
if (node.type === "block") ids.push(node.item);
|
|
769
|
+
});
|
|
770
|
+
return ids;
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Extract all inline block IDs (type "inlineBlock") from a DAST document.
|
|
774
|
+
*/
|
|
775
|
+
function extractInlineBlockIds(doc) {
|
|
776
|
+
const ids = [];
|
|
777
|
+
visitDastNode({
|
|
778
|
+
type: "root",
|
|
779
|
+
children: doc.document.children
|
|
780
|
+
}, (node) => {
|
|
781
|
+
if (node.type === "inlineBlock") ids.push(node.item);
|
|
782
|
+
});
|
|
783
|
+
return ids;
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Extract ALL block IDs (both "block" and "inlineBlock") from a DAST document.
|
|
787
|
+
* Used for write orchestration where both types need to be stored.
|
|
788
|
+
*/
|
|
789
|
+
function extractAllBlockIds(doc) {
|
|
790
|
+
const ids = [];
|
|
791
|
+
visitDastNode({
|
|
792
|
+
type: "root",
|
|
793
|
+
children: doc.document.children
|
|
794
|
+
}, (node) => {
|
|
795
|
+
if (node.type === "block" || node.type === "inlineBlock") ids.push(node.item);
|
|
796
|
+
});
|
|
797
|
+
return ids;
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Extract all record link IDs referenced in a DAST document.
|
|
801
|
+
*/
|
|
802
|
+
function extractLinkIds(doc) {
|
|
803
|
+
const ids = [];
|
|
804
|
+
visitDastNode({
|
|
805
|
+
type: "root",
|
|
806
|
+
children: doc.document.children
|
|
807
|
+
}, (node) => {
|
|
808
|
+
if (node.type === "itemLink" || node.type === "inlineItem") ids.push(node.item);
|
|
809
|
+
});
|
|
810
|
+
return ids;
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Remove block/inlineBlock nodes whose item ID is in the given set.
|
|
814
|
+
* Returns a deep-cloned document with those nodes pruned from the tree.
|
|
815
|
+
*/
|
|
816
|
+
function pruneBlockNodes(doc, blockIdsToRemove) {
|
|
817
|
+
function isSpanOnlyChildren(node) {
|
|
818
|
+
return node.type === "link" || node.type === "itemLink";
|
|
819
|
+
}
|
|
820
|
+
function pruneInlineNode(node) {
|
|
821
|
+
if (node.type === "inlineBlock" && blockIdsToRemove.has(node.item)) return null;
|
|
822
|
+
if (isSpanOnlyChildren(node)) return node;
|
|
823
|
+
return node;
|
|
824
|
+
}
|
|
825
|
+
function pruneParagraphNode(node) {
|
|
826
|
+
return {
|
|
827
|
+
...node,
|
|
828
|
+
children: node.children.map((child) => pruneInlineNode(child)).filter((child) => child !== null)
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
function pruneBlockLevelNode(node) {
|
|
832
|
+
if (node.type === "block" && blockIdsToRemove.has(node.item)) return null;
|
|
833
|
+
switch (node.type) {
|
|
834
|
+
case "paragraph": return pruneParagraphNode(node);
|
|
835
|
+
case "heading": return {
|
|
836
|
+
...node,
|
|
837
|
+
children: node.children.map((child) => pruneInlineNode(child)).filter((child) => child !== null)
|
|
838
|
+
};
|
|
839
|
+
case "list": return {
|
|
840
|
+
...node,
|
|
841
|
+
children: node.children.map((child) => ({
|
|
842
|
+
...child,
|
|
843
|
+
children: child.children.map((nested) => pruneBlockLevelNode(nested)).filter((nested) => nested !== null && (nested.type === "paragraph" || nested.type === "list"))
|
|
844
|
+
}))
|
|
845
|
+
};
|
|
846
|
+
case "blockquote": return {
|
|
847
|
+
...node,
|
|
848
|
+
children: node.children.map((child) => pruneParagraphNode(child))
|
|
849
|
+
};
|
|
850
|
+
case "table": return {
|
|
851
|
+
...node,
|
|
852
|
+
children: node.children.map((row) => ({
|
|
853
|
+
...row,
|
|
854
|
+
children: row.children.map((cell) => ({
|
|
855
|
+
...cell,
|
|
856
|
+
children: cell.children.map((child) => "item" in child || "url" in child || "value" in child ? pruneInlineNode(child) : pruneBlockLevelNode(child)).filter((child) => child !== null)
|
|
857
|
+
}))
|
|
858
|
+
}))
|
|
859
|
+
};
|
|
860
|
+
default: return node;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
return {
|
|
864
|
+
schema: "dast",
|
|
865
|
+
document: {
|
|
866
|
+
type: "root",
|
|
867
|
+
children: doc.document.children.map((child) => pruneBlockLevelNode(child)).filter((child) => child !== null)
|
|
868
|
+
}
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
//#endregion
|
|
872
|
+
//#region src/dast/markdown.ts
|
|
873
|
+
/**
|
|
874
|
+
* Editable DAST ↔ Markdown projection.
|
|
875
|
+
*
|
|
876
|
+
* This is NOT a generic serializer. Markdown is a projection of DAST for
|
|
877
|
+
* editing text structure. DAST remains the source of truth.
|
|
878
|
+
*
|
|
879
|
+
* Contract:
|
|
880
|
+
* - Block refs, inline refs survive as stable sentinels
|
|
881
|
+
* - Deleting a sentinel deletes the ref; moving it reorders it
|
|
882
|
+
* - Unsupported DAST metadata (link.meta, paragraph.style, heading.style,
|
|
883
|
+
* blockquote.attribution, code.highlight) is preserved in a sidecar map
|
|
884
|
+
* and re-attached on round-trip if the node is untouched
|
|
885
|
+
* - No new blocks can be authored from markdown
|
|
886
|
+
* - No block payloads are editable from markdown
|
|
887
|
+
*
|
|
888
|
+
* API:
|
|
889
|
+
* dastToEditableMarkdown(doc) → { markdown, preservation }
|
|
890
|
+
* editableMarkdownToDast(markdown, preservation) → DastDocument
|
|
891
|
+
*
|
|
892
|
+
* Legacy wrappers dastToMarkdown/markdownToDast are preserved for
|
|
893
|
+
* non-editing use cases (export, display).
|
|
894
|
+
*/
|
|
895
|
+
const BLOCK_SENTINEL_RE = /^<!--\s*cms:block:(\S+)\s*-->$/;
|
|
896
|
+
const NODE_SENTINEL_RE = /^<!--\s*cms:n(\d+)\s*-->$/;
|
|
897
|
+
const INLINE_ITEM_SENTINEL_RE = /^<!--\s*cms:inlineItem:(\S+)\s*-->$/;
|
|
898
|
+
const INLINE_BLOCK_SENTINEL_RE = /^<!--\s*cms:inlineBlock:(\S+)\s*-->$/;
|
|
899
|
+
const LINK_META_SENTINEL_RE = /^<!--\s*cms:linkMeta:(\S+)\s*-->$/;
|
|
900
|
+
const ITEM_LINK_META_SENTINEL_RE = /^<!--\s*cms:itemLinkMeta:(\S+)\s*-->$/;
|
|
901
|
+
const ITEM_LINK_PREFIX = "itemLink:";
|
|
902
|
+
const MARK_RE = /^<mark>([\s\S]*)<\/mark>$/;
|
|
903
|
+
const UNDERLINE_RE = /^<u>([\s\S]*)<\/u>$/;
|
|
904
|
+
function decodeSentinelValue(value) {
|
|
905
|
+
try {
|
|
906
|
+
return decodeURIComponent(value);
|
|
907
|
+
} catch {
|
|
908
|
+
return value;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
function escapeHtmlText(value) {
|
|
912
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
913
|
+
}
|
|
914
|
+
function decodeHtmlText(value) {
|
|
915
|
+
return value.replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&");
|
|
916
|
+
}
|
|
917
|
+
/** Map of opening HTML tags to DAST marks */
|
|
918
|
+
const HTML_TAG_TO_MARK = {
|
|
919
|
+
"<u>": "underline",
|
|
920
|
+
"<mark>": "highlight"
|
|
921
|
+
};
|
|
922
|
+
const HTML_CLOSING_TAGS = new Set(["</u>", "</mark>"]);
|
|
923
|
+
function isOpeningMarkTag(value) {
|
|
924
|
+
return value in HTML_TAG_TO_MARK;
|
|
925
|
+
}
|
|
926
|
+
function isClosingMarkTag(value) {
|
|
927
|
+
return HTML_CLOSING_TAGS.has(value);
|
|
928
|
+
}
|
|
929
|
+
function mergeHtmlTagSequences(nodes) {
|
|
930
|
+
const result = [];
|
|
931
|
+
let i = 0;
|
|
932
|
+
while (i < nodes.length) {
|
|
933
|
+
const node = nodes[i];
|
|
934
|
+
if (node.type === "html" && isOpeningMarkTag(node.value)) {
|
|
935
|
+
let depth = 1;
|
|
936
|
+
let raw = node.value;
|
|
937
|
+
i++;
|
|
938
|
+
while (i < nodes.length) {
|
|
939
|
+
const current = nodes[i];
|
|
940
|
+
if (current.type === "html") {
|
|
941
|
+
raw += current.value;
|
|
942
|
+
if (isOpeningMarkTag(current.value)) depth++;
|
|
943
|
+
else if (isClosingMarkTag(current.value)) {
|
|
944
|
+
depth--;
|
|
945
|
+
i++;
|
|
946
|
+
if (depth === 0) break;
|
|
947
|
+
continue;
|
|
948
|
+
}
|
|
949
|
+
} else if (current.type === "text") raw += escapeHtmlText(current.value);
|
|
950
|
+
else if (current.type === "inlineCode") raw += escapeHtmlText(current.value);
|
|
951
|
+
else {
|
|
952
|
+
result.push(node);
|
|
953
|
+
i++;
|
|
954
|
+
break;
|
|
955
|
+
}
|
|
956
|
+
i++;
|
|
957
|
+
}
|
|
958
|
+
if (depth === 0) result.push({
|
|
959
|
+
type: "html",
|
|
960
|
+
value: raw
|
|
961
|
+
});
|
|
962
|
+
} else {
|
|
963
|
+
result.push(node);
|
|
964
|
+
i++;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
return result;
|
|
968
|
+
}
|
|
969
|
+
function addMark(inline, mark) {
|
|
970
|
+
if (inline.type !== "span") return inline;
|
|
971
|
+
const existing = inline.marks ?? [];
|
|
972
|
+
if (existing.includes(mark)) return inline;
|
|
973
|
+
return {
|
|
974
|
+
...inline,
|
|
975
|
+
marks: [...existing, mark]
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
function mdastPhrasingToSpans(nodes, pres) {
|
|
979
|
+
return mdastPhrasingToDastInlines(nodes, pres).map((inline) => inline.type === "span" ? inline : {
|
|
980
|
+
type: "span",
|
|
981
|
+
value: extractText(inline)
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
function extractLinkMetaId(children, pattern) {
|
|
985
|
+
let metaId = null;
|
|
986
|
+
const remaining = [];
|
|
987
|
+
for (const child of children) {
|
|
988
|
+
if (child.type === "html") {
|
|
989
|
+
const match = pattern.exec(child.value);
|
|
990
|
+
if (match) {
|
|
991
|
+
metaId = decodeSentinelValue(match[1]);
|
|
992
|
+
continue;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
remaining.push(child);
|
|
996
|
+
}
|
|
997
|
+
return {
|
|
998
|
+
metaId,
|
|
999
|
+
children: remaining
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
function extractText(node) {
|
|
1003
|
+
switch (node.type) {
|
|
1004
|
+
case "span": return node.value;
|
|
1005
|
+
case "link":
|
|
1006
|
+
case "itemLink": return node.children.map((c) => c.value).join("");
|
|
1007
|
+
case "inlineItem":
|
|
1008
|
+
case "inlineBlock": return "";
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
function mdastPhrasingToDastInlines(rawNodes, pres) {
|
|
1012
|
+
const nodes = mergeHtmlTagSequences(rawNodes);
|
|
1013
|
+
const result = [];
|
|
1014
|
+
let pendingLinkMetaId = null;
|
|
1015
|
+
let pendingItemLinkMetaId = null;
|
|
1016
|
+
for (const node of nodes) switch (node.type) {
|
|
1017
|
+
case "text":
|
|
1018
|
+
result.push({
|
|
1019
|
+
type: "span",
|
|
1020
|
+
value: node.value
|
|
1021
|
+
});
|
|
1022
|
+
break;
|
|
1023
|
+
case "inlineCode":
|
|
1024
|
+
result.push({
|
|
1025
|
+
type: "span",
|
|
1026
|
+
value: node.value,
|
|
1027
|
+
marks: ["code"]
|
|
1028
|
+
});
|
|
1029
|
+
break;
|
|
1030
|
+
case "strong":
|
|
1031
|
+
for (const inline of mdastPhrasingToDastInlines(node.children, pres)) result.push(addMark(inline, "strong"));
|
|
1032
|
+
break;
|
|
1033
|
+
case "emphasis":
|
|
1034
|
+
for (const inline of mdastPhrasingToDastInlines(node.children, pres)) result.push(addMark(inline, "emphasis"));
|
|
1035
|
+
break;
|
|
1036
|
+
case "delete":
|
|
1037
|
+
for (const inline of mdastPhrasingToDastInlines(node.children, pres)) result.push(addMark(inline, "strikethrough"));
|
|
1038
|
+
break;
|
|
1039
|
+
case "link":
|
|
1040
|
+
if (node.url.startsWith(ITEM_LINK_PREFIX)) {
|
|
1041
|
+
const itemId = node.url.slice(9);
|
|
1042
|
+
const extracted = extractLinkMetaId(node.children, ITEM_LINK_META_SENTINEL_RE);
|
|
1043
|
+
const children = mdastPhrasingToSpans(extracted.children, pres);
|
|
1044
|
+
const preserved = extracted.metaId ? pres.itemLinks[extracted.metaId] : pendingItemLinkMetaId ? pres.itemLinks[pendingItemLinkMetaId] : void 0;
|
|
1045
|
+
const itemLink = {
|
|
1046
|
+
type: "itemLink",
|
|
1047
|
+
item: itemId,
|
|
1048
|
+
children,
|
|
1049
|
+
...preserved?.meta ? { meta: preserved.meta } : {}
|
|
1050
|
+
};
|
|
1051
|
+
result.push(itemLink);
|
|
1052
|
+
pendingItemLinkMetaId = null;
|
|
1053
|
+
} else {
|
|
1054
|
+
const extracted = extractLinkMetaId(node.children, LINK_META_SENTINEL_RE);
|
|
1055
|
+
const children = mdastPhrasingToSpans(extracted.children, pres);
|
|
1056
|
+
const preserved = extracted.metaId ? pres.links[extracted.metaId] : pendingLinkMetaId ? pres.links[pendingLinkMetaId] : void 0;
|
|
1057
|
+
const link = {
|
|
1058
|
+
type: "link",
|
|
1059
|
+
url: node.url,
|
|
1060
|
+
children,
|
|
1061
|
+
...preserved?.meta ? { meta: preserved.meta } : {}
|
|
1062
|
+
};
|
|
1063
|
+
result.push(link);
|
|
1064
|
+
pendingLinkMetaId = null;
|
|
1065
|
+
}
|
|
1066
|
+
break;
|
|
1067
|
+
case "html": {
|
|
1068
|
+
const linkMetaMatch = LINK_META_SENTINEL_RE.exec(node.value);
|
|
1069
|
+
if (linkMetaMatch) {
|
|
1070
|
+
pendingLinkMetaId = decodeSentinelValue(linkMetaMatch[1]);
|
|
1071
|
+
break;
|
|
1072
|
+
}
|
|
1073
|
+
const itemLinkMetaMatch = ITEM_LINK_META_SENTINEL_RE.exec(node.value);
|
|
1074
|
+
if (itemLinkMetaMatch) {
|
|
1075
|
+
pendingItemLinkMetaId = decodeSentinelValue(itemLinkMetaMatch[1]);
|
|
1076
|
+
break;
|
|
1077
|
+
}
|
|
1078
|
+
const m1 = INLINE_ITEM_SENTINEL_RE.exec(node.value);
|
|
1079
|
+
if (m1) {
|
|
1080
|
+
result.push({
|
|
1081
|
+
type: "inlineItem",
|
|
1082
|
+
item: decodeSentinelValue(m1[1])
|
|
1083
|
+
});
|
|
1084
|
+
break;
|
|
1085
|
+
}
|
|
1086
|
+
const m2 = INLINE_BLOCK_SENTINEL_RE.exec(node.value);
|
|
1087
|
+
if (m2) {
|
|
1088
|
+
result.push({
|
|
1089
|
+
type: "inlineBlock",
|
|
1090
|
+
item: decodeSentinelValue(m2[1])
|
|
1091
|
+
});
|
|
1092
|
+
break;
|
|
1093
|
+
}
|
|
1094
|
+
const marked = parseMarkedHtml(node.value);
|
|
1095
|
+
if (marked) {
|
|
1096
|
+
result.push(marked);
|
|
1097
|
+
break;
|
|
1098
|
+
}
|
|
1099
|
+
result.push({
|
|
1100
|
+
type: "span",
|
|
1101
|
+
value: node.value
|
|
1102
|
+
});
|
|
1103
|
+
break;
|
|
1104
|
+
}
|
|
1105
|
+
case "break":
|
|
1106
|
+
result.push({
|
|
1107
|
+
type: "span",
|
|
1108
|
+
value: "\n"
|
|
1109
|
+
});
|
|
1110
|
+
break;
|
|
1111
|
+
default: break;
|
|
1112
|
+
}
|
|
1113
|
+
return result;
|
|
1114
|
+
}
|
|
1115
|
+
function parseMarkedHtml(value) {
|
|
1116
|
+
const marks = [];
|
|
1117
|
+
let current = value;
|
|
1118
|
+
for (;;) {
|
|
1119
|
+
const underlineMatch = UNDERLINE_RE.exec(current);
|
|
1120
|
+
if (underlineMatch) {
|
|
1121
|
+
marks.push("underline");
|
|
1122
|
+
current = underlineMatch[1];
|
|
1123
|
+
continue;
|
|
1124
|
+
}
|
|
1125
|
+
const highlightMatch = MARK_RE.exec(current);
|
|
1126
|
+
if (highlightMatch) {
|
|
1127
|
+
marks.push("highlight");
|
|
1128
|
+
current = highlightMatch[1];
|
|
1129
|
+
continue;
|
|
1130
|
+
}
|
|
1131
|
+
break;
|
|
1132
|
+
}
|
|
1133
|
+
if (marks.length === 0) return null;
|
|
1134
|
+
return {
|
|
1135
|
+
type: "span",
|
|
1136
|
+
value: decodeHtmlText(current),
|
|
1137
|
+
marks
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* Parse markdown back into DAST, re-attaching preserved metadata.
|
|
1142
|
+
* The `pendingNodeId` is set when we encounter a `<!-- cms:nX -->` sentinel,
|
|
1143
|
+
* and consumed by the next block-level node.
|
|
1144
|
+
*/
|
|
1145
|
+
function mdastBlockToDast(node, pres, nodeId) {
|
|
1146
|
+
const meta = nodeId ? pres.nodes[nodeId] : void 0;
|
|
1147
|
+
switch (node.type) {
|
|
1148
|
+
case "paragraph": return {
|
|
1149
|
+
type: "paragraph",
|
|
1150
|
+
children: mdastPhrasingToDastInlines(node.children, pres),
|
|
1151
|
+
...meta?.style !== void 0 ? { style: meta.style } : {}
|
|
1152
|
+
};
|
|
1153
|
+
case "heading": return {
|
|
1154
|
+
type: "heading",
|
|
1155
|
+
level: node.depth,
|
|
1156
|
+
children: mdastPhrasingToDastInlines(node.children, pres),
|
|
1157
|
+
...meta?.style !== void 0 ? { style: meta.style } : {}
|
|
1158
|
+
};
|
|
1159
|
+
case "list": return {
|
|
1160
|
+
type: "list",
|
|
1161
|
+
style: node.ordered ? "numbered" : "bulleted",
|
|
1162
|
+
children: node.children.map((item) => mdastListItemToDast(item, pres))
|
|
1163
|
+
};
|
|
1164
|
+
case "blockquote": return {
|
|
1165
|
+
type: "blockquote",
|
|
1166
|
+
children: node.children.filter((c) => c.type === "paragraph").map((p) => ({
|
|
1167
|
+
type: "paragraph",
|
|
1168
|
+
children: mdastPhrasingToDastInlines(p.children, pres)
|
|
1169
|
+
})),
|
|
1170
|
+
...meta?.attribution !== void 0 ? { attribution: meta.attribution } : {}
|
|
1171
|
+
};
|
|
1172
|
+
case "code": return {
|
|
1173
|
+
type: "code",
|
|
1174
|
+
code: node.value,
|
|
1175
|
+
...node.lang ? { language: node.lang } : {},
|
|
1176
|
+
...meta?.highlight !== void 0 ? { highlight: meta.highlight } : {}
|
|
1177
|
+
};
|
|
1178
|
+
case "thematicBreak": return { type: "thematicBreak" };
|
|
1179
|
+
case "html": {
|
|
1180
|
+
const blockMatch = BLOCK_SENTINEL_RE.exec(node.value);
|
|
1181
|
+
if (blockMatch) return {
|
|
1182
|
+
type: "block",
|
|
1183
|
+
item: decodeSentinelValue(blockMatch[1])
|
|
1184
|
+
};
|
|
1185
|
+
return {
|
|
1186
|
+
type: "paragraph",
|
|
1187
|
+
children: [{
|
|
1188
|
+
type: "span",
|
|
1189
|
+
value: node.value
|
|
1190
|
+
}]
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
case "table": return {
|
|
1194
|
+
type: "table",
|
|
1195
|
+
children: node.children.map((row) => mdastTableRowToDast(row, pres))
|
|
1196
|
+
};
|
|
1197
|
+
default: return null;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
function mdastListItemToDast(item, pres) {
|
|
1201
|
+
const children = [];
|
|
1202
|
+
for (const child of item.children) if (child.type === "paragraph") children.push({
|
|
1203
|
+
type: "paragraph",
|
|
1204
|
+
children: mdastPhrasingToDastInlines(child.children, pres)
|
|
1205
|
+
});
|
|
1206
|
+
else if (child.type === "list") children.push({
|
|
1207
|
+
type: "list",
|
|
1208
|
+
style: child.ordered ? "numbered" : "bulleted",
|
|
1209
|
+
children: child.children.map((li) => mdastListItemToDast(li, pres))
|
|
1210
|
+
});
|
|
1211
|
+
return {
|
|
1212
|
+
type: "listItem",
|
|
1213
|
+
children
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
function mdastTableRowToDast(row, pres) {
|
|
1217
|
+
return {
|
|
1218
|
+
type: "tableRow",
|
|
1219
|
+
children: row.children.map((cell) => mdastTableCellToDast(cell, pres))
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
function mdastTableCellToDast(cell, pres) {
|
|
1223
|
+
const inlines = mdastPhrasingToDastInlines(cell.children, pres);
|
|
1224
|
+
if (inlines.length === 0) return {
|
|
1225
|
+
type: "tableCell",
|
|
1226
|
+
children: [{
|
|
1227
|
+
type: "paragraph",
|
|
1228
|
+
children: [{
|
|
1229
|
+
type: "span",
|
|
1230
|
+
value: ""
|
|
1231
|
+
}]
|
|
1232
|
+
}]
|
|
1233
|
+
};
|
|
1234
|
+
return {
|
|
1235
|
+
type: "tableCell",
|
|
1236
|
+
children: [{
|
|
1237
|
+
type: "paragraph",
|
|
1238
|
+
children: inlines
|
|
1239
|
+
}]
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
/**
|
|
1243
|
+
* Parse edited markdown back into DAST, re-attaching preserved metadata
|
|
1244
|
+
* from the sidecar. Sentinels that were deleted are omitted; sentinels
|
|
1245
|
+
* that were reordered are reflected in the output order.
|
|
1246
|
+
*/
|
|
1247
|
+
function editableMarkdownToDast(markdown, preservation) {
|
|
1248
|
+
const mdastRoot = unified().use(remarkParse).use(remarkGfm).parse(markdown);
|
|
1249
|
+
const children = [];
|
|
1250
|
+
let pendingNodeId = null;
|
|
1251
|
+
let pendingInlineSentinels = [];
|
|
1252
|
+
for (const child of mdastRoot.children) {
|
|
1253
|
+
if (child.type === "html") {
|
|
1254
|
+
const nodeMatch = NODE_SENTINEL_RE.exec(child.value);
|
|
1255
|
+
if (nodeMatch) {
|
|
1256
|
+
pendingNodeId = `n${nodeMatch[1]}`;
|
|
1257
|
+
continue;
|
|
1258
|
+
}
|
|
1259
|
+
if (LINK_META_SENTINEL_RE.test(child.value) || ITEM_LINK_META_SENTINEL_RE.test(child.value)) {
|
|
1260
|
+
pendingInlineSentinels.push(child);
|
|
1261
|
+
continue;
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
const childWithPendingSentinels = pendingInlineSentinels.length > 0 && (child.type === "paragraph" || child.type === "heading") ? {
|
|
1265
|
+
...child,
|
|
1266
|
+
children: [...pendingInlineSentinels, ...child.children]
|
|
1267
|
+
} : child;
|
|
1268
|
+
pendingInlineSentinels = [];
|
|
1269
|
+
const dastNode = mdastBlockToDast(childWithPendingSentinels, preservation, pendingNodeId);
|
|
1270
|
+
pendingNodeId = null;
|
|
1271
|
+
if (dastNode) children.push(dastNode);
|
|
1272
|
+
}
|
|
1273
|
+
return {
|
|
1274
|
+
schema: "dast",
|
|
1275
|
+
document: {
|
|
1276
|
+
type: "root",
|
|
1277
|
+
children
|
|
1278
|
+
}
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
/** Parse a CommonMark markdown string into a DAST document (no preservation). */
|
|
1282
|
+
function markdownToDast(markdown) {
|
|
1283
|
+
return editableMarkdownToDast(markdown, {
|
|
1284
|
+
nodes: {},
|
|
1285
|
+
links: {},
|
|
1286
|
+
itemLinks: {}
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
//#endregion
|
|
1290
|
+
//#region src/graphql/sql-metrics.ts
|
|
1291
|
+
const sqlMetricsStorage = new AsyncLocalStorage();
|
|
1292
|
+
function withSqlMetrics(run) {
|
|
1293
|
+
return sqlMetricsStorage.run({
|
|
1294
|
+
statementCount: 0,
|
|
1295
|
+
hopCount: 0,
|
|
1296
|
+
batchHopCount: 0,
|
|
1297
|
+
batchedStatementCount: 0,
|
|
1298
|
+
totalDurationMs: 0,
|
|
1299
|
+
slowestSamplesMs: [],
|
|
1300
|
+
byPhase: /* @__PURE__ */ new Map()
|
|
1301
|
+
}, run);
|
|
1302
|
+
}
|
|
1303
|
+
function recordSqlMetrics(durationMs, options) {
|
|
1304
|
+
const store = sqlMetricsStorage.getStore();
|
|
1305
|
+
if (!store) return;
|
|
1306
|
+
const statementCount = options?.statementCount ?? 1;
|
|
1307
|
+
const hopCount = options?.hopCount ?? 1;
|
|
1308
|
+
const batchHopCount = options?.batchHopCount ?? 0;
|
|
1309
|
+
const batchedStatementCount = options?.batchedStatementCount ?? 0;
|
|
1310
|
+
store.statementCount += statementCount;
|
|
1311
|
+
store.hopCount += hopCount;
|
|
1312
|
+
store.batchHopCount += batchHopCount;
|
|
1313
|
+
store.batchedStatementCount += batchedStatementCount;
|
|
1314
|
+
store.totalDurationMs += durationMs;
|
|
1315
|
+
store.slowestSamplesMs.push(Number(durationMs.toFixed(3)));
|
|
1316
|
+
store.slowestSamplesMs.sort((a, b) => b - a);
|
|
1317
|
+
if (store.slowestSamplesMs.length > 5) store.slowestSamplesMs.length = 5;
|
|
1318
|
+
const phase = options?.phase;
|
|
1319
|
+
if (phase) {
|
|
1320
|
+
const current = store.byPhase.get(phase) ?? {
|
|
1321
|
+
statementCount: 0,
|
|
1322
|
+
hopCount: 0,
|
|
1323
|
+
batchHopCount: 0,
|
|
1324
|
+
batchedStatementCount: 0,
|
|
1325
|
+
totalDurationMs: 0
|
|
1326
|
+
};
|
|
1327
|
+
current.statementCount += statementCount;
|
|
1328
|
+
current.hopCount += hopCount;
|
|
1329
|
+
current.batchHopCount += batchHopCount;
|
|
1330
|
+
current.batchedStatementCount += batchedStatementCount;
|
|
1331
|
+
current.totalDurationMs += durationMs;
|
|
1332
|
+
store.byPhase.set(phase, current);
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
function getSqlMetrics() {
|
|
1336
|
+
const store = sqlMetricsStorage.getStore();
|
|
1337
|
+
if (!store) return null;
|
|
1338
|
+
return {
|
|
1339
|
+
statementCount: store.statementCount,
|
|
1340
|
+
hopCount: store.hopCount,
|
|
1341
|
+
batchHopCount: store.batchHopCount,
|
|
1342
|
+
batchedStatementCount: store.batchedStatementCount,
|
|
1343
|
+
totalDurationMs: Number(store.totalDurationMs.toFixed(3)),
|
|
1344
|
+
slowestSamplesMs: store.slowestSamplesMs,
|
|
1345
|
+
byPhase: Object.fromEntries([...store.byPhase.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([phase, value]) => [phase, {
|
|
1346
|
+
statementCount: value.statementCount,
|
|
1347
|
+
hopCount: value.hopCount,
|
|
1348
|
+
batchHopCount: value.batchHopCount,
|
|
1349
|
+
batchedStatementCount: value.batchedStatementCount,
|
|
1350
|
+
totalDurationMs: Number(value.totalDurationMs.toFixed(3))
|
|
1351
|
+
}]))
|
|
1352
|
+
};
|
|
1353
|
+
}
|
|
1354
|
+
//#endregion
|
|
1355
|
+
//#region src/db/run-batched-queries.ts
|
|
1356
|
+
function isD1Database(value) {
|
|
1357
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1358
|
+
return typeof Reflect.get(value, "prepare") === "function" && typeof Reflect.get(value, "batch") === "function";
|
|
1359
|
+
}
|
|
1360
|
+
function isD1ClientLike(value) {
|
|
1361
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1362
|
+
const config = Reflect.get(value, "config");
|
|
1363
|
+
if (typeof config !== "object" || config === null) return false;
|
|
1364
|
+
return isD1Database(Reflect.get(config, "db"));
|
|
1365
|
+
}
|
|
1366
|
+
function runBatchedQueries(queries, options) {
|
|
1367
|
+
return Effect.gen(function* () {
|
|
1368
|
+
if (queries.length === 0) return [];
|
|
1369
|
+
const startedAt = performance.now();
|
|
1370
|
+
const sql = yield* SqlClient.SqlClient;
|
|
1371
|
+
if (isD1ClientLike(sql)) return yield* Effect.tryPromise({
|
|
1372
|
+
try: async () => {
|
|
1373
|
+
const statements = queries.map((query) => sql.config.db.prepare(query.sql).bind(...query.params));
|
|
1374
|
+
const results = await sql.config.db.batch(statements);
|
|
1375
|
+
recordSqlMetrics(performance.now() - startedAt, {
|
|
1376
|
+
statementCount: queries.length,
|
|
1377
|
+
hopCount: 1,
|
|
1378
|
+
batchHopCount: 1,
|
|
1379
|
+
batchedStatementCount: queries.length,
|
|
1380
|
+
phase: options?.phase
|
|
1381
|
+
});
|
|
1382
|
+
return results.map((result) => result.results);
|
|
1383
|
+
},
|
|
1384
|
+
catch: (cause) => new SqlError.SqlError({
|
|
1385
|
+
cause,
|
|
1386
|
+
message: "Failed to execute D1 batch query"
|
|
1387
|
+
})
|
|
1388
|
+
});
|
|
1389
|
+
const results = yield* Effect.all(queries.map((query) => sql.unsafe(query.sql, query.params)), { concurrency: 1 });
|
|
1390
|
+
recordSqlMetrics(performance.now() - startedAt, {
|
|
1391
|
+
statementCount: queries.length,
|
|
1392
|
+
hopCount: 1,
|
|
1393
|
+
batchHopCount: 1,
|
|
1394
|
+
batchedStatementCount: queries.length,
|
|
1395
|
+
phase: options?.phase
|
|
1396
|
+
});
|
|
1397
|
+
return results;
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
//#endregion
|
|
1401
|
+
//#region src/types.ts
|
|
1402
|
+
/** Field types supported in v1 */
|
|
1403
|
+
const FIELD_TYPES = [
|
|
1404
|
+
"string",
|
|
1405
|
+
"text",
|
|
1406
|
+
"boolean",
|
|
1407
|
+
"integer",
|
|
1408
|
+
"slug",
|
|
1409
|
+
"media",
|
|
1410
|
+
"media_gallery",
|
|
1411
|
+
"link",
|
|
1412
|
+
"links",
|
|
1413
|
+
"structured_text",
|
|
1414
|
+
"seo",
|
|
1415
|
+
"json",
|
|
1416
|
+
"float",
|
|
1417
|
+
"date",
|
|
1418
|
+
"date_time",
|
|
1419
|
+
"color",
|
|
1420
|
+
"lat_lon",
|
|
1421
|
+
"video"
|
|
1422
|
+
];
|
|
1423
|
+
/** Type guard for FieldType */
|
|
1424
|
+
function isFieldType(value) {
|
|
1425
|
+
return FIELD_TYPES.includes(value);
|
|
1426
|
+
}
|
|
1427
|
+
//#endregion
|
|
1428
|
+
//#region src/services/structured-text-service.ts
|
|
1429
|
+
function getStructuredTextStorageKey(fieldApiKey, localeCode) {
|
|
1430
|
+
return localeCode ? `${fieldApiKey}:${localeCode}` : fieldApiKey;
|
|
1431
|
+
}
|
|
1432
|
+
function mergeRowMaps(target, source) {
|
|
1433
|
+
for (const [tableName, rows] of source) {
|
|
1434
|
+
const existing = target.get(tableName);
|
|
1435
|
+
if (existing) existing.push(...rows);
|
|
1436
|
+
else target.set(tableName, [...rows]);
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
function serializeValue(value) {
|
|
1440
|
+
if (value === void 0 || value === null) return null;
|
|
1441
|
+
if (typeof value === "boolean") return value ? 1 : 0;
|
|
1442
|
+
if (typeof value === "object") return encodeJson(value);
|
|
1443
|
+
return value;
|
|
1444
|
+
}
|
|
1445
|
+
function deserializeValue(value) {
|
|
1446
|
+
if (typeof value === "string" && (value.startsWith("{") || value.startsWith("["))) return decodeJsonStringOr(value, value);
|
|
1447
|
+
return value;
|
|
1448
|
+
}
|
|
1449
|
+
function decodeStructuredTextInput(fieldApiKey, value) {
|
|
1450
|
+
return Schema.decodeUnknown(StructuredTextWriteInput)(value).pipe(Effect.mapError((e) => new ValidationError({
|
|
1451
|
+
message: `Invalid StructuredText for field '${fieldApiKey}': ${e.message}`,
|
|
1452
|
+
field: fieldApiKey
|
|
1453
|
+
})));
|
|
1454
|
+
}
|
|
1455
|
+
function getParsedFields(sql, modelId) {
|
|
1456
|
+
return Effect.gen(function* () {
|
|
1457
|
+
return (yield* sql.unsafe("SELECT * FROM fields WHERE model_id = ? ORDER BY position", [modelId])).map(parseFieldValidators);
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1460
|
+
function getBlockModelSchema(sql, blockApiKey) {
|
|
1461
|
+
return Effect.gen(function* () {
|
|
1462
|
+
const rows = yield* sql.unsafe("SELECT id, api_key FROM models WHERE api_key = ? AND is_block = 1", [blockApiKey]);
|
|
1463
|
+
if (rows.length === 0) return yield* new ValidationError({ message: `Block type '${blockApiKey}' does not exist` });
|
|
1464
|
+
const model = rows[0];
|
|
1465
|
+
const fields = yield* getParsedFields(sql, model.id);
|
|
1466
|
+
const structuredTextAllowedBlockApiKeysByField = /* @__PURE__ */ new Map();
|
|
1467
|
+
for (const field of fields) {
|
|
1468
|
+
if (field.field_type !== "structured_text") continue;
|
|
1469
|
+
structuredTextAllowedBlockApiKeysByField.set(field.api_key, getBlockWhitelist(field.validators) ?? []);
|
|
1470
|
+
}
|
|
1471
|
+
return {
|
|
1472
|
+
id: model.id,
|
|
1473
|
+
apiKey: model.api_key,
|
|
1474
|
+
fields,
|
|
1475
|
+
structuredTextAllowedBlockApiKeysByField
|
|
1476
|
+
};
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1479
|
+
function getBlockModelSchemaCached(ctx, sql, blockApiKey) {
|
|
1480
|
+
return Effect.gen(function* () {
|
|
1481
|
+
const cached = ctx.blockModelSchemas.get(blockApiKey);
|
|
1482
|
+
if (cached) return cached;
|
|
1483
|
+
const schema = yield* getBlockModelSchema(sql, blockApiKey);
|
|
1484
|
+
ctx.blockModelSchemas.set(blockApiKey, schema);
|
|
1485
|
+
return schema;
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
function fetchBlockModelsCached(ctx, sql) {
|
|
1489
|
+
return Effect.gen(function* () {
|
|
1490
|
+
if (ctx.blockModels) return ctx.blockModels;
|
|
1491
|
+
const blockModels = yield* fetchBlockModels(sql);
|
|
1492
|
+
ctx.blockModels = blockModels;
|
|
1493
|
+
return blockModels;
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
function getCandidateBlockModelsCached(ctx, blockModels, allowedBlockApiKeys) {
|
|
1497
|
+
const cacheKey = allowedBlockApiKeys && allowedBlockApiKeys.length > 0 ? allowedBlockApiKeys.join(",") : "*";
|
|
1498
|
+
const cached = ctx.candidateBlockModels.get(cacheKey);
|
|
1499
|
+
if (cached) return cached;
|
|
1500
|
+
const candidateBlockModels = allowedBlockApiKeys && allowedBlockApiKeys.length > 0 ? blockModels.filter((model) => allowedBlockApiKeys.includes(model.api_key)) : blockModels;
|
|
1501
|
+
ctx.candidateBlockModels.set(cacheKey, candidateBlockModels);
|
|
1502
|
+
return candidateBlockModels;
|
|
1503
|
+
}
|
|
1504
|
+
function runHotBlockQueries(queries) {
|
|
1505
|
+
return runBatchedQueries(queries, { phase: "st_frontier" });
|
|
1506
|
+
}
|
|
1507
|
+
function formatDastParseErrors(error) {
|
|
1508
|
+
return ParseResult.ArrayFormatter.formatErrorSync(error).map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ");
|
|
1509
|
+
}
|
|
1510
|
+
function validateDastForField(fieldApiKey, value, blocksOnly) {
|
|
1511
|
+
return Effect.gen(function* () {
|
|
1512
|
+
const dast = yield* Schema.decodeUnknown(DastDocumentSchema)(value).pipe(Effect.mapError((e) => new ValidationError({
|
|
1513
|
+
message: `Invalid DAST document: ${formatDastParseErrors(e)}`,
|
|
1514
|
+
field: fieldApiKey
|
|
1515
|
+
})));
|
|
1516
|
+
if (blocksOnly) {
|
|
1517
|
+
const blocksOnlyErrors = validateBlocksOnly(value);
|
|
1518
|
+
if (blocksOnlyErrors.length > 0) return yield* new ValidationError({
|
|
1519
|
+
message: `Blocks-only field '${fieldApiKey}': ${blocksOnlyErrors.map((e) => e.message).join("; ")}`,
|
|
1520
|
+
field: fieldApiKey
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
return dast;
|
|
1524
|
+
});
|
|
1525
|
+
}
|
|
1526
|
+
function compileStructuredText(ctx, container, params) {
|
|
1527
|
+
return Effect.gen(function* () {
|
|
1528
|
+
const { sql, seenBlockIds } = ctx;
|
|
1529
|
+
const { fieldApiKey, input, allowedBlockTypes, blocksOnly } = params;
|
|
1530
|
+
const dast = yield* validateDastForField(fieldApiKey, input.value, blocksOnly);
|
|
1531
|
+
const referencedBlockIds = extractAllBlockIds(dast);
|
|
1532
|
+
const providedBlockIds = Object.keys(input.blocks);
|
|
1533
|
+
for (const blockId of referencedBlockIds) if (!input.blocks[blockId]) return yield* new ValidationError({
|
|
1534
|
+
message: `DAST references block '${blockId}' but no block data provided for it`,
|
|
1535
|
+
field: fieldApiKey
|
|
1536
|
+
});
|
|
1537
|
+
for (const blockId of providedBlockIds) if (!referencedBlockIds.includes(blockId)) return yield* new ValidationError({
|
|
1538
|
+
message: `StructuredText field '${fieldApiKey}' includes unreferenced block '${blockId}'`,
|
|
1539
|
+
field: fieldApiKey
|
|
1540
|
+
});
|
|
1541
|
+
const rowsByTable = /* @__PURE__ */ new Map();
|
|
1542
|
+
for (const blockId of referencedBlockIds) {
|
|
1543
|
+
if (seenBlockIds.has(blockId)) return yield* new ValidationError({
|
|
1544
|
+
message: `StructuredText graph reuses block id '${blockId}' multiple times`,
|
|
1545
|
+
field: fieldApiKey
|
|
1546
|
+
});
|
|
1547
|
+
seenBlockIds.add(blockId);
|
|
1548
|
+
const rawBlock = input.blocks[blockId];
|
|
1549
|
+
if (typeof rawBlock !== "object" || rawBlock === null || Array.isArray(rawBlock)) return yield* new ValidationError({
|
|
1550
|
+
message: `Block '${blockId}' must be an object`,
|
|
1551
|
+
field: fieldApiKey
|
|
1552
|
+
});
|
|
1553
|
+
const blockData = rawBlock;
|
|
1554
|
+
if (typeof blockData._type !== "string" || blockData._type.length === 0) return yield* new ValidationError({
|
|
1555
|
+
message: `Block '${blockId}' must have a _type property`,
|
|
1556
|
+
field: fieldApiKey
|
|
1557
|
+
});
|
|
1558
|
+
if (allowedBlockTypes.length > 0 && !allowedBlockTypes.includes(blockData._type)) return yield* new ValidationError({
|
|
1559
|
+
message: `Block type '${blockData._type}' is not allowed in field '${fieldApiKey}'. Allowed: ${allowedBlockTypes.join(", ")}`,
|
|
1560
|
+
field: fieldApiKey
|
|
1561
|
+
});
|
|
1562
|
+
const blockModel = yield* getBlockModelSchema(sql, blockData._type);
|
|
1563
|
+
const row = {
|
|
1564
|
+
id: blockId,
|
|
1565
|
+
_root_record_id: ctx.rootRecordId,
|
|
1566
|
+
_root_field_api_key: ctx.rootFieldApiKey,
|
|
1567
|
+
_parent_container_model_api_key: container.parentContainerModelApiKey,
|
|
1568
|
+
_parent_block_id: container.parentBlockId,
|
|
1569
|
+
_parent_field_api_key: container.parentFieldApiKey,
|
|
1570
|
+
_depth: container.depth
|
|
1571
|
+
};
|
|
1572
|
+
const nestedRows = /* @__PURE__ */ new Map();
|
|
1573
|
+
for (const field of blockModel.fields) {
|
|
1574
|
+
const value = blockData[field.api_key];
|
|
1575
|
+
if (value === void 0) continue;
|
|
1576
|
+
if (field.field_type === "structured_text") {
|
|
1577
|
+
if (value === null) {
|
|
1578
|
+
row[field.api_key] = null;
|
|
1579
|
+
continue;
|
|
1580
|
+
}
|
|
1581
|
+
const nestedInput = yield* decodeStructuredTextInput(field.api_key, value);
|
|
1582
|
+
const nestedCompiled = yield* compileStructuredText(ctx, {
|
|
1583
|
+
parentContainerModelApiKey: blockModel.apiKey,
|
|
1584
|
+
parentBlockId: blockId,
|
|
1585
|
+
parentFieldApiKey: field.api_key,
|
|
1586
|
+
depth: container.depth + 1
|
|
1587
|
+
}, {
|
|
1588
|
+
fieldApiKey: field.api_key,
|
|
1589
|
+
input: nestedInput,
|
|
1590
|
+
allowedBlockTypes: getBlockWhitelist(field.validators) ?? [],
|
|
1591
|
+
blocksOnly: getBlocksOnly(field.validators)
|
|
1592
|
+
});
|
|
1593
|
+
row[field.api_key] = nestedCompiled.dast;
|
|
1594
|
+
mergeRowMaps(nestedRows, nestedCompiled.rowsByTable);
|
|
1595
|
+
continue;
|
|
1596
|
+
}
|
|
1597
|
+
if (isFieldType(field.field_type)) {
|
|
1598
|
+
const fieldDef = getFieldTypeDef(field.field_type);
|
|
1599
|
+
if (fieldDef.inputSchema) yield* Schema.decodeUnknown(fieldDef.inputSchema)(value).pipe(Effect.mapError((e) => new ValidationError({
|
|
1600
|
+
message: `Invalid ${field.field_type} for block field '${field.api_key}': ${e.message}`,
|
|
1601
|
+
field: field.api_key
|
|
1602
|
+
})));
|
|
1603
|
+
}
|
|
1604
|
+
row[field.api_key] = value;
|
|
1605
|
+
}
|
|
1606
|
+
const tableName = `block_${blockModel.apiKey}`;
|
|
1607
|
+
const rows = rowsByTable.get(tableName);
|
|
1608
|
+
if (rows) rows.push(row);
|
|
1609
|
+
else rowsByTable.set(tableName, [row]);
|
|
1610
|
+
mergeRowMaps(rowsByTable, nestedRows);
|
|
1611
|
+
}
|
|
1612
|
+
return {
|
|
1613
|
+
dast,
|
|
1614
|
+
rowsByTable
|
|
1615
|
+
};
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1618
|
+
function insertCompiledRows(sql, rowsByTable) {
|
|
1619
|
+
return Effect.gen(function* () {
|
|
1620
|
+
for (const [tableName, rows] of rowsByTable) for (const row of rows) {
|
|
1621
|
+
const columns = Object.keys(row);
|
|
1622
|
+
const colList = columns.map((c) => `"${c}"`).join(", ");
|
|
1623
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
1624
|
+
const values = columns.map((c) => serializeValue(row[c]));
|
|
1625
|
+
yield* sql.unsafe(`INSERT INTO "${tableName}" (${colList}) VALUES (${placeholders})`, values);
|
|
1626
|
+
}
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
function fetchBlockModels(sql) {
|
|
1630
|
+
return sql.unsafe("SELECT api_key FROM models WHERE is_block = 1 ORDER BY api_key");
|
|
1631
|
+
}
|
|
1632
|
+
function collectDescendantBlockIds(sql, startIds) {
|
|
1633
|
+
return Effect.gen(function* () {
|
|
1634
|
+
const blockModels = yield* fetchBlockModels(sql);
|
|
1635
|
+
const allIds = new Set(startIds);
|
|
1636
|
+
let frontier = [...startIds];
|
|
1637
|
+
while (frontier.length > 0) {
|
|
1638
|
+
const next = /* @__PURE__ */ new Set();
|
|
1639
|
+
const placeholders = frontier.map(() => "?").join(", ");
|
|
1640
|
+
for (const model of blockModels) {
|
|
1641
|
+
const rows = yield* sql.unsafe(`SELECT id FROM "block_${model.api_key}" WHERE _parent_block_id IN (${placeholders})`, frontier);
|
|
1642
|
+
for (const row of rows) if (!allIds.has(row.id)) {
|
|
1643
|
+
allIds.add(row.id);
|
|
1644
|
+
next.add(row.id);
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
frontier = [...next];
|
|
1648
|
+
}
|
|
1649
|
+
return allIds;
|
|
1650
|
+
});
|
|
1651
|
+
}
|
|
1652
|
+
function writeStructuredText(params) {
|
|
1653
|
+
return Effect.gen(function* () {
|
|
1654
|
+
const sql = yield* SqlClient.SqlClient;
|
|
1655
|
+
const input = yield* decodeStructuredTextInput(params.fieldApiKey, {
|
|
1656
|
+
value: params.value,
|
|
1657
|
+
blocks: params.blocks ?? {}
|
|
1658
|
+
});
|
|
1659
|
+
const compiled = yield* compileStructuredText({
|
|
1660
|
+
sql,
|
|
1661
|
+
rootRecordId: params.rootRecordId,
|
|
1662
|
+
rootFieldApiKey: params.rootFieldStorageKey ?? params.fieldApiKey,
|
|
1663
|
+
rootModelApiKey: params.rootModelApiKey,
|
|
1664
|
+
seenBlockIds: /* @__PURE__ */ new Set()
|
|
1665
|
+
}, {
|
|
1666
|
+
parentContainerModelApiKey: params.rootModelApiKey,
|
|
1667
|
+
parentBlockId: null,
|
|
1668
|
+
parentFieldApiKey: params.fieldApiKey,
|
|
1669
|
+
depth: 0
|
|
1670
|
+
}, {
|
|
1671
|
+
fieldApiKey: params.fieldApiKey,
|
|
1672
|
+
input,
|
|
1673
|
+
allowedBlockTypes: params.allowedBlockTypes ?? [],
|
|
1674
|
+
blocksOnly: params.blocksOnly ?? false
|
|
1675
|
+
});
|
|
1676
|
+
yield* insertCompiledRows(sql, compiled.rowsByTable);
|
|
1677
|
+
return compiled.dast;
|
|
1678
|
+
});
|
|
1679
|
+
}
|
|
1680
|
+
function deleteBlocksForField(params) {
|
|
1681
|
+
return Effect.gen(function* () {
|
|
1682
|
+
const sql = yield* SqlClient.SqlClient;
|
|
1683
|
+
const blockModels = yield* fetchBlockModels(sql);
|
|
1684
|
+
for (const model of blockModels) if (params.includeLocalizedVariants) yield* sql.unsafe(`DELETE FROM "block_${model.api_key}"
|
|
1685
|
+
WHERE _root_record_id = ?
|
|
1686
|
+
AND (_root_field_api_key = ? OR _root_field_api_key LIKE ?)`, [
|
|
1687
|
+
params.rootRecordId,
|
|
1688
|
+
params.fieldApiKey,
|
|
1689
|
+
`${params.fieldApiKey}:%`
|
|
1690
|
+
]);
|
|
1691
|
+
else yield* sql.unsafe(`DELETE FROM "block_${model.api_key}" WHERE _root_record_id = ? AND _root_field_api_key = ?`, [params.rootRecordId, params.fieldApiKey]);
|
|
1692
|
+
});
|
|
1693
|
+
}
|
|
1694
|
+
function deleteBlockSubtrees(params) {
|
|
1695
|
+
return Effect.gen(function* () {
|
|
1696
|
+
if (params.blockIds.length === 0) return;
|
|
1697
|
+
const sql = yield* SqlClient.SqlClient;
|
|
1698
|
+
const blockModels = yield* fetchBlockModels(sql);
|
|
1699
|
+
const ids = [...yield* collectDescendantBlockIds(sql, params.blockIds)];
|
|
1700
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
1701
|
+
for (const model of blockModels) yield* sql.unsafe(`DELETE FROM "block_${model.api_key}" WHERE id IN (${placeholders})`, ids);
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1704
|
+
function parseMaterializeStructuredTextRequest(request) {
|
|
1705
|
+
const dast = decodeJsonIfString(request.rawValue);
|
|
1706
|
+
if (!dast || typeof dast !== "object") return null;
|
|
1707
|
+
if (!("document" in dast) || typeof dast.document !== "object" || dast.document === null || !("children" in dast.document)) return null;
|
|
1708
|
+
const doc = dast;
|
|
1709
|
+
const blockIds = extractAllBlockIds(doc);
|
|
1710
|
+
return {
|
|
1711
|
+
requestKey: request.requestKey,
|
|
1712
|
+
params: request,
|
|
1713
|
+
doc,
|
|
1714
|
+
blockIds,
|
|
1715
|
+
blockIdSet: new Set(blockIds)
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1718
|
+
function getMaterializeBatchGroupKey(params) {
|
|
1719
|
+
const allowed = params.allowedBlockApiKeys?.join(",") ?? "*";
|
|
1720
|
+
const planKey = serializeMaterializePlan(params.selectedNestedFieldsPlan);
|
|
1721
|
+
return [
|
|
1722
|
+
params.parentContainerModelApiKey,
|
|
1723
|
+
params.parentFieldApiKey,
|
|
1724
|
+
params.rootFieldApiKey,
|
|
1725
|
+
params.parentBlockId === null ? "root" : "nested",
|
|
1726
|
+
allowed,
|
|
1727
|
+
planKey
|
|
1728
|
+
].join(":");
|
|
1729
|
+
}
|
|
1730
|
+
function serializeMaterializePlan(plan) {
|
|
1731
|
+
if (!plan) return "*";
|
|
1732
|
+
return [...plan.fieldsByBlockApiKey.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([blockApiKey, fieldPlans]) => [blockApiKey, [...fieldPlans.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([fieldApiKey, nestedPlan]) => `${fieldApiKey}(${serializeMaterializePlan(nestedPlan)})`).join(",")]).map(([blockApiKey, nested]) => `${blockApiKey}[${nested}]`).join("|");
|
|
1733
|
+
}
|
|
1734
|
+
function buildMaterializeQueries(params) {
|
|
1735
|
+
const rootRecordPlaceholders = params.rootRecordIds.map(() => "?").join(", ");
|
|
1736
|
+
const blockPlaceholders = params.blockIds.map(() => "?").join(", ");
|
|
1737
|
+
const parentBlockPlaceholders = params.parentBlockIds.map(() => "?").join(", ");
|
|
1738
|
+
return params.blockModels.map((model) => {
|
|
1739
|
+
const payloadParts = model.fields.map((field) => `'${field.api_key}', "${field.api_key}"`).join(", ");
|
|
1740
|
+
return {
|
|
1741
|
+
sql: `SELECT id, _root_record_id, _root_field_api_key, _parent_block_id, '${model.apiKey}' AS __block_api_key, json_object(${payloadParts}) AS __payload
|
|
1742
|
+
FROM "block_${model.apiKey}"
|
|
1743
|
+
WHERE _root_record_id IN (${rootRecordPlaceholders})
|
|
1744
|
+
AND _root_field_api_key = ?
|
|
1745
|
+
AND _parent_container_model_api_key = ?
|
|
1746
|
+
AND _parent_field_api_key = ?
|
|
1747
|
+
AND ${params.parentBlockIds.length === 0 ? "_parent_block_id IS NULL" : `_parent_block_id IN (${parentBlockPlaceholders})`}
|
|
1748
|
+
AND id IN (${blockPlaceholders})`,
|
|
1749
|
+
params: [
|
|
1750
|
+
...params.rootRecordIds,
|
|
1751
|
+
params.rootFieldApiKey,
|
|
1752
|
+
params.parentContainerModelApiKey,
|
|
1753
|
+
params.parentFieldApiKey,
|
|
1754
|
+
...params.parentBlockIds.length === 0 ? [] : params.parentBlockIds,
|
|
1755
|
+
...params.blockIds
|
|
1756
|
+
]
|
|
1757
|
+
};
|
|
1758
|
+
});
|
|
1759
|
+
}
|
|
1760
|
+
function materializeStructuredTextValues(params) {
|
|
1761
|
+
return Effect.gen(function* () {
|
|
1762
|
+
const sql = yield* SqlClient.SqlClient;
|
|
1763
|
+
const materializeContext = params.materializeContext ?? {
|
|
1764
|
+
blockModelSchemas: /* @__PURE__ */ new Map(),
|
|
1765
|
+
candidateBlockModels: /* @__PURE__ */ new Map()
|
|
1766
|
+
};
|
|
1767
|
+
const results = /* @__PURE__ */ new Map();
|
|
1768
|
+
const parsedRequests = [];
|
|
1769
|
+
for (const request of params.requests) {
|
|
1770
|
+
const parsed = parseMaterializeStructuredTextRequest(request);
|
|
1771
|
+
if (!parsed) {
|
|
1772
|
+
results.set(request.requestKey, null);
|
|
1773
|
+
continue;
|
|
1774
|
+
}
|
|
1775
|
+
if (parsed.blockIds.length === 0) {
|
|
1776
|
+
results.set(parsed.requestKey, {
|
|
1777
|
+
value: parsed.doc,
|
|
1778
|
+
blocks: {}
|
|
1779
|
+
});
|
|
1780
|
+
continue;
|
|
1781
|
+
}
|
|
1782
|
+
parsedRequests.push(parsed);
|
|
1783
|
+
}
|
|
1784
|
+
if (parsedRequests.length === 0) return results;
|
|
1785
|
+
const blockModels = yield* fetchBlockModelsCached(materializeContext, sql);
|
|
1786
|
+
const requestsByGroup = /* @__PURE__ */ new Map();
|
|
1787
|
+
for (const request of parsedRequests) {
|
|
1788
|
+
const groupKey = getMaterializeBatchGroupKey(request.params);
|
|
1789
|
+
const group = requestsByGroup.get(groupKey);
|
|
1790
|
+
if (group) group.push(request);
|
|
1791
|
+
else requestsByGroup.set(groupKey, [request]);
|
|
1792
|
+
results.set(request.requestKey, {
|
|
1793
|
+
value: request.doc,
|
|
1794
|
+
blocks: {}
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1797
|
+
const nestedRequests = [];
|
|
1798
|
+
const nestedAssignments = [];
|
|
1799
|
+
for (const requests of requestsByGroup.values()) {
|
|
1800
|
+
const sample = requests[0];
|
|
1801
|
+
if (!sample) continue;
|
|
1802
|
+
const requestByParentKey = /* @__PURE__ */ new Map();
|
|
1803
|
+
const requestBlockIds = /* @__PURE__ */ new Map();
|
|
1804
|
+
const allBlockIds = /* @__PURE__ */ new Set();
|
|
1805
|
+
const rootRecordIds = /* @__PURE__ */ new Set();
|
|
1806
|
+
const parentBlockIds = /* @__PURE__ */ new Set();
|
|
1807
|
+
for (const request of requests) {
|
|
1808
|
+
const parentKey = `${request.params.rootRecordId}:${request.params.parentBlockId ?? "root"}`;
|
|
1809
|
+
requestByParentKey.set(parentKey, request);
|
|
1810
|
+
requestBlockIds.set(request.requestKey, request.blockIdSet);
|
|
1811
|
+
rootRecordIds.add(request.params.rootRecordId);
|
|
1812
|
+
if (request.params.parentBlockId !== null) parentBlockIds.add(request.params.parentBlockId);
|
|
1813
|
+
for (const blockId of request.blockIds) allBlockIds.add(blockId);
|
|
1814
|
+
}
|
|
1815
|
+
const candidateBlockModels = getCandidateBlockModelsCached(materializeContext, blockModels, sample.params.allowedBlockApiKeys);
|
|
1816
|
+
const blockModelSchemas = yield* Effect.all(candidateBlockModels.map((model) => getBlockModelSchemaCached(materializeContext, sql, model.api_key)), { concurrency: "unbounded" });
|
|
1817
|
+
const blockModelByApiKey = new Map(blockModelSchemas.map((model) => [model.apiKey, model]));
|
|
1818
|
+
const rootRecordIdList = [...rootRecordIds];
|
|
1819
|
+
const blockIds = [...allBlockIds];
|
|
1820
|
+
const parentBlockIdList = [...parentBlockIds];
|
|
1821
|
+
const rows = (yield* runHotBlockQueries(buildMaterializeQueries({
|
|
1822
|
+
blockModels: blockModelSchemas,
|
|
1823
|
+
rootRecordIds: rootRecordIdList,
|
|
1824
|
+
rootFieldApiKey: sample.params.rootFieldApiKey,
|
|
1825
|
+
parentContainerModelApiKey: sample.params.parentContainerModelApiKey,
|
|
1826
|
+
parentFieldApiKey: sample.params.parentFieldApiKey,
|
|
1827
|
+
parentBlockIds: parentBlockIdList,
|
|
1828
|
+
blockIds
|
|
1829
|
+
}))).flat();
|
|
1830
|
+
if (rows.length === 0) continue;
|
|
1831
|
+
for (const row of rows) {
|
|
1832
|
+
const rootRecordId = String(row._root_record_id);
|
|
1833
|
+
const parentBlockId = typeof row._parent_block_id === "string" ? row._parent_block_id : "root";
|
|
1834
|
+
const request = requestByParentKey.get(`${rootRecordId}:${parentBlockId}`);
|
|
1835
|
+
if (!request) continue;
|
|
1836
|
+
const allowedBlockIds = requestBlockIds.get(request.requestKey);
|
|
1837
|
+
const rowId = String(row.id);
|
|
1838
|
+
if (!allowedBlockIds?.has(rowId)) continue;
|
|
1839
|
+
const blockApiKey = typeof row.__block_api_key === "string" ? row.__block_api_key : null;
|
|
1840
|
+
if (!blockApiKey) continue;
|
|
1841
|
+
const blockModel = blockModelByApiKey.get(blockApiKey);
|
|
1842
|
+
if (!blockModel) continue;
|
|
1843
|
+
const rawPayload = decodeJsonIfString(row.__payload);
|
|
1844
|
+
if (typeof rawPayload !== "object" || rawPayload === null || Array.isArray(rawPayload)) continue;
|
|
1845
|
+
const payload = { _type: blockApiKey };
|
|
1846
|
+
const selectedFieldPlans = request.params.selectedNestedFieldsPlan?.fieldsByBlockApiKey.get(blockApiKey);
|
|
1847
|
+
for (const field of blockModel.fields) {
|
|
1848
|
+
const rawValue = deserializeValue(Reflect.get(rawPayload, field.api_key));
|
|
1849
|
+
if (rawValue === void 0) continue;
|
|
1850
|
+
if (field.field_type === "structured_text" && rawValue !== null) {
|
|
1851
|
+
const nestedPlan = selectedFieldPlans?.get(field.api_key);
|
|
1852
|
+
if (request.params.selectedNestedFieldsPlan && !nestedPlan) continue;
|
|
1853
|
+
const requestKey = `nested:${nestedAssignments.length}`;
|
|
1854
|
+
nestedRequests.push({
|
|
1855
|
+
requestKey,
|
|
1856
|
+
materializeContext,
|
|
1857
|
+
allowedBlockApiKeys: blockModel.structuredTextAllowedBlockApiKeysByField.get(field.api_key) ?? [],
|
|
1858
|
+
selectedNestedFieldsPlan: nestedPlan,
|
|
1859
|
+
parentContainerModelApiKey: blockApiKey,
|
|
1860
|
+
parentBlockId: rowId,
|
|
1861
|
+
parentFieldApiKey: field.api_key,
|
|
1862
|
+
rootRecordId,
|
|
1863
|
+
rootFieldApiKey: String(row._root_field_api_key),
|
|
1864
|
+
rawValue
|
|
1865
|
+
});
|
|
1866
|
+
nestedAssignments.push({
|
|
1867
|
+
requestKey,
|
|
1868
|
+
target: payload,
|
|
1869
|
+
fieldApiKey: field.api_key
|
|
1870
|
+
});
|
|
1871
|
+
continue;
|
|
1872
|
+
}
|
|
1873
|
+
payload[field.api_key] = rawValue;
|
|
1874
|
+
}
|
|
1875
|
+
const envelope = results.get(request.requestKey);
|
|
1876
|
+
if (envelope) envelope.blocks[rowId] = payload;
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
if (nestedRequests.length > 0) {
|
|
1880
|
+
const nestedResults = yield* materializeStructuredTextValues({
|
|
1881
|
+
materializeContext,
|
|
1882
|
+
requests: nestedRequests
|
|
1883
|
+
});
|
|
1884
|
+
for (const assignment of nestedAssignments) assignment.target[assignment.fieldApiKey] = nestedResults.get(assignment.requestKey) ?? null;
|
|
1885
|
+
}
|
|
1886
|
+
return results;
|
|
1887
|
+
});
|
|
1888
|
+
}
|
|
1889
|
+
function materializeStructuredTextValue(params) {
|
|
1890
|
+
return Effect.gen(function* () {
|
|
1891
|
+
return (yield* materializeStructuredTextValues({
|
|
1892
|
+
materializeContext: params.materializeContext,
|
|
1893
|
+
requests: [{
|
|
1894
|
+
requestKey: "single",
|
|
1895
|
+
...params
|
|
1896
|
+
}]
|
|
1897
|
+
})).get("single") ?? null;
|
|
1898
|
+
});
|
|
1899
|
+
}
|
|
1900
|
+
function materializeRecordStructuredTextFields(params) {
|
|
1901
|
+
return Effect.gen(function* () {
|
|
1902
|
+
const materialized = { ...params.record };
|
|
1903
|
+
for (const field of params.fields) {
|
|
1904
|
+
if (field.field_type !== "structured_text") continue;
|
|
1905
|
+
const rawValue = params.record[field.api_key];
|
|
1906
|
+
if (rawValue === null || rawValue === void 0) continue;
|
|
1907
|
+
const materializeContext = {
|
|
1908
|
+
blockModelSchemas: /* @__PURE__ */ new Map(),
|
|
1909
|
+
candidateBlockModels: /* @__PURE__ */ new Map()
|
|
1910
|
+
};
|
|
1911
|
+
if (field.localized) {
|
|
1912
|
+
const localeMap = decodeJsonIfString(rawValue);
|
|
1913
|
+
if (typeof localeMap !== "object" || localeMap === null || Array.isArray(localeMap)) continue;
|
|
1914
|
+
const localized = {};
|
|
1915
|
+
for (const [localeCode, localeValue] of Object.entries(localeMap)) {
|
|
1916
|
+
if (localeValue === null || localeValue === void 0) {
|
|
1917
|
+
localized[localeCode] = localeValue;
|
|
1918
|
+
continue;
|
|
1919
|
+
}
|
|
1920
|
+
localized[localeCode] = yield* materializeStructuredTextValue({
|
|
1921
|
+
allowedBlockApiKeys: getBlockWhitelist(field.validators) ?? [],
|
|
1922
|
+
parentContainerModelApiKey: params.modelApiKey,
|
|
1923
|
+
materializeContext,
|
|
1924
|
+
parentBlockId: null,
|
|
1925
|
+
parentFieldApiKey: field.api_key,
|
|
1926
|
+
rootRecordId: String(params.record.id),
|
|
1927
|
+
rootFieldApiKey: getStructuredTextStorageKey(field.api_key, localeCode),
|
|
1928
|
+
rawValue: localeValue
|
|
1929
|
+
});
|
|
1930
|
+
}
|
|
1931
|
+
materialized[field.api_key] = localized;
|
|
1932
|
+
continue;
|
|
1933
|
+
}
|
|
1934
|
+
const envelope = yield* materializeStructuredTextValue({
|
|
1935
|
+
allowedBlockApiKeys: getBlockWhitelist(field.validators) ?? [],
|
|
1936
|
+
parentContainerModelApiKey: params.modelApiKey,
|
|
1937
|
+
materializeContext,
|
|
1938
|
+
parentBlockId: null,
|
|
1939
|
+
parentFieldApiKey: field.api_key,
|
|
1940
|
+
rootRecordId: String(params.record.id),
|
|
1941
|
+
rootFieldApiKey: field.api_key,
|
|
1942
|
+
rawValue
|
|
1943
|
+
});
|
|
1944
|
+
materialized[field.api_key] = envelope;
|
|
1945
|
+
}
|
|
1946
|
+
return materialized;
|
|
1947
|
+
});
|
|
1948
|
+
}
|
|
1949
|
+
//#endregion
|
|
1950
|
+
export { isSearchable as A, encodeJson as B, findUniqueConstraintViolations as C, getLinksTargets as D, getLinkTargets as E, parseMediaGalleryReferences as F, ReferenceConflictError as G, getFieldTypeDef as H, decodeJsonIfString as I, ValidationError as J, SchemaEngineError as K, decodeJsonRecordStringOr as L, supportsUniqueValidation as M, mergeAssetWithMediaReference as N, getSlugSource as O, parseMediaFieldReference as P, decodeJsonString as R, computeIsValid as S, getBlocksOnly as T, DuplicateError as U, FIELD_TYPE_REGISTRY as V, NotFoundError as W, isCmsError as X, errorToResponse as Y, extractLinkIds as _, materializeStructuredTextValue as a, isContentRow as b, FIELD_TYPES as c, getSqlMetrics as d, recordSqlMetrics as f, extractInlineBlockIds as g, extractBlockIds as h, materializeRecordStructuredTextFields as i, isUnique as j, isRequired as k, isFieldType as l, markdownToDast as m, deleteBlocksForField as n, materializeStructuredTextValues as o, withSqlMetrics as p, UnauthorizedError as q, getStructuredTextStorageKey as r, writeStructuredText as s, deleteBlockSubtrees as t, runBatchedQueries as u, pruneBlockNodes as v, getBlockWhitelist as w, parseFieldValidators as x, StructuredTextWriteInput as y, decodeJsonStringOr as z };
|
|
1951
|
+
|
|
1952
|
+
//# sourceMappingURL=structured-text-service-B4xSlUg_.mjs.map
|