simple-module-pagebuilder 0.0.1__py3-none-any.whl

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 (189) hide show
  1. pagebuilder/__init__.py +0 -0
  2. pagebuilder/components/DiffSummary.tsx +107 -0
  3. pagebuilder/components/ScheduledBadge.tsx +34 -0
  4. pagebuilder/components/StatusBadge.tsx +21 -0
  5. pagebuilder/components/blockRegistry.test.ts +56 -0
  6. pagebuilder/components/blockRegistry.ts +80 -0
  7. pagebuilder/components/blocks/Button.tsx +142 -0
  8. pagebuilder/components/blocks/Columns.tsx +64 -0
  9. pagebuilder/components/blocks/Heading.tsx +92 -0
  10. pagebuilder/components/blocks/Image.tsx +125 -0
  11. pagebuilder/components/blocks/MediaPicker.tsx +178 -0
  12. pagebuilder/components/blocks/Spacer.tsx +31 -0
  13. pagebuilder/components/blocks/Text.tsx +80 -0
  14. pagebuilder/components/editor/PageEditorToolbar.tsx +169 -0
  15. pagebuilder/components/editor/RevisionHistoryPanel.tsx +95 -0
  16. pagebuilder/components/editor/SchedulePanel.tsx +85 -0
  17. pagebuilder/components/editor/SeoSettingsPanel.tsx +119 -0
  18. pagebuilder/components/layoutPuckConfig.tsx +109 -0
  19. pagebuilder/components/media/MediaFilters.tsx +83 -0
  20. pagebuilder/components/media/MediaFolderSidebar.tsx +97 -0
  21. pagebuilder/components/media/MediaGrid.tsx +76 -0
  22. pagebuilder/components/media/MediaHeader.tsx +37 -0
  23. pagebuilder/components/media/MediaUploadQueue.tsx +103 -0
  24. pagebuilder/components/media/types.ts +20 -0
  25. pagebuilder/components/migrateContent.test.ts +146 -0
  26. pagebuilder/components/migrateContent.ts +135 -0
  27. pagebuilder/components/puckConfig.test.tsx +118 -0
  28. pagebuilder/components/puckConfig.tsx +121 -0
  29. pagebuilder/components/widgetCatalog.test.tsx +123 -0
  30. pagebuilder/components/widgetCatalog.ts +208 -0
  31. pagebuilder/components/widgets/_internal/accent-text.tsx +60 -0
  32. pagebuilder/components/widgets/_internal/disclosure-chevron.tsx +14 -0
  33. pagebuilder/components/widgets/_internal/eyebrow-split-section.tsx +71 -0
  34. pagebuilder/components/widgets/_internal/link-arrow.tsx +23 -0
  35. pagebuilder/components/widgets/_internal/rich-text.tsx +243 -0
  36. pagebuilder/components/widgets/_internal/solid-icons.tsx +73 -0
  37. pagebuilder/components/widgets/_shared/background-media.tsx +74 -0
  38. pagebuilder/components/widgets/_shared/content-media.tsx +43 -0
  39. pagebuilder/components/widgets/_shared/cta-button.tsx +69 -0
  40. pagebuilder/components/widgets/_shared/empty-placeholder.tsx +15 -0
  41. pagebuilder/components/widgets/_shared/eyebrow-text.tsx +27 -0
  42. pagebuilder/components/widgets/_shared/heading.tsx +88 -0
  43. pagebuilder/components/widgets/_shared/index.ts +39 -0
  44. pagebuilder/components/widgets/_shared/mask.ts +54 -0
  45. pagebuilder/components/widgets/_shared/section.tsx +60 -0
  46. pagebuilder/components/widgets/_shared/social-icon.tsx +71 -0
  47. pagebuilder/components/widgets/_shared/text-link.tsx +47 -0
  48. pagebuilder/components/widgets/accordion-widget.tsx +64 -0
  49. pagebuilder/components/widgets/alert-widget.tsx +47 -0
  50. pagebuilder/components/widgets/app-store-badges-widget.tsx +91 -0
  51. pagebuilder/components/widgets/article-cards-render.tsx +132 -0
  52. pagebuilder/components/widgets/article-cards-widget.tsx +72 -0
  53. pagebuilder/components/widgets/call-to-action-render.tsx +285 -0
  54. pagebuilder/components/widgets/call-to-action-styles.ts +28 -0
  55. pagebuilder/components/widgets/call-to-action-widget.tsx +83 -0
  56. pagebuilder/components/widgets/carousel-widget.tsx +97 -0
  57. pagebuilder/components/widgets/centered-hero-widget.tsx +54 -0
  58. pagebuilder/components/widgets/colored-action-list-widget.tsx +117 -0
  59. pagebuilder/components/widgets/column-widget.tsx +63 -0
  60. pagebuilder/components/widgets/contact-cards-widget.tsx +172 -0
  61. pagebuilder/components/widgets/contact-form-render.tsx +293 -0
  62. pagebuilder/components/widgets/contact-form-widget.tsx +69 -0
  63. pagebuilder/components/widgets/container-widget.tsx +93 -0
  64. pagebuilder/components/widgets/definition-list-widget.tsx +144 -0
  65. pagebuilder/components/widgets/divider-widget.tsx +56 -0
  66. pagebuilder/components/widgets/eyebrow-section-widget.tsx +56 -0
  67. pagebuilder/components/widgets/faq-render.tsx +268 -0
  68. pagebuilder/components/widgets/faq-widget.tsx +64 -0
  69. pagebuilder/components/widgets/feature-card-widget.tsx +98 -0
  70. pagebuilder/components/widgets/feature-cards-render.tsx +258 -0
  71. pagebuilder/components/widgets/feature-cards-styles.ts +107 -0
  72. pagebuilder/components/widgets/feature-cards-widget.tsx +133 -0
  73. pagebuilder/components/widgets/feature-grid-widget.tsx +118 -0
  74. pagebuilder/components/widgets/feature-showcase-widget.tsx +118 -0
  75. pagebuilder/components/widgets/gallery-widget.tsx +139 -0
  76. pagebuilder/components/widgets/grid-widget.tsx +59 -0
  77. pagebuilder/components/widgets/hero-widget.tsx +292 -0
  78. pagebuilder/components/widgets/html-widget.tsx +42 -0
  79. pagebuilder/components/widgets/iframe-widget.tsx +44 -0
  80. pagebuilder/components/widgets/leaderboard-widget.tsx +203 -0
  81. pagebuilder/components/widgets/list-widget.tsx +56 -0
  82. pagebuilder/components/widgets/logo-cloud-widget.tsx +127 -0
  83. pagebuilder/components/widgets/markdown-widget.tsx +148 -0
  84. pagebuilder/components/widgets/media-object-layout.ts +156 -0
  85. pagebuilder/components/widgets/media-object-render.tsx +284 -0
  86. pagebuilder/components/widgets/media-object-text.tsx +202 -0
  87. pagebuilder/components/widgets/media-object-widget.tsx +176 -0
  88. pagebuilder/components/widgets/newsletter-widget.tsx +217 -0
  89. pagebuilder/components/widgets/page-header-widget.tsx +118 -0
  90. pagebuilder/components/widgets/prose-section-widget.tsx +99 -0
  91. pagebuilder/components/widgets/quote-widget.tsx +51 -0
  92. pagebuilder/components/widgets/row-widget.tsx +74 -0
  93. pagebuilder/components/widgets/section-intro-widget.tsx +143 -0
  94. pagebuilder/components/widgets/signup-banner-widget.tsx +50 -0
  95. pagebuilder/components/widgets/simple-action-list-widget.tsx +91 -0
  96. pagebuilder/components/widgets/site-footer-widget.tsx +97 -0
  97. pagebuilder/components/widgets/site-header-nav.tsx +153 -0
  98. pagebuilder/components/widgets/site-header-widget.tsx +182 -0
  99. pagebuilder/components/widgets/social-banner-widget.tsx +83 -0
  100. pagebuilder/components/widgets/stats-widget.tsx +200 -0
  101. pagebuilder/components/widgets/steps-widget.tsx +211 -0
  102. pagebuilder/components/widgets/story-cards-widget.tsx +182 -0
  103. pagebuilder/components/widgets/table-widget.tsx +86 -0
  104. pagebuilder/components/widgets/tabs-widget.tsx +95 -0
  105. pagebuilder/components/widgets/tags-widget.tsx +85 -0
  106. pagebuilder/components/widgets/testimonial-widget.tsx +102 -0
  107. pagebuilder/components/widgets/timeline-layout.ts +73 -0
  108. pagebuilder/components/widgets/timeline-render.tsx +294 -0
  109. pagebuilder/components/widgets/timeline-widget.tsx +106 -0
  110. pagebuilder/components/widgets/under-construction-widget.tsx +40 -0
  111. pagebuilder/components/widgets/video-widget.tsx +103 -0
  112. pagebuilder/components/widgets/welcome-widget.tsx +38 -0
  113. pagebuilder/contracts/__init__.py +57 -0
  114. pagebuilder/contracts/events.py +25 -0
  115. pagebuilder/contracts/schemas.py +239 -0
  116. pagebuilder/conversion/blocks-to-html.test.ts +105 -0
  117. pagebuilder/conversion/blocks-to-html.ts +102 -0
  118. pagebuilder/conversion/index.ts +2 -0
  119. pagebuilder/conversion/types.ts +5 -0
  120. pagebuilder/deps.py +26 -0
  121. pagebuilder/diff.py +129 -0
  122. pagebuilder/endpoints/__init__.py +0 -0
  123. pagebuilder/endpoints/api/__init__.py +21 -0
  124. pagebuilder/endpoints/api/_deps.py +21 -0
  125. pagebuilder/endpoints/api/layout.py +78 -0
  126. pagebuilder/endpoints/api/pages.py +85 -0
  127. pagebuilder/endpoints/api/revisions.py +79 -0
  128. pagebuilder/endpoints/api/uploads.py +92 -0
  129. pagebuilder/endpoints/api/workflow.py +124 -0
  130. pagebuilder/endpoints/seo.py +95 -0
  131. pagebuilder/endpoints/views.py +228 -0
  132. pagebuilder/fields/checkbox-field.tsx +22 -0
  133. pagebuilder/fields/file-picker/file-gallery.tsx +152 -0
  134. pagebuilder/fields/file-picker/image-picker-field.tsx +214 -0
  135. pagebuilder/fields/file-picker/index.ts +6 -0
  136. pagebuilder/fields/file-picker/types.ts +15 -0
  137. pagebuilder/fields/index.ts +9 -0
  138. pagebuilder/fields/media-adapter.ts +47 -0
  139. pagebuilder/hooks/useAutosave.ts +134 -0
  140. pagebuilder/hooks/useEditorForm.ts +193 -0
  141. pagebuilder/hooks/useMediaUploads.ts +96 -0
  142. pagebuilder/hooks/usePageRevisions.ts +59 -0
  143. pagebuilder/hooks/usePageSchedule.ts +72 -0
  144. pagebuilder/hooks/usePageWorkflow.ts +173 -0
  145. pagebuilder/layout_service.py +101 -0
  146. pagebuilder/media_images.py +185 -0
  147. pagebuilder/media_queries.py +92 -0
  148. pagebuilder/media_service.py +222 -0
  149. pagebuilder/models.py +209 -0
  150. pagebuilder/module.py +279 -0
  151. pagebuilder/package.json +18 -0
  152. pagebuilder/pages/LayoutEditor.tsx +183 -0
  153. pagebuilder/pages/MediaLibrary.tsx +249 -0
  154. pagebuilder/pages/PageEditor.tsx +181 -0
  155. pagebuilder/pages/PageList.tsx +129 -0
  156. pagebuilder/pages/PendingReview.tsx +128 -0
  157. pagebuilder/pages/PublicPage.tsx +126 -0
  158. pagebuilder/permissions.py +44 -0
  159. pagebuilder/py.typed +0 -0
  160. pagebuilder/security.py +230 -0
  161. pagebuilder/service/__init__.py +141 -0
  162. pagebuilder/service/_common.py +24 -0
  163. pagebuilder/service/_revisions.py +92 -0
  164. pagebuilder/service/_workflow.py +207 -0
  165. pagebuilder/services.py +19 -0
  166. pagebuilder/settings.py +161 -0
  167. pagebuilder/static/widgets-base.css +57 -0
  168. pagebuilder/translation/apply.ts +54 -0
  169. pagebuilder/translation/extract.ts +141 -0
  170. pagebuilder/translation/index.ts +20 -0
  171. pagebuilder/translation/translatable-fields.ts +65 -0
  172. pagebuilder/translation/translation.test.ts +167 -0
  173. pagebuilder/utils/api.ts +12 -0
  174. pagebuilder/utils/datetime.ts +25 -0
  175. pagebuilder/utils/decorative-image.ts +15 -0
  176. pagebuilder/utils/editorSnapshot.ts +39 -0
  177. pagebuilder/utils/markdown.ts +160 -0
  178. pagebuilder/utils/mediaApi.ts +112 -0
  179. pagebuilder/utils/mediaFormat.ts +19 -0
  180. pagebuilder/utils/pagesApi.ts +111 -0
  181. pagebuilder/utils/parse-list.ts +7 -0
  182. pagebuilder/utils/request.ts +52 -0
  183. pagebuilder/utils/slugify.ts +9 -0
  184. pagebuilder/utils/types.ts +171 -0
  185. pagebuilder/utils/widgetUtils.ts +11 -0
  186. simple_module_pagebuilder-0.0.1.dist-info/METADATA +152 -0
  187. simple_module_pagebuilder-0.0.1.dist-info/RECORD +189 -0
  188. simple_module_pagebuilder-0.0.1.dist-info/WHEEL +4 -0
  189. simple_module_pagebuilder-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,65 @@
