caracms 0.0.0-stage → 0.2.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 (281) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -2
  3. package/bin/caracms.mjs +174 -0
  4. package/bin/gate.mjs +275 -0
  5. package/bin/init.mjs +712 -0
  6. package/bin/media.mjs +662 -0
  7. package/bin/site-runner.mjs +61 -0
  8. package/bin/templates/Page.astro.txt +31 -0
  9. package/bin/templates/TextBlock.astro.txt +13 -0
  10. package/bin/templates/caracms.config.ts.txt +22 -0
  11. package/bin/templates/catch-all.astro.txt +25 -0
  12. package/bin/templates/health.ts.txt +6 -0
  13. package/bin/templates/railway.ts.txt +147 -0
  14. package/package.json +66 -4
  15. package/skills/caracms-adopt/SKILL.md +508 -0
  16. package/skills/caracms-adopt/look-same.mjs +957 -0
  17. package/skills/caracms-blocks/SKILL.md +376 -0
  18. package/skills/caracms-content/SKILL.md +201 -0
  19. package/skills/caracms-tina/SKILL.md +427 -0
  20. package/skills/caracms-tina/field-kinds.md +88 -0
  21. package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
  22. package/src/block-runs.ts +84 -0
  23. package/src/cards.ts +158 -0
  24. package/src/cli/schemas.ts +122 -0
  25. package/src/cli/site.ts +66 -0
  26. package/src/cli/validate.ts +471 -0
  27. package/src/code-routes-disk.ts +67 -0
  28. package/src/code-routes.ts +134 -0
  29. package/src/columns.ts +39 -0
  30. package/src/components/Block.astro +46 -0
  31. package/src/components/BlockRunTree.astro +45 -0
  32. package/src/components/Blocks.astro +139 -0
  33. package/src/components/Buttons.astro +116 -0
  34. package/src/components/Fonts.astro +14 -0
  35. package/src/components/Form.astro +221 -0
  36. package/src/components/Head.astro +62 -0
  37. package/src/components/Link.astro +68 -0
  38. package/src/components/Lockup.astro +116 -0
  39. package/src/components/Media.astro +124 -0
  40. package/src/components/MediaPlayer.astro +102 -0
  41. package/src/components/RenderDocument.astro +64 -0
  42. package/src/components/RichText.astro +59 -0
  43. package/src/components/Text.astro +81 -0
  44. package/src/components/attributes.ts +33 -0
  45. package/src/components/descriptor.ts +45 -0
  46. package/src/components/form-client.ts +182 -0
  47. package/src/components/media-player.ts +46 -0
  48. package/src/components/media-props.ts +35 -0
  49. package/src/components/sitemap.xml.ts +36 -0
  50. package/src/config.ts +825 -0
  51. package/src/content-schema.ts +166 -0
  52. package/src/content.ts +422 -0
  53. package/src/document.ts +579 -0
  54. package/src/editor/README.md +405 -0
  55. package/src/editor/addresses.ts +61 -0
  56. package/src/editor/brand.ts +118 -0
  57. package/src/editor/client/block-list/BlockList.tsx +342 -0
  58. package/src/editor/client/block-list/Picker.tsx +105 -0
  59. package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
  60. package/src/editor/client/block-list/insert-points.ts +56 -0
  61. package/src/editor/client/block-list/link.ts +71 -0
  62. package/src/editor/client/block-list/reorder.ts +105 -0
  63. package/src/editor/client/block-list/summary.ts +118 -0
  64. package/src/editor/client/collections/CollectionGrid.tsx +143 -0
  65. package/src/editor/client/collections/CollectionTable.tsx +406 -0
  66. package/src/editor/client/collections/table.ts +270 -0
  67. package/src/editor/client/context.tsx +27 -0
  68. package/src/editor/client/documents/DocMenu.tsx +154 -0
  69. package/src/editor/client/documents/actions.ts +192 -0
  70. package/src/editor/client/documents/dialogs.tsx +687 -0
  71. package/src/editor/client/editor.ts +995 -0
  72. package/src/editor/client/fields/ButtonsField.tsx +87 -0
  73. package/src/editor/client/fields/DateField.tsx +200 -0
  74. package/src/editor/client/fields/FieldControl.tsx +61 -0
  75. package/src/editor/client/fields/FieldFrame.tsx +109 -0
  76. package/src/editor/client/fields/GroupField.tsx +113 -0
  77. package/src/editor/client/fields/HiddenToggle.tsx +51 -0
  78. package/src/editor/client/fields/LinkField.tsx +307 -0
  79. package/src/editor/client/fields/ListField.tsx +642 -0
  80. package/src/editor/client/fields/LockupField.tsx +365 -0
  81. package/src/editor/client/fields/MediaField.tsx +823 -0
  82. package/src/editor/client/fields/PageFields.tsx +82 -0
  83. package/src/editor/client/fields/ReferenceField.tsx +209 -0
  84. package/src/editor/client/fields/RichTextField.tsx +306 -0
  85. package/src/editor/client/fields/SelectField.tsx +63 -0
  86. package/src/editor/client/fields/SeoGroup.tsx +282 -0
  87. package/src/editor/client/fields/SimpleFields.tsx +89 -0
  88. package/src/editor/client/fields/TextField.tsx +51 -0
  89. package/src/editor/client/fields/date.ts +73 -0
  90. package/src/editor/client/fields/host.tsx +172 -0
  91. package/src/editor/client/fields/list-summary.ts +85 -0
  92. package/src/editor/client/fields/lockup-rules.ts +181 -0
  93. package/src/editor/client/fields/parts.tsx +458 -0
  94. package/src/editor/client/fields/rich-text-commands.ts +183 -0
  95. package/src/editor/client/fields/rich-text-doc.ts +156 -0
  96. package/src/editor/client/fields/rich-text-schema.ts +224 -0
  97. package/src/editor/client/fields/stale.ts +58 -0
  98. package/src/editor/client/inbox/InboxStage.tsx +272 -0
  99. package/src/editor/client/inbox/store.ts +311 -0
  100. package/src/editor/client/live/format.ts +77 -0
  101. package/src/editor/client/live/marks.tsx +90 -0
  102. package/src/editor/client/live/notices.tsx +213 -0
  103. package/src/editor/client/live/publish.tsx +617 -0
  104. package/src/editor/client/live/session.tsx +162 -0
  105. package/src/editor/client/main.tsx +12 -0
  106. package/src/editor/client/media/LibraryStage.tsx +75 -0
  107. package/src/editor/client/media/ReplaceDialog.tsx +355 -0
  108. package/src/editor/client/media/store.ts +618 -0
  109. package/src/editor/client/nav/DocRow.tsx +122 -0
  110. package/src/editor/client/nav/search.ts +71 -0
  111. package/src/editor/client/nav/tree.ts +93 -0
  112. package/src/editor/client/overlays/OverlayMount.tsx +26 -0
  113. package/src/editor/client/overlays/editor-marks.ts +159 -0
  114. package/src/editor/client/overlays/index.ts +50 -0
  115. package/src/editor/client/overlays/layer.ts +437 -0
  116. package/src/editor/client/overlays/overlay-css.ts +177 -0
  117. package/src/editor/client/overlays/rich-text-bar.ts +717 -0
  118. package/src/editor/client/pane/Pane.tsx +91 -0
  119. package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
  120. package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
  121. package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
  122. package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
  123. package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
  124. package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
  125. package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
  126. package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
  127. package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
  128. package/src/editor/client/pane/parts.tsx +179 -0
  129. package/src/editor/client/pane/switcher.tsx +165 -0
  130. package/src/editor/client/preview/driver.ts +640 -0
  131. package/src/editor/client/preview/field-text.ts +71 -0
  132. package/src/editor/client/preview/follow-links.ts +234 -0
  133. package/src/editor/client/preview/layout.ts +37 -0
  134. package/src/editor/client/preview/link-target.ts +50 -0
  135. package/src/editor/client/preview/markers.ts +86 -0
  136. package/src/editor/client/preview/page-edit.ts +632 -0
  137. package/src/editor/client/preview/page-editor-registry.ts +63 -0
  138. package/src/editor/client/preview/page-editor.ts +235 -0
  139. package/src/editor/client/preview/render-sequencer.ts +113 -0
  140. package/src/editor/client/preview/selection.ts +116 -0
  141. package/src/editor/client/preview/warnings.ts +61 -0
  142. package/src/editor/client/shell/App.tsx +101 -0
  143. package/src/editor/client/shell/Bar.tsx +197 -0
  144. package/src/editor/client/shell/Brand.tsx +33 -0
  145. package/src/editor/client/shell/Icons.tsx +87 -0
  146. package/src/editor/client/shell/NarrowNote.tsx +47 -0
  147. package/src/editor/client/shell/Popovers.tsx +47 -0
  148. package/src/editor/client/shell/Stage.tsx +132 -0
  149. package/src/editor/client/shell/Toast.tsx +61 -0
  150. package/src/editor/client/state/document-store.ts +465 -0
  151. package/src/editor/client/state/keeper.ts +713 -0
  152. package/src/editor/client/state/live.ts +257 -0
  153. package/src/editor/client/state/schema.ts +42 -0
  154. package/src/editor/client/state/store.ts +39 -0
  155. package/src/editor/fonts/OFL-GeistMono.txt +93 -0
  156. package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
  157. package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
  158. package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
  159. package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
  160. package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
  161. package/src/editor/middleware.ts +62 -0
  162. package/src/editor/routes/_live.ts +26 -0
  163. package/src/editor/routes/admin.astro +80 -0
  164. package/src/editor/routes/create.ts +19 -0
  165. package/src/editor/routes/delete.ts +19 -0
  166. package/src/editor/routes/document.ts +39 -0
  167. package/src/editor/routes/documents.ts +30 -0
  168. package/src/editor/routes/github.ts +23 -0
  169. package/src/editor/routes/history.ts +9 -0
  170. package/src/editor/routes/lock.ts +9 -0
  171. package/src/editor/routes/login/assets.d.ts +10 -0
  172. package/src/editor/routes/login/login.ts +43 -0
  173. package/src/editor/routes/media.ts +16 -0
  174. package/src/editor/routes/move.ts +17 -0
  175. package/src/editor/routes/notice.ts +9 -0
  176. package/src/editor/routes/preview.astro +77 -0
  177. package/src/editor/routes/publish.ts +9 -0
  178. package/src/editor/routes/status.ts +9 -0
  179. package/src/editor/routes/submissions.ts +16 -0
  180. package/src/editor/routes/try-again.ts +9 -0
  181. package/src/editor/routes/write.ts +18 -0
  182. package/src/editor/server/admin-props.ts +151 -0
  183. package/src/editor/server/cards.ts +61 -0
  184. package/src/editor/server/create.ts +414 -0
  185. package/src/editor/server/drafts-store.ts +127 -0
  186. package/src/editor/server/media/alt.ts +107 -0
  187. package/src/editor/server/media/bucket.ts +295 -0
  188. package/src/editor/server/media/config.ts +78 -0
  189. package/src/editor/server/media/handler.ts +221 -0
  190. package/src/editor/server/media/library.ts +254 -0
  191. package/src/editor/server/media/memory.ts +184 -0
  192. package/src/editor/server/media/mux.ts +241 -0
  193. package/src/editor/server/media/sniff.ts +599 -0
  194. package/src/editor/server/media/upload.ts +175 -0
  195. package/src/editor/server/mode.ts +63 -0
  196. package/src/editor/server/move.ts +295 -0
  197. package/src/editor/server/render.tsx +9 -0
  198. package/src/editor/server/replace.ts +72 -0
  199. package/src/editor/server/settings.ts +35 -0
  200. package/src/editor/server/write.ts +366 -0
  201. package/src/editor/styles/editor.css +502 -0
  202. package/src/editor/styles/fonts.css +40 -0
  203. package/src/editor/styles/levels/README.md +4 -0
  204. package/src/editor/styles/levels/collections.css +99 -0
  205. package/src/editor/styles/levels/document-level.css +10 -0
  206. package/src/editor/styles/levels/documents.css +9 -0
  207. package/src/editor/styles/levels/fields.css +171 -0
  208. package/src/editor/styles/levels/files.css +95 -0
  209. package/src/editor/styles/levels/live.css +10 -0
  210. package/src/editor/styles/levels/m5a-documents.css +51 -0
  211. package/src/editor/styles/levels/m6.css +82 -0
  212. package/src/editor/styles/levels/media.css +175 -0
  213. package/src/editor/styles/levels/switcher.css +3 -0
  214. package/src/editor/styles/tokens.css +114 -0
  215. package/src/editor/tsconfig.json +16 -0
  216. package/src/editor/types.ts +881 -0
  217. package/src/editor/ui-state.ts +113 -0
  218. package/src/email.ts +67 -0
  219. package/src/fields.ts +1129 -0
  220. package/src/files-route.ts +201 -0
  221. package/src/files.ts +116 -0
  222. package/src/forms/email.ts +149 -0
  223. package/src/forms/endpoint.ts +347 -0
  224. package/src/forms/inbox.ts +273 -0
  225. package/src/forms/rate-limit.ts +130 -0
  226. package/src/forms/route.ts +24 -0
  227. package/src/forms/rules.ts +278 -0
  228. package/src/forms/store.ts +486 -0
  229. package/src/head.ts +125 -0
  230. package/src/index.ts +434 -0
  231. package/src/json-source.ts +116 -0
  232. package/src/links.ts +121 -0
  233. package/src/live/README.md +43 -0
  234. package/src/live/auth/allow-rules.ts +70 -0
  235. package/src/live/auth/allow.ts +128 -0
  236. package/src/live/auth/auth.ts +155 -0
  237. package/src/live/auth/email.ts +84 -0
  238. package/src/live/auth/guard.ts +111 -0
  239. package/src/live/auth/index.ts +68 -0
  240. package/src/live/auth/links.ts +101 -0
  241. package/src/live/auth/login.ts +245 -0
  242. package/src/live/auth/middleware.ts +53 -0
  243. package/src/live/auth/request.ts +56 -0
  244. package/src/live/auth/routes.ts +9 -0
  245. package/src/live/auth/session.ts +181 -0
  246. package/src/live/auth/views.ts +355 -0
  247. package/src/live/autosaves.ts +306 -0
  248. package/src/live/backend.ts +652 -0
  249. package/src/live/branches.ts +19 -0
  250. package/src/live/drafts-store.ts +591 -0
  251. package/src/live/editor.ts +465 -0
  252. package/src/live/git.ts +1021 -0
  253. package/src/live/github.ts +271 -0
  254. package/src/live/locals.ts +24 -0
  255. package/src/live/locks.ts +273 -0
  256. package/src/live/merge.ts +197 -0
  257. package/src/live/messages.ts +151 -0
  258. package/src/live/mode.ts +210 -0
  259. package/src/live/redis.ts +533 -0
  260. package/src/live/runtime.ts +164 -0
  261. package/src/live/status.ts +109 -0
  262. package/src/live/sweep.ts +228 -0
  263. package/src/live/timings.ts +47 -0
  264. package/src/live/webhook.ts +39 -0
  265. package/src/lockup-css.ts +58 -0
  266. package/src/measure.ts +145 -0
  267. package/src/media-value.ts +239 -0
  268. package/src/media.ts +255 -0
  269. package/src/read.ts +256 -0
  270. package/src/redirect-middleware.ts +49 -0
  271. package/src/redirects.ts +76 -0
  272. package/src/relink.ts +293 -0
  273. package/src/reserved.ts +24 -0
  274. package/src/rich-text.ts +690 -0
  275. package/src/schema-key.ts +63 -0
  276. package/src/seo.ts +124 -0
  277. package/src/serialize.ts +697 -0
  278. package/src/skipped.ts +35 -0
  279. package/src/slug.ts +121 -0
  280. package/src/text-styles.ts +62 -0
  281. package/src/virtual.d.ts +28 -0
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The `$schema` key of a content file (M8 Decision 14): its first key, the relative path
3
+ * from the file to its collection's generated JSON Schema (`schemas/<collection>.schema.json`,
4
+ * `schemas/shared.schema.json`, `schemas/site-settings.schema.json`). Editors such as VS
5
+ * Code read it; caracms's reader ignores it (it reads only the keys it knows), and the
6
+ * serializer keeps it where it is (it splices known keys and leaves the rest as text).
7
+ *
8
+ * The editor stamps a new document (create.ts) and a moved one (move.ts: a move can change
9
+ * the folder's depth); `caracms schemas` stamps every file that lacks it. A save never adds
10
+ * it to a file that hasn't got it, so an edit's diff stays the edit.
11
+ *
12
+ * Browser-safe (no imports beyond the JSON source parser).
13
+ */
14
+ import { parseSource } from './json-source.ts';
15
+
16
+ export const SCHEMA_KEY = '$schema';
17
+ /** The generated schemas' folder, relative to the site's root. */
18
+ export const SCHEMAS_FOLDER = 'schemas';
19
+
20
+ /** A collection's schema file name: the reserved two have fixed names (Contracts → Schemas). */
21
+ export function schemaFileName(collection: { name: string; kind: string }): string {
22
+ if (collection.kind === 'settings') return 'site-settings.schema.json';
23
+ if (collection.kind === 'shared') return 'shared.schema.json';
24
+ return `${collection.name}.schema.json`;
25
+ }
26
+
27
+ /** The `$schema` value for a content file (`file`: site-relative, `/`-separated): `../../schemas/pages.schema.json`. */
28
+ export function schemaRef(collection: { name: string; kind: string }, file: string): string {
29
+ const depth = file.split('/').filter(Boolean).length - 1;
30
+ return `${'../'.repeat(depth)}${SCHEMAS_FOLDER}/${schemaFileName(collection)}`;
31
+ }
32
+
33
+ /**
34
+ * The file's text with `"$schema": ref` as its first key: added when missing, its value
35
+ * replaced when different (in place, wherever it sits). Anything else, and a file that
36
+ * isn't a JSON object, comes back as it was. `add: false` only corrects a key the file
37
+ * has (a move: a file that had none still has none).
38
+ */
39
+ export function stampSchema(text: string, ref: string, { add = true }: { add?: boolean } = {}): string {
40
+ let source;
41
+ try {
42
+ source = parseSource(text);
43
+ } catch {
44
+ return text;
45
+ }
46
+ const root = source.root;
47
+ if (root.kind !== 'object') return text;
48
+ const value = JSON.stringify(ref);
49
+ const existing = root.members.find((m) => m.key === SCHEMA_KEY);
50
+ if (existing) {
51
+ if (existing.value.kind === 'scalar' && existing.value.value === ref) return text;
52
+ return text.slice(0, existing.value.start) + value + text.slice(existing.value.end);
53
+ }
54
+ if (!add) return text;
55
+ const line = `${JSON.stringify(SCHEMA_KEY)}: ${value}`;
56
+ if (root.members.length === 0) return text.slice(0, root.start) + `{\n ${line}\n}` + text.slice(root.end);
57
+ // Before the first member, at its key's quote, with that member's own indentation after.
58
+ const keyStart = text.indexOf('"', root.start);
59
+ const lineStart = text.lastIndexOf('\n', keyStart) + 1;
60
+ const indent = lineStart > root.start ? text.slice(lineStart, keyStart) : ' ';
61
+ const sep = lineStart > root.start ? `,\n${indent}` : ', ';
62
+ return text.slice(0, keyStart) + line + sep + text.slice(keyStart);
63
+ }
package/src/seo.ts ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Search and sharing (M6 Decisions 13, 14 and 16): a page's own `seo` key, Site
3
+ * Settings' `seo` defaults, `googleTag` and `formRecipients`. How each parses (the
4
+ * server's check on write and the head's on render) and how the file writes it.
5
+ *
6
+ * - `seo` is `{ "title", "description", "image": { "key", "w", "h", "focal"? } }`. **Blank
7
+ * parts are left out**, and a `seo` with nothing set is removed from the file, so a
8
+ * file that never had one is untouched. The share image is image-only with no alt
9
+ * (like the brand's logo, M5b Decision 11).
10
+ * - `googleTag` is an ID, never a script: `GTM-XXXXXXX` (Tag Manager) or `G-XXXXXXXXXX`
11
+ * (gtag.js). Anything else renders nothing.
12
+ * - `formRecipients` is one list of addresses per form id. Stored trimmed, a duplicate
13
+ * (without case) dropped, **an invalid address kept as typed** (the editor marks it
14
+ * "Gets no email"; the email skips it).
15
+ *
16
+ * Browser-safe (no imports beyond media-value.ts): the editor reads the limits and the
17
+ * tag rule too.
18
+ */
19
+ import { isFileKey } from './files.ts';
20
+ import { parseFocal, parseMedia } from './media-value.ts';
21
+
22
+ export interface SeoImage {
23
+ key: string;
24
+ w?: number;
25
+ h?: number;
26
+ focal?: [number, number];
27
+ }
28
+
29
+ export interface Seo {
30
+ title?: string;
31
+ description?: string;
32
+ image?: SeoImage;
33
+ }
34
+
35
+ /** M5a's amber soft limits (Decision 13): over these, a note that blocks nothing. */
36
+ export const SEO_TITLE_SOFT_LIMIT = 60;
37
+ export const SEO_DESCRIPTION_SOFT_LIMIT = 160;
38
+
39
+ /** The share image's render (Decision 15): a signed `rs:fill` at this size. */
40
+ export const SHARE_IMAGE = { width: 1200, height: 630 } as const;
41
+
42
+ const text = (v: unknown) => (typeof v === 'string' ? v.trim() : '');
43
+
44
+ /** An image value as a share image: key, size and focal point only (no alt). Null for anything else. */
45
+ export function parseSeoImage(raw: unknown): SeoImage | null {
46
+ const v = parseMedia(raw);
47
+ if (!v || !('key' in v) || isFileKey(v.key)) return null;
48
+ const out: SeoImage = { key: v.key };
49
+ if (v.w !== undefined) out.w = v.w;
50
+ if (v.h !== undefined) out.h = v.h;
51
+ const focal = parseFocal((raw as Record<string, unknown>).focal);
52
+ if (focal) out.focal = focal;
53
+ return out;
54
+ }
55
+
56
+ /** `seo` as it applies: trimmed text, blank parts left out. `{}` when nothing is set. */
57
+ export function parseSeo(raw: unknown): Seo {
58
+ const o = raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
59
+ const out: Seo = {};
60
+ const title = text(o.title);
61
+ const description = text(o.description);
62
+ const image = parseSeoImage(o.image);
63
+ if (title) out.title = title;
64
+ if (description) out.description = description;
65
+ if (image) out.image = image;
66
+ return out;
67
+ }
68
+
69
+ export const isEmptySeo = (seo: Seo): boolean => !seo.title && !seo.description && !seo.image;
70
+
71
+ const line = (o: Record<string, unknown>) =>
72
+ `{ ${Object.entries(o)
73
+ .map(([k, v]) => `${JSON.stringify(k)}: ${Array.isArray(v) ? `[${v.map((x) => JSON.stringify(x)).join(', ')}]` : JSON.stringify(v)}`)
74
+ .join(', ')} }`;
75
+
76
+ /** `seo` as the file writes it: one member per line, the image on one line (as media values are). */
77
+ export function seoText(value: unknown, depth: number): string {
78
+ const seo = parseSeo(value);
79
+ const pad = ' '.repeat(depth + 1);
80
+ const members: string[] = [];
81
+ if (seo.title) members.push(`${pad}"title": ${JSON.stringify(seo.title)}`);
82
+ if (seo.description) members.push(`${pad}"description": ${JSON.stringify(seo.description)}`);
83
+ if (seo.image) members.push(`${pad}"image": ${line(seo.image as unknown as Record<string, unknown>)}`);
84
+ return members.length ? `{\n${members.join(',\n')}\n${' '.repeat(depth)}}` : '{}';
85
+ }
86
+
87
+ /** `googleTag` as written: the trimmed text (checked again when it's rendered). */
88
+ export const parseGoogleTag = (raw: unknown): string => text(raw);
89
+
90
+ /** What a Google tag ID loads: Tag Manager, gtag.js, or nothing (anything that isn't an ID). */
91
+ export function googleTagKind(id: unknown): 'gtm' | 'gtag' | null {
92
+ if (typeof id !== 'string') return null;
93
+ if (/^GTM-[A-Z0-9]{4,12}$/.test(id)) return 'gtm';
94
+ if (/^G-[A-Z0-9]{6,16}$/.test(id)) return 'gtag';
95
+ return null;
96
+ }
97
+
98
+ /**
99
+ * The ID inside a pasted Google snippet (the mockup's "Use G-…"): the first `GTM-…` or
100
+ * `G-…` in the text, or null.
101
+ */
102
+ export function googleTagIn(pasted: string): string | null {
103
+ const m = /\b(GTM-[A-Z0-9]{4,12}|G-[A-Z0-9]{6,16})\b/.exec(pasted);
104
+ return m ? m[1]! : null;
105
+ }
106
+
107
+ /** `formRecipients` as stored: per form, the entries trimmed, blanks and duplicates (without case) dropped, invalid ones kept. Forms with none left out. */
108
+ export function parseFormRecipients(raw: unknown): Record<string, string[]> {
109
+ const o = raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
110
+ const out: Record<string, string[]> = {};
111
+ for (const [id, list] of Object.entries(o)) {
112
+ if (!Array.isArray(list)) continue;
113
+ const seen = new Set<string>();
114
+ const kept: string[] = [];
115
+ for (const entry of list) {
116
+ const address = text(entry);
117
+ if (!address || seen.has(address.toLowerCase())) continue;
118
+ seen.add(address.toLowerCase());
119
+ kept.push(address);
120
+ }
121
+ if (kept.length) out[id] = kept;
122
+ }
123
+ return out;
124
+ }