@growth-labs/cms 0.5.21 → 0.5.23

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 (66) hide show
  1. package/README.md +48 -0
  2. package/dist/engine/published-content.js +1 -1
  3. package/dist/engine/published-content.js.map +1 -1
  4. package/dist/engine/publisher.d.ts +8 -0
  5. package/dist/engine/publisher.d.ts.map +1 -1
  6. package/dist/engine/publisher.js +15 -8
  7. package/dist/engine/publisher.js.map +1 -1
  8. package/dist/engine/revisions.d.ts.map +1 -1
  9. package/dist/engine/revisions.js +6 -2
  10. package/dist/engine/revisions.js.map +1 -1
  11. package/dist/puck/config.d.ts +21 -0
  12. package/dist/puck/config.d.ts.map +1 -0
  13. package/dist/puck/config.js +121 -0
  14. package/dist/puck/config.js.map +1 -0
  15. package/dist/puck/index.d.ts +4 -0
  16. package/dist/puck/index.d.ts.map +1 -0
  17. package/dist/puck/index.js +10 -0
  18. package/dist/puck/index.js.map +1 -0
  19. package/dist/puck/markdown.d.ts +5 -0
  20. package/dist/puck/markdown.d.ts.map +1 -0
  21. package/dist/puck/markdown.js +78 -0
  22. package/dist/puck/markdown.js.map +1 -0
  23. package/dist/routes/content.d.ts.map +1 -1
  24. package/dist/routes/content.js +17 -1
  25. package/dist/routes/content.js.map +1 -1
  26. package/dist/schema/layout.d.ts +126 -0
  27. package/dist/schema/layout.d.ts.map +1 -0
  28. package/dist/schema/layout.js +52 -0
  29. package/dist/schema/layout.js.map +1 -0
  30. package/dist/schema/migrations.d.ts.map +1 -1
  31. package/dist/schema/migrations.js +7 -0
  32. package/dist/schema/migrations.js.map +1 -1
  33. package/dist/schema/types.d.ts +2 -0
  34. package/dist/schema/types.d.ts.map +1 -1
  35. package/dist/schema/types.js.map +1 -1
  36. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  37. package/dist/ui/editor/ContentForm.js +54 -1
  38. package/dist/ui/editor/ContentForm.js.map +1 -1
  39. package/dist/ui/editor/LayoutBuilder.d.ts +12 -0
  40. package/dist/ui/editor/LayoutBuilder.d.ts.map +1 -0
  41. package/dist/ui/editor/LayoutBuilder.js +66 -0
  42. package/dist/ui/editor/LayoutBuilder.js.map +1 -0
  43. package/dist/ui/editor/content-payload.d.ts +2 -0
  44. package/dist/ui/editor/content-payload.d.ts.map +1 -1
  45. package/dist/ui/editor/content-payload.js +3 -0
  46. package/dist/ui/editor/content-payload.js.map +1 -1
  47. package/dist/ui/editor/layout-mode.d.ts +21 -0
  48. package/dist/ui/editor/layout-mode.d.ts.map +1 -0
  49. package/dist/ui/editor/layout-mode.js +41 -0
  50. package/dist/ui/editor/layout-mode.js.map +1 -0
  51. package/migrations/0025_page_layout_json.sql +7 -0
  52. package/package.json +12 -1
  53. package/src/engine/published-content.ts +1 -1
  54. package/src/engine/publisher.ts +21 -6
  55. package/src/engine/revisions.ts +12 -2
  56. package/src/puck/config.tsx +194 -0
  57. package/src/puck/index.ts +23 -0
  58. package/src/puck/markdown.ts +78 -0
  59. package/src/routes/content.ts +18 -1
  60. package/src/schema/layout.ts +73 -0
  61. package/src/schema/migrations.ts +7 -0
  62. package/src/schema/types.ts +2 -0
  63. package/src/ui/editor/ContentForm.tsx +117 -6
  64. package/src/ui/editor/LayoutBuilder.tsx +100 -0
  65. package/src/ui/editor/content-payload.ts +5 -0
  66. package/src/ui/editor/layout-mode.ts +58 -0