1
+ import type { Field } from '@puckeditor/core';
2
+
3
+ /** Puck field types whose values are free-form human copy worth translating. */
4
+ export const TRANSLATABLE_TEXT_TYPES: ReadonlySet<string> = new Set([
5
+ 'text',
6
+ 'textarea',
7
+ 'richtext',
8
+ ]);
9
+
10
+ // Key-based denylist: a `text`-typed field can still hold a URL, a colour or an
11
+ // id rather than copy (FeatureCards.items[].linkHref, Button.color). Applied on
12
+ // top of the type check so those are never sent to a translator even though
13
+ // Puck models them as plain text.
14
+ const DENYLIST_PATTERNS: readonly RegExp[] = [
15
+ /href$/i,
16
+ /url$/i,
17
+ /src$/i,
18
+ /colou?r$/i,
19
+ /bg$/i,
20
+ /^id$/i,
21
+ // `srcset` and `sizes` are responsive-image descriptors —
22
+ // "/img-800.png 800w, /img-1600.png 1600w" and "(max-width: 600px) 100vw".
23
+ // `/src$/i` is end-anchored so it does not catch `srcset`, and nothing
24
+ // catches `sizes`. A translator handed either returns something reflowed,
25
+ // and the browser then requests a URL that doesn't exist.
26
+ /^srcset$/i,
27
+ /^sizes$/i,
28
+ // CSS lengths: `height` (Html, Iframe), `bodyMaxWidth` (MediaObject). Any
29
+ // casing, so `maxWidth` and `minHeight` are covered too.
30
+ /(^|[a-z])(width|height)$/i,
31
+ // The Html widget's field is raw markup, not prose. Sending it out for
32
+ // translation gets the tags rewritten along with the words.
33
+ /^html$/i,
34
+ // camelCase technical identifiers (`questId`, `clusterId`). Anchored to a
35
+ // capital `I` so it matches a real `…Id` suffix and never a lowercase word
36
+ // that happens to end in "id" (`grid`, `valid`).
37
+ /Id$/,
38
+ // Numbered image-URL fields nested in arrays (DefinitionList's
39
+ // items[].sections[].image1). The digit suffix means `src`/`url` above don't
40
+ // match. Fully anchored so it hits exactly `image1` and never its genuine
41
+ // alt-text sibling `image1Alt`.
42
+ /^image\d+$/i,
43
+ ];
44
+
45
+ function isDenylistedKey(key: string): boolean {
46
+ return DENYLIST_PATTERNS.some((pattern) => pattern.test(key));
47
+ }
48
+
49
+ /**
50
+ * True when `field` (found at prop `key`) holds translatable copy.
51
+ *
52
+ * Upstream also threads a per-component set of image-field names through here,
53
+ * built from its `IMAGE_FIELDS` / `ARRAY_IMAGE_FIELDS` maps, because over there
54
+ * an image field is declared as `type: "text"` and only swapped for the picker
55
+ * when the config is assembled — so by name it is indistinguishable from copy.
56
+ *
57
+ * This module inlines `createImageField` at the field instead, which yields
58
+ * `type: "custom"`. That fails the type check on the first line, so image
59
+ * fields are excluded structurally and the whole map-plumbing is unnecessary
60
+ * here. Worth knowing if these are ever diffed against each other.
61
+ */
62
+ export function isTranslatableField(key: string, field: Field | undefined): boolean {
63
+ if (!field || !TRANSLATABLE_TEXT_TYPES.has(field.type)) return false;
64
+ return !isDenylistedKey(key);
65
+ }
@@ -0,0 +1,167 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { basePageConfig } from '../components/puckConfig';
4
+ import { applyTranslatedStrings, extractTranslatableStrings } from './index';
5
+
6
+ const config = basePageConfig as never;
7
+
8
+ const fixture = JSON.stringify({
9
+ content: [
10
+ { type: 'Heading', props: { id: 'h1', text: 'About us', level: 'h1', align: 'left' } },
11
+ { type: 'Text', props: { id: 't1', text: 'Welcome to **us**', size: 'base', align: 'left' } },
12
+ {
13
+ type: 'FeatureCards',
14
+ props: {
15
+ id: 'f1',
16
+ title: 'Features',
17
+ items: [{ title: 'Fast', description: 'Very fast', href: '/a', iconUrl: '/i.png' }],
18
+ },
19
+ },
20
+ { type: 'Image', props: { id: 'i1', src: '/pic.png', alt: 'A picture' } },
21
+ ],
22
+ root: { props: { title: 'The page', width: 'contained' } },
23
+ });
24
+
25
+ describe('extract / apply round trip', () => {
26
+ it('takes the copy and leaves urls, ids and images alone', () => {
27
+ const { strings } = extractTranslatableStrings(fixture, config);
28
+ expect(strings).toContain('About us');
29
+ expect(strings).toContain('Welcome to **us**');
30
+ expect(strings).toContain('Features');
31
+ expect(strings).toContain('Fast');
32
+ expect(strings).toContain('Very fast');
33
+ expect(strings).toContain('A picture');
34
+ // `href` is denylisted by key; `src` and `iconUrl` are picker fields, so
35
+ // they are `type: "custom"` and never text-typed to begin with.
36
+ expect(strings).not.toContain('/a');
37
+ expect(strings).not.toContain('/i.png');
38
+ expect(strings).not.toContain('/pic.png');
39
+ });
40
+
41
+ it('reproduces the document when the strings are fed straight back', () => {
42
+ const { strings, paths } = extractTranslatableStrings(fixture, config);
43
+ expect(JSON.parse(applyTranslatedStrings(fixture, paths, strings))).toEqual(
44
+ JSON.parse(fixture),
45
+ );
46
+ });
47
+
48
+ it('writes each translation to the place it came from', () => {
49
+ const { strings, paths } = extractTranslatableStrings(fixture, config);
50
+ const out = JSON.parse(
51
+ applyTranslatedStrings(
52
+ fixture,
53
+ paths,
54
+ strings.map((s) => `[de]${s}`),
55
+ ),
56
+ );
57
+ expect(out.content[0].props.text).toBe('[de]About us');
58
+ expect(out.content[2].props.items[0].description).toBe('[de]Very fast');
59
+ expect(out.content[2].props.items[0].href).toBe('/a');
60
+ expect(out.content[3].props.src).toBe('/pic.png');
61
+ });
62
+
63
+ it('leaves the original in place for a null or missing translation', () => {
64
+ // A backend deserializing into a nullable list can hand back a literal
65
+ // `null` element, and a short array yields `undefined`. Neither may
66
+ // overwrite real copy.
67
+ const { paths } = extractTranslatableStrings(fixture, config);
68
+ const original = JSON.parse(fixture);
69
+ const out = JSON.parse(
70
+ applyTranslatedStrings(fixture, paths, [null as unknown as string]),
71
+ );
72
+ expect(out.content[0].props.text).toBe(original.content[0].props.text);
73
+ });
74
+
75
+ it('survives content that is not valid JSON, on both halves', () => {
76
+ // The two are documented as exact inverses, so `apply` must not crash on
77
+ // input that `extract` handled by returning nothing.
78
+ expect(extractTranslatableStrings('not json', config).strings).toEqual([]);
79
+ expect(applyTranslatedStrings('not json', [], [])).toBe('not json');
80
+ });
81
+
82
+ it('leaves machine-readable text fields out of the translation set', () => {
83
+ // These are all `text`/`textarea` — indistinguishable from copy by field
84
+ // type. A translator handed a srcset returns something reflowed, and the
85
+ // browser then requests a URL that does not exist.
86
+ const technical = JSON.stringify({
87
+ content: [
88
+ {
89
+ type: 'Image',
90
+ props: {
91
+ id: 'i1',
92
+ src: '/a.png',
93
+ alt: 'Real copy',
94
+ srcset: '/a-800.png 800w, /a-1600.png 1600w',
95
+ sizes: '(max-width: 600px) 100vw, 50vw',
96
+ },
97
+ },
98
+ { type: 'Html', props: { id: 'x1', html: '<b>markup</b>', height: '400px' } },
99
+ ],
100
+ root: { props: { title: 'P', width: 'contained' } },
101
+ });
102
+
103
+ const { strings } = extractTranslatableStrings(technical, config);
104
+ expect(strings).toContain('Real copy');
105
+ expect(strings).not.toContain('/a-800.png 800w, /a-1600.png 1600w');
106
+ expect(strings).not.toContain('(max-width: 600px) 100vw, 50vw');
107
+ expect(strings).not.toContain('<b>markup</b>');
108
+ expect(strings).not.toContain('400px');
109
+ });
110
+ });
111
+
112
+ describe('blocks nested in a slot', () => {
113
+ // The case upstream has no equivalent for: it kept nested blocks in `zones`,
114
+ // whereas Columns now holds them in a slot inside an array item. A walker
115
+ // that only understands `array` fields would find the Columns block, see a
116
+ // slot it has no rule for, and silently skip everything an author put in it.
117
+ const nested = JSON.stringify({
118
+ content: [
119
+ {
120
+ type: 'Columns',
121
+ props: {
122
+ id: 'c1',
123
+ gap: 'md',
124
+ columns: [
125
+ {
126
+ width: 1,
127
+ content: [
128
+ { type: 'Heading', props: { id: 'nh', text: 'Nested title', level: 'h2' } },
129
+ ],
130
+ },
131
+ { width: 1, content: [] },
132
+ ],
133
+ },
134
+ },
135
+ ],
136
+ root: { props: { title: 'P', width: 'contained' } },
137
+ });
138
+
139
+ it('reaches copy inside a column', () => {
140
+ expect(extractTranslatableStrings(nested, config).strings).toContain('Nested title');
141
+ });
142
+
143
+ it('writes a translation back into the column it came from', () => {
144
+ const { strings, paths } = extractTranslatableStrings(nested, config);
145
+ const out = JSON.parse(
146
+ applyTranslatedStrings(
147
+ nested,
148
+ paths,
149
+ strings.map((s) => `[de]${s}`),
150
+ ),
151
+ );
152
+ expect(out.content[0].props.columns[0].content[0].props.text).toBe('[de]Nested title');
153
+ });
154
+ });
155
+
156
+ describe('pre-slots documents', () => {
157
+ it('still reaches blocks parked in a zones map', () => {
158
+ const zoned = JSON.stringify({
159
+ content: [{ type: 'Columns', props: { id: 'c1', gap: 'md', columns: [] } }],
160
+ zones: {
161
+ 'c1:col-0': [{ type: 'Heading', props: { id: 'zh', text: 'Zoned', level: 'h2' } }],
162
+ },
163
+ root: { props: { title: 'P', width: 'contained' } },
164
+ });
165
+ expect(extractTranslatableStrings(zoned, config).strings).toContain('Zoned');
166
+ });
167
+ });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Public entry point for the pagebuilder admin API client.
3
+ *
4
+ * The implementation is split across `types`, `request`, `pagesApi`, and
5
+ * `mediaApi`; this barrel keeps `import { savePage } from '../utils/api'`
6
+ * working for every consumer.
7
+ */
8
+
9
+ export * from './mediaApi';
10
+ export * from './pagesApi';
11
+ export { BASE, CSRF_COOKIE, readCookie, request } from './request';
12
+ export * from './types';
@@ -0,0 +1,25 @@
1
+ /** Conversions between ISO strings and `<input type="datetime-local">` values. */
2
+
3
+ /**
4
+ * Format an ISO string (with offset, from the server) into the local
5
+ * ``YYYY-MM-DDTHH:mm`` shape an ``<input type="datetime-local">``
6
+ * expects. Returns ``""`` for null so the input renders empty.
7
+ */
8
+ export function toLocalInput(iso: string | null): string {
9
+ if (!iso) return '';
10
+ const d = new Date(iso);
11
+ if (Number.isNaN(d.getTime())) return '';
12
+ const pad = (n: number) => String(n).padStart(2, '0');
13
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
14
+ }
15
+
16
+ /**
17
+ * Inverse of toLocalInput: take whatever a ``datetime-local`` produced
18
+ * (a naive local-time string) and emit an ISO string with offset that
19
+ * round-trips through the server's UTC normalisation.
20
+ */
21
+ export function fromLocalInput(value: string | null): string | null {
22
+ if (!value) return null;
23
+ const d = new Date(value);
24
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
25
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Returns the ARIA props that mark an image as decorative when alt is empty,
3
+ * or the empty object when alt is descriptive. Callers must still write
4
+ * `alt={imageAlt}` explicitly — biome's `useAltText` lint rule can't see
5
+ * spread attributes, and keeping `alt` at the call-site makes the intent
6
+ * visible to readers too.
7
+ */
8
+ export function decorativeAriaProps(
9
+ alt: string | undefined,
10
+ ): { 'aria-hidden': 'true'; role: 'presentation' } | Record<string, never> {
11
+ if (!alt || alt.trim() === '') {
12
+ return { 'aria-hidden': 'true', role: 'presentation' };
13
+ }
14
+ return {};
15
+ }
@@ -0,0 +1,39 @@
1
+ /** Dirty-tracking primitives for the page editor's autosave. */
2
+
3
+ import type { Data } from '@puckeditor/core';
4
+
5
+ export const AUTOSAVE_DEBOUNCE_MS = 2000;
6
+
7
+ export type SaveState = 'idle' | 'saving' | 'error';
8
+
9
+ export interface EditorSnapshot {
10
+ title: string;
11
+ slug: string;
12
+ metaDescription: string;
13
+ ogImage: string;
14
+ canonicalUrl: string;
15
+ indexInSearch: boolean;
16
+ jsonLdText: string;
17
+ data: Data;
18
+ }
19
+
20
+ export function snapshotKey(payload: EditorSnapshot): string {
21
+ return JSON.stringify(payload);
22
+ }
23
+
24
+ export function formatSaveLabel(
25
+ state: SaveState,
26
+ isDirty: boolean,
27
+ lastSavedAt: Date | null,
28
+ errorMessage: string | null,
29
+ ): string {
30
+ if (state === 'saving') return 'Saving…';
31
+ if (state === 'error') return errorMessage ? `Save failed: ${errorMessage}` : 'Save failed';
32
+ if (isDirty) return 'Unsaved changes';
33
+ if (lastSavedAt) {
34
+ const hh = String(lastSavedAt.getHours()).padStart(2, '0');
35
+ const mm = String(lastSavedAt.getMinutes()).padStart(2, '0');
36
+ return `Saved at ${hh}:${mm}`;
37
+ }
38
+ return 'Saved';
39
+ }
@@ -0,0 +1,160 @@
1
+ export type MarkdownBlock =
2
+ | { type: 'h1' | 'h2' | 'h3' | 'p'; text: string }
3
+ | { type: 'ul'; items: string[] };
4
+
5
+ export type ParseMarkdownBlocksOptions = {
6
+ /**
7
+ * Keep single newlines inside a paragraph (joined with "\n" instead of a
8
+ * space) so renderers can emit soft line breaks. Widget textareas want
9
+ * this — several rendered with `whitespace-pre-line` before markdown
10
+ * support, so a bare newline has always been a visible break there. The
11
+ * default (space-join) is standard markdown and what the HTML converter
12
+ * and the Markdown widget keep using.
13
+ */
14
+ softBreaks?: boolean;
15
+ };
16
+
17
+ export function parseMarkdownBlocks(
18
+ content: string,
19
+ opts?: ParseMarkdownBlocksOptions,
20
+ ): MarkdownBlock[] {
21
+ const joiner = opts?.softBreaks ? '\n' : ' ';
22
+ const lines = content.split(/\r?\n/);
23
+ const blocks: MarkdownBlock[] = [];
24
+ let paragraph: string[] = [];
25
+ let listItems: string[] = [];
26
+
27
+ const flushParagraph = () => {
28
+ if (paragraph.length > 0) {
29
+ blocks.push({ type: 'p', text: paragraph.join(joiner) });
30
+ paragraph = [];
31
+ }
32
+ };
33
+ const flushList = () => {
34
+ if (listItems.length > 0) {
35
+ blocks.push({ type: 'ul', items: listItems });
36
+ listItems = [];
37
+ }
38
+ };
39
+
40
+ for (const line of lines) {
41
+ if (!line.trim()) {
42
+ flushParagraph();
43
+ flushList();
44
+ continue;
45
+ }
46
+ if (line.startsWith('### ')) {
47
+ flushParagraph();
48
+ flushList();
49
+ blocks.push({ type: 'h3', text: line.slice(4) });
50
+ } else if (line.startsWith('## ')) {
51
+ flushParagraph();
52
+ flushList();
53
+ blocks.push({ type: 'h2', text: line.slice(3) });
54
+ } else if (line.startsWith('# ')) {
55
+ flushParagraph();
56
+ flushList();
57
+ blocks.push({ type: 'h1', text: line.slice(2) });
58
+ } else if (line.startsWith('- ')) {
59
+ flushParagraph();
60
+ listItems.push(line.slice(2));
61
+ } else {
62
+ flushList();
63
+ paragraph.push(line.trim());
64
+ }
65
+ }
66
+ flushParagraph();
67
+ flushList();
68
+ return blocks;
69
+ }
70
+
71
+ // Alternatives are ordered longest-marker-first so "***x***" is captured
72
+ // whole as bold italic. The single-asterisk closer carries a (?!\*) guard so
73
+ // a lone `*` never pairs with the first asterisk of a following `**` (e.g.
74
+ // "* and **" stays literal instead of becoming a spurious <em>).
75
+ const INLINE_PATTERN = /(\*\*\*[^*\n]+\*\*\*|\*\*[^*\n]+\*\*|\*[^*\n]+\*(?!\*)|`[^`\n]+`)/g;
76
+
77
+ export function escapeHtml(text: string): string {
78
+ return text
79
+ .replaceAll('&', '&amp;')
80
+ .replaceAll('<', '&lt;')
81
+ .replaceAll('>', '&gt;')
82
+ .replaceAll('"', '&quot;');
83
+ }
84
+
85
+ export type InlineToken = {
86
+ type: 'text' | 'strong' | 'em' | 'strongem' | 'code';
87
+ content: string;
88
+ };
89
+
90
+ /**
91
+ * Tokenize inline markdown (`***bold italic***`, `**strong**`, `*em*`,
92
+ * `` `code` ``) into a flat token list. Single source of truth for the
93
+ * converter's HTML output and the Markdown widget's React output — keep both
94
+ * on this function.
95
+ *
96
+ * Capture parity: with `String.split(capturingRegex)`, only ODD-indexed parts
97
+ * are captured separators. Even-indexed parts are literal text and are never
98
+ * treated as markup, even when they look like markers — never an empty
99
+ * <em>/<strong>/<code>. The triple-asterisk alternative must come first so
100
+ * "***bold***" captures whole as a strongem span instead of leaving leftover
101
+ * `*` markers that could mispair with later lone asterisks. Note the pattern
102
+ * genuinely captures single-asterisk spans containing spaces
103
+ * ("* 3 and 4 *" → em), which is intentional for this lightweight tokenizer.
104
+ */
105
+ export function parseInlineTokens(text: string): InlineToken[] {
106
+ const parts = text.split(INLINE_PATTERN);
107
+ const tokens: InlineToken[] = [];
108
+ for (let i = 0; i < parts.length; i++) {
109
+ const part = parts[i];
110
+ if (!part) continue;
111
+ if (i % 2 === 0) {
112
+ tokens.push({ type: 'text', content: part });
113
+ continue;
114
+ }
115
+ let type: InlineToken['type'];
116
+ let content: string;
117
+ if (part.startsWith('***')) {
118
+ type = 'strongem';
119
+ content = part.slice(3, -3);
120
+ } else if (part.startsWith('**')) {
121
+ type = 'strong';
122
+ content = part.slice(2, -2);
123
+ } else if (part.startsWith('`')) {
124
+ type = 'code';
125
+ content = part.slice(1, -1);
126
+ } else {
127
+ type = 'em';
128
+ content = part.slice(1, -1);
129
+ }
130
+ // Defensive: the pattern requires non-empty content between markers,
131
+ // but never emit an empty token — fall back to the literal text.
132
+ tokens.push(content ? { type, content } : { type: 'text', content: part });
133
+ }
134
+ return tokens;
135
+ }
136
+
137
+ function inlineMarkdownToHtml(text: string): string {
138
+ return parseInlineTokens(text)
139
+ .map((token) => {
140
+ const escaped = escapeHtml(token.content);
141
+ if (token.type === 'text') return escaped;
142
+ if (token.type === 'strongem') {
143
+ return `<strong><em>${escaped}</em></strong>`;
144
+ }
145
+ return `<${token.type}>${escaped}</${token.type}>`;
146
+ })
147
+ .join('');
148
+ }
149
+
150
+ export function markdownToHtml(content: string): string {
151
+ return parseMarkdownBlocks(content)
152
+ .map((block) => {
153
+ if (block.type === 'ul') {
154
+ const items = block.items.map((item) => `<li>${inlineMarkdownToHtml(item)}</li>`).join('');
155
+ return `<ul>${items}</ul>`;
156
+ }
157
+ return `<${block.type}>${inlineMarkdownToHtml(block.text)}</${block.type}>`;
158
+ })
159
+ .join('\n');
160
+ }
@@ -0,0 +1,112 @@
1
+ /** Media library calls, upload progress, and srcset helpers. */
2
+
3
+ import { BASE, CSRF_COOKIE, readCookie, request } from './request';
4
+ import type {
5
+ MediaAssetRead,
6
+ MediaListQuery,
7
+ MediaListResponse,
8
+ UploadMediaOptions,
9
+ } from './types';
10
+
11
+ function buildMediaQuery(params: MediaListQuery | undefined): string {
12
+ if (!params) return '';
13
+ const qs = new URLSearchParams();
14
+ if (params.search !== undefined && params.search !== '') qs.set('search', params.search);
15
+ if (params.content_type) qs.set('content_type', params.content_type);
16
+ // Explicitly preserve `folder=""` — that's how the API expresses the
17
+ // "Unfiled" bucket. URLSearchParams keeps empty values as `key=`.
18
+ if (params.folder !== undefined) qs.set('folder', params.folder);
19
+ if (params.min_size_bytes !== undefined) qs.set('min_size_bytes', String(params.min_size_bytes));
20
+ if (params.max_size_bytes !== undefined) qs.set('max_size_bytes', String(params.max_size_bytes));
21
+ if (params.cursor !== undefined) qs.set('cursor', String(params.cursor));
22
+ if (params.offset !== undefined) qs.set('offset', String(params.offset));
23
+ if (params.limit !== undefined) qs.set('limit', String(params.limit));
24
+ const q = qs.toString();
25
+ return q ? `?${q}` : '';
26
+ }
27
+
28
+ export const listMedia = (params?: MediaListQuery) =>
29
+ request<MediaListResponse>(`/uploads${buildMediaQuery(params)}`);
30
+
31
+ export const uploadMedia = (
32
+ file: File,
33
+ options: UploadMediaOptions = {},
34
+ ): Promise<MediaAssetRead> => {
35
+ const { folder, onProgress, signal } = options;
36
+ const body = new FormData();
37
+ body.append('file', file);
38
+ if (folder !== undefined && folder !== null && folder !== '') {
39
+ body.append('folder', folder);
40
+ }
41
+ // XHR (not fetch) is used here purely for the upload-progress event;
42
+ // fetch streams uploads in some browsers but exposes no progress
43
+ // callback. Everything else (CSRF header, cookies, error shape)
44
+ // mirrors the shared `request` helper.
45
+ if (!onProgress) {
46
+ return request<MediaAssetRead>('/uploads', { method: 'POST', body, signal });
47
+ }
48
+ return new Promise<MediaAssetRead>((resolve, reject) => {
49
+ const xhr = new XMLHttpRequest();
50
+ xhr.open('POST', `${BASE}/uploads`);
51
+ xhr.withCredentials = true;
52
+ xhr.responseType = 'text';
53
+ const token = readCookie(CSRF_COOKIE);
54
+ if (token) xhr.setRequestHeader('X-CSRF-Token', token);
55
+ xhr.setRequestHeader('Accept', 'application/json');
56
+ xhr.upload.onprogress = (e) => {
57
+ if (e.lengthComputable) onProgress(e.loaded, e.total);
58
+ };
59
+ xhr.onload = () => {
60
+ if (xhr.status >= 200 && xhr.status < 300) {
61
+ try {
62
+ resolve(JSON.parse(xhr.responseText) as MediaAssetRead);
63
+ } catch (err) {
64
+ reject(err);
65
+ }
66
+ } else {
67
+ reject(new Error(`Request failed (${xhr.status}): ${xhr.responseText}`));
68
+ }
69
+ };
70
+ xhr.onerror = () => reject(new Error('Network error during upload'));
71
+ xhr.onabort = () => reject(new DOMException('Aborted', 'AbortError'));
72
+ if (signal) {
73
+ if (signal.aborted) {
74
+ xhr.abort();
75
+ } else {
76
+ signal.addEventListener('abort', () => xhr.abort(), { once: true });
77
+ }
78
+ }
79
+ xhr.send(body);
80
+ });
81
+ };
82
+
83
+ export const deleteMedia = (id: number) => request<void>(`/uploads/${id}`, { method: 'DELETE' });
84
+
85
+ export const DEFAULT_IMAGE_SIZES = '(max-width: 768px) 100vw, 768px';
86
+
87
+ export function buildSrcset(asset: MediaAssetRead): string {
88
+ return Object.values(asset.variants)
89
+ .slice()
90
+ .sort((a, b) => a.width - b.width)
91
+ .map((v) => `${v.url} ${v.width}w`)
92
+ .join(', ');
93
+ }
94
+
95
+ // Cache of MediaAssetRead objects emitted by the MediaPicker, keyed by
96
+ // asset URL. The Image block's `resolveData` reads from this so the
97
+ // auto-fill round-trip doesn't have to refetch the full list that the
98
+ // picker just loaded. Falls back to a fresh `listMedia()` for URLs not
99
+ // in the cache (manual entry, page reload, etc.).
100
+ const pickedAssetCache = new Map<string, MediaAssetRead>();
101
+
102
+ export function rememberPickedAsset(asset: MediaAssetRead): void {
103
+ pickedAssetCache.set(asset.url, asset);
104
+ }
105
+
106
+ export async function lookupAsset(url: string): Promise<MediaAssetRead | undefined> {
107
+ const cached = pickedAssetCache.get(url);
108
+ if (cached) return cached;
109
+ const { items } = await listMedia();
110
+ for (const item of items) pickedAssetCache.set(item.url, item);
111
+ return pickedAssetCache.get(url);
112
+ }
@@ -0,0 +1,19 @@
1
+ /** Formatting and parsing helpers for the media library UI. */
2
+
3
+ export function formatBytes(bytes: number) {
4
+ if (bytes < 1024) return `${bytes} B`;
5
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
6
+ return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
7
+ }
8
+
9
+ export function parseKB(raw: string): number | undefined {
10
+ const trimmed = raw.trim();
11
+ if (!trimmed) return undefined;
12
+ const n = Number(trimmed);
13
+ if (!Number.isFinite(n) || n < 0) return undefined;
14
+ return Math.round(n * 1024);
15
+ }
16
+
17
+ export function uploadKey(file: File): string {
18
+ return `${file.name}:${file.size}:${file.lastModified}:${Math.random().toString(36).slice(2, 8)}`;
19
+ }