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.
- pagebuilder/__init__.py +0 -0
- pagebuilder/components/DiffSummary.tsx +107 -0
- pagebuilder/components/ScheduledBadge.tsx +34 -0
- pagebuilder/components/StatusBadge.tsx +21 -0
- pagebuilder/components/blockRegistry.test.ts +56 -0
- pagebuilder/components/blockRegistry.ts +80 -0
- pagebuilder/components/blocks/Button.tsx +142 -0
- pagebuilder/components/blocks/Columns.tsx +64 -0
- pagebuilder/components/blocks/Heading.tsx +92 -0
- pagebuilder/components/blocks/Image.tsx +125 -0
- pagebuilder/components/blocks/MediaPicker.tsx +178 -0
- pagebuilder/components/blocks/Spacer.tsx +31 -0
- pagebuilder/components/blocks/Text.tsx +80 -0
- pagebuilder/components/editor/PageEditorToolbar.tsx +169 -0
- pagebuilder/components/editor/RevisionHistoryPanel.tsx +95 -0
- pagebuilder/components/editor/SchedulePanel.tsx +85 -0
- pagebuilder/components/editor/SeoSettingsPanel.tsx +119 -0
- pagebuilder/components/layoutPuckConfig.tsx +109 -0
- pagebuilder/components/media/MediaFilters.tsx +83 -0
- pagebuilder/components/media/MediaFolderSidebar.tsx +97 -0
- pagebuilder/components/media/MediaGrid.tsx +76 -0
- pagebuilder/components/media/MediaHeader.tsx +37 -0
- pagebuilder/components/media/MediaUploadQueue.tsx +103 -0
- pagebuilder/components/media/types.ts +20 -0
- pagebuilder/components/migrateContent.test.ts +146 -0
- pagebuilder/components/migrateContent.ts +135 -0
- pagebuilder/components/puckConfig.test.tsx +118 -0
- pagebuilder/components/puckConfig.tsx +121 -0
- pagebuilder/components/widgetCatalog.test.tsx +123 -0
- pagebuilder/components/widgetCatalog.ts +208 -0
- pagebuilder/components/widgets/_internal/accent-text.tsx +60 -0
- pagebuilder/components/widgets/_internal/disclosure-chevron.tsx +14 -0
- pagebuilder/components/widgets/_internal/eyebrow-split-section.tsx +71 -0
- pagebuilder/components/widgets/_internal/link-arrow.tsx +23 -0
- pagebuilder/components/widgets/_internal/rich-text.tsx +243 -0
- pagebuilder/components/widgets/_internal/solid-icons.tsx +73 -0
- pagebuilder/components/widgets/_shared/background-media.tsx +74 -0
- pagebuilder/components/widgets/_shared/content-media.tsx +43 -0
- pagebuilder/components/widgets/_shared/cta-button.tsx +69 -0
- pagebuilder/components/widgets/_shared/empty-placeholder.tsx +15 -0
- pagebuilder/components/widgets/_shared/eyebrow-text.tsx +27 -0
- pagebuilder/components/widgets/_shared/heading.tsx +88 -0
- pagebuilder/components/widgets/_shared/index.ts +39 -0
- pagebuilder/components/widgets/_shared/mask.ts +54 -0
- pagebuilder/components/widgets/_shared/section.tsx +60 -0
- pagebuilder/components/widgets/_shared/social-icon.tsx +71 -0
- pagebuilder/components/widgets/_shared/text-link.tsx +47 -0
- pagebuilder/components/widgets/accordion-widget.tsx +64 -0
- pagebuilder/components/widgets/alert-widget.tsx +47 -0
- pagebuilder/components/widgets/app-store-badges-widget.tsx +91 -0
- pagebuilder/components/widgets/article-cards-render.tsx +132 -0
- pagebuilder/components/widgets/article-cards-widget.tsx +72 -0
- pagebuilder/components/widgets/call-to-action-render.tsx +285 -0
- pagebuilder/components/widgets/call-to-action-styles.ts +28 -0
- pagebuilder/components/widgets/call-to-action-widget.tsx +83 -0
- pagebuilder/components/widgets/carousel-widget.tsx +97 -0
- pagebuilder/components/widgets/centered-hero-widget.tsx +54 -0
- pagebuilder/components/widgets/colored-action-list-widget.tsx +117 -0
- pagebuilder/components/widgets/column-widget.tsx +63 -0
- pagebuilder/components/widgets/contact-cards-widget.tsx +172 -0
- pagebuilder/components/widgets/contact-form-render.tsx +293 -0
- pagebuilder/components/widgets/contact-form-widget.tsx +69 -0
- pagebuilder/components/widgets/container-widget.tsx +93 -0
- pagebuilder/components/widgets/definition-list-widget.tsx +144 -0
- pagebuilder/components/widgets/divider-widget.tsx +56 -0
- pagebuilder/components/widgets/eyebrow-section-widget.tsx +56 -0
- pagebuilder/components/widgets/faq-render.tsx +268 -0
- pagebuilder/components/widgets/faq-widget.tsx +64 -0
- pagebuilder/components/widgets/feature-card-widget.tsx +98 -0
- pagebuilder/components/widgets/feature-cards-render.tsx +258 -0
- pagebuilder/components/widgets/feature-cards-styles.ts +107 -0
- pagebuilder/components/widgets/feature-cards-widget.tsx +133 -0
- pagebuilder/components/widgets/feature-grid-widget.tsx +118 -0
- pagebuilder/components/widgets/feature-showcase-widget.tsx +118 -0
- pagebuilder/components/widgets/gallery-widget.tsx +139 -0
- pagebuilder/components/widgets/grid-widget.tsx +59 -0
- pagebuilder/components/widgets/hero-widget.tsx +292 -0
- pagebuilder/components/widgets/html-widget.tsx +42 -0
- pagebuilder/components/widgets/iframe-widget.tsx +44 -0
- pagebuilder/components/widgets/leaderboard-widget.tsx +203 -0
- pagebuilder/components/widgets/list-widget.tsx +56 -0
- pagebuilder/components/widgets/logo-cloud-widget.tsx +127 -0
- pagebuilder/components/widgets/markdown-widget.tsx +148 -0
- pagebuilder/components/widgets/media-object-layout.ts +156 -0
- pagebuilder/components/widgets/media-object-render.tsx +284 -0
- pagebuilder/components/widgets/media-object-text.tsx +202 -0
- pagebuilder/components/widgets/media-object-widget.tsx +176 -0
- pagebuilder/components/widgets/newsletter-widget.tsx +217 -0
- pagebuilder/components/widgets/page-header-widget.tsx +118 -0
- pagebuilder/components/widgets/prose-section-widget.tsx +99 -0
- pagebuilder/components/widgets/quote-widget.tsx +51 -0
- pagebuilder/components/widgets/row-widget.tsx +74 -0
- pagebuilder/components/widgets/section-intro-widget.tsx +143 -0
- pagebuilder/components/widgets/signup-banner-widget.tsx +50 -0
- pagebuilder/components/widgets/simple-action-list-widget.tsx +91 -0
- pagebuilder/components/widgets/site-footer-widget.tsx +97 -0
- pagebuilder/components/widgets/site-header-nav.tsx +153 -0
- pagebuilder/components/widgets/site-header-widget.tsx +182 -0
- pagebuilder/components/widgets/social-banner-widget.tsx +83 -0
- pagebuilder/components/widgets/stats-widget.tsx +200 -0
- pagebuilder/components/widgets/steps-widget.tsx +211 -0
- pagebuilder/components/widgets/story-cards-widget.tsx +182 -0
- pagebuilder/components/widgets/table-widget.tsx +86 -0
- pagebuilder/components/widgets/tabs-widget.tsx +95 -0
- pagebuilder/components/widgets/tags-widget.tsx +85 -0
- pagebuilder/components/widgets/testimonial-widget.tsx +102 -0
- pagebuilder/components/widgets/timeline-layout.ts +73 -0
- pagebuilder/components/widgets/timeline-render.tsx +294 -0
- pagebuilder/components/widgets/timeline-widget.tsx +106 -0
- pagebuilder/components/widgets/under-construction-widget.tsx +40 -0
- pagebuilder/components/widgets/video-widget.tsx +103 -0
- pagebuilder/components/widgets/welcome-widget.tsx +38 -0
- pagebuilder/contracts/__init__.py +57 -0
- pagebuilder/contracts/events.py +25 -0
- pagebuilder/contracts/schemas.py +239 -0
- pagebuilder/conversion/blocks-to-html.test.ts +105 -0
- pagebuilder/conversion/blocks-to-html.ts +102 -0
- pagebuilder/conversion/index.ts +2 -0
- pagebuilder/conversion/types.ts +5 -0
- pagebuilder/deps.py +26 -0
- pagebuilder/diff.py +129 -0
- pagebuilder/endpoints/__init__.py +0 -0
- pagebuilder/endpoints/api/__init__.py +21 -0
- pagebuilder/endpoints/api/_deps.py +21 -0
- pagebuilder/endpoints/api/layout.py +78 -0
- pagebuilder/endpoints/api/pages.py +85 -0
- pagebuilder/endpoints/api/revisions.py +79 -0
- pagebuilder/endpoints/api/uploads.py +92 -0
- pagebuilder/endpoints/api/workflow.py +124 -0
- pagebuilder/endpoints/seo.py +95 -0
- pagebuilder/endpoints/views.py +228 -0
- pagebuilder/fields/checkbox-field.tsx +22 -0
- pagebuilder/fields/file-picker/file-gallery.tsx +152 -0
- pagebuilder/fields/file-picker/image-picker-field.tsx +214 -0
- pagebuilder/fields/file-picker/index.ts +6 -0
- pagebuilder/fields/file-picker/types.ts +15 -0
- pagebuilder/fields/index.ts +9 -0
- pagebuilder/fields/media-adapter.ts +47 -0
- pagebuilder/hooks/useAutosave.ts +134 -0
- pagebuilder/hooks/useEditorForm.ts +193 -0
- pagebuilder/hooks/useMediaUploads.ts +96 -0
- pagebuilder/hooks/usePageRevisions.ts +59 -0
- pagebuilder/hooks/usePageSchedule.ts +72 -0
- pagebuilder/hooks/usePageWorkflow.ts +173 -0
- pagebuilder/layout_service.py +101 -0
- pagebuilder/media_images.py +185 -0
- pagebuilder/media_queries.py +92 -0
- pagebuilder/media_service.py +222 -0
- pagebuilder/models.py +209 -0
- pagebuilder/module.py +279 -0
- pagebuilder/package.json +18 -0
- pagebuilder/pages/LayoutEditor.tsx +183 -0
- pagebuilder/pages/MediaLibrary.tsx +249 -0
- pagebuilder/pages/PageEditor.tsx +181 -0
- pagebuilder/pages/PageList.tsx +129 -0
- pagebuilder/pages/PendingReview.tsx +128 -0
- pagebuilder/pages/PublicPage.tsx +126 -0
- pagebuilder/permissions.py +44 -0
- pagebuilder/py.typed +0 -0
- pagebuilder/security.py +230 -0
- pagebuilder/service/__init__.py +141 -0
- pagebuilder/service/_common.py +24 -0
- pagebuilder/service/_revisions.py +92 -0
- pagebuilder/service/_workflow.py +207 -0
- pagebuilder/services.py +19 -0
- pagebuilder/settings.py +161 -0
- pagebuilder/static/widgets-base.css +57 -0
- pagebuilder/translation/apply.ts +54 -0
- pagebuilder/translation/extract.ts +141 -0
- pagebuilder/translation/index.ts +20 -0
- pagebuilder/translation/translatable-fields.ts +65 -0
- pagebuilder/translation/translation.test.ts +167 -0
- pagebuilder/utils/api.ts +12 -0
- pagebuilder/utils/datetime.ts +25 -0
- pagebuilder/utils/decorative-image.ts +15 -0
- pagebuilder/utils/editorSnapshot.ts +39 -0
- pagebuilder/utils/markdown.ts +160 -0
- pagebuilder/utils/mediaApi.ts +112 -0
- pagebuilder/utils/mediaFormat.ts +19 -0
- pagebuilder/utils/pagesApi.ts +111 -0
- pagebuilder/utils/parse-list.ts +7 -0
- pagebuilder/utils/request.ts +52 -0
- pagebuilder/utils/slugify.ts +9 -0
- pagebuilder/utils/types.ts +171 -0
- pagebuilder/utils/widgetUtils.ts +11 -0
- simple_module_pagebuilder-0.0.1.dist-info/METADATA +152 -0
- simple_module_pagebuilder-0.0.1.dist-info/RECORD +189 -0
- simple_module_pagebuilder-0.0.1.dist-info/WHEEL +4 -0
- 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
|
+
});
|
pagebuilder/utils/api.ts
ADDED
|
@@ -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('&', '&')
|
|
80
|
+
.replaceAll('<', '<')
|
|
81
|
+
.replaceAll('>', '>')
|
|
82
|
+
.replaceAll('"', '"');
|
|
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
|
+
}
|