@@ -0,0 +1,12 @@
1
+ import '@puckeditor/core/no-external.css';
2
+ import { type LayoutData } from '../../puck/index.js';
3
+ import { type RteProps } from './Rte.js';
4
+ export interface LayoutBuilderProps {
5
+ /** Stored layout envelope JSON at mount time; null starts a blank canvas. */
6
+ layoutJson: string | null;
7
+ /** Fires on every canvas edit with the raw Puck data. */
8
+ onDataChange: (data: LayoutData) => void;
9
+ onImageUpload?: RteProps['onImageUpload'];
10
+ }
11
+ export declare function LayoutBuilder({ layoutJson, onDataChange, onImageUpload }: LayoutBuilderProps): import("react").JSX.Element;
12
+ //# sourceMappingURL=LayoutBuilder.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"LayoutBuilder.d.ts","sourceRoot":"","sources":["../../../src/ui/editor/LayoutBuilder.tsx"],"names":[],"mappings":"AAYA,OAAO,kCAAkC,CAAA;AAGzC,OAAO,EAAoB,KAAK,UAAU,EAAE,MAAM,qBAAqB,CAAA;AAKvE,OAAO,EAAO,KAAK,QAAQ,EAAE,MAAM,UAAU,CAAA;AAgD7C,MAAM,WAAW,kBAAkB;IAClC,6EAA6E;IAC7E,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,yDAAyD;IACzD,YAAY,EAAE,CAAC,IAAI,EAAE,UAAU,KAAK,IAAI,CAAA;IACxC,aAAa,CAAC,EAAE,QAAQ,CAAC,eAAe,CAAC,CAAA;CACzC;AAED,wBAAgB,aAAa,CAAC,EAAE,UAAU,EAAE,YAAY,EAAE,aAAa,EAAE,EAAE,kBAAkB,+BAuB5F"}
@@ -0,0 +1,66 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // src/ui/editor/LayoutBuilder.tsx
3
+ // Builder mode for page documents: mounts the Puck editor over the shared
4
+ // component config (src/puck/config.tsx). Browser-only by nature — this
5
+ // module imports the full @puckeditor/core editor and TipTap HTML generation,
6
+ // and it ships in the admin client bundle only.
7
+ //
8
+ // <Puck data> is initial-only: the parent renders this component conditionally
9
+ // (or keyed per doc), so every mount re-reads the stored envelope. Canvas
10
+ // changes flow up through onDataChange as plain LayoutData; the parent owns
11
+ // wrapping, projection, and autosave (layout-mode.ts).
12
+ import { Puck } from '@puckeditor/core';
13
+ import '@puckeditor/core/no-external.css';
14
+ import { generateHTML } from '@tiptap/core';
15
+ import { useMemo, useState } from 'react';
16
+ import { createPuckConfig } from '../../puck/index.js';
17
+ import { parsePortableTextEnvelope } from '../../schema/portable-text.js';
18
+ import { createRteExtensions } from './extensions.js';
19
+ import { initialLayoutData, richTextMarkdownFromEnvelope } from './layout-mode.js';
20
+ import { portableTextToDoc } from './portable-text.js';
21
+ import { Rte } from './Rte.js';
22
+ function escapeHtml(value) {
23
+ return value
24
+ .replaceAll('&', '&amp;')
25
+ .replaceAll('<', '&lt;')
26
+ .replaceAll('>', '&gt;')
27
+ .replaceAll('"', '&quot;')
28
+ .replaceAll("'", '&#39;');
29
+ }
30
+ // Canvas preview for RichText blocks: the same TipTap schema the Rte uses,
31
+ // so the preview matches what the field editor shows. Falls back to escaped
32
+ // text if HTML generation fails; empty envelopes show a placeholder hint.
33
+ function canvasRichTextHtml(envelopeJson) {
34
+ const parsed = parsePortableTextEnvelope(envelopeJson);
35
+ if (!parsed) {
36
+ return '<p class="gl-builder-richtext-empty">Empty rich text — select the block to write.</p>';
37
+ }
38
+ try {
39
+ return generateHTML(portableTextToDoc(parsed.content), createRteExtensions());
40
+ }
41
+ catch {
42
+ return `<p>${escapeHtml(richTextMarkdownFromEnvelope(envelopeJson))}</p>`;
43
+ }
44
+ }
45
+ function makeRichTextField(onImageUpload) {
46
+ return {
47
+ type: 'custom',
48
+ label: 'Rich text',
49
+ render: ({ value, onChange }) => {
50
+ const stored = typeof value === 'string' ? value : '';
51
+ return (_jsx("div", { className: "gl-builder-richtext-field", children: _jsx(Rte, { value: richTextMarkdownFromEnvelope(stored), portableText: stored || null, onChange: (_md, portableText) => onChange(portableText), onImageUpload: onImageUpload }) }));
52
+ },
53
+ };
54
+ }
55
+ const EMPTY_DATA = { root: { props: {} }, content: [] };
56
+ export function LayoutBuilder({ layoutJson, onDataChange, onImageUpload }) {
57
+ const config = useMemo(() => createPuckConfig({
58
+ renderRichTextHtml: canvasRichTextHtml,
59
+ richTextField: makeRichTextField(onImageUpload),
60
+ }), [onImageUpload]);
61
+ // Initial-only by contract — a lazy state initializer reads the prop once
62
+ // at mount (Puck ignores later data props; a remount re-reads the envelope).
63
+ const [initialData] = useState(() => initialLayoutData(layoutJson) ?? EMPTY_DATA);
64
+ return (_jsx("div", { className: "gl-layout-builder", style: { border: '1px solid var(--line)', borderRadius: 8 }, children: _jsx(Puck, { config: config, data: initialData, onChange: (data) => onDataChange(data), iframe: { enabled: false }, height: "72vh" }) }));
65
+ }
66
+ //# sourceMappingURL=LayoutBuilder.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"LayoutBuilder.js","sourceRoot":"","sources":["../../../src/ui/editor/LayoutBuilder.tsx"],"names":[],"mappings":"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"}
@@ -5,6 +5,8 @@ export interface ContentDraftFields {
5
5
  body: string;
6
6
  /** Serialized Portable Text envelope for the body; null until the editor emits one. */
7
7
  bodyPortableText?: string | null;
8
+ /** Serialized Puck layout envelope (pages only); null clears, undefined never sent. */
9
+ layoutJson?: string | null;
8
10
  videoUrl: string;
9
11
  videoSourceKind?: string | null;
10
12
  videoId?: string | null;
@@ -1 +1 @@
1
- {"version":3,"file":"content-payload.d.ts","sourceRoot":"","sources":["../../../src/ui/editor/content-payload.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAExD,MAAM,WAAW,kBAAkB;IAClC,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;IACZ,uFAAuF;IACvF,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAChC,QAAQ,EAAE,MAAM,CAAA;IAChB,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC/B,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAChC,QAAQ,EAAE,MAAM,CAAA;IAChB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACvB;AAED,wBAAgB,6BAA6B,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAG3E;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAQlD;AAED,wBAAgB,qBAAqB,CACpC,WAAW,EAAE,WAAW,EACxB,KAAK,EAAE,kBAAkB,GACvB,OAAO,CAkBT;AAED,wBAAgB,6BAA6B,CAC5C,WAAW,EAAE,WAAW,EACxB,KAAK,EAAE,kBAAkB,GACvB,MAAM,GAAG,IAAI,CAUf;AAED,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,GAAG,UAAU,CAe9E;AAED,wBAAgB,yBAAyB,CACxC,WAAW,EAAE,WAAW,EACxB,KAAK,EAAE,kBAAkB,GACvB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CA6CzB;AAED,wBAAgB,yBAAyB,CACxC,WAAW,EAAE,WAAW,EACxB,KAAK,EAAE,kBAAkB,GACvB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAQzB"}
1
+ {"version":3,"file":"content-payload.d.ts","sourceRoot":"","sources":["../../../src/ui/editor/content-payload.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAExD,MAAM,WAAW,kBAAkB;IAClC,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;IACZ,uFAAuF;IACvF,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAChC,uFAAuF;IACvF,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,QAAQ,EAAE,MAAM,CAAA;IAChB,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC/B,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAChC,QAAQ,EAAE,MAAM,CAAA;IAChB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACvB;AAED,wBAAgB,6BAA6B,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAG3E;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAQlD;AAED,wBAAgB,qBAAqB,CACpC,WAAW,EAAE,WAAW,EACxB,KAAK,EAAE,kBAAkB,GACvB,OAAO,CAkBT;AAED,wBAAgB,6BAA6B,CAC5C,WAAW,EAAE,WAAW,EACxB,KAAK,EAAE,kBAAkB,GACvB,MAAM,GAAG,IAAI,CAUf;AAED,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,GAAG,UAAU,CAe9E;AAED,wBAAgB,yBAAyB,CACxC,WAAW,EAAE,WAAW,EACxB,KAAK,EAAE,kBAAkB,GACvB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAgDzB;AAED,wBAAgB,yBAAyB,CACxC,WAAW,EAAE,WAAW,EACxB,KAAK,EAAE,kBAAkB,GACvB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAQzB"}
@@ -73,6 +73,9 @@ export function buildContentUpdatePayload(contentType, draft) {
73
73
  bodyPortableText: draft.bodyPortableText || null,
74
74
  wordCount,
75
75
  readTimeMinutes: estimateReadTime(wordCount),
76
+ // Pages always state their layout explicitly: a value writes it,
77
+ // null clears it (the engine preserves only when the key is absent).
78
+ ...(contentType === 'page' ? { layoutJson: draft.layoutJson ?? null } : {}),
76
79
  };
77
80
  }
78
81
  else if (contentType === 'video') {
@@ -1 +1 @@
1
- {"version":3,"file":"content-payload.js","sourceRoot":"","sources":["../../../src/ui/editor/content-payload.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"content-payload.js","sourceRoot":"","sources":["../../../src/ui/editor/content-payload.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAA;AAqBxE,MAAM,UAAU,6BAA6B,CAAC,KAAc;IAC3D,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC;QAAE,OAAO,IAAI,CAAA;IACnF,OAAO,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;AACxB,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,KAAa;IACzC,OAAO,KAAK;SACV,WAAW,EAAE;SACb,IAAI,EAAE;SACN,OAAO,CAAC,WAAW,EAAE,EAAE,CAAC;SACxB,OAAO,CAAC,UAAU,EAAE,GAAG,CAAC;SACxB,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC;SACvB,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAA;AACf,CAAC;AAED,MAAM,UAAU,qBAAqB,CACpC,WAAwB,EACxB,KAAyB;IAEzB,IAAI,KAAK,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC;QAAE,OAAO,KAAK,CAAA;IAC/C,IAAI,WAAW,KAAK,SAAS,IAAI,WAAW,KAAK,YAAY,IAAI,WAAW,KAAK,MAAM,EAAE,CAAC;QACzF,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAA;IACpC,CAAC;IACD,IAAI,WAAW,KAAK,OAAO,EAAE,CAAC;QAC7B,OAAO,CACN,KAAK,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC;YAChC,6BAA6B,CAAC,KAAK,CAAC,eAAe,CAAC,KAAK,IAAI,CAC7D,CAAA;IACF,CAAC;IACD,IAAI,WAAW,KAAK,SAAS,EAAE,CAAC;QAC/B,OAAO,CACN,KAAK,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC;YAChC,6BAA6B,CAAC,KAAK,CAAC,eAAe,CAAC,KAAK,IAAI,CAC7D,CAAA;IACF,CAAC;IACD,OAAO,KAAK,CAAA;AACb,CAAC;AAED,MAAM,UAAU,6BAA6B,CAC5C,WAAwB,EACxB,KAAyB;IAEzB,IAAI,WAAW,KAAK,OAAO,EAAE,CAAC;QAC7B,MAAM,SAAS,GAAG,KAAK,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAA;QACvC,OAAO,SAAS,IAAI,IAAI,CAAA;IACzB,CAAC;IACD,IAAI,WAAW,KAAK,SAAS,EAAE,CAAC;QAC/B,MAAM,UAAU,GAAG,KAAK,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAA;QACxC,OAAO,UAAU,IAAI,IAAI,CAAA;IAC1B,CAAC;IACD,OAAO,IAAI,CAAA;AACZ,CAAC;AAED,MAAM,UAAU,oBAAoB,CAAC,SAAiB;IACrD,IAAI,CAAC;QACJ,MAAM,EAAE,QAAQ,EAAE,GAAG,IAAI,GAAG,CAAC,SAAS,CAAC,CAAA;QACvC,MAAM,IAAI,GAAG,QAAQ,CAAC,WAAW,EAAE,CAAA;QACnC,IACC,IAAI,KAAK,aAAa;YACtB,IAAI,KAAK,iBAAiB;YAC1B,IAAI,KAAK,2BAA2B,EACnC,CAAC;YACF,OAAO,SAAS,CAAA;QACjB,CAAC;IACF,CAAC;IAAC,MAAM,CAAC;QACR,sEAAsE;IACvE,CAAC;IACD,OAAO,UAAU,CAAA;AAClB,CAAC;AAED,MAAM,UAAU,yBAAyB,CACxC,WAAwB,EACxB,KAAyB;IAEzB,MAAM,OAAO,GAA4B;QACxC,KAAK,EAAE,KAAK,CAAC,KAAK;QAClB,OAAO,EAAE,KAAK,CAAC,GAAG,IAAI,IAAI;KAC1B,CAAA;IACD,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,EAAE,IAAI,EAAE,CAAA;IACrC,OAAO,CAAC,eAAe,GAAG,OAAO,IAAI,IAAI,CAAA;IACzC,MAAM,WAAW,GAAG,KAAK,CAAC,WAAW,EAAE,IAAI,EAAE,CAAA;IAC7C,IAAI,WAAW,EAAE,CAAC;QACjB,OAAO,CAAC,WAAW,GAAG,WAAW,CAAA;IAClC,CAAC;IAED,IAAI,WAAW,KAAK,SAAS,IAAI,WAAW,KAAK,YAAY,IAAI,WAAW,KAAK,MAAM,EAAE,CAAC;QACzF,MAAM,SAAS,GAAG,UAAU,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;QACxC,OAAO,CAAC,OAAO,GAAG;YACjB,YAAY,EAAE,KAAK,CAAC,IAAI;YACxB,gBAAgB,EAAE,KAAK,CAAC,gBAAgB,IAAI,IAAI;YAChD,SAAS;YACT,eAAe,EAAE,gBAAgB,CAAC,SAAS,CAAC;YAC5C,iEAAiE;YACjE,qEAAqE;YACrE,GAAG,CAAC,WAAW,KAAK,MAAM,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,KAAK,CAAC,UAAU,IAAI,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SAC3E,CAAA;IACF,CAAC;SAAM,IAAI,WAAW,KAAK,OAAO,EAAE,CAAC;QACpC,MAAM,SAAS,GAAG,KAAK,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAA;QACvC,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,EAAE,IAAI,EAAE,IAAI,YAAY,CAAC,KAAK,CAAC,KAAK,IAAI,OAAO,CAAC,CAAA;QAC7E,MAAM,gBAAgB,GAAG,KAAK,CAAC,gBAAgB,EAAE,IAAI,EAAE,CAAA;QACvD,OAAO,CAAC,OAAO,GAAG;YACjB,MAAM,EAAE,EAAE;YACV,OAAO;YACP,eAAe,EAAE,6BAA6B,CAAC,KAAK,CAAC,eAAe,CAAC;YACrE,GAAG,CAAC,gBAAgB,CAAC,CAAC,CAAC,EAAE,gBAAgB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACjD,GAAG,CAAC,SAAS;gBACZ,CAAC,CAAC;oBACA,mBAAmB,EAAE,SAAS;oBAC9B,oBAAoB,EAAE,KAAK,CAAC,eAAe,IAAI,oBAAoB,CAAC,SAAS,CAAC;iBAC9E;gBACF,CAAC,CAAC,EAAE,CAAC;SACN,CAAA;IACF,CAAC;SAAM,IAAI,WAAW,KAAK,SAAS,EAAE,CAAC;QACtC,OAAO,CAAC,OAAO,GAAG;YACjB,UAAU,EAAE,EAAE;YACd,UAAU,EAAE,KAAK,CAAC,QAAQ;YAC1B,eAAe,EAAE,6BAA6B,CAAC,KAAK,CAAC,eAAe,CAAC;SACrE,CAAA;IACF,CAAC;IAED,OAAO,OAAO,CAAA;AACf,CAAC;AAED,MAAM,UAAU,yBAAyB,CACxC,WAAwB,EACxB,KAAyB;IAEzB,MAAM,OAAO,GAA4B;QACxC,IAAI,EAAE,WAAW;QACjB,IAAI,EAAE,YAAY,CAAC,KAAK,CAAC,KAAK,IAAI,UAAU,CAAC;QAC7C,GAAG,yBAAyB,CAAC,WAAW,EAAE,KAAK,CAAC;KAChD,CAAA;IACD,IAAI,OAAO,CAAC,eAAe,KAAK,IAAI;QAAE,OAAO,OAAO,CAAC,eAAe,CAAA;IACpE,OAAO,OAAO,CAAA;AACf,CAAC"}
@@ -0,0 +1,21 @@
1
+ import { type LayoutData } from '../../puck/index.js';
2
+ export interface LayoutDraft {
3
+ layoutJson: string;
4
+ body: string;
5
+ bodyPortableText: null;
6
+ }
7
+ /**
8
+ * Turn a Puck canvas change into the fields a builder save persists. The
9
+ * classic body stays populated with the derived-markdown projection (search,
10
+ * SEO, non-layout surfaces); an unprojectable layout keeps the previous body,
11
+ * then the title, so create/publish body requirements still hold.
12
+ */
13
+ export declare function layoutDraftFromData(data: LayoutData, previousBody: string, title: string): LayoutDraft;
14
+ /** Stored layout JSON → Puck canvas data; null for absent/malformed. */
15
+ export declare function initialLayoutData(layoutJson: string | null): LayoutData | null;
16
+ /**
17
+ * Portable Text envelope JSON (a RichText block's stored prop) → markdown,
18
+ * for seeding the embedded Rte. Empty string when the envelope is malformed.
19
+ */
20
+ export declare function richTextMarkdownFromEnvelope(envelopeJson: string): string;
21
+ //# sourceMappingURL=layout-mode.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"layout-mode.d.ts","sourceRoot":"","sources":["../../../src/ui/editor/layout-mode.ts"],"names":[],"mappings":"AAMA,OAAO,EACN,KAAK,UAAU,EAIf,MAAM,qBAAqB,CAAA;AAK5B,MAAM,WAAW,WAAW;IAC3B,UAAU,EAAE,MAAM,CAAA;IAClB,IAAI,EAAE,MAAM,CAAA;IACZ,gBAAgB,EAAE,IAAI,CAAA;CACtB;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAClC,IAAI,EAAE,UAAU,EAChB,YAAY,EAAE,MAAM,EACpB,KAAK,EAAE,MAAM,GACX,WAAW,CAUb;AAED,wEAAwE;AACxE,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,GAAG,UAAU,GAAG,IAAI,CAE9E;AAED;;;GAGG;AACH,wBAAgB,4BAA4B,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAIzE"}
@@ -0,0 +1,41 @@
1
+ // src/ui/editor/layout-mode.ts
2
+ // Pure Builder-mode logic: canvas data → persisted draft triple, stored
3
+ // envelope → canvas data, and the Portable Text ↔ markdown bridge for the
4
+ // RichText custom field. No DOM, no Puck imports — the LayoutBuilder
5
+ // component owns the browser-only pieces.
6
+ import { layoutToMarkdown, parseLayoutEnvelope, wrapLayout, } from '../../puck/index.js';
7
+ import { parsePortableTextEnvelope } from '../../schema/portable-text.js';
8
+ import { portableTextToDoc } from './portable-text.js';
9
+ import { docToMarkdown } from './serialize.js';
10
+ /**
11
+ * Turn a Puck canvas change into the fields a builder save persists. The
12
+ * classic body stays populated with the derived-markdown projection (search,
13
+ * SEO, non-layout surfaces); an unprojectable layout keeps the previous body,
14
+ * then the title, so create/publish body requirements still hold.
15
+ */
16
+ export function layoutDraftFromData(data, previousBody, title) {
17
+ const envelope = wrapLayout(data);
18
+ const projected = layoutToMarkdown(envelope).trim();
19
+ return {
20
+ layoutJson: JSON.stringify(envelope),
21
+ body: projected || previousBody.trim() || title.trim(),
22
+ // The derived body is markdown-only — stale Portable Text must not
23
+ // survive a builder save (same staleness rule the engine enforces).
24
+ bodyPortableText: null,
25
+ };
26
+ }
27
+ /** Stored layout JSON → Puck canvas data; null for absent/malformed. */
28
+ export function initialLayoutData(layoutJson) {
29
+ return parseLayoutEnvelope(layoutJson)?.data ?? null;
30
+ }
31
+ /**
32
+ * Portable Text envelope JSON (a RichText block's stored prop) → markdown,
33
+ * for seeding the embedded Rte. Empty string when the envelope is malformed.
34
+ */
35
+ export function richTextMarkdownFromEnvelope(envelopeJson) {
36
+ const parsed = parsePortableTextEnvelope(envelopeJson);
37
+ if (!parsed)
38
+ return '';
39
+ return docToMarkdown(portableTextToDoc(parsed.content)).trim();
40
+ }
41
+ //# sourceMappingURL=layout-mode.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"layout-mode.js","sourceRoot":"","sources":["../../../src/ui/editor/layout-mode.ts"],"names":[],"mappings":"AAAA,+BAA+B;AAC/B,wEAAwE;AACxE,0EAA0E;AAC1E,qEAAqE;AACrE,0CAA0C;AAE1C,OAAO,EAEN,gBAAgB,EAChB,mBAAmB,EACnB,UAAU,GACV,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EAAE,yBAAyB,EAAE,MAAM,+BAA+B,CAAA;AACzE,OAAO,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AACtD,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAQ9C;;;;;GAKG;AACH,MAAM,UAAU,mBAAmB,CAClC,IAAgB,EAChB,YAAoB,EACpB,KAAa;IAEb,MAAM,QAAQ,GAAG,UAAU,CAAC,IAAI,CAAC,CAAA;IACjC,MAAM,SAAS,GAAG,gBAAgB,CAAC,QAAQ,CAAC,CAAC,IAAI,EAAE,CAAA;IACnD,OAAO;QACN,UAAU,EAAE,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC;QACpC,IAAI,EAAE,SAAS,IAAI,YAAY,CAAC,IAAI,EAAE,IAAI,KAAK,CAAC,IAAI,EAAE;QACtD,mEAAmE;QACnE,oEAAoE;QACpE,gBAAgB,EAAE,IAAI;KACtB,CAAA;AACF,CAAC;AAED,wEAAwE;AACxE,MAAM,UAAU,iBAAiB,CAAC,UAAyB;IAC1D,OAAO,mBAAmB,CAAC,UAAU,CAAC,EAAE,IAAI,IAAI,IAAI,CAAA;AACrD,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,4BAA4B,CAAC,YAAoB;IAChE,MAAM,MAAM,GAAG,yBAAyB,CAAC,YAAY,CAAC,CAAA;IACtD,IAAI,CAAC,MAAM;QAAE,OAAO,EAAE,CAAA;IACtB,OAAO,aAAa,CAAC,iBAAiB,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,EAAE,CAAA;AAC/D,CAAC"}
@@ -0,0 +1,7 @@
1
+ -- Puck page-layout envelope for builder-mode pages. Nullable JSON
2
+ -- ({ version, data }) written alongside the classic body columns; NULL means
3
+ -- "classic page — render via body_portable_text/markdown". The body columns
4
+ -- are never dropped; builder pages derive body_markdown from their RichText
5
+ -- blocks so text consumers keep working.
6
+ ALTER TABLE article_content
7
+ ADD COLUMN layout_json TEXT;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@growth-labs/cms",
3
- "version": "0.5.21",
3
+ "version": "0.5.23",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "growth-labs-cms-migrations": "./dist/cli/migrations.js"
@@ -35,6 +35,10 @@
35
35
  "types": "./dist/migration-vendor.d.ts",
36
36
  "import": "./dist/migration-vendor.js"
37
37
  },
38
+ "./puck": {
39
+ "types": "./dist/puck/index.d.ts",
40
+ "import": "./dist/puck/index.js"
41
+ },
38
42
  "./ui": {
39
43
  "types": "./dist/ui/components/CmsApp.d.ts",
40
44
  "import": "./dist/ui/components/CmsApp.js"
@@ -54,10 +58,16 @@
54
58
  "README.md"
55
59
  ],
56
60
  "peerDependencies": {
61
+ "@puckeditor/core": "^0.23.0",
57
62
  "astro": "^6.1.10 || ^7.0.0",
58
63
  "react": "^19.0.0",
59
64
  "react-dom": "^19.0.0"
60
65
  },
66
+ "peerDependenciesMeta": {
67
+ "@puckeditor/core": {
68
+ "optional": true
69
+ }
70
+ },
61
71
  "publishConfig": {
62
72
  "access": "public",
63
73
  "registry": "https://registry.npmjs.org/"
@@ -78,6 +88,7 @@
78
88
  "zod": "^3.24.0"
79
89
  },
80
90
  "devDependencies": {
91
+ "@puckeditor/core": "0.23.0",
81
92
  "@types/node": "^25.5.0",
82
93
  "@types/react": "19.2.16",
83
94
  "@types/react-dom": "19.2.3",
@@ -122,7 +122,7 @@ FROM content_items ci
122
122
  // SQL. Every byte accounting downstream (page budget, per-item cap, the
123
123
  // changed-during-read integrity check) uses the STRIPPED length consistently,
124
124
  // so the contracts hold with no special cases.
125
- const BODYLESS_PAYLOAD_EXPR = `json_remove(cr.payload_json, '$.content.body_markdown', '$.content.body_html', '$.content.body_portable_text')`
125
+ const BODYLESS_PAYLOAD_EXPR = `json_remove(cr.payload_json, '$.content.body_markdown', '$.content.body_html', '$.content.body_portable_text', '$.content.layout_json')`
126
126
  const SELECT_PUBLISHED_CONTENT_BODYLESS = `
127
127
  SELECT
128
128
  ci.id AS content_id,
@@ -44,7 +44,7 @@ export {
44
44
  type ContentVisibility = 'free' | 'premium'
45
45
  type BodyBackedContentType = 'article' | 'newsletter' | 'page'
46
46
 
47
- function isBodyBackedContentType(type: ContentType): type is BodyBackedContentType {
47
+ export function isBodyBackedContentType(type: ContentType): type is BodyBackedContentType {
48
48
  return type === 'article' || type === 'newsletter' || type === 'page'
49
49
  }
50
50
 
@@ -123,6 +123,8 @@ export interface ArticleInput extends BaseContentInput {
123
123
  bodyHtml?: string | null
124
124
  /** Serialized Portable Text envelope JSON (schema/portable-text.ts). */
125
125
  bodyPortableText?: string | null
126
+ /** Serialized Puck layout envelope JSON (schema/layout.ts, pages only). */
127
+ layoutJson?: string | null
126
128
  subtitle?: string | null
127
129
  wordCount?: number | null
128
130
  readTimeMinutes?: number | null
@@ -158,6 +160,8 @@ export interface NewsletterInput extends BaseContentInput {
158
160
  bodyHtml?: string | null
159
161
  /** Serialized Portable Text envelope JSON (schema/portable-text.ts). */
160
162
  bodyPortableText?: string | null
163
+ /** Serialized Puck layout envelope JSON (schema/layout.ts, pages only). */
164
+ layoutJson?: string | null
161
165
  subtitle?: string | null
162
166
  wordCount?: number | null
163
167
  readTimeMinutes?: number | null
@@ -172,6 +176,8 @@ export interface PageInput extends BaseContentInput {
172
176
  bodyHtml?: string | null
173
177
  /** Serialized Portable Text envelope JSON (schema/portable-text.ts). */
174
178
  bodyPortableText?: string | null
179
+ /** Serialized Puck layout envelope JSON (schema/layout.ts, pages only). */
180
+ layoutJson?: string | null
175
181
  subtitle?: string | null
176
182
  wordCount?: number | null
177
183
  readTimeMinutes?: number | null
@@ -666,14 +672,15 @@ export async function createContent(
666
672
  const rt = input.content.readTimeMinutes ?? estimateReadTime(wc)
667
673
  await db
668
674
  .prepare(
669
- `INSERT INTO article_content (content_id, body_markdown, body_html, body_portable_text, word_count, read_time_minutes, subtitle, editor_takeaways)
670
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
675
+ `INSERT INTO article_content (content_id, body_markdown, body_html, body_portable_text, layout_json, word_count, read_time_minutes, subtitle, editor_takeaways)
676
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
671
677
  )
672
678
  .bind(
673
679
  id,
674
680
  input.content.bodyMarkdown,
675
681
  input.content.bodyHtml || null,
676
682
  input.content.bodyPortableText || null,
683
+ input.content.layoutJson || null,
677
684
  wc || null,
678
685
  rt || null,
679
686
  input.content.subtitle || null,
@@ -982,7 +989,7 @@ export async function updateContentItem(
982
989
  const current = await db
983
990
  .prepare(
984
991
  `
985
- SELECT body_markdown, body_html, body_portable_text, word_count, read_time_minutes, subtitle, editor_takeaways
992
+ SELECT body_markdown, body_html, body_portable_text, layout_json, word_count, read_time_minutes, subtitle, editor_takeaways
986
993
  FROM article_content
987
994
  WHERE content_id = ?
988
995
  LIMIT 1
@@ -993,6 +1000,7 @@ export async function updateContentItem(
993
1000
  body_markdown: string
994
1001
  body_html: string | null
995
1002
  body_portable_text: string | null
1003
+ layout_json: string | null
996
1004
  word_count: number | null
997
1005
  read_time_minutes: number | null
998
1006
  subtitle: string | null
@@ -1030,6 +1038,7 @@ export async function updateContentItem(
1030
1038
  body_markdown = ?,
1031
1039
  body_html = ?,
1032
1040
  body_portable_text = ?,
1041
+ layout_json = ?,
1033
1042
  word_count = ?,
1034
1043
  read_time_minutes = ?,
1035
1044
  subtitle = ?,
@@ -1053,6 +1062,12 @@ export async function updateContentItem(
1053
1062
  : bodyMarkdownProvided
1054
1063
  ? null
1055
1064
  : current.body_portable_text,
1065
+ // The layout is a separate representation from the body columns:
1066
+ // classic body edits do NOT invalidate a page layout, so absent
1067
+ // means "preserve" with no markdown coupling.
1068
+ hasOwn(articleContent, 'layoutJson')
1069
+ ? (articleContent.layoutJson ?? null)
1070
+ : current.layout_json,
1056
1071
  nextWordCount,
1057
1072
  nextReadTime,
1058
1073
  hasOwn(articleContent, 'subtitle') ? (articleContent.subtitle ?? null) : current.subtitle,
@@ -1317,8 +1332,8 @@ export async function duplicateContentItem(
1317
1332
  // generated faq_json carry across so the duplicate keeps its SEO/FAQ work.
1318
1333
  await db
1319
1334
  .prepare(
1320
- `INSERT INTO article_content (content_id, body_markdown, body_html, body_portable_text, word_count, read_time_minutes, subtitle, editor_takeaways, faq_json)
1321
- SELECT ?, body_markdown, body_html, body_portable_text, word_count, read_time_minutes, subtitle, editor_takeaways, faq_json
1335
+ `INSERT INTO article_content (content_id, body_markdown, body_html, body_portable_text, layout_json, word_count, read_time_minutes, subtitle, editor_takeaways, faq_json)
1336
+ SELECT ?, body_markdown, body_html, body_portable_text, layout_json, word_count, read_time_minutes, subtitle, editor_takeaways, faq_json
1322
1337
  FROM article_content WHERE content_id = ?`,
1323
1338
  )
1324
1339
  .bind(newId, sourceId)
@@ -7,7 +7,13 @@
7
7
 
8
8
  import { parseContentMetadata } from './content-metadata.js'
9
9
  import type { D1Database } from './d1.js'
10
- import { countWords, getContentItem, getContentSnapshot, updateContentItem } from './publisher.js'
10
+ import {
11
+ countWords,
12
+ getContentItem,
13
+ getContentSnapshot,
14
+ isBodyBackedContentType,
15
+ updateContentItem,
16
+ } from './publisher.js'
11
17
  import { validateRevisionTaxonomy } from './taxonomy.js'
12
18
 
13
19
  /** The one canonical revision snapshot shape (spec §4 "ContentRevisionPayload"). */
@@ -193,6 +199,7 @@ export async function restoreRevision(
193
199
  body_markdown?: string
194
200
  body_html?: string | null
195
201
  body_portable_text?: string | null
202
+ layout_json?: string | null
196
203
  subtitle?: string | null
197
204
  script?: string
198
205
  video_id?: string
@@ -254,11 +261,14 @@ export async function restoreRevision(
254
261
  reason: r.reason,
255
262
  })),
256
263
  content:
257
- existing.type === 'article' || existing.type === 'newsletter'
264
+ // Pages are body-backed too they must take this branch, or a page
265
+ // restore silently skips its body and layout entirely.
266
+ isBodyBackedContentType(existing.type)
258
267
  ? {
259
268
  bodyMarkdown: body?.body_markdown ?? '',
260
269
  bodyHtml: body?.body_html ?? null,
261
270
  bodyPortableText: body?.body_portable_text ?? null,
271
+ layoutJson: body?.layout_json ?? null,
262
272
  subtitle: body?.subtitle ?? null,
263
273
  // Editor takeaways were captured in the snapshot — roll them back.
264
274
  editorTakeaways: restoredTakeaways,
@@ -0,0 +1,194 @@
1
+ // src/puck/config.tsx
2
+ // The cms-owned Puck component library (v1): the Config consumed by both the
3
+ // admin editor (<Puck>) and the public renderer (<Render> from
4
+ // @puckeditor/core/rsc). Every render function is server-safe — plain JSX,
5
+ // no hooks, no DOM access — so the fronts Worker can render layouts with
6
+ // zero client JS. @puckeditor/core itself is only a TYPE dependency here;
7
+ // the runtime dependency lives with the consumer (optional peer).
8
+ //
9
+ // RichText stores a Portable Text envelope (schema/portable-text.ts) in its
10
+ // props — NEVER Puck's built-in `richtext` field, whose /rsc render path
11
+ // requires a DOM (@tiptap/html + happy-dom) and breaks on workerd. The HTML
12
+ // projection is injected by the consumer so site render pipelines (sanitize,
13
+ // heading ids, entity linking) stay site-owned.
14
+
15
+ import type { Config, Field } from '@puckeditor/core'
16
+ import type { ReactNode } from 'react'
17
+
18
+ export interface PuckConfigOptions {
19
+ /**
20
+ * Portable Text envelope JSON → trusted HTML for RichText blocks. The
21
+ * consumer owns this (fronts routes it through its article render
22
+ * pipeline). Without it, RichText renders a loud inline marker instead of
23
+ * silently dropping content.
24
+ */
25
+ renderRichTextHtml?: (portableTextEnvelopeJson: string) => string
26
+ /**
27
+ * Editor-side replacement for the RichText placeholder field — typically a
28
+ * custom field embedding the Portable Text editor. Injected here so the
29
+ * shared config module stays server-safe (no TipTap/DOM imports).
30
+ */
31
+ richTextField?: Field
32
+ }
33
+
34
+ /** The v1 layout component set, in palette order. */
35
+ export const LAYOUT_COMPONENT_TYPES = [
36
+ 'Section',
37
+ 'Heading',
38
+ 'RichText',
39
+ 'Image',
40
+ 'CTAButton',
41
+ 'Spacer',
42
+ ] as const
43
+
44
+ export type LayoutComponentType = (typeof LAYOUT_COMPONENT_TYPES)[number]
45
+
46
+ export function createPuckConfig(options: PuckConfigOptions = {}): Config {
47
+ const { renderRichTextHtml, richTextField } = options
48
+ return {
49
+ components: {
50
+ Section: {
51
+ label: 'Section',
52
+ fields: {
53
+ width: {
54
+ type: 'select',
55
+ label: 'Width',
56
+ options: [
57
+ { label: 'Normal', value: 'normal' },
58
+ { label: 'Wide', value: 'wide' },
59
+ ],
60
+ },
61
+ content: { type: 'slot' },
62
+ },
63
+ defaultProps: { width: 'normal', content: [] },
64
+ render: ({ width, content }) => (
65
+ <section className={`gl-layout-section gl-layout-section--${width || 'normal'}`}>
66
+ {typeof content === 'function' ? content() : null}
67
+ </section>
68
+ ),
69
+ },
70
+ Heading: {
71
+ label: 'Heading',
72
+ fields: {
73
+ text: { type: 'text', label: 'Text', contentEditable: true },
74
+ level: {
75
+ type: 'select',
76
+ label: 'Level',
77
+ options: [
78
+ { label: 'Heading 2', value: '2' },
79
+ { label: 'Heading 3', value: '3' },
80
+ ],
81
+ },
82
+ },
83
+ defaultProps: { text: '', level: '2' },
84
+ render: ({ text, level }) =>
85
+ level === '3' ? (
86
+ <h3 className="gl-layout-heading">{text}</h3>
87
+ ) : (
88
+ <h2 className="gl-layout-heading">{text}</h2>
89
+ ),
90
+ },
91
+ RichText: {
92
+ label: 'Rich text',
93
+ // The editor injects its Portable Text custom field via
94
+ // options.richTextField; the textarea is the headless fallback.
95
+ fields: {
96
+ portableText: richTextField ?? {
97
+ type: 'textarea',
98
+ label: 'Portable Text envelope',
99
+ },
100
+ },
101
+ defaultProps: { portableText: '' },
102
+ render: ({ portableText }) => {
103
+ const raw = typeof portableText === 'string' ? portableText : ''
104
+ if (renderRichTextHtml && raw !== '') {
105
+ return (
106
+ <div
107
+ className="gl-layout-richtext"
108
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: the consumer-injected projection owns sanitization
109
+ dangerouslySetInnerHTML={{ __html: renderRichTextHtml(raw) }}
110
+ />
111
+ )
112
+ }
113
+ // Loud, greppable marker — mirrors the fronts data-pt-unknown
114
+ // convention so a missing renderer never silently drops prose.
115
+ return (
116
+ <div
117
+ className="gl-layout-richtext"
118
+ data-gl-layout-unknown="richtext-renderer-missing"
119
+ />
120
+ )
121
+ },
122
+ },
123
+ Image: {
124
+ label: 'Image',
125
+ fields: {
126
+ src: { type: 'text', label: 'Image URL' },
127
+ alt: { type: 'text', label: 'Alt text' },
128
+ caption: { type: 'text', label: 'Caption' },
129
+ },
130
+ defaultProps: { src: '', alt: '', caption: '' },
131
+ render: ({ src, alt, caption }) => (
132
+ <figure className="gl-layout-image">
133
+ {typeof src === 'string' && src !== '' ? (
134
+ <img src={src} alt={typeof alt === 'string' ? alt : ''} loading="lazy" />
135
+ ) : null}
136
+ {typeof caption === 'string' && caption !== '' ? (
137
+ <figcaption>{caption}</figcaption>
138
+ ) : null}
139
+ </figure>
140
+ ),
141
+ },
142
+ CTAButton: {
143
+ label: 'CTA button',
144
+ fields: {
145
+ label: { type: 'text', label: 'Label', contentEditable: true },
146
+ href: { type: 'text', label: 'Link URL' },
147
+ tone: {
148
+ type: 'select',
149
+ label: 'Tone',
150
+ options: [
151
+ { label: 'Primary', value: 'primary' },
152
+ { label: 'Secondary', value: 'secondary' },
153
+ ],
154
+ },
155
+ },
156
+ defaultProps: { label: '', href: '', tone: 'primary' },
157
+ render: ({ label, href, tone }) => (
158
+ <a
159
+ className={`gl-layout-cta gl-layout-cta--${tone || 'primary'}`}
160
+ href={typeof href === 'string' && href !== '' ? href : undefined}
161
+ >
162
+ {label}
163
+ </a>
164
+ ),
165
+ },
166
+ Spacer: {
167
+ label: 'Spacer',
168
+ fields: {
169
+ size: {
170
+ type: 'select',
171
+ label: 'Size',
172
+ options: [
173
+ { label: 'Small', value: 'sm' },
174
+ { label: 'Medium', value: 'md' },
175
+ { label: 'Large', value: 'lg' },
176
+ ],
177
+ },
178
+ },
179
+ defaultProps: { size: 'md' },
180
+ render: ({ size }) => (
181
+ <div
182
+ className={`gl-layout-spacer gl-layout-spacer--${size || 'md'}`}
183
+ aria-hidden="true"
184
+ />
185
+ ),
186
+ },
187
+ },
188
+ root: {
189
+ // The page shell (nav, footer, prose width) belongs to the consumer;
190
+ // the layout root is deliberately chromeless.
191
+ render: ({ children }: { children?: ReactNode }) => <>{children}</>,
192
+ },
193
+ }
194
+ }
@@ -0,0 +1,23 @@
1
+ // src/puck/index.ts
2
+ // The `@growth-labs/cms/puck` subpath: the page-builder component library
3
+ // (Puck Config factory + server-safe React renders), the derived-markdown
4
+ // projection, and the layout storage envelope contract. Consumers pair this
5
+ // with @puckeditor/core (optional peer): the admin editor mounts <Puck>, the
6
+ // public site renders with <Render> from @puckeditor/core/rsc.
7
+
8
+ export {
9
+ LAYOUT_VERSION,
10
+ type LayoutComponent,
11
+ type LayoutData,
12
+ type LayoutEnvelope,
13
+ layoutEnvelopeSchema,
14
+ parseLayoutEnvelope,
15
+ wrapLayout,
16
+ } from '../schema/layout.js'
17
+ export {
18
+ createPuckConfig,
19
+ LAYOUT_COMPONENT_TYPES,
20
+ type LayoutComponentType,
21
+ type PuckConfigOptions,
22
+ } from './config.js'
23
+ export { layoutJsonToMarkdown, layoutToMarkdown } from './markdown.js'