@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.
Files changed (223) hide show
  1. package/README.md +116 -11
  2. package/dist/index.d.ts +15 -2
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +78 -5
  5. package/dist/index.js.map +1 -1
  6. package/dist/loader.d.ts.map +1 -1
  7. package/dist/loader.js +8 -0
  8. package/dist/loader.js.map +1 -1
  9. package/dist/markdown/paragraphs.d.ts +14 -0
  10. package/dist/markdown/paragraphs.d.ts.map +1 -0
  11. package/dist/markdown/paragraphs.js +61 -0
  12. package/dist/markdown/paragraphs.js.map +1 -0
  13. package/dist/markdown/remark.d.ts.map +1 -1
  14. package/dist/markdown/remark.js +1 -0
  15. package/dist/markdown/remark.js.map +1 -1
  16. package/dist/markdown/satteri.d.ts.map +1 -1
  17. package/dist/markdown/satteri.js +3 -1
  18. package/dist/markdown/satteri.js.map +1 -1
  19. package/dist/markdown/stamp.d.ts +0 -9
  20. package/dist/markdown/stamp.d.ts.map +1 -1
  21. package/dist/markdown/stamp.js +6 -0
  22. package/dist/markdown/stamp.js.map +1 -1
  23. package/dist/providers/deployment/github.d.ts +27 -0
  24. package/dist/providers/deployment/github.d.ts.map +1 -0
  25. package/dist/providers/deployment/github.js +180 -0
  26. package/dist/providers/deployment/github.js.map +1 -0
  27. package/dist/providers/deployment/simulated.d.ts +10 -0
  28. package/dist/providers/deployment/simulated.d.ts.map +1 -0
  29. package/dist/providers/deployment/simulated.js +29 -0
  30. package/dist/providers/deployment/simulated.js.map +1 -0
  31. package/dist/runtime/authorization.d.ts +11 -0
  32. package/dist/runtime/authorization.d.ts.map +1 -0
  33. package/dist/runtime/authorization.js +29 -0
  34. package/dist/runtime/authorization.js.map +1 -0
  35. package/dist/runtime/collection-policy.d.ts +11 -0
  36. package/dist/runtime/collection-policy.d.ts.map +1 -0
  37. package/dist/runtime/collection-policy.js +44 -0
  38. package/dist/runtime/collection-policy.js.map +1 -0
  39. package/dist/runtime/content-errors.d.ts +9 -0
  40. package/dist/runtime/content-errors.d.ts.map +1 -0
  41. package/dist/runtime/content-errors.js +17 -0
  42. package/dist/runtime/content-errors.js.map +1 -0
  43. package/dist/runtime/content.d.ts.map +1 -1
  44. package/dist/runtime/content.js +13 -4
  45. package/dist/runtime/content.js.map +1 -1
  46. package/dist/runtime/deployment-status.d.ts +14 -0
  47. package/dist/runtime/deployment-status.d.ts.map +1 -0
  48. package/dist/runtime/deployment-status.js +57 -0
  49. package/dist/runtime/deployment-status.js.map +1 -0
  50. package/dist/runtime/draft-state.d.ts +16 -0
  51. package/dist/runtime/draft-state.d.ts.map +1 -0
  52. package/dist/runtime/draft-state.js +29 -0
  53. package/dist/runtime/draft-state.js.map +1 -0
  54. package/dist/runtime/i18n.d.ts +13 -2
  55. package/dist/runtime/i18n.d.ts.map +1 -1
  56. package/dist/runtime/i18n.js +26 -4
  57. package/dist/runtime/i18n.js.map +1 -1
  58. package/dist/runtime/index.d.ts +1 -0
  59. package/dist/runtime/index.d.ts.map +1 -1
  60. package/dist/runtime/index.js +1 -0
  61. package/dist/runtime/index.js.map +1 -1
  62. package/dist/runtime/middleware.d.ts.map +1 -1
  63. package/dist/runtime/middleware.js +21 -2
  64. package/dist/runtime/middleware.js.map +1 -1
  65. package/dist/runtime/mutations/contracts.d.ts +9 -0
  66. package/dist/runtime/mutations/contracts.d.ts.map +1 -1
  67. package/dist/runtime/mutations/contracts.js +45 -4
  68. package/dist/runtime/mutations/contracts.js.map +1 -1
  69. package/dist/runtime/mutations/engine.d.ts +2 -0
  70. package/dist/runtime/mutations/engine.d.ts.map +1 -1
  71. package/dist/runtime/mutations/engine.js +115 -63
  72. package/dist/runtime/mutations/engine.js.map +1 -1
  73. package/dist/runtime/providers.d.ts +3 -1
  74. package/dist/runtime/providers.d.ts.map +1 -1
  75. package/dist/runtime/providers.js +12 -1
  76. package/dist/runtime/providers.js.map +1 -1
  77. package/dist/runtime/publish-recovery.d.ts +32 -0
  78. package/dist/runtime/publish-recovery.d.ts.map +1 -0
  79. package/dist/runtime/publish-recovery.js +98 -0
  80. package/dist/runtime/publish-recovery.js.map +1 -0
  81. package/dist/runtime/publish.d.ts +10 -4
  82. package/dist/runtime/publish.d.ts.map +1 -1
  83. package/dist/runtime/publish.js +104 -67
  84. package/dist/runtime/publish.js.map +1 -1
  85. package/dist/runtime/rebuild-webhook.d.ts +4 -0
  86. package/dist/runtime/rebuild-webhook.d.ts.map +1 -1
  87. package/dist/runtime/rebuild-webhook.js +12 -2
  88. package/dist/runtime/rebuild-webhook.js.map +1 -1
  89. package/dist/runtime/request-context.d.ts +3 -1
  90. package/dist/runtime/request-context.d.ts.map +1 -1
  91. package/dist/runtime/request-context.js.map +1 -1
  92. package/dist/runtime/rewrite.d.ts.map +1 -1
  93. package/dist/runtime/rewrite.js +28 -2
  94. package/dist/runtime/rewrite.js.map +1 -1
  95. package/dist/runtime/rich-allowlist.js +1 -1
  96. package/dist/runtime/rich-allowlist.js.map +1 -1
  97. package/dist/runtime/routes/admin-entry.d.ts.map +1 -1
  98. package/dist/runtime/routes/admin-entry.js +24 -12
  99. package/dist/runtime/routes/admin-entry.js.map +1 -1
  100. package/dist/runtime/routes/auth-session.d.ts.map +1 -1
  101. package/dist/runtime/routes/auth-session.js +6 -0
  102. package/dist/runtime/routes/auth-session.js.map +1 -1
  103. package/dist/runtime/routes/collection-list.d.ts.map +1 -1
  104. package/dist/runtime/routes/collection-list.js +164 -40
  105. package/dist/runtime/routes/collection-list.js.map +1 -1
  106. package/dist/runtime/routes/deployment.d.ts +5 -0
  107. package/dist/runtime/routes/deployment.d.ts.map +1 -0
  108. package/dist/runtime/routes/deployment.js +38 -0
  109. package/dist/runtime/routes/deployment.js.map +1 -0
  110. package/dist/runtime/routes/draft.d.ts.map +1 -1
  111. package/dist/runtime/routes/draft.js +10 -3
  112. package/dist/runtime/routes/draft.js.map +1 -1
  113. package/dist/runtime/routes/entries.d.ts.map +1 -1
  114. package/dist/runtime/routes/entries.js +74 -39
  115. package/dist/runtime/routes/entries.js.map +1 -1
  116. package/dist/runtime/routes/history.d.ts.map +1 -1
  117. package/dist/runtime/routes/history.js +37 -21
  118. package/dist/runtime/routes/history.js.map +1 -1
  119. package/dist/runtime/routes/mutate.d.ts.map +1 -1
  120. package/dist/runtime/routes/mutate.js +4 -1
  121. package/dist/runtime/routes/mutate.js.map +1 -1
  122. package/dist/runtime/routes/publish.d.ts.map +1 -1
  123. package/dist/runtime/routes/publish.js +127 -33
  124. package/dist/runtime/routes/publish.js.map +1 -1
  125. package/dist/runtime/routes/schema.d.ts.map +1 -1
  126. package/dist/runtime/routes/schema.js +4 -0
  127. package/dist/runtime/routes/schema.js.map +1 -1
  128. package/dist/runtime/routes/studio-home.d.ts.map +1 -1
  129. package/dist/runtime/routes/studio-home.js +3 -1
  130. package/dist/runtime/routes/studio-home.js.map +1 -1
  131. package/dist/runtime/routes/upload.d.ts.map +1 -1
  132. package/dist/runtime/routes/upload.js +3 -0
  133. package/dist/runtime/routes/upload.js.map +1 -1
  134. package/dist/runtime/schema-registry.d.ts.map +1 -1
  135. package/dist/runtime/schema-registry.js +2 -0
  136. package/dist/runtime/schema-registry.js.map +1 -1
  137. package/dist/runtime/storage/entry-commit.d.ts +8 -0
  138. package/dist/runtime/storage/entry-commit.d.ts.map +1 -0
  139. package/dist/runtime/storage/entry-commit.js +45 -0
  140. package/dist/runtime/storage/entry-commit.js.map +1 -0
  141. package/dist/runtime/storage/filesystem-adapter.d.ts +5 -0
  142. package/dist/runtime/storage/filesystem-adapter.d.ts.map +1 -1
  143. package/dist/runtime/storage/filesystem-adapter.js +12 -3
  144. package/dist/runtime/storage/filesystem-adapter.js.map +1 -1
  145. package/dist/runtime/storage/frontmatter-codec.d.ts +5 -23
  146. package/dist/runtime/storage/frontmatter-codec.d.ts.map +1 -1
  147. package/dist/runtime/storage/frontmatter-codec.js +156 -13
  148. package/dist/runtime/storage/frontmatter-codec.js.map +1 -1
  149. package/dist/runtime/storage/in-memory-adapter.d.ts +7 -0
  150. package/dist/runtime/storage/in-memory-adapter.d.ts.map +1 -1
  151. package/dist/runtime/storage/in-memory-adapter.js +6 -0
  152. package/dist/runtime/storage/in-memory-adapter.js.map +1 -1
  153. package/dist/runtime/storage/markdown-adapter.d.ts.map +1 -1
  154. package/dist/runtime/storage/markdown-adapter.js +18 -20
  155. package/dist/runtime/storage/markdown-adapter.js.map +1 -1
  156. package/dist/runtime/storage/session-overlay-adapter.d.ts +12 -4
  157. package/dist/runtime/storage/session-overlay-adapter.d.ts.map +1 -1
  158. package/dist/runtime/storage/session-overlay-adapter.js +100 -13
  159. package/dist/runtime/storage/session-overlay-adapter.js.map +1 -1
  160. package/dist/runtime/storage/sidecar-meta-store.d.ts +5 -0
  161. package/dist/runtime/storage/sidecar-meta-store.d.ts.map +1 -1
  162. package/dist/runtime/storage/sidecar-meta-store.js +48 -5
  163. package/dist/runtime/storage/sidecar-meta-store.js.map +1 -1
  164. package/dist/runtime/utils.d.ts.map +1 -1
  165. package/dist/runtime/utils.js +3 -4
  166. package/dist/runtime/utils.js.map +1 -1
  167. package/dist/types.d.ts +123 -1
  168. package/dist/types.d.ts.map +1 -1
  169. package/package.json +12 -3
  170. package/static/cms/admin-entry.css +9 -0
  171. package/static/cms/admin-entry.js +251 -687
  172. package/static/cms/dev-toolbar/app.js +78 -19
  173. package/static/cms/editor/config.js +35 -15
  174. package/static/cms/editor/content-map.css +8 -1
  175. package/static/cms/editor/content-map.js +62 -23
  176. package/static/cms/editor/deployment-status.js +66 -0
  177. package/static/cms/editor/draft-client.js +87 -0
  178. package/static/cms/editor/guards.js +20 -0
  179. package/static/cms/editor/helpers.js +30 -1
  180. package/static/cms/editor/highlight.js +1 -0
  181. package/static/cms/editor/image-edit.js +43 -1
  182. package/static/cms/editor/image-utils.js +23 -7
  183. package/static/cms/editor/link-follow.js +122 -41
  184. package/static/cms/editor/link-ui.css +10 -1
  185. package/static/cms/editor/linkify.js +2 -0
  186. package/static/cms/editor/md-block-edit.js +140 -37
  187. package/static/cms/editor/md-paragraphs.js +98 -0
  188. package/static/cms/editor/md-serialize.js +1 -1
  189. package/static/cms/editor/panel.js +43 -15
  190. package/static/cms/editor/preview-mode.js +44 -0
  191. package/static/cms/editor/publish-result.js +100 -0
  192. package/static/cms/editor/rich-toolbar.js +58 -15
  193. package/static/cms/editor/sanitize.js +6 -2
  194. package/static/cms/editor/save-queue.js +75 -17
  195. package/static/cms/editor/section-controls/activation.js +3 -0
  196. package/static/cms/editor/section-controls/api.js +25 -7
  197. package/static/cms/editor/section-controls/index.js +60 -8
  198. package/static/cms/editor/section-controls/model.js +39 -4
  199. package/static/cms/editor/section-controls/mutations.js +15 -0
  200. package/static/cms/editor/section-controls/page-context.js +9 -0
  201. package/static/cms/editor/section-controls/pickers.js +53 -2
  202. package/static/cms/editor/section-controls/reorder.js +23 -0
  203. package/static/cms/editor/section-controls/selection.js +6 -0
  204. package/static/cms/editor/section-controls/spacing-drag.js +16 -0
  205. package/static/cms/editor/section-controls/ui-elements.js +7 -0
  206. package/static/cms/editor/section-controls/utils.js +7 -0
  207. package/static/cms/editor/section-controls.css +18 -3
  208. package/static/cms/editor/security.js +13 -2
  209. package/static/cms/editor/stega-hydrate.js +17 -4
  210. package/static/cms/editor/sync.js +208 -10
  211. package/static/cms/editor/text-edit.js +37 -3
  212. package/static/cms/editor/text-link-interactions.js +59 -17
  213. package/static/cms/editor/toast.js +23 -9
  214. package/static/cms/editor/toolbar.css +5 -0
  215. package/static/cms/editor/toolbar.js +91 -101
  216. package/static/cms/editor.js +2 -1
  217. package/static/cms/studio/entry-loader.js +102 -0
  218. package/static/cms/studio/field-model.js +110 -0
  219. package/static/cms/studio/fields.js +319 -0
  220. package/static/cms/studio/history-client.js +74 -0
  221. package/static/cms/studio/mutation-client.js +74 -0
  222. package/static/cms/studio/sync-client.js +107 -0
  223. 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
+ }