caracms 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -2
  3. package/bin/caracms.mjs +174 -0
  4. package/bin/gate.mjs +275 -0
  5. package/bin/init.mjs +712 -0
  6. package/bin/media.mjs +662 -0
  7. package/bin/site-runner.mjs +61 -0
  8. package/bin/templates/Page.astro.txt +31 -0
  9. package/bin/templates/TextBlock.astro.txt +13 -0
  10. package/bin/templates/caracms.config.ts.txt +22 -0
  11. package/bin/templates/catch-all.astro.txt +25 -0
  12. package/bin/templates/health.ts.txt +6 -0
  13. package/bin/templates/railway.ts.txt +147 -0
  14. package/package.json +66 -4
  15. package/skills/caracms-adopt/SKILL.md +508 -0
  16. package/skills/caracms-adopt/look-same.mjs +957 -0
  17. package/skills/caracms-blocks/SKILL.md +376 -0
  18. package/skills/caracms-content/SKILL.md +201 -0
  19. package/skills/caracms-tina/SKILL.md +427 -0
  20. package/skills/caracms-tina/field-kinds.md +88 -0
  21. package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
  22. package/src/block-runs.ts +84 -0
  23. package/src/cards.ts +158 -0
  24. package/src/cli/schemas.ts +122 -0
  25. package/src/cli/site.ts +66 -0
  26. package/src/cli/validate.ts +471 -0
  27. package/src/code-routes-disk.ts +67 -0
  28. package/src/code-routes.ts +134 -0
  29. package/src/columns.ts +39 -0
  30. package/src/components/Block.astro +46 -0
  31. package/src/components/BlockRunTree.astro +45 -0
  32. package/src/components/Blocks.astro +139 -0
  33. package/src/components/Buttons.astro +116 -0
  34. package/src/components/Fonts.astro +14 -0
  35. package/src/components/Form.astro +221 -0
  36. package/src/components/Head.astro +62 -0
  37. package/src/components/Link.astro +68 -0
  38. package/src/components/Lockup.astro +116 -0
  39. package/src/components/Media.astro +124 -0
  40. package/src/components/MediaPlayer.astro +102 -0
  41. package/src/components/RenderDocument.astro +64 -0
  42. package/src/components/RichText.astro +59 -0
  43. package/src/components/Text.astro +81 -0
  44. package/src/components/attributes.ts +33 -0
  45. package/src/components/descriptor.ts +45 -0
  46. package/src/components/form-client.ts +182 -0
  47. package/src/components/media-player.ts +46 -0
  48. package/src/components/media-props.ts +35 -0
  49. package/src/components/sitemap.xml.ts +36 -0
  50. package/src/config.ts +825 -0
  51. package/src/content-schema.ts +166 -0
  52. package/src/content.ts +422 -0
  53. package/src/document.ts +579 -0
  54. package/src/editor/README.md +405 -0
  55. package/src/editor/addresses.ts +61 -0
  56. package/src/editor/brand.ts +118 -0
  57. package/src/editor/client/block-list/BlockList.tsx +342 -0
  58. package/src/editor/client/block-list/Picker.tsx +105 -0
  59. package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
  60. package/src/editor/client/block-list/insert-points.ts +56 -0
  61. package/src/editor/client/block-list/link.ts +71 -0
  62. package/src/editor/client/block-list/reorder.ts +105 -0
  63. package/src/editor/client/block-list/summary.ts +118 -0
  64. package/src/editor/client/collections/CollectionGrid.tsx +143 -0
  65. package/src/editor/client/collections/CollectionTable.tsx +406 -0
  66. package/src/editor/client/collections/table.ts +270 -0
  67. package/src/editor/client/context.tsx +27 -0
  68. package/src/editor/client/documents/DocMenu.tsx +154 -0
  69. package/src/editor/client/documents/actions.ts +192 -0
  70. package/src/editor/client/documents/dialogs.tsx +687 -0
  71. package/src/editor/client/editor.ts +995 -0
  72. package/src/editor/client/fields/ButtonsField.tsx +87 -0
  73. package/src/editor/client/fields/DateField.tsx +200 -0
  74. package/src/editor/client/fields/FieldControl.tsx +61 -0
  75. package/src/editor/client/fields/FieldFrame.tsx +109 -0
  76. package/src/editor/client/fields/GroupField.tsx +113 -0
  77. package/src/editor/client/fields/HiddenToggle.tsx +51 -0
  78. package/src/editor/client/fields/LinkField.tsx +307 -0
  79. package/src/editor/client/fields/ListField.tsx +642 -0
  80. package/src/editor/client/fields/LockupField.tsx +365 -0
  81. package/src/editor/client/fields/MediaField.tsx +823 -0
  82. package/src/editor/client/fields/PageFields.tsx +82 -0
  83. package/src/editor/client/fields/ReferenceField.tsx +209 -0
  84. package/src/editor/client/fields/RichTextField.tsx +306 -0
  85. package/src/editor/client/fields/SelectField.tsx +63 -0
  86. package/src/editor/client/fields/SeoGroup.tsx +282 -0
  87. package/src/editor/client/fields/SimpleFields.tsx +89 -0
  88. package/src/editor/client/fields/TextField.tsx +51 -0
  89. package/src/editor/client/fields/date.ts +73 -0
  90. package/src/editor/client/fields/host.tsx +172 -0
  91. package/src/editor/client/fields/list-summary.ts +85 -0
  92. package/src/editor/client/fields/lockup-rules.ts +181 -0
  93. package/src/editor/client/fields/parts.tsx +458 -0
  94. package/src/editor/client/fields/rich-text-commands.ts +183 -0
  95. package/src/editor/client/fields/rich-text-doc.ts +156 -0
  96. package/src/editor/client/fields/rich-text-schema.ts +224 -0
  97. package/src/editor/client/fields/stale.ts +58 -0
  98. package/src/editor/client/inbox/InboxStage.tsx +272 -0
  99. package/src/editor/client/inbox/store.ts +311 -0
  100. package/src/editor/client/live/format.ts +77 -0
  101. package/src/editor/client/live/marks.tsx +90 -0
  102. package/src/editor/client/live/notices.tsx +213 -0
  103. package/src/editor/client/live/publish.tsx +617 -0
  104. package/src/editor/client/live/session.tsx +162 -0
  105. package/src/editor/client/main.tsx +12 -0
  106. package/src/editor/client/media/LibraryStage.tsx +75 -0
  107. package/src/editor/client/media/ReplaceDialog.tsx +355 -0
  108. package/src/editor/client/media/store.ts +618 -0
  109. package/src/editor/client/nav/DocRow.tsx +122 -0
  110. package/src/editor/client/nav/search.ts +71 -0
  111. package/src/editor/client/nav/tree.ts +93 -0
  112. package/src/editor/client/overlays/OverlayMount.tsx +26 -0
  113. package/src/editor/client/overlays/editor-marks.ts +159 -0
  114. package/src/editor/client/overlays/index.ts +50 -0
  115. package/src/editor/client/overlays/layer.ts +437 -0
  116. package/src/editor/client/overlays/overlay-css.ts +177 -0
  117. package/src/editor/client/overlays/rich-text-bar.ts +717 -0
  118. package/src/editor/client/pane/Pane.tsx +91 -0
  119. package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
  120. package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
  121. package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
  122. package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
  123. package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
  124. package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
  125. package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
  126. package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
  127. package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
  128. package/src/editor/client/pane/parts.tsx +179 -0
  129. package/src/editor/client/pane/switcher.tsx +165 -0
  130. package/src/editor/client/preview/driver.ts +640 -0
  131. package/src/editor/client/preview/field-text.ts +71 -0
  132. package/src/editor/client/preview/follow-links.ts +234 -0
  133. package/src/editor/client/preview/layout.ts +37 -0
  134. package/src/editor/client/preview/link-target.ts +50 -0
  135. package/src/editor/client/preview/markers.ts +86 -0
  136. package/src/editor/client/preview/page-edit.ts +632 -0
  137. package/src/editor/client/preview/page-editor-registry.ts +63 -0
  138. package/src/editor/client/preview/page-editor.ts +235 -0
  139. package/src/editor/client/preview/render-sequencer.ts +113 -0
  140. package/src/editor/client/preview/selection.ts +116 -0
  141. package/src/editor/client/preview/warnings.ts +61 -0
  142. package/src/editor/client/shell/App.tsx +101 -0
  143. package/src/editor/client/shell/Bar.tsx +197 -0
  144. package/src/editor/client/shell/Brand.tsx +33 -0
  145. package/src/editor/client/shell/Icons.tsx +87 -0
  146. package/src/editor/client/shell/NarrowNote.tsx +47 -0
  147. package/src/editor/client/shell/Popovers.tsx +47 -0
  148. package/src/editor/client/shell/Stage.tsx +132 -0
  149. package/src/editor/client/shell/Toast.tsx +61 -0
  150. package/src/editor/client/state/document-store.ts +465 -0
  151. package/src/editor/client/state/keeper.ts +713 -0
  152. package/src/editor/client/state/live.ts +257 -0
  153. package/src/editor/client/state/schema.ts +42 -0
  154. package/src/editor/client/state/store.ts +39 -0
  155. package/src/editor/fonts/OFL-GeistMono.txt +93 -0
  156. package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
  157. package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
  158. package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
  159. package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
  160. package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
  161. package/src/editor/middleware.ts +62 -0
  162. package/src/editor/routes/_live.ts +26 -0
  163. package/src/editor/routes/admin.astro +80 -0
  164. package/src/editor/routes/create.ts +19 -0
  165. package/src/editor/routes/delete.ts +19 -0
  166. package/src/editor/routes/document.ts +39 -0
  167. package/src/editor/routes/documents.ts +30 -0
  168. package/src/editor/routes/github.ts +23 -0
  169. package/src/editor/routes/history.ts +9 -0
  170. package/src/editor/routes/lock.ts +9 -0
  171. package/src/editor/routes/login/assets.d.ts +10 -0
  172. package/src/editor/routes/login/login.ts +43 -0
  173. package/src/editor/routes/media.ts +16 -0
  174. package/src/editor/routes/move.ts +17 -0
  175. package/src/editor/routes/notice.ts +9 -0
  176. package/src/editor/routes/preview.astro +77 -0
  177. package/src/editor/routes/publish.ts +9 -0
  178. package/src/editor/routes/status.ts +9 -0
  179. package/src/editor/routes/submissions.ts +16 -0
  180. package/src/editor/routes/try-again.ts +9 -0
  181. package/src/editor/routes/write.ts +18 -0
  182. package/src/editor/server/admin-props.ts +151 -0
  183. package/src/editor/server/cards.ts +61 -0
  184. package/src/editor/server/create.ts +414 -0
  185. package/src/editor/server/drafts-store.ts +127 -0
  186. package/src/editor/server/media/alt.ts +107 -0
  187. package/src/editor/server/media/bucket.ts +295 -0
  188. package/src/editor/server/media/config.ts +78 -0
  189. package/src/editor/server/media/handler.ts +221 -0
  190. package/src/editor/server/media/library.ts +254 -0
  191. package/src/editor/server/media/memory.ts +184 -0
  192. package/src/editor/server/media/mux.ts +241 -0
  193. package/src/editor/server/media/sniff.ts +599 -0
  194. package/src/editor/server/media/upload.ts +175 -0
  195. package/src/editor/server/mode.ts +63 -0
  196. package/src/editor/server/move.ts +295 -0
  197. package/src/editor/server/render.tsx +9 -0
  198. package/src/editor/server/replace.ts +72 -0
  199. package/src/editor/server/settings.ts +35 -0
  200. package/src/editor/server/write.ts +366 -0
  201. package/src/editor/styles/editor.css +502 -0
  202. package/src/editor/styles/fonts.css +40 -0
  203. package/src/editor/styles/levels/README.md +4 -0
  204. package/src/editor/styles/levels/collections.css +99 -0
  205. package/src/editor/styles/levels/document-level.css +10 -0
  206. package/src/editor/styles/levels/documents.css +9 -0
  207. package/src/editor/styles/levels/fields.css +171 -0
  208. package/src/editor/styles/levels/files.css +95 -0
  209. package/src/editor/styles/levels/live.css +10 -0
  210. package/src/editor/styles/levels/m5a-documents.css +51 -0
  211. package/src/editor/styles/levels/m6.css +82 -0
  212. package/src/editor/styles/levels/media.css +175 -0
  213. package/src/editor/styles/levels/switcher.css +3 -0
  214. package/src/editor/styles/tokens.css +114 -0
  215. package/src/editor/tsconfig.json +16 -0
  216. package/src/editor/types.ts +881 -0
  217. package/src/editor/ui-state.ts +113 -0
  218. package/src/email.ts +67 -0
  219. package/src/fields.ts +1129 -0
  220. package/src/files-route.ts +201 -0
  221. package/src/files.ts +116 -0
  222. package/src/forms/email.ts +149 -0
  223. package/src/forms/endpoint.ts +347 -0
  224. package/src/forms/inbox.ts +273 -0
  225. package/src/forms/rate-limit.ts +130 -0
  226. package/src/forms/route.ts +24 -0
  227. package/src/forms/rules.ts +278 -0
  228. package/src/forms/store.ts +486 -0
  229. package/src/head.ts +125 -0
  230. package/src/index.ts +434 -0
  231. package/src/json-source.ts +116 -0
  232. package/src/links.ts +121 -0
  233. package/src/live/README.md +43 -0
  234. package/src/live/auth/allow-rules.ts +70 -0
  235. package/src/live/auth/allow.ts +128 -0
  236. package/src/live/auth/auth.ts +155 -0
  237. package/src/live/auth/email.ts +84 -0
  238. package/src/live/auth/guard.ts +111 -0
  239. package/src/live/auth/index.ts +68 -0
  240. package/src/live/auth/links.ts +101 -0
  241. package/src/live/auth/login.ts +245 -0
  242. package/src/live/auth/middleware.ts +53 -0
  243. package/src/live/auth/request.ts +56 -0
  244. package/src/live/auth/routes.ts +9 -0
  245. package/src/live/auth/session.ts +181 -0
  246. package/src/live/auth/views.ts +355 -0
  247. package/src/live/autosaves.ts +306 -0
  248. package/src/live/backend.ts +652 -0
  249. package/src/live/branches.ts +19 -0
  250. package/src/live/drafts-store.ts +591 -0
  251. package/src/live/editor.ts +465 -0
  252. package/src/live/git.ts +1021 -0
  253. package/src/live/github.ts +271 -0
  254. package/src/live/locals.ts +24 -0
  255. package/src/live/locks.ts +273 -0
  256. package/src/live/merge.ts +197 -0
  257. package/src/live/messages.ts +151 -0
  258. package/src/live/mode.ts +210 -0
  259. package/src/live/redis.ts +533 -0
  260. package/src/live/runtime.ts +164 -0
  261. package/src/live/status.ts +109 -0
  262. package/src/live/sweep.ts +228 -0
  263. package/src/live/timings.ts +47 -0
  264. package/src/live/webhook.ts +39 -0
  265. package/src/lockup-css.ts +58 -0
  266. package/src/measure.ts +145 -0
  267. package/src/media-value.ts +239 -0
  268. package/src/media.ts +255 -0
  269. package/src/read.ts +256 -0
  270. package/src/redirect-middleware.ts +49 -0
  271. package/src/redirects.ts +76 -0
  272. package/src/relink.ts +293 -0
  273. package/src/reserved.ts +24 -0
  274. package/src/rich-text.ts +690 -0
  275. package/src/schema-key.ts +63 -0
  276. package/src/seo.ts +124 -0
  277. package/src/serialize.ts +697 -0
  278. package/src/skipped.ts +35 -0
  279. package/src/slug.ts +121 -0
  280. package/src/text-styles.ts +62 -0
  281. package/src/virtual.d.ts +28 -0
