@caretcms/core 0.3.0 → 0.4.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/README.md +116 -11
- package/dist/index.d.ts +15 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +78 -5
- package/dist/index.js.map +1 -1
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +8 -0
- package/dist/loader.js.map +1 -1
- package/dist/markdown/paragraphs.d.ts +14 -0
- package/dist/markdown/paragraphs.d.ts.map +1 -0
- package/dist/markdown/paragraphs.js +61 -0
- package/dist/markdown/paragraphs.js.map +1 -0
- package/dist/markdown/remark.d.ts.map +1 -1
- package/dist/markdown/remark.js +1 -0
- package/dist/markdown/remark.js.map +1 -1
- package/dist/markdown/satteri.d.ts.map +1 -1
- package/dist/markdown/satteri.js +3 -1
- package/dist/markdown/satteri.js.map +1 -1
- package/dist/markdown/stamp.d.ts +0 -9
- package/dist/markdown/stamp.d.ts.map +1 -1
- package/dist/markdown/stamp.js +6 -0
- package/dist/markdown/stamp.js.map +1 -1
- package/dist/providers/deployment/github.d.ts +27 -0
- package/dist/providers/deployment/github.d.ts.map +1 -0
- package/dist/providers/deployment/github.js +180 -0
- package/dist/providers/deployment/github.js.map +1 -0
- package/dist/providers/deployment/simulated.d.ts +10 -0
- package/dist/providers/deployment/simulated.d.ts.map +1 -0
- package/dist/providers/deployment/simulated.js +29 -0
- package/dist/providers/deployment/simulated.js.map +1 -0
- package/dist/runtime/authorization.d.ts +11 -0
- package/dist/runtime/authorization.d.ts.map +1 -0
- package/dist/runtime/authorization.js +29 -0
- package/dist/runtime/authorization.js.map +1 -0
- package/dist/runtime/collection-policy.d.ts +11 -0
- package/dist/runtime/collection-policy.d.ts.map +1 -0
- package/dist/runtime/collection-policy.js +44 -0
- package/dist/runtime/collection-policy.js.map +1 -0
- package/dist/runtime/content-errors.d.ts +9 -0
- package/dist/runtime/content-errors.d.ts.map +1 -0
- package/dist/runtime/content-errors.js +17 -0
- package/dist/runtime/content-errors.js.map +1 -0
- package/dist/runtime/content.d.ts.map +1 -1
- package/dist/runtime/content.js +13 -4
- package/dist/runtime/content.js.map +1 -1
- package/dist/runtime/deployment-status.d.ts +14 -0
- package/dist/runtime/deployment-status.d.ts.map +1 -0
- package/dist/runtime/deployment-status.js +57 -0
- package/dist/runtime/deployment-status.js.map +1 -0
- package/dist/runtime/draft-state.d.ts +16 -0
- package/dist/runtime/draft-state.d.ts.map +1 -0
- package/dist/runtime/draft-state.js +29 -0
- package/dist/runtime/draft-state.js.map +1 -0
- package/dist/runtime/i18n.d.ts +13 -2
- package/dist/runtime/i18n.d.ts.map +1 -1
- package/dist/runtime/i18n.js +26 -4
- package/dist/runtime/i18n.js.map +1 -1
- package/dist/runtime/index.d.ts +1 -0
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +1 -0
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/middleware.d.ts.map +1 -1
- package/dist/runtime/middleware.js +21 -2
- package/dist/runtime/middleware.js.map +1 -1
- package/dist/runtime/mutations/contracts.d.ts +9 -0
- package/dist/runtime/mutations/contracts.d.ts.map +1 -1
- package/dist/runtime/mutations/contracts.js +45 -4
- package/dist/runtime/mutations/contracts.js.map +1 -1
- package/dist/runtime/mutations/engine.d.ts +2 -0
- package/dist/runtime/mutations/engine.d.ts.map +1 -1
- package/dist/runtime/mutations/engine.js +115 -63
- package/dist/runtime/mutations/engine.js.map +1 -1
- package/dist/runtime/providers.d.ts +3 -1
- package/dist/runtime/providers.d.ts.map +1 -1
- package/dist/runtime/providers.js +12 -1
- package/dist/runtime/providers.js.map +1 -1
- package/dist/runtime/publish-recovery.d.ts +32 -0
- package/dist/runtime/publish-recovery.d.ts.map +1 -0
- package/dist/runtime/publish-recovery.js +98 -0
- package/dist/runtime/publish-recovery.js.map +1 -0
- package/dist/runtime/publish.d.ts +10 -4
- package/dist/runtime/publish.d.ts.map +1 -1
- package/dist/runtime/publish.js +104 -67
- package/dist/runtime/publish.js.map +1 -1
- package/dist/runtime/rebuild-webhook.d.ts +4 -0
- package/dist/runtime/rebuild-webhook.d.ts.map +1 -1
- package/dist/runtime/rebuild-webhook.js +12 -2
- package/dist/runtime/rebuild-webhook.js.map +1 -1
- package/dist/runtime/request-context.d.ts +3 -1
- package/dist/runtime/request-context.d.ts.map +1 -1
- package/dist/runtime/request-context.js.map +1 -1
- package/dist/runtime/rewrite.d.ts.map +1 -1
- package/dist/runtime/rewrite.js +28 -2
- package/dist/runtime/rewrite.js.map +1 -1
- package/dist/runtime/rich-allowlist.js +1 -1
- package/dist/runtime/rich-allowlist.js.map +1 -1
- package/dist/runtime/routes/admin-entry.d.ts.map +1 -1
- package/dist/runtime/routes/admin-entry.js +24 -12
- package/dist/runtime/routes/admin-entry.js.map +1 -1
- package/dist/runtime/routes/auth-session.d.ts.map +1 -1
- package/dist/runtime/routes/auth-session.js +6 -0
- package/dist/runtime/routes/auth-session.js.map +1 -1
- package/dist/runtime/routes/collection-list.d.ts.map +1 -1
- package/dist/runtime/routes/collection-list.js +164 -40
- package/dist/runtime/routes/collection-list.js.map +1 -1
- package/dist/runtime/routes/deployment.d.ts +5 -0
- package/dist/runtime/routes/deployment.d.ts.map +1 -0
- package/dist/runtime/routes/deployment.js +38 -0
- package/dist/runtime/routes/deployment.js.map +1 -0
- package/dist/runtime/routes/draft.d.ts.map +1 -1
- package/dist/runtime/routes/draft.js +10 -3
- package/dist/runtime/routes/draft.js.map +1 -1
- package/dist/runtime/routes/entries.d.ts.map +1 -1
- package/dist/runtime/routes/entries.js +74 -39
- package/dist/runtime/routes/entries.js.map +1 -1
- package/dist/runtime/routes/history.d.ts.map +1 -1
- package/dist/runtime/routes/history.js +37 -21
- package/dist/runtime/routes/history.js.map +1 -1
- package/dist/runtime/routes/mutate.d.ts.map +1 -1
- package/dist/runtime/routes/mutate.js +4 -1
- package/dist/runtime/routes/mutate.js.map +1 -1
- package/dist/runtime/routes/publish.d.ts.map +1 -1
- package/dist/runtime/routes/publish.js +127 -33
- package/dist/runtime/routes/publish.js.map +1 -1
- package/dist/runtime/routes/schema.d.ts.map +1 -1
- package/dist/runtime/routes/schema.js +4 -0
- package/dist/runtime/routes/schema.js.map +1 -1
- package/dist/runtime/routes/studio-home.d.ts.map +1 -1
- package/dist/runtime/routes/studio-home.js +3 -1
- package/dist/runtime/routes/studio-home.js.map +1 -1
- package/dist/runtime/routes/upload.d.ts.map +1 -1
- package/dist/runtime/routes/upload.js +3 -0
- package/dist/runtime/routes/upload.js.map +1 -1
- package/dist/runtime/schema-registry.d.ts.map +1 -1
- package/dist/runtime/schema-registry.js +2 -0
- package/dist/runtime/schema-registry.js.map +1 -1
- package/dist/runtime/storage/entry-commit.d.ts +8 -0
- package/dist/runtime/storage/entry-commit.d.ts.map +1 -0
- package/dist/runtime/storage/entry-commit.js +45 -0
- package/dist/runtime/storage/entry-commit.js.map +1 -0
- package/dist/runtime/storage/filesystem-adapter.d.ts +5 -0
- package/dist/runtime/storage/filesystem-adapter.d.ts.map +1 -1
- package/dist/runtime/storage/filesystem-adapter.js +12 -3
- package/dist/runtime/storage/filesystem-adapter.js.map +1 -1
- package/dist/runtime/storage/frontmatter-codec.d.ts +5 -23
- package/dist/runtime/storage/frontmatter-codec.d.ts.map +1 -1
- package/dist/runtime/storage/frontmatter-codec.js +156 -13
- package/dist/runtime/storage/frontmatter-codec.js.map +1 -1
- package/dist/runtime/storage/in-memory-adapter.d.ts +7 -0
- package/dist/runtime/storage/in-memory-adapter.d.ts.map +1 -1
- package/dist/runtime/storage/in-memory-adapter.js +6 -0
- package/dist/runtime/storage/in-memory-adapter.js.map +1 -1
- package/dist/runtime/storage/markdown-adapter.d.ts.map +1 -1
- package/dist/runtime/storage/markdown-adapter.js +18 -20
- package/dist/runtime/storage/markdown-adapter.js.map +1 -1
- package/dist/runtime/storage/session-overlay-adapter.d.ts +12 -4
- package/dist/runtime/storage/session-overlay-adapter.d.ts.map +1 -1
- package/dist/runtime/storage/session-overlay-adapter.js +100 -13
- package/dist/runtime/storage/session-overlay-adapter.js.map +1 -1
- package/dist/runtime/storage/sidecar-meta-store.d.ts +5 -0
- package/dist/runtime/storage/sidecar-meta-store.d.ts.map +1 -1
- package/dist/runtime/storage/sidecar-meta-store.js +48 -5
- package/dist/runtime/storage/sidecar-meta-store.js.map +1 -1
- package/dist/runtime/utils.d.ts.map +1 -1
- package/dist/runtime/utils.js +3 -4
- package/dist/runtime/utils.js.map +1 -1
- package/dist/types.d.ts +123 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +12 -3
- package/static/cms/admin-entry.css +9 -0
- package/static/cms/admin-entry.js +251 -687
- package/static/cms/dev-toolbar/app.js +78 -19
- package/static/cms/editor/config.js +35 -15
- package/static/cms/editor/content-map.css +8 -1
- package/static/cms/editor/content-map.js +62 -23
- package/static/cms/editor/deployment-status.js +66 -0
- package/static/cms/editor/draft-client.js +87 -0
- package/static/cms/editor/guards.js +20 -0
- package/static/cms/editor/helpers.js +30 -1
- package/static/cms/editor/highlight.js +1 -0
- package/static/cms/editor/image-edit.js +43 -1
- package/static/cms/editor/image-utils.js +23 -7
- package/static/cms/editor/link-follow.js +122 -41
- package/static/cms/editor/link-ui.css +10 -1
- package/static/cms/editor/linkify.js +2 -0
- package/static/cms/editor/md-block-edit.js +140 -37
- package/static/cms/editor/md-paragraphs.js +98 -0
- package/static/cms/editor/md-serialize.js +1 -1
- package/static/cms/editor/panel.js +43 -15
- package/static/cms/editor/preview-mode.js +44 -0
- package/static/cms/editor/publish-result.js +100 -0
- package/static/cms/editor/rich-toolbar.js +58 -15
- package/static/cms/editor/sanitize.js +6 -2
- package/static/cms/editor/save-queue.js +75 -17
- package/static/cms/editor/section-controls/activation.js +3 -0
- package/static/cms/editor/section-controls/api.js +25 -7
- package/static/cms/editor/section-controls/index.js +60 -8
- package/static/cms/editor/section-controls/model.js +39 -4
- package/static/cms/editor/section-controls/mutations.js +15 -0
- package/static/cms/editor/section-controls/page-context.js +9 -0
- package/static/cms/editor/section-controls/pickers.js +53 -2
- package/static/cms/editor/section-controls/reorder.js +23 -0
- package/static/cms/editor/section-controls/selection.js +6 -0
- package/static/cms/editor/section-controls/spacing-drag.js +16 -0
- package/static/cms/editor/section-controls/ui-elements.js +7 -0
- package/static/cms/editor/section-controls/utils.js +7 -0
- package/static/cms/editor/section-controls.css +18 -3
- package/static/cms/editor/security.js +13 -2
- package/static/cms/editor/stega-hydrate.js +17 -4
- package/static/cms/editor/sync.js +208 -10
- package/static/cms/editor/text-edit.js +37 -3
- package/static/cms/editor/text-link-interactions.js +59 -17
- package/static/cms/editor/toast.js +23 -9
- package/static/cms/editor/toolbar.css +5 -0
- package/static/cms/editor/toolbar.js +91 -101
- package/static/cms/editor.js +2 -1
- package/static/cms/studio/entry-loader.js +102 -0
- package/static/cms/studio/field-model.js +110 -0
- package/static/cms/studio/fields.js +319 -0
- package/static/cms/studio/history-client.js +74 -0
- package/static/cms/studio/mutation-client.js +74 -0
- package/static/cms/studio/sync-client.js +107 -0
- package/static/cms/studio/upload-client.js +109 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { templateFromSchema } from './field-model.js';
|
|
2
|
+
|
|
3
|
+
/** @typedef {import('../../../src/schema-utils.js').JsonSchemaNode} Schema */
|
|
4
|
+
/** @typedef {{ path: string, message: string }} ValidationIssue */
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {{
|
|
7
|
+
* kind: 'ready', data: Record<string, unknown>, revision: number, schema: Schema | null,
|
|
8
|
+
* validationIssues: ValidationIssue[], publicationFieldName: string | null,
|
|
9
|
+
* initialized: boolean,
|
|
10
|
+
* } | { kind: 'missing', schema: Schema | null, publicationFieldName: string | null }
|
|
11
|
+
* | { kind: 'unauthorized' }
|
|
12
|
+
* | { kind: 'error', code: string }} EntryLoadResult
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
16
|
+
function isRecord(value) {
|
|
17
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** @param {unknown} value @returns {ValidationIssue[]} */
|
|
21
|
+
function validationIssues(value) {
|
|
22
|
+
if (!Array.isArray(value)) return [];
|
|
23
|
+
return value.flatMap(issue => isRecord(issue)
|
|
24
|
+
&& typeof issue.path === 'string'
|
|
25
|
+
&& typeof issue.message === 'string'
|
|
26
|
+
? [{ path: issue.path, message: issue.message }]
|
|
27
|
+
: []);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** @param {unknown} value @returns {Schema | null} */
|
|
31
|
+
function schemaValue(value) {
|
|
32
|
+
return isRecord(value) ? /** @type {Schema} */ (value) : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @param {{ apiBasePath: string, fetchImpl?: typeof fetch }} options */
|
|
36
|
+
export function createStudioEntryLoader({ apiBasePath, fetchImpl = fetch }) {
|
|
37
|
+
/**
|
|
38
|
+
* @param {{ collection: string, id: string, isNew?: boolean, initializeIfMissing?: boolean }} input
|
|
39
|
+
* @returns {Promise<EntryLoadResult>}
|
|
40
|
+
*/
|
|
41
|
+
async function load(input) {
|
|
42
|
+
const entrySearch = new URLSearchParams({ collection: input.collection, id: input.id });
|
|
43
|
+
const schemaSearch = new URLSearchParams({ collection: input.collection });
|
|
44
|
+
const [entryResponse, schemaResponse] = await Promise.all([
|
|
45
|
+
fetchImpl(`${apiBasePath}/entries?${entrySearch}`, { credentials: 'same-origin' }),
|
|
46
|
+
fetchImpl(`${apiBasePath}/schema?${schemaSearch}`, { credentials: 'same-origin' }),
|
|
47
|
+
]);
|
|
48
|
+
if (entryResponse.status === 401 || schemaResponse.status === 401) return { kind: 'unauthorized' };
|
|
49
|
+
|
|
50
|
+
if (!entryResponse.ok) {
|
|
51
|
+
const failure = await entryResponse.json().catch(() => null);
|
|
52
|
+
return {
|
|
53
|
+
kind: 'error',
|
|
54
|
+
code: isRecord(failure) && typeof failure.code === 'string' ? failure.code : 'loadFailed',
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const entryBody = await entryResponse.json().catch(() => null);
|
|
59
|
+
if (!isRecord(entryBody) || !Array.isArray(entryBody.entries)) {
|
|
60
|
+
return { kind: 'error', code: 'loadFailed' };
|
|
61
|
+
}
|
|
62
|
+
const schemaBody = schemaResponse.ok
|
|
63
|
+
? await schemaResponse.json().catch(() => null)
|
|
64
|
+
: null;
|
|
65
|
+
const schema = isRecord(schemaBody) ? schemaValue(schemaBody.schema) : null;
|
|
66
|
+
const metadata = isRecord(schemaBody) && isRecord(schemaBody.metadata) ? schemaBody.metadata : null;
|
|
67
|
+
const publication = metadata && isRecord(metadata.publication) ? metadata.publication : null;
|
|
68
|
+
const publicationFieldName = publication
|
|
69
|
+
? (typeof publication.field === 'string' ? publication.field : 'published')
|
|
70
|
+
: null;
|
|
71
|
+
|
|
72
|
+
const rawEntry = entryBody.entries[0];
|
|
73
|
+
if (rawEntry !== undefined) {
|
|
74
|
+
if (!isRecord(rawEntry) || !isRecord(rawEntry.data)) {
|
|
75
|
+
return { kind: 'error', code: 'loadFailed' };
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
kind: 'ready',
|
|
79
|
+
data: rawEntry.data,
|
|
80
|
+
revision: typeof rawEntry.revision === 'number' ? rawEntry.revision : 0,
|
|
81
|
+
schema,
|
|
82
|
+
validationIssues: validationIssues(rawEntry.validationIssues),
|
|
83
|
+
publicationFieldName,
|
|
84
|
+
initialized: false,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!input.isNew && !input.initializeIfMissing) {
|
|
89
|
+
return { kind: 'missing', schema, publicationFieldName };
|
|
90
|
+
}
|
|
91
|
+
const template = isRecord(schemaBody) && isRecord(schemaBody.template)
|
|
92
|
+
? schemaBody.template
|
|
93
|
+
: templateFromSchema(schema);
|
|
94
|
+
if (!isRecord(template)) return { kind: 'error', code: 'loadFailed' };
|
|
95
|
+
return {
|
|
96
|
+
kind: 'ready', data: template, revision: 0, schema,
|
|
97
|
+
validationIssues: [], publicationFieldName, initialized: true,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { load };
|
|
102
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/** Stateless Studio schema defaults and field presentation helpers. */
|
|
2
|
+
/** @typedef {import('../../../src/schema-utils.js').JsonSchemaNode} Schema */
|
|
3
|
+
|
|
4
|
+
/** @template T @param {T} v @returns {T} */
|
|
5
|
+
export function deepClone(v) { return JSON.parse(JSON.stringify(v)); }
|
|
6
|
+
|
|
7
|
+
/** @param {Schema | null | undefined} schema @returns {unknown} */
|
|
8
|
+
export function templateFromSchema(schema) {
|
|
9
|
+
if (!schema || typeof schema !== "object") return "";
|
|
10
|
+
if (schema.default !== undefined) return deepClone(schema.default);
|
|
11
|
+
if (Array.isArray(schema.enum) && schema.enum.length > 0) return deepClone(schema.enum[0]);
|
|
12
|
+
if (schema.type === "object") {
|
|
13
|
+
/** @type {Record<string, unknown>} */
|
|
14
|
+
var objectTemplate = {};
|
|
15
|
+
Object.keys(schema.properties || {}).forEach(function (key) {
|
|
16
|
+
objectTemplate[key] = templateFromSchema(schema.properties?.[key]);
|
|
17
|
+
});
|
|
18
|
+
return objectTemplate;
|
|
19
|
+
}
|
|
20
|
+
if (schema.type === "array") return [];
|
|
21
|
+
if (schema.type === "boolean") return false;
|
|
22
|
+
if (schema.type === "number" || schema.type === "integer") {
|
|
23
|
+
return typeof schema.minimum === "number" ? schema.minimum : 0;
|
|
24
|
+
}
|
|
25
|
+
return "";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** @param {string} path @param {number} index @returns {string} */
|
|
29
|
+
export function generatedId(path, index) {
|
|
30
|
+
var parts = path.split(".").filter(Boolean);
|
|
31
|
+
var key = parts.pop() || "item";
|
|
32
|
+
if (/^\d+$/.test(key)) key = parts.pop() || "item";
|
|
33
|
+
key = key.replace(/ies$/, "y").replace(/s$/, "");
|
|
34
|
+
return (key + "-" + (index + 1) + "-" + Date.now().toString(36))
|
|
35
|
+
.toLowerCase()
|
|
36
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
37
|
+
.replace(/^-|-$/g, "");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @param {string} filename @returns {string} */
|
|
41
|
+
export function friendlyFileTitle(filename) {
|
|
42
|
+
return String(filename || "Image")
|
|
43
|
+
.replace(/\.[^.]+$/, "")
|
|
44
|
+
.replace(/[-_]+/g, " ")
|
|
45
|
+
.replace(/\s+/g, " ")
|
|
46
|
+
.trim()
|
|
47
|
+
.replace(/\b\w/g, function (c) { return c.toUpperCase(); }) || "Image";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @param {string} path @returns {string} */
|
|
51
|
+
export function singularItemLabel(path) {
|
|
52
|
+
var key = (path.split(".").filter(Boolean).pop() || "item").toLowerCase();
|
|
53
|
+
if (key === "images") return "image";
|
|
54
|
+
if (key === "details") return "detail";
|
|
55
|
+
if (key === "documents") return "document";
|
|
56
|
+
if (key === "videos") return "video";
|
|
57
|
+
if (key === "links") return "link";
|
|
58
|
+
if (key === "resume") return "résumé item";
|
|
59
|
+
return key.replace(/ies$/, "y").replace(/s$/, "") || "item";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @param {Record<string, unknown> | null | undefined} data @returns {string} */
|
|
63
|
+
export function getTitle(data) {
|
|
64
|
+
if (!data) return "Untitled";
|
|
65
|
+
var keys = ["name", "title", "question", "company_name", "headline", "label"];
|
|
66
|
+
for (var i = 0; i < keys.length; i++) {
|
|
67
|
+
const value = data[keys[i]];
|
|
68
|
+
if (typeof value === "string" && value) return value;
|
|
69
|
+
}
|
|
70
|
+
return "Untitled";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** @param {string} key @returns {string} */
|
|
74
|
+
export function humanizeKey(key) {
|
|
75
|
+
return key.replace(/_/g, " ").replace(/\b\w/g, function (c) { return c.toUpperCase(); });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** @param {string} path @returns {string} */
|
|
79
|
+
export function fieldIdFromPath(path) {
|
|
80
|
+
return "caret-field-" + String(path || "").replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** @param {unknown} data @param {string} path @returns {unknown} */
|
|
84
|
+
export function getNestedValue(data, path) {
|
|
85
|
+
return path.split(".").reduce(function (value, key) {
|
|
86
|
+
if (value === null || typeof value !== "object") return undefined;
|
|
87
|
+
return /** @type {Record<string, unknown>} */ (value)[key];
|
|
88
|
+
}, data);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {Record<string, unknown>} data
|
|
93
|
+
* @param {string} path
|
|
94
|
+
* @param {unknown} value
|
|
95
|
+
*/
|
|
96
|
+
export function setNestedValue(data, path, value) {
|
|
97
|
+
var keys = path.split(".");
|
|
98
|
+
/** @type {Record<string, unknown>} */
|
|
99
|
+
var target = data;
|
|
100
|
+
for (var i = 0; i < keys.length - 1; i++) {
|
|
101
|
+
var key = keys[i];
|
|
102
|
+
var next = target[key];
|
|
103
|
+
if (next === null || typeof next !== "object") {
|
|
104
|
+
next = /^\d+$/.test(keys[i + 1]) ? [] : {};
|
|
105
|
+
target[key] = next;
|
|
106
|
+
}
|
|
107
|
+
target = /** @type {Record<string, unknown>} */ (next);
|
|
108
|
+
}
|
|
109
|
+
target[keys[keys.length - 1]] = value;
|
|
110
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/** Studio field rendering. Persistence and specialized widgets are injected. */
|
|
2
|
+
import { fieldIdFromPath, humanizeKey, templateFromSchema } from './field-model.js';
|
|
3
|
+
/** @typedef {import('./field-model.js').Schema} Schema */
|
|
4
|
+
/** @typedef {Record<string, unknown>} Entry */
|
|
5
|
+
|
|
6
|
+
/** @param {string} label @param {boolean} isRequired @param {boolean} [inline] */
|
|
7
|
+
export function createFieldGroup(label, isRequired, inline) {
|
|
8
|
+
var wrapper = document.createElement("div");
|
|
9
|
+
wrapper.className = "caret-field-group" + (inline ? " caret-field-inline" : "");
|
|
10
|
+
|
|
11
|
+
var labelEl = document.createElement("label");
|
|
12
|
+
labelEl.className = "studio-label";
|
|
13
|
+
labelEl.textContent = label;
|
|
14
|
+
if (isRequired === false) {
|
|
15
|
+
var opt = document.createElement("span");
|
|
16
|
+
opt.textContent = " (optional)";
|
|
17
|
+
opt.style.cssText = "color:var(--studio-text-dim);font-weight:400;text-transform:none;letter-spacing:normal;";
|
|
18
|
+
labelEl.appendChild(opt);
|
|
19
|
+
}
|
|
20
|
+
wrapper.appendChild(labelEl);
|
|
21
|
+
|
|
22
|
+
// Once children are populated, link the label to the first form control
|
|
23
|
+
// for screen-reader accessibility. Path is read from data-fieldPath
|
|
24
|
+
// (set by the caller after createFieldGroup returns).
|
|
25
|
+
queueMicrotask(function () {
|
|
26
|
+
var path = wrapper.dataset.fieldPath;
|
|
27
|
+
if (!path) return;
|
|
28
|
+
// Nested object/array groups contain other field groups. Only claim a
|
|
29
|
+
// control whose nearest field-group owner is this wrapper; otherwise an
|
|
30
|
+
// outer `hero` group can overwrite `hero.headline`'s stable identity.
|
|
31
|
+
var control = Array.from(/** @type {NodeListOf<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>} */ (wrapper.querySelectorAll("input, select, textarea"))).find(function (candidate) {
|
|
32
|
+
return candidate.closest(".caret-field-group") === wrapper;
|
|
33
|
+
});
|
|
34
|
+
if (!control) return;
|
|
35
|
+
var fid = fieldIdFromPath(path);
|
|
36
|
+
control.id = fid;
|
|
37
|
+
control.name = path;
|
|
38
|
+
labelEl.htmlFor = control.id;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return wrapper;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {{
|
|
46
|
+
* updateField: (path: string, value: unknown) => void,
|
|
47
|
+
* buildSingleImage: (value: unknown, path: string) => HTMLElement,
|
|
48
|
+
* buildImageGallery: (value: unknown, path: string) => HTMLElement,
|
|
49
|
+
* buildObjectArray: (value: unknown, path: string, schema: Schema | null) => HTMLElement,
|
|
50
|
+
* buildTagList: (value: unknown, path: string) => HTMLElement,
|
|
51
|
+
* getPublicationField: () => string | null
|
|
52
|
+
* }} dependencies
|
|
53
|
+
*/
|
|
54
|
+
export function createFieldRenderer({ updateField, buildSingleImage, buildImageGallery, buildObjectArray, buildTagList, getPublicationField }) {
|
|
55
|
+
/* ─── Schema-driven rendering ───────────────────────────────────── */
|
|
56
|
+
/** @param {HTMLElement} container @param {Entry | null} data @param {Schema | null} schema @param {string} basePath */
|
|
57
|
+
function renderFields(container, data, schema, basePath) {
|
|
58
|
+
if (!schema || !schema.properties) {
|
|
59
|
+
renderFieldsFallback(container, data, basePath);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
var required = new Set(schema.required || []);
|
|
64
|
+
|
|
65
|
+
const properties = schema.properties;
|
|
66
|
+
Object.keys(properties).forEach(function (key) {
|
|
67
|
+
var prop = properties[key];
|
|
68
|
+
var path = basePath ? basePath + "." + key : key;
|
|
69
|
+
var label = String(prop.title || humanizeKey(key));
|
|
70
|
+
var isRequired = required.has(key);
|
|
71
|
+
var value = data ? data[key] : undefined;
|
|
72
|
+
|
|
73
|
+
// Backfill missing keys with sensible defaults
|
|
74
|
+
if (value === undefined && data) {
|
|
75
|
+
data[key] = templateFromSchema(prop);
|
|
76
|
+
}
|
|
77
|
+
var current = data ? data[key] : undefined;
|
|
78
|
+
|
|
79
|
+
var group;
|
|
80
|
+
|
|
81
|
+
if (prop.type === "string" && prop.enum) {
|
|
82
|
+
group = createFieldGroup(label, isRequired);
|
|
83
|
+
group.dataset.fieldPath = path;
|
|
84
|
+
var select = document.createElement("select");
|
|
85
|
+
select.className = "studio-input";
|
|
86
|
+
prop.enum.forEach(function (opt) {
|
|
87
|
+
var option = document.createElement("option");
|
|
88
|
+
option.value = String(opt);
|
|
89
|
+
option.textContent = String(opt).charAt(0).toUpperCase() + String(opt).slice(1);
|
|
90
|
+
if (opt === current) option.selected = true;
|
|
91
|
+
select.appendChild(option);
|
|
92
|
+
});
|
|
93
|
+
select.addEventListener("change", function () { updateField(path, select.value); });
|
|
94
|
+
group.appendChild(select);
|
|
95
|
+
} else if (prop.type === "string" && prop.format === "image") {
|
|
96
|
+
group = createFieldGroup(label, isRequired);
|
|
97
|
+
group.dataset.fieldPath = path;
|
|
98
|
+
group.appendChild(buildSingleImage(current || "", path));
|
|
99
|
+
} else if (prop.type === "string" && (prop.format === "textarea" || prop.format === "html")) {
|
|
100
|
+
group = createFieldGroup(label, isRequired);
|
|
101
|
+
group.dataset.fieldPath = path;
|
|
102
|
+
var ta = document.createElement("textarea");
|
|
103
|
+
ta.className = "studio-input";
|
|
104
|
+
ta.rows = 4;
|
|
105
|
+
ta.value = String(current || "");
|
|
106
|
+
ta.addEventListener("input", function () { updateField(path, ta.value); });
|
|
107
|
+
group.appendChild(ta);
|
|
108
|
+
} else if (prop.type === "string" && (prop.format === "email" || prop.format === "uri" || prop.format === "url")) {
|
|
109
|
+
group = createFieldGroup(label, isRequired);
|
|
110
|
+
group.dataset.fieldPath = path;
|
|
111
|
+
var emailInput = document.createElement("input");
|
|
112
|
+
emailInput.type = prop.format === "email" ? "email" : "url";
|
|
113
|
+
emailInput.className = "studio-input";
|
|
114
|
+
emailInput.value = String(current || "");
|
|
115
|
+
emailInput.addEventListener("input", function () { updateField(path, emailInput.value); });
|
|
116
|
+
group.appendChild(emailInput);
|
|
117
|
+
} else if (prop.type === "string") {
|
|
118
|
+
group = createFieldGroup(label, isRequired);
|
|
119
|
+
group.dataset.fieldPath = path;
|
|
120
|
+
/** @type {HTMLInputElement | HTMLTextAreaElement} */
|
|
121
|
+
var input;
|
|
122
|
+
if (typeof current === "string" && (current.length > 100 || /[\r\n]/.test(current))) {
|
|
123
|
+
input = document.createElement("textarea");
|
|
124
|
+
input.rows = 4;
|
|
125
|
+
} else {
|
|
126
|
+
input = document.createElement("input");
|
|
127
|
+
input.type = "text";
|
|
128
|
+
}
|
|
129
|
+
input.className = "studio-input";
|
|
130
|
+
input.value = String(current || "");
|
|
131
|
+
input.addEventListener("input", function () { updateField(path, input.value); });
|
|
132
|
+
group.appendChild(input);
|
|
133
|
+
} else if (prop.type === "number" || prop.type === "integer") {
|
|
134
|
+
group = createFieldGroup(label, isRequired);
|
|
135
|
+
group.dataset.fieldPath = path;
|
|
136
|
+
var num = document.createElement("input");
|
|
137
|
+
num.type = "number";
|
|
138
|
+
num.className = "studio-input";
|
|
139
|
+
num.value = String(current == null ? 0 : current);
|
|
140
|
+
if (prop.minimum !== undefined) num.min = String(prop.minimum);
|
|
141
|
+
if (prop.maximum !== undefined) num.max = String(prop.maximum);
|
|
142
|
+
num.addEventListener("input", function () { updateField(path, Number(num.value) || 0); });
|
|
143
|
+
group.appendChild(num);
|
|
144
|
+
} else if (prop.type === "boolean") {
|
|
145
|
+
group = createFieldGroup(label, isRequired, true);
|
|
146
|
+
group.dataset.fieldPath = path;
|
|
147
|
+
var toggle = document.createElement("button");
|
|
148
|
+
toggle.type = "button";
|
|
149
|
+
toggle.className = "studio-toggle" + (current ? " active" : "");
|
|
150
|
+
toggle.addEventListener("click", function () {
|
|
151
|
+
var next = !toggle.classList.contains("active");
|
|
152
|
+
toggle.classList.toggle("active");
|
|
153
|
+
updateField(path, next);
|
|
154
|
+
});
|
|
155
|
+
toggle.setAttribute("role", "switch");
|
|
156
|
+
toggle.setAttribute("aria-label", label);
|
|
157
|
+
toggle.setAttribute("aria-checked", current ? "true" : "false");
|
|
158
|
+
toggle.addEventListener("click", function () {
|
|
159
|
+
toggle.setAttribute("aria-checked", toggle.classList.contains("active") ? "true" : "false");
|
|
160
|
+
});
|
|
161
|
+
group.appendChild(toggle);
|
|
162
|
+
if (path === getPublicationField()) {
|
|
163
|
+
group.classList.add("caret-field-with-help");
|
|
164
|
+
var publishHelp = document.createElement("p");
|
|
165
|
+
publishHelp.className = "caret-field-help";
|
|
166
|
+
publishHelp.textContent = current
|
|
167
|
+
? "Turn this off and Save to remove the entry from the public site without deleting it."
|
|
168
|
+
: "Turn this on and Save when the entry is ready to appear on the public site.";
|
|
169
|
+
toggle.addEventListener("click", function () {
|
|
170
|
+
publishHelp.textContent = toggle.classList.contains("active")
|
|
171
|
+
? "Turn this off and Save to remove the entry from the public site without deleting it."
|
|
172
|
+
: "Turn this on and Save when the entry is ready to appear on the public site.";
|
|
173
|
+
});
|
|
174
|
+
group.appendChild(publishHelp);
|
|
175
|
+
}
|
|
176
|
+
} else if (prop.type === "array" && prop.format === "image-gallery") {
|
|
177
|
+
group = createFieldGroup(label, isRequired);
|
|
178
|
+
group.dataset.fieldPath = path;
|
|
179
|
+
group.appendChild(buildImageGallery(current || [], path));
|
|
180
|
+
} else if (prop.type === "array" && prop.items && prop.items.type === "object") {
|
|
181
|
+
group = createFieldGroup(label, isRequired);
|
|
182
|
+
group.dataset.fieldPath = path;
|
|
183
|
+
group.appendChild(buildObjectArray(current || [], path, prop.items));
|
|
184
|
+
} else if (prop.type === "array" && prop.items && prop.items.type === "string") {
|
|
185
|
+
group = createFieldGroup(label, isRequired);
|
|
186
|
+
group.dataset.fieldPath = path;
|
|
187
|
+
group.appendChild(buildTagList(current || [], path));
|
|
188
|
+
} else if (prop.type === "object" && prop.properties) {
|
|
189
|
+
group = createFieldGroup(label, isRequired);
|
|
190
|
+
group.dataset.fieldPath = path;
|
|
191
|
+
var nested = document.createElement("div");
|
|
192
|
+
nested.className = "caret-nested";
|
|
193
|
+
renderFields(nested, /** @type {Entry} */ (current || {}), prop, path);
|
|
194
|
+
group.appendChild(nested);
|
|
195
|
+
} else {
|
|
196
|
+
group = createFieldGroup(label, isRequired);
|
|
197
|
+
group.dataset.fieldPath = path;
|
|
198
|
+
var jsonTa = document.createElement("textarea");
|
|
199
|
+
jsonTa.className = "studio-input";
|
|
200
|
+
jsonTa.style.fontFamily = "var(--studio-font-mono)";
|
|
201
|
+
jsonTa.style.fontSize = "11px";
|
|
202
|
+
jsonTa.rows = 4;
|
|
203
|
+
jsonTa.value = JSON.stringify(current, null, 2);
|
|
204
|
+
jsonTa.addEventListener("input", function () {
|
|
205
|
+
try { updateField(path, JSON.parse(jsonTa.value)); } catch (e) { /* ignore until valid */ }
|
|
206
|
+
});
|
|
207
|
+
group.appendChild(jsonTa);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
container.appendChild(group);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** @param {HTMLElement} container @param {Entry | null} data @param {string} basePath */
|
|
215
|
+
function renderFieldsFallback(container, data, basePath) {
|
|
216
|
+
if (!data || typeof data !== "object") return;
|
|
217
|
+
Object.keys(data).forEach(function (key) {
|
|
218
|
+
var value = data[key];
|
|
219
|
+
var path = basePath ? basePath + "." + key : key;
|
|
220
|
+
var label = humanizeKey(key);
|
|
221
|
+
|
|
222
|
+
if (key === "images" && Array.isArray(value) && value.every(function (v) { return typeof v === "string"; })) {
|
|
223
|
+
var g = createFieldGroup(label, true);
|
|
224
|
+
g.dataset.fieldPath = path;
|
|
225
|
+
g.appendChild(buildImageGallery(value, path));
|
|
226
|
+
container.appendChild(g);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if ((key === "image" || key === "thumbnail" || key === "bg_image") && typeof value === "string") {
|
|
230
|
+
var g2 = createFieldGroup(label, true);
|
|
231
|
+
g2.dataset.fieldPath = path;
|
|
232
|
+
g2.appendChild(buildSingleImage(value, path));
|
|
233
|
+
container.appendChild(g2);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (Array.isArray(value) && value.every(function (v) { return typeof v === "string"; })) {
|
|
237
|
+
var g3 = createFieldGroup(label, true);
|
|
238
|
+
g3.dataset.fieldPath = path;
|
|
239
|
+
g3.appendChild(buildTagList(value, path));
|
|
240
|
+
container.appendChild(g3);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (Array.isArray(value) && value.every(function (v) { return typeof v === "object" && v !== null && !Array.isArray(v); })) {
|
|
244
|
+
var g4 = createFieldGroup(label, true);
|
|
245
|
+
g4.dataset.fieldPath = path;
|
|
246
|
+
g4.appendChild(buildObjectArray(value, path, null));
|
|
247
|
+
container.appendChild(g4);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
251
|
+
var g5 = createFieldGroup(label, true);
|
|
252
|
+
g5.dataset.fieldPath = path;
|
|
253
|
+
var nested = document.createElement("div");
|
|
254
|
+
nested.className = "caret-nested";
|
|
255
|
+
renderFieldsFallback(nested, /** @type {Entry} */ (value), path);
|
|
256
|
+
g5.appendChild(nested);
|
|
257
|
+
container.appendChild(g5);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (typeof value === "boolean") {
|
|
261
|
+
var g6 = createFieldGroup(label, true, true);
|
|
262
|
+
g6.dataset.fieldPath = path;
|
|
263
|
+
var t = document.createElement("button");
|
|
264
|
+
t.type = "button";
|
|
265
|
+
t.className = "studio-toggle" + (value ? " active" : "");
|
|
266
|
+
t.addEventListener("click", function () {
|
|
267
|
+
var next = !t.classList.contains("active");
|
|
268
|
+
t.classList.toggle("active");
|
|
269
|
+
updateField(path, next);
|
|
270
|
+
});
|
|
271
|
+
g6.appendChild(t);
|
|
272
|
+
container.appendChild(g6);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (typeof value === "number") {
|
|
276
|
+
var g7 = createFieldGroup(label, true);
|
|
277
|
+
g7.dataset.fieldPath = path;
|
|
278
|
+
var n = document.createElement("input");
|
|
279
|
+
n.type = "number";
|
|
280
|
+
n.className = "studio-input";
|
|
281
|
+
n.value = String(value);
|
|
282
|
+
n.addEventListener("input", function () { updateField(path, Number(n.value) || 0); });
|
|
283
|
+
g7.appendChild(n);
|
|
284
|
+
container.appendChild(g7);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (typeof value === "string") {
|
|
288
|
+
var g8 = createFieldGroup(label, true);
|
|
289
|
+
g8.dataset.fieldPath = path;
|
|
290
|
+
/** @type {HTMLInputElement | HTMLTextAreaElement} */
|
|
291
|
+
var i;
|
|
292
|
+
if (value.length > 100 || /[\r\n]/.test(value)) { i = document.createElement("textarea"); i.rows = 4; }
|
|
293
|
+
else { i = document.createElement("input"); i.type = "text"; }
|
|
294
|
+
i.className = "studio-input";
|
|
295
|
+
i.value = value;
|
|
296
|
+
i.addEventListener("input", function () { updateField(path, i.value); });
|
|
297
|
+
g8.appendChild(i);
|
|
298
|
+
container.appendChild(g8);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
// Fallback to JSON
|
|
302
|
+
var g9 = createFieldGroup(label, true);
|
|
303
|
+
g9.dataset.fieldPath = path;
|
|
304
|
+
var jt = document.createElement("textarea");
|
|
305
|
+
jt.className = "studio-input";
|
|
306
|
+
jt.style.fontFamily = "var(--studio-font-mono)";
|
|
307
|
+
jt.style.fontSize = "11px";
|
|
308
|
+
jt.rows = 4;
|
|
309
|
+
jt.value = JSON.stringify(value, null, 2);
|
|
310
|
+
jt.addEventListener("input", function () {
|
|
311
|
+
try { updateField(path, JSON.parse(jt.value)); } catch (e) { /* ignore */ }
|
|
312
|
+
});
|
|
313
|
+
g9.appendChild(jt);
|
|
314
|
+
container.appendChild(g9);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return { renderFields, renderFieldsFallback };
|
|
319
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** @typedef {{ id: string, name?: string, email?: string }} HistoryEditor */
|
|
2
|
+
/** @typedef {{ ts: number, action: string, editor?: HistoryEditor }} HistoryItem */
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {{ kind: 'loaded', items: HistoryItem[] }
|
|
5
|
+
* | { kind: 'unauthorized' }
|
|
6
|
+
* | { kind: 'error' }} HistoryResult
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {{ kind: 'restored', data: unknown, revision?: number }
|
|
10
|
+
* | { kind: 'unauthorized' }
|
|
11
|
+
* | { kind: 'error' }} RestoreResult
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
15
|
+
function isRecord(value) {
|
|
16
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** @param {unknown} value @returns {HistoryItem[]} */
|
|
20
|
+
function historyItems(value) {
|
|
21
|
+
if (!Array.isArray(value)) return [];
|
|
22
|
+
return value.flatMap(item => {
|
|
23
|
+
if (!isRecord(item) || typeof item.ts !== 'number' || !Number.isFinite(item.ts)) return [];
|
|
24
|
+
/** @type {HistoryItem} */
|
|
25
|
+
const normalized = {
|
|
26
|
+
ts: item.ts,
|
|
27
|
+
action: typeof item.action === 'string' && item.action ? item.action : 'save',
|
|
28
|
+
};
|
|
29
|
+
if (isRecord(item.editor) && typeof item.editor.id === 'string') {
|
|
30
|
+
normalized.editor = { id: item.editor.id };
|
|
31
|
+
if (typeof item.editor.name === 'string') normalized.editor.name = item.editor.name;
|
|
32
|
+
if (typeof item.editor.email === 'string') normalized.editor.email = item.editor.email;
|
|
33
|
+
}
|
|
34
|
+
return [normalized];
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** @param {{ apiBasePath: string, fetchImpl?: typeof fetch }} options */
|
|
39
|
+
export function createStudioHistoryClient({ apiBasePath, fetchImpl = fetch }) {
|
|
40
|
+
/** @param {{ collection: string, id: string }} input @returns {Promise<HistoryResult>} */
|
|
41
|
+
async function list(input) {
|
|
42
|
+
const search = new URLSearchParams(input);
|
|
43
|
+
const response = await fetchImpl(`${apiBasePath}/history?${search}`, {
|
|
44
|
+
credentials: 'same-origin',
|
|
45
|
+
});
|
|
46
|
+
if (response.status === 401) return { kind: 'unauthorized' };
|
|
47
|
+
if (!response.ok) return { kind: 'error' };
|
|
48
|
+
const body = await response.json().catch(() => null);
|
|
49
|
+
return isRecord(body)
|
|
50
|
+
? { kind: 'loaded', items: historyItems(body.history) }
|
|
51
|
+
: { kind: 'error' };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @param {{ collection: string, id: string, ts: number }} input @returns {Promise<RestoreResult>} */
|
|
55
|
+
async function restore(input) {
|
|
56
|
+
const response = await fetchImpl(`${apiBasePath}/history`, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: { 'Content-Type': 'application/json', 'x-caret-request': '1' },
|
|
59
|
+
credentials: 'same-origin',
|
|
60
|
+
body: JSON.stringify(input),
|
|
61
|
+
});
|
|
62
|
+
if (response.status === 401) return { kind: 'unauthorized' };
|
|
63
|
+
if (!response.ok) return { kind: 'error' };
|
|
64
|
+
const body = await response.json().catch(() => null);
|
|
65
|
+
if (!isRecord(body) || !Object.prototype.hasOwnProperty.call(body, 'data')) {
|
|
66
|
+
return { kind: 'error' };
|
|
67
|
+
}
|
|
68
|
+
return typeof body.revision === 'number'
|
|
69
|
+
? { kind: 'restored', data: body.data, revision: body.revision }
|
|
70
|
+
: { kind: 'restored', data: body.data };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { list, restore };
|
|
74
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** @typedef {{ path: string, message: string }} ValidationIssue */
|
|
2
|
+
/**
|
|
3
|
+
* @typedef {{ kind: 'saved', revision?: number }
|
|
4
|
+
* | { kind: 'validation', issues: ValidationIssue[] }
|
|
5
|
+
* | { kind: 'conflict', currentRevision: number }
|
|
6
|
+
* | { kind: 'unauthorized' }
|
|
7
|
+
* | { kind: 'error' }} SaveResult
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
11
|
+
function isRecord(value) {
|
|
12
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** @param {unknown} value @returns {ValidationIssue[]} */
|
|
16
|
+
function validationIssues(value) {
|
|
17
|
+
if (!Array.isArray(value)) return [];
|
|
18
|
+
return value.flatMap(issue => isRecord(issue)
|
|
19
|
+
&& typeof issue.path === 'string'
|
|
20
|
+
&& typeof issue.message === 'string'
|
|
21
|
+
? [{ path: issue.path, message: issue.message }]
|
|
22
|
+
: []);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {{ apiBasePath: string, fetchImpl?: typeof fetch }} options
|
|
27
|
+
*/
|
|
28
|
+
export function createStudioMutationClient({ apiBasePath, fetchImpl = fetch }) {
|
|
29
|
+
/** @param {Record<string, unknown>} payload */
|
|
30
|
+
async function mutate(payload) {
|
|
31
|
+
const response = await fetchImpl(`${apiBasePath}/mutate`, {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: { 'Content-Type': 'application/json', 'x-caret-request': '1' },
|
|
34
|
+
credentials: 'same-origin',
|
|
35
|
+
body: JSON.stringify(payload),
|
|
36
|
+
});
|
|
37
|
+
if (response.status === 401) return { response, body: null };
|
|
38
|
+
const body = await response.json().catch(() => null);
|
|
39
|
+
return { response, body };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {{ collection: string, id: string, data: Record<string, unknown>, expectedRevision?: number }} input
|
|
44
|
+
* @returns {Promise<SaveResult>}
|
|
45
|
+
*/
|
|
46
|
+
async function save(input) {
|
|
47
|
+
const payload = { type: 'put_entry', ...input };
|
|
48
|
+
const { response, body } = await mutate(payload);
|
|
49
|
+
if (response.status === 401) return { kind: 'unauthorized' };
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
const issues = isRecord(body) ? validationIssues(body.issues) : [];
|
|
52
|
+
if (issues.length > 0) return { kind: 'validation', issues };
|
|
53
|
+
if (response.status === 409 && isRecord(body) && typeof body.currentRevision === 'number') {
|
|
54
|
+
return { kind: 'conflict', currentRevision: body.currentRevision };
|
|
55
|
+
}
|
|
56
|
+
return { kind: 'error' };
|
|
57
|
+
}
|
|
58
|
+
return isRecord(body) && typeof body.revision === 'number'
|
|
59
|
+
? { kind: 'saved', revision: body.revision }
|
|
60
|
+
: { kind: 'saved' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {{ collection: string, id: string, expectedRevision?: number }} input
|
|
65
|
+
* @returns {Promise<'deleted' | 'unauthorized' | 'error'>}
|
|
66
|
+
*/
|
|
67
|
+
async function remove(input) {
|
|
68
|
+
const { response } = await mutate({ type: 'delete_entry', ...input });
|
|
69
|
+
if (response.status === 401) return 'unauthorized';
|
|
70
|
+
return response.ok ? 'deleted' : 'error';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { save, remove };
|
|
74
|
+
}
|