@@ -0,0 +1,82 @@
1
+ /**
2
+ * A section builder's page fields (M6c G2): the collection's own `fields`, beside the
3
+ * page's blocks (a game's tile details, the header's "side"). One group at the document
4
+ * level, under the block list and above Search and sharing, open by default (they're the
5
+ * page's content, where Search and sharing falls back to the site's defaults). Sidebar
6
+ * only: nothing here is on the page, so nothing is picked from it.
7
+ *
8
+ * Closed, it carries a warning mark when any page field (a group's fields too) is warned
9
+ * (the editor-friction batch, item 5): the section a group field is drawn as
10
+ * (GroupField.tsx → FieldSection), so the two look and behave the same.
11
+ *
12
+ * The values live in the document store beside the blocks (`PAGE_SCOPE`,
13
+ * document-store.ts), picked at the document level as `page.<path>` (so they never ring
14
+ * the page's blocks), and are written as the file's top-level keys (serialize.ts).
15
+ */
16
+ import { useMemo } from 'react';
17
+ import { titleCase, type Field } from '../../../fields.ts';
18
+ import { useEditor, useStore } from '../context.tsx';
19
+ import type { Editor, View } from '../editor.ts';
20
+ import { pageFieldsLabel } from '../documents/actions.ts';
21
+ import { PAGE_SCOPE } from '../state/document-store.ts';
22
+ import { formRows } from '../pane/levels/FormLevel.tsx';
23
+ import { FieldControl } from './FieldControl.tsx';
24
+ import { FieldSection, useReveal } from './GroupField.tsx';
25
+ import { fieldsWarnings, FieldHostProvider, parseBase, useWarningContext, type FieldHost } from './host.tsx';
26
+
27
+ const PREFIX = `${PAGE_SCOPE}.`;
28
+
29
+ /** The page fields' host: picked at the document level as `page.<path>`. */
30
+ function pageHost(editor: Editor, raw: Record<string, unknown> | undefined, readOnly: boolean): FieldHost {
31
+ return {
32
+ scope: PAGE_SCOPE,
33
+ picked: (view: View) => (view.level === 'document' && view.field?.startsWith(PREFIX) ? view.field.slice(PREFIX.length) : null),
34
+ pick(path) {
35
+ if (editor.view.get().field !== `${PREFIX}${path}`) editor.view.update({ field: `${PREFIX}${path}`, picked: false });
36
+ },
37
+ set: (path, value, options) => editor.doc.setField(PAGE_SCOPE, path, value, options),
38
+ removeItem(path, index, label) {
39
+ editor.doc.removeItem(PAGE_SCOPE, path, index, { label });
40
+ editor.announce(`${label}. Undo with Command Z.`);
41
+ },
42
+ raw: (name) => (raw && Object.hasOwn(raw, name) ? raw[name] : undefined),
43
+ readOnly,
44
+ };
45
+ }
46
+
47
+ /** The group, for the open section builder; null when its collection has no page fields. */
48
+ export function PageFields() {
49
+ const editor = useEditor();
50
+ const ref = useStore(editor.doc.state, (s) => s.ref);
51
+ const base = useStore(editor.doc.state, (s) => s.base);
52
+ const data = useStore(editor.doc.state, (s) => s.document.data);
53
+ const readOnly = useStore(editor.access, (a) => a.readOnly);
54
+ const context = useWarningContext();
55
+ // A path picked inside (Publish's list of images without alt text) opens the section.
56
+ const reveal = useReveal((v) => (v.level === 'document' && v.field?.startsWith(PREFIX) ? v.field : null));
57
+ const raw = useMemo(() => {
58
+ const parsed = parseBase(base);
59
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : undefined;
60
+ }, [base]);
61
+ const host = useMemo(() => pageHost(editor, raw, readOnly), [editor, raw, readOnly]);
62
+ const collection = editor.site.collections.find((c) => c.name === ref.collection);
63
+ const fields = collection?.kind === 'sections' ? collection.fields : null;
64
+ const warned = useMemo(() => (fields && data ? fieldsWarnings(fields, data, raw, context).length : 0), [fields, data, raw, context]);
65
+ if (!collection || !fields || !data) return null;
66
+ return (
67
+ <FieldSection label={pageFieldsLabel(collection)} className="page-fields" warnings={warned} reveal={reveal} data={{ 'data-page-fields': '' }}>
68
+ <FieldHostProvider host={host}>
69
+ {formRows(fields).map((row) => {
70
+ const control = ([name, field]: [string, Field]) => <FieldControl key={`${PAGE_SCOPE}:${name}`} name={name} label={field.label ?? titleCase(name)} field={field} value={data[name]} />;
71
+ return row.length === 2 ? (
72
+ <div key={`${PAGE_SCOPE}:${row[0]![0]}+`} className="two">
73
+ {row.map(control)}
74
+ </div>
75
+ ) : (
76
+ control(row[0]!)
77
+ );
78
+ })}
79
+ </FieldHostProvider>
80
+ </FieldSection>
81
+ );
82
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Reference (M5a Decision 6): the path of a document in collection `to`. The
3
+ * picker (the mockup's `.lpop` with a find input) lists that collection's
4
+ * documents by title, searched as the ⌘K switcher searches (nav/search.ts). A
5
+ * reference to a document that doesn't exist is shown as missing, with a warning;
6
+ * the page gets null for it.
7
+ *
8
+ * `RefRow` and `RefPicker` are the pieces a list of references uses too (ListField).
9
+ */
10
+ import { useMemo, useState, type KeyboardEvent, type ReactNode } from 'react';
11
+ import type { ReferenceField as ReferenceDescriptor } from '../../../fields.ts';
12
+ import { useEditor, useStore } from '../context.tsx';
13
+ import { docIcon } from '../nav/DocRow.tsx';
14
+ import { searchDocuments } from '../nav/search.ts';
15
+ import { Icon } from '../shell/Icons.tsx';
16
+ import { sameDoc, type ListedDocument } from '../../types.ts';
17
+ import { FieldFrame } from './FieldFrame.tsx';
18
+ import { useFieldWarnings, useHost } from './host.tsx';
19
+ import { Warnings } from './parts.tsx';
20
+
21
+ /** A collection's label, and its documents in listing order. */
22
+ export function useCollection(name: string): { label: string; icon: string; documents: ListedDocument[] } {
23
+ const editor = useEditor();
24
+ const documents = useStore(editor.documents);
25
+ return useMemo(() => {
26
+ const collection = editor.site.collections.find((c) => c.name === name);
27
+ return {
28
+ label: collection?.label ?? name,
29
+ icon: docIcon(collection),
30
+ documents: documents.filter((d) => d.collection === name),
31
+ };
32
+ }, [editor.site.collections, documents, name]);
33
+ }
34
+
35
+ /** One referenced document: its title and address, or "gone" when it doesn't exist. */
36
+ export function RefRow(props: {
37
+ to: string;
38
+ path: string;
39
+ grip?: boolean;
40
+ onRemove: () => void;
41
+ removeLabel?: string;
42
+ /** For a row that reorders by keys (a list of references): focusable, with its keys. */
43
+ rowProps?: Record<string, unknown>;
44
+ /** Extra classes on the row (a list of references' drag classes). */
45
+ rowClass?: string;
46
+ extra?: ReactNode;
47
+ }) {
48
+ const { label, icon, documents } = useCollection(props.to);
49
+ const doc = documents.find((d) => d.path === props.path);
50
+ const remove = (
51
+ <button type="button" className="ib" aria-label={props.removeLabel ?? `Remove ${doc ? doc.label || doc.title : 'the missing one'}`} onClick={props.onRemove}>
52
+ <Icon name="x" size="sm" />
53
+ </button>
54
+ );
55
+ const grip = props.grip ? (
56
+ <span className="grip" aria-hidden="true">
57
+ <Icon name="grip" />
58
+ </span>
59
+ ) : null;
60
+ const extra = props.rowClass ? ` ${props.rowClass}` : '';
61
+ if (!doc) {
62
+ return (
63
+ <div className={`ref is-missing${extra}`} data-ref={props.path} {...props.rowProps}>
64
+ {grip}
65
+ <Icon name="warn" />
66
+ <span className="t">
67
+ <b>One that's gone</b>
68
+ <small>
69
+ {label} / {props.path} · skipped on the site
70
+ </small>
71
+ </span>
72
+ {remove}
73
+ </div>
74
+ );
75
+ }
76
+ return (
77
+ <div className={`ref${extra}`} data-ref={props.path} {...props.rowProps}>
78
+ {grip}
79
+ <Icon name={icon} />
80
+ <span className="t">
81
+ <b>{doc.label || doc.title}</b>
82
+ <small>{doc.url ?? doc.path}</small>
83
+ </span>
84
+ {props.extra}
85
+ {remove}
86
+ </div>
87
+ );
88
+ }
89
+
90
+ /** The picker: the collection's documents by title, filtered by what's typed. `taken` are shown as added. */
91
+ export function RefPicker(props: { to: string; taken?: readonly string[]; onPick: (path: string) => void; onClose: () => void }) {
92
+ const editor = useEditor();
93
+ const all = useStore(editor.documents);
94
+ const open = useStore(editor.doc.state, (s) => s.ref);
95
+ const { label, icon } = useCollection(props.to);
96
+ const [query, setQuery] = useState('');
97
+ const hits = useMemo(() => {
98
+ const collection = editor.site.collections.filter((c) => c.name === props.to);
99
+ const docs = all.filter((d) => d.collection === props.to && !sameDoc(d, open));
100
+ if (!query.trim()) return docs;
101
+ return searchDocuments(query, docs, collection).flatMap((g) => g.hits.map((h) => h.doc));
102
+ }, [all, editor.site.collections, open, props.to, query]);
103
+ const shown = hits.slice(0, 6);
104
+ const taken = new Set(props.taken ?? []);
105
+ const first = shown.find((d) => !taken.has(d.path));
106
+ const onKey = (event: KeyboardEvent<HTMLInputElement>) => {
107
+ if (event.key === 'Enter') {
108
+ event.preventDefault();
109
+ if (first) props.onPick(first.path);
110
+ } else if (event.key === 'Escape') {
111
+ event.preventDefault();
112
+ props.onClose();
113
+ }
114
+ };
115
+ return (
116
+ <div className="lpop" role="dialog" aria-label={`Choose from ${label}`}>
117
+ <div className="find">
118
+ <Icon name="search" />
119
+ <input
120
+ className="input"
121
+ value={query}
122
+ placeholder={`Find in ${label}`}
123
+ aria-label={`Find in ${label}`}
124
+ autoComplete="off"
125
+ spellCheck={false}
126
+ autoFocus
127
+ data-ref-find=""
128
+ onChange={(e) => setQuery(e.target.value)}
129
+ onKeyDown={onKey}
130
+ onBlur={(e) => {
131
+ if (!e.relatedTarget || !(e.currentTarget.closest('.lpop')?.contains(e.relatedTarget as Node) ?? false)) props.onClose();
132
+ }}
133
+ />
134
+ </div>
135
+ <div className="lpop-list">
136
+ {shown.map((d) => {
137
+ const on = taken.has(d.path);
138
+ return (
139
+ <button
140
+ key={d.path}
141
+ type="button"
142
+ className={`drow${d === first ? ' is-active' : ''}`}
143
+ data-path-pick={d.path}
144
+ disabled={on}
145
+ onMouseDown={(e) => e.preventDefault()}
146
+ onClick={() => props.onPick(d.path)}
147
+ >
148
+ <Icon name={icon} />
149
+ <span className="nm">{d.label || d.title}</span>
150
+ <span className="meta">
151
+ {/* M9 Decision 7: a hidden page is still offered (the reference is legitimate), marked as the pages list marks it. */}
152
+ {d.hidden ? (
153
+ <span className="hmark" data-mark="hidden" title={d.hiddenBy ? 'Hidden from the site with the page it’s under' : 'Hidden from the site'}>
154
+ Hidden
155
+ </span>
156
+ ) : null}
157
+ <span>{on ? 'Added' : <span className="mono">{d.url ?? d.path}</span>}</span>
158
+ </span>
159
+ </button>
160
+ );
161
+ })}
162
+ {shown.length === 0 ? <div className="sw-more">{query.trim() ? `Nothing in ${label} called “${query.trim()}”.` : `${label} has nothing in it yet.`}</div> : null}
163
+ {hits.length > shown.length ? <div className="sw-more">{hits.length - shown.length} more: keep typing</div> : null}
164
+ </div>
165
+ </div>
166
+ );
167
+ }
168
+
169
+ export function ReferenceField(props: { name: string; label: string; field: ReferenceDescriptor; value: unknown }) {
170
+ const host = useHost();
171
+ const { label: collectionLabel } = useCollection(props.field.to);
172
+ const [picking, setPicking] = useState(false);
173
+ const value = typeof props.value === 'string' && props.value !== '' ? props.value : null;
174
+ const warnings = useFieldWarnings(props.name, props.field, props.value);
175
+ const choose = (
176
+ <div className="lnk" style={{ marginTop: value ? 'var(--space-2)' : 0 }}>
177
+ <button
178
+ type="button"
179
+ className="linkbtn subtle"
180
+ data-path={props.name}
181
+ aria-expanded={picking}
182
+ // While the picker is open, keep focus in its find input so this click closes it (and doesn't blur-close, then reopen).
183
+ onMouseDown={(e) => picking && e.preventDefault()}
184
+ onClick={() => setPicking(!picking)}
185
+ >
186
+ <Icon name={value ? 'search' : 'plus'} size="xs" />
187
+ {value ? 'Choose another' : `Choose from ${collectionLabel}`}
188
+ </button>
189
+ {picking ? (
190
+ <RefPicker
191
+ to={props.field.to}
192
+ taken={value ? [value] : []}
193
+ onPick={(path) => {
194
+ host.set(props.name, path);
195
+ setPicking(false);
196
+ }}
197
+ onClose={() => setPicking(false)}
198
+ />
199
+ ) : null}
200
+ </div>
201
+ );
202
+ return (
203
+ <FieldFrame blockKey={host.scope} name={props.name} label={props.label} help={props.field.help} labelId={`f-${host.scope}-${props.name}-l`}>
204
+ {value ? <RefRow to={props.field.to} path={value} onRemove={() => host.set(props.name, null)} /> : <div className="list-empty">None chosen.</div>}
205
+ <Warnings warnings={warnings} />
206
+ {choose}
207
+ </FieldFrame>
208
+ );
209
+ }
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Rich text (M5a Decision 3; the mockup's #rich-text, #rich-link, #rich-warn):
3
+ * Tiptap, editor-only, in the sidebar (Decision 13), with a schema of exactly the
4
+ * field's nodes and marks (rich-text-schema.ts: the keys, paste, no input rules).
5
+ *
6
+ * - **The toolbar:** the paragraph style (only when the field allows headings:
7
+ * Paragraph, Heading 2, and Heading 3 where allowed; never Heading 1), Bold,
8
+ * Italic, Bulleted list, Numbered list, and Link (when the field allows links). A heading
9
+ * field (M9 Decision 11) has Bold only.
10
+ * ⌘B, ⌘I and ⌘K do the same.
11
+ * - **The link popover** (⌘K or the toolbar): find a document by title or address,
12
+ * or type an address; picking a document stores its URL (Decision 4). An address
13
+ * that fails the link rule says the rule's message, and is kept as typed if used.
14
+ * With nothing selected, picking inserts the document's title as the link's text.
15
+ * - **What's stored** is the field's value after every change (rich-text-doc.ts →
16
+ * fromDoc, through Phase 1's parse), so the file gets Decision 3's format: one
17
+ * block per line, `**`, `*`, `[text](href)`, `\n`, literal characters escaped.
18
+ * - **Warnings** (amber): a kind the field doesn't allow or a link where links are
19
+ * off (from the file, until the field is edited: quiet, with a chip in the head),
20
+ * a link that fails the rule or goes to a document that doesn't exist.
21
+ *
22
+ * Before the editor mounts (the server render, hydration), the same frame shows
23
+ * the text as HTML, so nothing flickers in (no skeletons).
24
+ */
25
+ import { Editor } from '@tiptap/core';
26
+ import { useEffect, useRef, useState, type FocusEvent } from 'react';
27
+ import { richTextOptions, type RichTextField as RichTextDescriptor } from '../../../fields.ts';
28
+ import { fileAddress, fileKeyOf } from '../../../files.ts';
29
+ import type { LibraryFile } from '../../types.ts';
30
+ import { richTextHtml, type RichTextValue } from '../../../rich-text.ts';
31
+ import { Icon } from '../shell/Icons.tsx';
32
+ import { FieldFrame } from './FieldFrame.tsx';
33
+ import { useFieldWarnings, useHost } from './host.tsx';
34
+ import { documentAt, FieldIcon, LinkPopover, useLinkable, Warnings } from './parts.tsx';
35
+ import { setBlockStyle, toggleList } from './rich-text-commands.ts';
36
+ import { fromDoc, toDoc, type PMNode } from './rich-text-doc.ts';
37
+ import { clipboardTextParser, richTextExtensions } from './rich-text-schema.ts';
38
+
39
+ const STYLES = [
40
+ { v: 'p', name: 'Paragraph', cls: 'st-p' },
41
+ { v: 'h2', name: 'Heading 2', cls: 'st-h2' },
42
+ { v: 'h3', name: 'Heading 3', cls: 'st-h3' },
43
+ ] as const;
44
+
45
+ const isLinkKey = (event: KeyboardEvent) => (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'k';
46
+
47
+ export function RichTextField(props: { name: string; label: string; field: RichTextDescriptor; value: unknown }) {
48
+ const host = useHost();
49
+ const { name, field } = props;
50
+ const id = `f-${host.scope}-${name}`.replace(/\./g, '-');
51
+ const value = (Array.isArray(props.value) ? props.value : []) as RichTextValue;
52
+ const options = richTextOptions(field);
53
+ const optionsKey = `${field.headings.join(',')}|${field.links}|${!!field.heading}`;
54
+ const linkable = useLinkable();
55
+ const warnings = useFieldWarnings(name, field, props.value);
56
+
57
+ const mount = useRef<HTMLDivElement>(null);
58
+ const editorRef = useRef<Editor | null>(null);
59
+ /** The value this field last stored (or was given): a change from outside is anything else. */
60
+ const lastValue = useRef(JSON.stringify(value));
61
+ const latest = useRef({ host, name, options, openLink: () => {} });
62
+ const [initialHtml] = useState(() => `<div class="prose rt">${richTextHtml(value)}</div>`);
63
+ const [, setTick] = useState(0);
64
+ const [link, setLink] = useState<{ query: string; editing: boolean } | null>(null);
65
+ const [styleMenu, setStyleMenu] = useState(false);
66
+
67
+ const openLink = () => {
68
+ const ed = editorRef.current;
69
+ if (!ed || !field.links || !ed.isEditable) return;
70
+ const href = ed.getAttributes('link').href as string | undefined;
71
+ setStyleMenu(false);
72
+ setLink({ query: href ?? '', editing: href !== undefined });
73
+ };
74
+ latest.current = { host, name, options, openLink };
75
+
76
+ useEffect(() => {
77
+ const el = mount.current;
78
+ if (!el) return;
79
+ el.textContent = '';
80
+ const ed = new Editor({
81
+ element: el,
82
+ extensions: richTextExtensions(latest.current.options),
83
+ content: toDoc(JSON.parse(lastValue.current) as RichTextValue, latest.current.options),
84
+ injectCSS: false,
85
+ enableInputRules: false,
86
+ enablePasteRules: false,
87
+ editable: !latest.current.host.readOnly,
88
+ editorProps: {
89
+ attributes: {
90
+ class: 'prose rt',
91
+ role: 'textbox',
92
+ 'aria-multiline': 'true',
93
+ 'aria-labelledby': `${id}-l`,
94
+ 'data-path': name,
95
+ },
96
+ clipboardTextParser,
97
+ handleKeyDown(_view, event) {
98
+ if (!isLinkKey(event)) return false;
99
+ // ⌘K is the link here, not the document switcher (App.tsx listens on window).
100
+ event.preventDefault();
101
+ event.stopPropagation();
102
+ latest.current.openLink();
103
+ return true;
104
+ },
105
+ },
106
+ onUpdate: ({ editor }) => {
107
+ const next = fromDoc(editor.getJSON() as PMNode, latest.current.options);
108
+ const json = JSON.stringify(next);
109
+ if (json === lastValue.current) return;
110
+ lastValue.current = json;
111
+ latest.current.host.set(latest.current.name, next);
112
+ },
113
+ onTransaction: () => setTick((t) => t + 1),
114
+ });
115
+ editorRef.current = ed;
116
+ setTick((t) => t + 1);
117
+ return () => {
118
+ ed.destroy();
119
+ editorRef.current = null;
120
+ };
121
+ // The editor is made once per field (its schema is the field's).
122
+ }, [optionsKey, host.scope, name]);
123
+
124
+ // A value changed from outside (Undo of a list row holding this field, a document re-read): show it.
125
+ useEffect(() => {
126
+ const ed = editorRef.current;
127
+ const json = JSON.stringify(value);
128
+ if (!ed || json === lastValue.current) return;
129
+ lastValue.current = json;
130
+ if (JSON.stringify(fromDoc(ed.getJSON() as PMNode, options)) !== json) ed.commands.setContent(toDoc(value, options), { emitUpdate: false });
131
+ }, [props.value]);
132
+
133
+ useEffect(() => {
134
+ editorRef.current?.setEditable(!host.readOnly, false);
135
+ }, [host.readOnly]);
136
+
137
+ const ed = editorRef.current;
138
+ const active = (nameOrAttrs: string, attrs?: Record<string, unknown>) => !!ed && ed.isActive(nameOrAttrs, attrs);
139
+ const style = active('heading', { level: 2 }) ? 'h2' : active('heading', { level: 3 }) ? 'h3' : 'p';
140
+ const allowed = STYLES.filter((s) => s.v === 'p' || field.headings.includes(s.v));
141
+ const run = (fn: (e: Editor) => void) => {
142
+ if (!ed) return;
143
+ fn(ed);
144
+ };
145
+ const keep = (event: { preventDefault(): void }) => event.preventDefault();
146
+
147
+ const pick = (href: string, words?: string) => {
148
+ // M6c G4: a link to a file keeps its popover (and focus), so the text isn't focused.
149
+ const stay = !!fileKeyOf(href);
150
+ run((e) => {
151
+ const inLink = e.isActive('link');
152
+ const chain = stay ? e.chain() : e.chain().focus();
153
+ if (e.state.selection.empty && !inLink) {
154
+ const doc = documentAt(href, linkable);
155
+ const text = words ?? (doc ? doc.label || doc.title : href);
156
+ const from = e.state.selection.from;
157
+ chain.insertContent({ type: 'text', text, marks: [{ type: 'link', attrs: { href } }] }).run();
158
+ if (stay) e.commands.setTextSelection({ from, to: from + text.length });
159
+ } else {
160
+ chain.extendMarkRange('link').setMark('link', { href }).run();
161
+ }
162
+ });
163
+ // M6c G4: a link to a file keeps its popover, showing the file and (a PDF) New tab / Download.
164
+ setLink(stay ? { query: href, editing: true } : null);
165
+ };
166
+ const pickFile = (file: LibraryFile) => pick(fileAddress(file.key), file.name);
167
+ const closeLink = () => {
168
+ setLink(null);
169
+ ed?.commands.focus();
170
+ };
171
+ const onPopoverBlur = (event: FocusEvent<HTMLDivElement>) => {
172
+ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setLink(null);
173
+ };
174
+
175
+ const shownAsText = warnings.filter((w) => w.code === 'disallowed-kind').length;
176
+ const quiet = warnings.filter((w) => w.code === 'disallowed-kind' || w.code === 'link-off' || w.code === 'italic-off');
177
+ const loud = warnings.filter((w) => w.code !== 'disallowed-kind' && w.code !== 'link-off' && w.code !== 'italic-off');
178
+ const disabled = host.readOnly;
179
+
180
+ return (
181
+ <FieldFrame
182
+ blockKey={host.scope}
183
+ name={name}
184
+ label={props.label}
185
+ help={props.field.help}
186
+ labelId={`${id}-l`}
187
+ chips={
188
+ shownAsText ? (
189
+ <span className="chip warn">
190
+ <Icon name="warn" size="xs" />
191
+ {shownAsText === 1 ? 'One block' : `${shownAsText} blocks`} shown as text
192
+ </span>
193
+ ) : null
194
+ }
195
+ >
196
+ <div className="prose-wrap" data-rich-text={name}>
197
+ <div className="tb" role="toolbar" aria-label="Formatting">
198
+ {field.headings.length ? (
199
+ <>
200
+ <button
201
+ type="button"
202
+ className="tb-style"
203
+ aria-haspopup="menu"
204
+ aria-expanded={styleMenu}
205
+ aria-label={`Paragraph style: ${STYLES.find((s) => s.v === style)!.name}`}
206
+ disabled={disabled}
207
+ onMouseDown={keep}
208
+ onClick={() => setStyleMenu(!styleMenu)}
209
+ >
210
+ {STYLES.find((s) => s.v === style)!.name}
211
+ <Icon name="chev-d" size="xs" />
212
+ </button>
213
+ <span className="sep" aria-hidden="true" />
214
+ </>
215
+ ) : null}
216
+ <button type="button" aria-label="Bold" aria-pressed={active('bold')} disabled={disabled} onMouseDown={keep} onClick={() => run((e) => e.chain().focus().toggleMark('bold').run())}>
217
+ <b>B</b>
218
+ </button>
219
+ {/* M9 Decision 11: a heading field is bold and line breaks only. */}
220
+ {field.heading ? null : (
221
+ <>
222
+ <button type="button" aria-label="Italic" aria-pressed={active('italic')} disabled={disabled} onMouseDown={keep} onClick={() => run((e) => e.chain().focus().toggleMark('italic').run())}>
223
+ <i className="tb-i">I</i>
224
+ </button>
225
+ <span className="sep" aria-hidden="true" />
226
+ <button
227
+ type="button"
228
+ aria-label="Bulleted list"
229
+ aria-pressed={active('bulletList')}
230
+ disabled={disabled}
231
+ onMouseDown={keep}
232
+ onClick={() => run((e) => toggleList(e, 'bulletList'))}
233
+ >
234
+ <FieldIcon name="ul" size="sm" />
235
+ </button>
236
+ <button
237
+ type="button"
238
+ aria-label="Numbered list"
239
+ aria-pressed={active('orderedList')}
240
+ disabled={disabled}
241
+ onMouseDown={keep}
242
+ onClick={() => run((e) => toggleList(e, 'orderedList'))}
243
+ >
244
+ <FieldIcon name="ol" size="sm" />
245
+ </button>
246
+ </>
247
+ )}
248
+ {field.links ? (
249
+ <>
250
+ <span className="sep" aria-hidden="true" />
251
+ <button type="button" aria-label="Link" aria-pressed={!!link || active('link')} disabled={disabled} onMouseDown={keep} onClick={() => (link ? closeLink() : openLink())}>
252
+ <FieldIcon name="link" size="sm" />
253
+ </button>
254
+ </>
255
+ ) : null}
256
+ {styleMenu ? (
257
+ <div className="menu" role="menu" onKeyDown={(e) => e.key === 'Escape' && (e.preventDefault(), setStyleMenu(false), ed?.commands.focus())}>
258
+ {allowed.map((s) => (
259
+ <button
260
+ key={s.v}
261
+ type="button"
262
+ role="menuitemradio"
263
+ aria-checked={style === s.v}
264
+ onMouseDown={keep}
265
+ onClick={() => {
266
+ setStyleMenu(false);
267
+ run((e) => setBlockStyle(e, s.v));
268
+ }}
269
+ >
270
+ <span className={s.cls}>{s.name}</span>
271
+ {style === s.v ? (
272
+ <span className="k">
273
+ <Icon name="check" size="sm" />
274
+ </span>
275
+ ) : null}
276
+ </button>
277
+ ))}
278
+ </div>
279
+ ) : null}
280
+ </div>
281
+ <div ref={mount} className="rt-mount" suppressHydrationWarning dangerouslySetInnerHTML={{ __html: initialHtml }} />
282
+ {link ? (
283
+ <LinkPopover
284
+ onBlur={onPopoverBlur}
285
+ withFind
286
+ query={link.query}
287
+ onQuery={(q) => setLink({ ...link, query: q })}
288
+ onPick={(href) => pick(href)}
289
+ onPickFile={pickFile}
290
+ onClose={closeLink}
291
+ onRemove={
292
+ link.editing
293
+ ? () => {
294
+ run((e) => e.chain().focus().extendMarkRange('link').unsetMark('link').run());
295
+ setLink(null);
296
+ }
297
+ : undefined
298
+ }
299
+ />
300
+ ) : null}
301
+ </div>
302
+ {quiet.length ? <Warnings warnings={quiet} quiet /> : null}
303
+ <Warnings warnings={loud} />
304
+ </FieldFrame>
305
+ );
306
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * One of a fixed list (Decision 12). Edited in the sidebar only.
3
+ *
4
+ * M6: a select whose options are the site's forms (the starter's Form block picks one
5
+ * by its id, Decision 17) is the form picker (the mockup's #form-block): it says where the
6
+ * form's fields come from, and under it how many fields the chosen form has and who gets
7
+ * its email (Site Settings → Form emails, as drafted).
8
+ */
9
+ import { looksLikeEmail } from '../../../forms/rules.ts';
10
+ import type { SelectField as SelectDescriptor } from '../../../fields.ts';
11
+ import { useEditor } from '../context.tsx';
12
+ import type { Editor } from '../editor.ts';
13
+ import { Icon } from '../shell/Icons.tsx';
14
+ import { FieldFrame } from './FieldFrame.tsx';
15
+ import { useFieldHost } from './host.tsx';
16
+ import { useSiteSettings } from './SeoGroup.tsx';
17
+
18
+ /** Whether a select picks one of the site's forms: its options are exactly the forms' ids. */
19
+ export function isFormPicker(editor: Pick<Editor, 'forms'>, field: SelectDescriptor): boolean {
20
+ const ids = editor.forms.map((f) => f.id).sort();
21
+ const values = field.options.map((o) => o.value).sort();
22
+ return ids.length > 0 && ids.length === values.length && ids.every((id, i) => id === values[i]);
23
+ }
24
+
25
+ export function SelectField(props: { blockKey: string; name: string; label: string; field: SelectDescriptor; value: unknown }) {
26
+ const editor = useEditor();
27
+ const host = useFieldHost();
28
+ const id = `f-${props.blockKey}-${props.name}`.replace(/\./g, '-');
29
+ const set = (v: string) => (host ? host.set(props.name, v) : editor.doc.setField(props.blockKey, props.name, v));
30
+ const value = typeof props.value === 'string' ? props.value : props.field.default;
31
+ const forms = isFormPicker(editor, props.field);
32
+ return (
33
+ <FieldFrame blockKey={props.blockKey} name={props.name} label={props.label} help={props.field.help} htmlFor={id}>
34
+ <select className="input" id={id} data-path={props.name} value={value} onChange={(e) => set(e.target.value)}>
35
+ {props.field.options.map((o) => (
36
+ <option key={o.value} value={o.value}>
37
+ {o.label}
38
+ </option>
39
+ ))}
40
+ </select>
41
+ {forms ? <FormPickerHint formId={value} /> : null}
42
+ </FieldFrame>
43
+ );
44
+ }
45
+
46
+ function FormPickerHint({ formId }: { formId: string | undefined }) {
47
+ const editor = useEditor();
48
+ const settings = useSiteSettings();
49
+ const form = editor.forms.find((f) => f.id === formId);
50
+ const ok = (settings?.formRecipients[formId ?? ''] ?? []).filter(looksLikeEmail);
51
+ const to = !settings ? null : ok.length === 0 ? 'no one yet' : ok.length === 1 ? ok[0]! : `${ok.length} addresses`;
52
+ return (
53
+ <>
54
+ <p className="field-help">Who gets its email: Site Settings › Form emails.</p>
55
+ {form ? (
56
+ <p className="field-hint" data-form-hint={form.id}>
57
+ <Icon name="mail" size="xs" />
58
+ {form.fields.length} fields{to ? ` · its email goes to ${to}` : ''}
59
+ </p>
60
+ ) : null}
61
+ </>
62
+ );
63
+ }