@coywolf/emdash 0.0.0-stage → 0.35.13

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 (261) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -2
  3. package/package.json +70 -5
  4. package/src/admin/admin-css.ts +57 -0
  5. package/src/admin/ai.tsx +915 -0
  6. package/src/admin/backups.tsx +613 -0
  7. package/src/admin/breadcrumbs.tsx +542 -0
  8. package/src/admin/codeBlocks.tsx +204 -0
  9. package/src/admin/customBlocks.tsx +321 -0
  10. package/src/admin/dirty.ts +20 -0
  11. package/src/admin/discovery.tsx +463 -0
  12. package/src/admin/download.ts +28 -0
  13. package/src/admin/feature-switches.tsx +124 -0
  14. package/src/admin/features.tsx +142 -0
  15. package/src/admin/files.tsx +791 -0
  16. package/src/admin/formUploads.tsx +445 -0
  17. package/src/admin/guides.tsx +18 -0
  18. package/src/admin/headings.tsx +262 -0
  19. package/src/admin/images.tsx +825 -0
  20. package/src/admin/index.tsx +62 -0
  21. package/src/admin/links.tsx +980 -0
  22. package/src/admin/performance.tsx +330 -0
  23. package/src/admin/preview.tsx +63 -0
  24. package/src/admin/redirects-removed.tsx +266 -0
  25. package/src/admin/redirects.tsx +470 -0
  26. package/src/admin/reviews.tsx +232 -0
  27. package/src/admin/robots-bots.tsx +647 -0
  28. package/src/admin/robots-shared.tsx +109 -0
  29. package/src/admin/robots-wizard.tsx +748 -0
  30. package/src/admin/robots.tsx +911 -0
  31. package/src/admin/save-bar.tsx +134 -0
  32. package/src/admin/schema.tsx +1187 -0
  33. package/src/admin/search.tsx +355 -0
  34. package/src/admin/settings-ui.tsx +87 -0
  35. package/src/admin/trash-prompt-core.ts +135 -0
  36. package/src/admin/trash-prompt.tsx +156 -0
  37. package/src/admin/usage.tsx +301 -0
  38. package/src/admin/videos-display.tsx +248 -0
  39. package/src/admin/videos.tsx +1202 -0
  40. package/src/admin/wpImport.tsx +1516 -0
  41. package/src/ai/entities.ts +59 -0
  42. package/src/ai/export.ts +61 -0
  43. package/src/ai/logic.test.ts +187 -0
  44. package/src/ai/logic.ts +606 -0
  45. package/src/ai/pack.ts +121 -0
  46. package/src/ai/prompts.ts +87 -0
  47. package/src/ai/providers.ts +276 -0
  48. package/src/ai/queue.test.ts +97 -0
  49. package/src/ai/routes.ts +359 -0
  50. package/src/ai/store.ts +301 -0
  51. package/src/ai/wikidata.test.ts +184 -0
  52. package/src/ai/wikidata.ts +87 -0
  53. package/src/ai/worker.ts +530 -0
  54. package/src/astro/astro-modules.d.ts +6 -0
  55. package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
  56. package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
  57. package/src/astro/codeBlocks/CodeBlock.astro +62 -0
  58. package/src/astro/codeBlocks/EmDashCode.astro +61 -0
  59. package/src/astro/customBlocks/CustomBlock.astro +64 -0
  60. package/src/astro/customBlocks/Details.astro +8 -0
  61. package/src/astro/customBlocks/Disclosure.astro +8 -0
  62. package/src/astro/customBlocks/Note.astro +8 -0
  63. package/src/astro/customBlocks/Podcast.astro +8 -0
  64. package/src/astro/customBlocks/Quote.astro +8 -0
  65. package/src/astro/customBlocks/Testimonial.astro +8 -0
  66. package/src/astro/files/FileCard.astro +103 -0
  67. package/src/astro/headings/HeadingBlock.astro +129 -0
  68. package/src/astro/headings/TableOfContents.astro +45 -0
  69. package/src/astro/headings/request.ts +19 -0
  70. package/src/astro/images/ResponsiveImage.astro +60 -0
  71. package/src/astro/index.ts +84 -0
  72. package/src/astro/reviews/CoywolfReview.astro +27 -0
  73. package/src/astro/reviews/Review.astro +49 -0
  74. package/src/astro/search/SearchBox.astro +61 -0
  75. package/src/astro/search/SearchBoxForm.astro +509 -0
  76. package/src/astro/tables/CaptionedTable.astro +72 -0
  77. package/src/astro/videos/CoywolfVideo.astro +448 -0
  78. package/src/backups/download.ts +112 -0
  79. package/src/backups/dump.test.mjs +287 -0
  80. package/src/backups/dump.ts +288 -0
  81. package/src/backups/module.ts +276 -0
  82. package/src/backups/pack.ts +33 -0
  83. package/src/backups/restore.test.mjs +103 -0
  84. package/src/backups/restore.ts +209 -0
  85. package/src/backups/store.ts +409 -0
  86. package/src/breadcrumbs/pack.ts +113 -0
  87. package/src/breadcrumbs/settings.ts +102 -0
  88. package/src/breadcrumbs/stamp.ts +40 -0
  89. package/src/breadcrumbs/trail.ts +123 -0
  90. package/src/codeBlocks/contrast.ts +179 -0
  91. package/src/codeBlocks/hljs.d.ts +5 -0
  92. package/src/codeBlocks/pack.ts +110 -0
  93. package/src/codeBlocks/render.test.ts +152 -0
  94. package/src/codeBlocks/render.ts +304 -0
  95. package/src/codeBlocks/settings.ts +18 -0
  96. package/src/codeBlocks/themes.generated.ts +304 -0
  97. package/src/codeBlocks/themes.ts +114 -0
  98. package/src/core/compose.ts +244 -0
  99. package/src/core/content-url.ts +888 -0
  100. package/src/core/csv.ts +119 -0
  101. package/src/core/d1-batch.ts +65 -0
  102. package/src/core/entry-docs.ts +175 -0
  103. package/src/core/features-module.ts +48 -0
  104. package/src/core/features.ts +392 -0
  105. package/src/core/generation.ts +88 -0
  106. package/src/core/guide-content.ts +166 -0
  107. package/src/core/module.ts +104 -0
  108. package/src/core/read-failures.ts +57 -0
  109. package/src/core/request-memo.ts +71 -0
  110. package/src/core/secrets.ts +83 -0
  111. package/src/core/settings-enhance.ts +74 -0
  112. package/src/core/site.ts +36 -0
  113. package/src/core/storage.ts +27 -0
  114. package/src/customBlocks/pack.ts +260 -0
  115. package/src/customBlocks/render.ts +424 -0
  116. package/src/customBlocks/rich.ts +313 -0
  117. package/src/customBlocks/settings.ts +46 -0
  118. package/src/discovery/build.ts +252 -0
  119. package/src/discovery/feed-build.ts +217 -0
  120. package/src/discovery/feed.ts +172 -0
  121. package/src/discovery/indexnow.ts +114 -0
  122. package/src/discovery/llms.ts +79 -0
  123. package/src/discovery/markdown.ts +294 -0
  124. package/src/discovery/memory-cache.ts +97 -0
  125. package/src/discovery/middleware.ts +186 -0
  126. package/src/discovery/module.ts +353 -0
  127. package/src/discovery/news.ts +99 -0
  128. package/src/discovery/pack.ts +58 -0
  129. package/src/discovery/settings.ts +91 -0
  130. package/src/discovery/urls.ts +35 -0
  131. package/src/env.d.ts +10 -0
  132. package/src/files/card.ts +231 -0
  133. package/src/files/counts.test.ts +114 -0
  134. package/src/files/counts.ts +60 -0
  135. package/src/files/export.ts +44 -0
  136. package/src/files/format.test.ts +93 -0
  137. package/src/files/format.ts +163 -0
  138. package/src/files/module.ts +596 -0
  139. package/src/files/pack.ts +92 -0
  140. package/src/files/r2-rules.test.ts +30 -0
  141. package/src/files/r2-rules.ts +41 -0
  142. package/src/files/range.ts +50 -0
  143. package/src/files/s3.ts +114 -0
  144. package/src/files/serve.ts +141 -0
  145. package/src/files/sigv4.test.ts +64 -0
  146. package/src/files/sigv4.ts +160 -0
  147. package/src/files/site.ts +137 -0
  148. package/src/files/walker.ts +58 -0
  149. package/src/formUploads/lib.ts +266 -0
  150. package/src/formUploads/pack.ts +55 -0
  151. package/src/formUploads/wrap.ts +592 -0
  152. package/src/forms.ts +16 -0
  153. package/src/headings/headings.test.ts +159 -0
  154. package/src/headings/pack.ts +154 -0
  155. package/src/headings/render.ts +139 -0
  156. package/src/headings/settings.ts +117 -0
  157. package/src/headings/slug.ts +68 -0
  158. package/src/headings/stamp.ts +142 -0
  159. package/src/headings/test-hooks.mjs +14 -0
  160. package/src/headings/toc.ts +48 -0
  161. package/src/images/cloudflare.ts +329 -0
  162. package/src/images/lib.ts +221 -0
  163. package/src/images/module.ts +372 -0
  164. package/src/images/pack.ts +804 -0
  165. package/src/images/pending.ts +37 -0
  166. package/src/images/settings.ts +48 -0
  167. package/src/images/setup.ts +192 -0
  168. package/src/images/variants-estimate.ts +162 -0
  169. package/src/images/variants-store.ts +475 -0
  170. package/src/images/variants.ts +311 -0
  171. package/src/index.ts +143 -0
  172. package/src/links/check.ts +101 -0
  173. package/src/links/classify.ts +270 -0
  174. package/src/links/export.ts +59 -0
  175. package/src/links/module.ts +652 -0
  176. package/src/links/pack.ts +43 -0
  177. package/src/links/pt.ts +325 -0
  178. package/src/links/store.ts +704 -0
  179. package/src/middleware.ts +287 -0
  180. package/src/modules.ts +53 -0
  181. package/src/options.ts +66 -0
  182. package/src/pageCache/lib.test.mjs +224 -0
  183. package/src/pageCache/lib.ts +274 -0
  184. package/src/pageCache/pack.ts +240 -0
  185. package/src/pageCache/usage-core.ts +152 -0
  186. package/src/pageCache/usage.ts +262 -0
  187. package/src/pageCache/warm.test.mjs +670 -0
  188. package/src/pageCache/warm.ts +722 -0
  189. package/src/redirects/middleware.ts +169 -0
  190. package/src/redirects/module.ts +124 -0
  191. package/src/redirects/pack.ts +89 -0
  192. package/src/redirects/removed-core.ts +151 -0
  193. package/src/redirects/removed.ts +293 -0
  194. package/src/redirects/rules.ts +355 -0
  195. package/src/redirects/slug-changes.ts +207 -0
  196. package/src/redirects/slug-core.test.ts +50 -0
  197. package/src/redirects/slug-core.ts +49 -0
  198. package/src/redirects/transfer.ts +92 -0
  199. package/src/reviews/lib.ts +624 -0
  200. package/src/reviews/pack.ts +135 -0
  201. package/src/reviews/schema.ts +128 -0
  202. package/src/reviews/settings.ts +26 -0
  203. package/src/robots/bots.ts +321 -0
  204. package/src/robots/data/bots.json +696 -0
  205. package/src/robots/data/verified.json +582 -0
  206. package/src/robots/directory.ts +26 -0
  207. package/src/robots/explain.ts +191 -0
  208. package/src/robots/guided.ts +300 -0
  209. package/src/robots/importer.ts +263 -0
  210. package/src/robots/middleware.ts +151 -0
  211. package/src/robots/module.ts +446 -0
  212. package/src/robots/pack.ts +69 -0
  213. package/src/robots/radar.ts +129 -0
  214. package/src/robots/rep.test.ts +335 -0
  215. package/src/robots/rep.ts +362 -0
  216. package/src/robots/rules.test.ts +251 -0
  217. package/src/robots/rules.ts +883 -0
  218. package/src/robots/transfer.ts +45 -0
  219. package/src/robots/validate.ts +524 -0
  220. package/src/schema/catalog.ts +146 -0
  221. package/src/schema/graph.test.mjs +522 -0
  222. package/src/schema/graph.ts +791 -0
  223. package/src/schema/module.ts +1138 -0
  224. package/src/schema/og-image.ts +100 -0
  225. package/src/schema/pack.ts +58 -0
  226. package/src/search/engine.ts +578 -0
  227. package/src/search/fallback.ts +80 -0
  228. package/src/search/live-cache.ts +116 -0
  229. package/src/search/live-client.ts +770 -0
  230. package/src/search/live-serve.ts +282 -0
  231. package/src/search/module.ts +261 -0
  232. package/src/search/pack.ts +57 -0
  233. package/src/search/query.ts +132 -0
  234. package/src/search/ratelimit-core.ts +118 -0
  235. package/src/search/ratelimit.ts +79 -0
  236. package/src/search/snippet.ts +135 -0
  237. package/src/search/title-index.ts +139 -0
  238. package/src/search/title-match.ts +192 -0
  239. package/src/shared.ts +71 -0
  240. package/src/version.ts +2 -0
  241. package/src/videos/date.ts +40 -0
  242. package/src/videos/engagement.ts +75 -0
  243. package/src/videos/lib.ts +679 -0
  244. package/src/videos/module.ts +1160 -0
  245. package/src/videos/pack.ts +187 -0
  246. package/src/videos/pause.ts +42 -0
  247. package/src/videos/poster.ts +324 -0
  248. package/src/videos/render.ts +336 -0
  249. package/src/videos/store.ts +551 -0
  250. package/src/videos/stream.ts +134 -0
  251. package/src/wpImport/convert.ts +702 -0
  252. package/src/wpImport/guests.ts +329 -0
  253. package/src/wpImport/gutenberg.ts +157 -0
  254. package/src/wpImport/markers.ts +66 -0
  255. package/src/wpImport/pack.ts +451 -0
  256. package/src/wpImport/parents.ts +202 -0
  257. package/src/wpImport/prepare.ts +592 -0
  258. package/src/wpImport/redirects.ts +358 -0
  259. package/src/wpImport/sources.ts +67 -0
  260. package/src/wpImport/stream.ts +160 -0
  261. package/src/wpImport/urls.ts +354 -0
@@ -0,0 +1,1187 @@
1
+ /**
2
+ * Schema admin page: Site Details (publisher), page and article types per
3
+ * collection, author profiles, per-entry overrides, robots / Open Graph
4
+ * settings, and a JSON-LD preview.
5
+ */
6
+ import { Badge, Banner, Button, Checkbox, Dialog, Input, Loader, Select, Switch, Tabs } from "@cloudflare/kumo";
7
+ import { ImageSquare, MagnifyingGlass, PencilSimple, Plus, Trash, TreeStructure } from "@phosphor-icons/react";
8
+ import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
9
+ import * as React from "react";
10
+
11
+ import { SaveBar, isDirty } from "./save-bar.js";
12
+ import { PartSwitches } from "./feature-switches.js";
13
+
14
+ const API = "/_emdash/api/plugins/coywolf-pack/schema";
15
+
16
+ type RowValue = string | Record<string, string>;
17
+ interface Row {
18
+ prop: string;
19
+ value: RowValue;
20
+ /** Person rows: only on the person's profile page (where they're the main subject). */
21
+ profileOnly?: boolean;
22
+ }
23
+ interface PropertyInput {
24
+ input?: string;
25
+ fields?: Record<string, { label: string; input: string }>;
26
+ }
27
+ interface TypeChoice {
28
+ pageType?: string;
29
+ articleType?: string;
30
+ }
31
+ interface SiteDetails {
32
+ publisherType: "organization" | "person";
33
+ organizationType?: string | null;
34
+ personBylineId?: string | null;
35
+ orgRows: Row[];
36
+ }
37
+ interface Collection {
38
+ slug: string;
39
+ label: string;
40
+ routable: boolean;
41
+ dated: boolean;
42
+ }
43
+ interface Config {
44
+ settings: Record<string, string | number | boolean>;
45
+ site: SiteDetails;
46
+ types: Record<string, TypeChoice>;
47
+ collections: Collection[];
48
+ features: Record<string, boolean>;
49
+ catalog: {
50
+ pageTypes: Array<[string, string]>;
51
+ articleTypes: Array<[string, string]>;
52
+ organization: string[];
53
+ organizationTypes: Array<[string, string]>;
54
+ person: string[];
55
+ inputs: Record<string, PropertyInput>;
56
+ };
57
+ keys: { home: string; custom: string };
58
+ }
59
+ interface Byline {
60
+ id: string;
61
+ slug: string;
62
+ displayName: string;
63
+ bio: string | null;
64
+ websiteUrl: string | null;
65
+ locale: string;
66
+ rows: Row[] | null;
67
+ }
68
+ interface Override extends TypeChoice {
69
+ collection: string;
70
+ entryId: string;
71
+ title?: string;
72
+ /** "byline:<id>" or "publisher". */
73
+ mainSubject?: string;
74
+ updatedAt?: string;
75
+ }
76
+ interface EntryHit {
77
+ id: string;
78
+ slug: string | null;
79
+ status: string;
80
+ title: string;
81
+ }
82
+
83
+ const errorText = (cause: unknown, fallback: string) => (cause instanceof Error && cause.message ? cause.message : fallback);
84
+
85
+ async function get<T>(path: string): Promise<T> {
86
+ return parseApiResponse<T>(await apiFetch(`${API}/${path}`), "The request failed");
87
+ }
88
+ async function post<T>(path: string, body: unknown): Promise<T> {
89
+ const response = await apiFetch(`${API}/${path}`, {
90
+ method: "POST",
91
+ headers: { "Content-Type": "application/json" },
92
+ body: JSON.stringify(body),
93
+ });
94
+ return parseApiResponse<T>(response, "The request failed");
95
+ }
96
+
97
+ const INPUT_TYPES: Record<string, string> = { url: "url", image: "url", email: "email", tel: "tel", date: "date", number: "number" };
98
+
99
+ function Section(props: { title: string; description?: React.ReactNode; children: React.ReactNode }) {
100
+ return (
101
+ <section className="space-y-4 rounded-lg border border-kumo-line p-4">
102
+ <div>
103
+ <h2 className="text-base font-semibold">{props.title}</h2>
104
+ {props.description && <p className="mt-1 text-sm text-kumo-subtle">{props.description}</p>}
105
+ </div>
106
+ {props.children}
107
+ </section>
108
+ );
109
+ }
110
+
111
+ /** The last save's result ("Saved."), hidden again once there are new unsaved changes. */
112
+ function SavedStatus(props: { status?: string; dirty: boolean }) {
113
+ return (
114
+ <p className="text-sm text-kumo-subtle" aria-live="polite">
115
+ {props.dirty ? "" : props.status}
116
+ </p>
117
+ );
118
+ }
119
+
120
+ // ── Media picker ─────────────────────────────────────────────────
121
+
122
+ interface MediaItem {
123
+ id: string;
124
+ url: string;
125
+ filename: string;
126
+ alt: string | null;
127
+ width: number | null;
128
+ height: number | null;
129
+ }
130
+
131
+ function MediaDialog(props: { open: boolean; onClose: () => void; onPick: (item: MediaItem) => void }) {
132
+ const [items, setItems] = React.useState<MediaItem[]>([]);
133
+ const [cursor, setCursor] = React.useState<string>();
134
+ const [hasMore, setHasMore] = React.useState(false);
135
+ const [loading, setLoading] = React.useState(false);
136
+ const [error, setError] = React.useState<string>();
137
+
138
+ const load = React.useCallback(async (next?: string) => {
139
+ setLoading(true);
140
+ setError(undefined);
141
+ try {
142
+ const result = await post<{ items: MediaItem[]; cursor?: string; hasMore: boolean }>("media", { cursor: next });
143
+ setItems((current) => (next ? [...current, ...result.items] : result.items));
144
+ setCursor(result.cursor);
145
+ setHasMore(result.hasMore);
146
+ } catch (cause) {
147
+ setError(errorText(cause, "Could not load media"));
148
+ } finally {
149
+ setLoading(false);
150
+ }
151
+ }, []);
152
+ React.useEffect(() => {
153
+ if (props.open) void load();
154
+ }, [props.open, load]);
155
+
156
+ return (
157
+ <Dialog.Root open={props.open} onOpenChange={(open) => !open && props.onClose()}>
158
+ <Dialog className="p-6" size="lg">
159
+ <Dialog.Title className="text-lg font-semibold">Choose an image</Dialog.Title>
160
+ <Dialog.Description className="mt-1 text-sm text-kumo-subtle">Images from the media library.</Dialog.Description>
161
+ {error && <Banner className="mt-3" variant="error" role="alert" description={error} />}
162
+ <ul className="mt-4 grid max-h-96 grid-cols-3 gap-3 overflow-y-auto sm:grid-cols-4">
163
+ {items.map((item) => (
164
+ <li key={item.id}>
165
+ <button
166
+ type="button"
167
+ className="block w-full overflow-hidden rounded border border-kumo-line text-start focus-visible:outline-2 focus-visible:outline-kumo-brand"
168
+ onClick={() => props.onPick(item)}
169
+ >
170
+ <img src={item.url} alt={item.alt ?? ""} className="aspect-square w-full object-cover" loading="lazy" />
171
+ <span className="block truncate px-1 py-0.5 text-xs">{item.filename}</span>
172
+ </button>
173
+ </li>
174
+ ))}
175
+ </ul>
176
+ {loading && (
177
+ <div className="flex justify-center py-4">
178
+ <Loader />
179
+ </div>
180
+ )}
181
+ {!loading && !items.length && !error && <p className="py-6 text-center text-sm text-kumo-subtle">No images yet.</p>}
182
+ <div className="mt-4 flex justify-end gap-2">
183
+ {hasMore && (
184
+ <Button variant="secondary" disabled={loading} onClick={() => void load(cursor)}>
185
+ Load more
186
+ </Button>
187
+ )}
188
+ <Button variant="secondary" onClick={props.onClose}>
189
+ Cancel
190
+ </Button>
191
+ </div>
192
+ </Dialog>
193
+ </Dialog.Root>
194
+ );
195
+ }
196
+
197
+ // ── Property editor (the WordPress property picker) ──────────────
198
+
199
+ function PropertyEditor(props: {
200
+ rows: Row[];
201
+ onChange: (rows: Row[]) => void;
202
+ properties: string[];
203
+ inputs: Record<string, PropertyInput>;
204
+ label: string;
205
+ /** Show a "Profile page only" checkbox per row (Person rows). */
206
+ profileOnly?: boolean;
207
+ }) {
208
+ const [adding, setAdding] = React.useState(props.properties[0] ?? "");
209
+ const [picking, setPicking] = React.useState<number | null>(null);
210
+ const update = (index: number, value: RowValue) => props.onChange(props.rows.map((r, i) => (i === index ? { ...r, value } : r)));
211
+ const setProfileOnly = (index: number, on: boolean) =>
212
+ props.onChange(
213
+ props.rows.map((r, i) => {
214
+ if (i !== index) return r;
215
+ const { profileOnly: _drop, ...rest } = r;
216
+ return on ? { ...rest, profileOnly: true } : rest;
217
+ }),
218
+ );
219
+ const remove = (index: number) => props.onChange(props.rows.filter((_, i) => i !== index));
220
+ const add = () => {
221
+ const meta = props.inputs[adding];
222
+ props.onChange([...props.rows, { prop: adding, value: meta?.fields ? {} : "" }]);
223
+ };
224
+
225
+ return (
226
+ <div className="space-y-3">
227
+ {props.rows.length === 0 && <p className="text-sm text-kumo-subtle">No properties yet. Add one below.</p>}
228
+ {props.rows.map((row, index) => {
229
+ const meta = props.inputs[row.prop] ?? {};
230
+ return (
231
+ // biome-ignore lint/suspicious/noArrayIndexKey: rows are positional and repeatable.
232
+ <div key={index} className="flex items-start gap-2 rounded border border-kumo-line p-2">
233
+ <div className="w-40 shrink-0 pt-2 font-mono text-xs">{row.prop}</div>
234
+ <div className="min-w-0 flex-1 space-y-2">
235
+ {meta.fields ? (
236
+ Object.entries(meta.fields).map(([sub, subMeta]) => (
237
+ <Input
238
+ key={sub}
239
+ label={subMeta.label}
240
+ type={INPUT_TYPES[subMeta.input] ?? "text"}
241
+ value={(typeof row.value === "object" ? row.value[sub] : "") ?? ""}
242
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
243
+ update(index, { ...(typeof row.value === "object" ? row.value : {}), [sub]: e.target.value })
244
+ }
245
+ />
246
+ ))
247
+ ) : (
248
+ <div className="flex items-end gap-2">
249
+ <div className="flex-1">
250
+ <Input
251
+ aria-label={`${props.label} ${row.prop}`}
252
+ type={INPUT_TYPES[meta.input ?? ""] ?? "text"}
253
+ placeholder={meta.input === "url" || meta.input === "image" ? "https://…" : undefined}
254
+ value={typeof row.value === "string" ? row.value : ""}
255
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => update(index, e.target.value)}
256
+ />
257
+ </div>
258
+ {meta.input === "image" && (
259
+ <Button variant="secondary" icon={<ImageSquare aria-hidden="true" />} onClick={() => setPicking(index)}>
260
+ Media
261
+ </Button>
262
+ )}
263
+ </div>
264
+ )}
265
+ {props.profileOnly && row.prop !== "@id" && (
266
+ <Checkbox
267
+ label="Profile page only"
268
+ checked={!!row.profileOnly}
269
+ onCheckedChange={(checked: boolean) => setProfileOnly(index, checked)}
270
+ />
271
+ )}
272
+ </div>
273
+ <Button
274
+ variant="ghost"
275
+ shape="square"
276
+ icon={<Trash aria-hidden="true" />}
277
+ aria-label={`Remove ${row.prop}`}
278
+ onClick={() => remove(index)}
279
+ />
280
+ </div>
281
+ );
282
+ })}
283
+ <div className="flex items-end gap-2">
284
+ <div className="w-64">
285
+ <Select
286
+ label="Add a property"
287
+ value={adding}
288
+ onValueChange={(value: string | null) => setAdding(value ?? "")}
289
+ items={props.properties.map((p) => ({ value: p, label: p }))}
290
+ />
291
+ </div>
292
+ <Button variant="secondary" icon={<Plus aria-hidden="true" />} disabled={!adding} onClick={add}>
293
+ Add
294
+ </Button>
295
+ </div>
296
+ <p className="text-xs text-kumo-subtle">
297
+ Add a property more than once (sameAs, for example) to list several values. logo and image become ImageObjects with
298
+ their dimensions.
299
+ </p>
300
+ <MediaDialog
301
+ open={picking !== null}
302
+ onClose={() => setPicking(null)}
303
+ onPick={(item) => {
304
+ if (picking !== null) update(picking, item.url);
305
+ setPicking(null);
306
+ }}
307
+ />
308
+ </div>
309
+ );
310
+ }
311
+
312
+ // ── Site details ─────────────────────────────────────────────────
313
+
314
+ function SiteTab(props: { config: Config; bylines: Byline[] | undefined; onSaved: (site: SiteDetails) => void }) {
315
+ const [site, setSite] = React.useState<SiteDetails>(props.config.site);
316
+ const [pending, setPending] = React.useState(false);
317
+ const [status, setStatus] = React.useState<string>();
318
+ const [error, setError] = React.useState<string>();
319
+ const dirty = isDirty(site, props.config.site);
320
+
321
+ const save = async () => {
322
+ setPending(true);
323
+ setError(undefined);
324
+ try {
325
+ const result = await post<{ site: SiteDetails }>("site/save", site);
326
+ setSite(result.site);
327
+ props.onSaved(result.site);
328
+ setStatus("Saved.");
329
+ } catch (cause) {
330
+ setError(errorText(cause, "Could not save Site Details"));
331
+ } finally {
332
+ setPending(false);
333
+ }
334
+ };
335
+
336
+ return (
337
+ <div className="space-y-6">
338
+ <Section
339
+ title="Publisher"
340
+ description="Who publishes this site. It's the publisher of every article and the subject of the home page in the graph."
341
+ >
342
+ <Select
343
+ label="Publisher type"
344
+ value={site.publisherType}
345
+ onValueChange={(value: string | null) => setSite({ ...site, publisherType: value === "person" ? "person" : "organization" })}
346
+ items={[
347
+ { value: "organization", label: "Organization" },
348
+ { value: "person", label: "Person" },
349
+ ]}
350
+ />
351
+ {site.publisherType === "organization" ? (
352
+ <>
353
+ <Select
354
+ label="Organization type"
355
+ value={site.organizationType || "Organization"}
356
+ onValueChange={(value: string | null) => setSite({ ...site, organizationType: value || "Organization" })}
357
+ items={props.config.catalog.organizationTypes.map(([value, label]) => ({ value, label }))}
358
+ />
359
+ <PropertyEditor
360
+ label="Organization"
361
+ rows={site.orgRows}
362
+ onChange={(orgRows) => setSite({ ...site, orgRows })}
363
+ properties={props.config.catalog.organization}
364
+ inputs={props.config.catalog.inputs}
365
+ />
366
+ </>
367
+ ) : (
368
+ <div className="space-y-2">
369
+ {props.bylines === undefined ? (
370
+ <Loader />
371
+ ) : props.bylines.length === 0 ? (
372
+ <p className="text-sm text-kumo-subtle">Create a byline first (Content → Bylines).</p>
373
+ ) : (
374
+ <Select
375
+ label="Person (byline)"
376
+ value={site.personBylineId ?? ""}
377
+ onValueChange={(value: string | null) => setSite({ ...site, personBylineId: value || null })}
378
+ items={[{ value: "", label: "Choose a byline" }, ...props.bylines.map((b) => ({ value: b.id, label: `${b.displayName} (${b.locale})` }))]}
379
+ />
380
+ )}
381
+ <p className="text-sm text-kumo-subtle">The person's properties come from the Authors tab.</p>
382
+ </div>
383
+ )}
384
+ <p className="text-sm text-kumo-subtle">
385
+ Without a name, url or logo, the site title, site URL and site logo from EmDash's settings are used.
386
+ </p>
387
+ {error && <Banner variant="error" role="alert" description={error} />}
388
+ <SavedStatus status={status} dirty={dirty} />
389
+ </Section>
390
+ <SaveBar dirty={dirty} saving={pending} onSave={() => void save()} onDiscard={() => setSite(props.config.site)} />
391
+ </div>
392
+ );
393
+ }
394
+
395
+ // ── Types ────────────────────────────────────────────────────────
396
+
397
+ function TypesTab(props: { config: Config; onSaved: (types: Record<string, TypeChoice>) => void }) {
398
+ const { config } = props;
399
+ const [types, setTypes] = React.useState(config.types);
400
+ const [pending, setPending] = React.useState(false);
401
+ const [status, setStatus] = React.useState<string>();
402
+ const [error, setError] = React.useState<string>();
403
+ const dirty = isDirty(types, config.types);
404
+ const pageItems = [{ value: "", label: "Default (Web Page)" }, ...config.catalog.pageTypes.map(([value, label]) => ({ value, label }))];
405
+ const articleItems = [
406
+ { value: "", label: "Default (Blog Posting on article pages)" },
407
+ ...config.catalog.articleTypes.map(([value, label]) => ({ value, label })),
408
+ ];
409
+ const rows = [
410
+ { key: config.keys.home, label: "Home page", hint: "The site root" },
411
+ { key: config.keys.custom, label: "Other pages", hint: "Archives and pages that aren't a content entry" },
412
+ ...config.collections.map((c) => ({ key: c.slug, label: c.label, hint: c.slug })),
413
+ ];
414
+ const set = (key: string, patch: TypeChoice) => setTypes((t) => ({ ...t, [key]: { ...t[key], ...patch } }));
415
+
416
+ const save = async () => {
417
+ setPending(true);
418
+ setError(undefined);
419
+ try {
420
+ const result = await post<{ types: Record<string, TypeChoice> }>("types/save", { types });
421
+ setTypes(result.types);
422
+ props.onSaved(result.types);
423
+ setStatus("Saved.");
424
+ } catch (cause) {
425
+ setError(errorText(cause, "Could not save types"));
426
+ } finally {
427
+ setPending(false);
428
+ }
429
+ };
430
+
431
+ return (
432
+ <div className="space-y-6">
433
+ <Section
434
+ title="Page and article types"
435
+ description="The WebPage subtype and Article subtype for each kind of page. Override them for single entries on the Overrides tab."
436
+ >
437
+ <div className="rounded-lg border">
438
+ <div className="cw-md-flex items-center gap-4 border-b bg-kumo-tint/50 px-4 py-2 text-sm font-medium text-kumo-subtle">
439
+ <div className="flex-1">Pages</div>
440
+ <div className="cw-w-60">Page type</div>
441
+ <div className="cw-w-60">Article type</div>
442
+ </div>
443
+ {rows.map((row) => (
444
+ <div key={row.key} className="flex flex-col gap-2 border-b px-4 py-3 last:border-0 cw-md-row md:gap-4">
445
+ <div className="min-w-0 flex-1">
446
+ <div className="text-sm font-medium">{row.label}</div>
447
+ <div className="truncate text-xs text-kumo-subtle">{row.hint}</div>
448
+ </div>
449
+ <div className="cw-md-w-60">
450
+ <Select
451
+ aria-label={`${row.label} page type`}
452
+ className="w-full min-w-0"
453
+ value={types[row.key]?.pageType ?? ""}
454
+ onValueChange={(value: string | null) => set(row.key, { pageType: value || undefined })}
455
+ items={pageItems}
456
+ />
457
+ </div>
458
+ <div className="cw-md-w-60">
459
+ <Select
460
+ aria-label={`${row.label} article type`}
461
+ className="w-full min-w-0"
462
+ value={types[row.key]?.articleType ?? ""}
463
+ onValueChange={(value: string | null) => set(row.key, { articleType: value || undefined })}
464
+ items={articleItems}
465
+ />
466
+ </div>
467
+ </div>
468
+ ))}
469
+ </div>
470
+ {error && <Banner variant="error" role="alert" description={error} />}
471
+ <SavedStatus status={status} dirty={dirty} />
472
+ </Section>
473
+ <SaveBar dirty={dirty} saving={pending} onSave={() => void save()} onDiscard={() => setTypes(config.types)} />
474
+ </div>
475
+ );
476
+ }
477
+
478
+ // ── Authors ──────────────────────────────────────────────────────
479
+
480
+ function defaultRows(byline: Byline): Row[] {
481
+ const rows: Row[] = [{ prop: "name", value: byline.displayName }];
482
+ if (byline.websiteUrl) rows.push({ prop: "url", value: byline.websiteUrl });
483
+ if (byline.bio?.trim()) rows.push({ prop: "description", value: byline.bio.trim() });
484
+ rows.push({ prop: "jobTitle", value: "" }, { prop: "sameAs", value: "" });
485
+ return rows;
486
+ }
487
+
488
+ function AuthorsTab(props: { config: Config; bylines: Byline[] | undefined; authorsOn: boolean; reload: () => void }) {
489
+ const [selected, setSelected] = React.useState<string>();
490
+ const [rows, setRows] = React.useState<Row[]>([]);
491
+ // What the selected byline has saved, to tell when `rows` has unsaved changes.
492
+ const [baseline, setBaseline] = React.useState<Row[]>([]);
493
+ const [pending, setPending] = React.useState(false);
494
+ const [status, setStatus] = React.useState<string>();
495
+ const [error, setError] = React.useState<string>();
496
+ const byline = props.bylines?.find((b) => b.id === selected);
497
+
498
+ React.useEffect(() => {
499
+ if (!selected && props.bylines?.length === 1) setSelected(props.bylines[0].id);
500
+ }, [props.bylines, selected]);
501
+ React.useEffect(() => {
502
+ const initial = byline ? (byline.rows ?? defaultRows(byline)) : [];
503
+ setRows(initial);
504
+ setBaseline(initial);
505
+ setStatus(undefined);
506
+ setError(undefined);
507
+ }, [byline]);
508
+
509
+ const dirty = !!byline && isDirty(rows, baseline);
510
+
511
+ const save = async () => {
512
+ if (!byline) return;
513
+ setPending(true);
514
+ setError(undefined);
515
+ try {
516
+ const result = await post<{ rows: Row[] }>("authors/save", { bylineId: byline.id, rows });
517
+ const next = result.rows.length ? result.rows : defaultRows(byline);
518
+ setRows(next);
519
+ setBaseline(next);
520
+ setStatus("Saved.");
521
+ props.reload();
522
+ } catch (cause) {
523
+ setError(errorText(cause, "Could not save the author"));
524
+ } finally {
525
+ setPending(false);
526
+ }
527
+ };
528
+
529
+ if (props.bylines === undefined)
530
+ return (
531
+ <div className="flex justify-center py-12">
532
+ <Loader />
533
+ </div>
534
+ );
535
+
536
+ return (
537
+ <div className="space-y-6">
538
+ <Section
539
+ title="Authors"
540
+ description="Schema.org Person properties for each byline, used as the author of articles they're credited on (and as the publisher, if it's a person). Unsaved bylines use their name, website, bio and avatar."
541
+ >
542
+ {!props.authorsOn && (
543
+ <Banner variant="default" role="status" description="Turn on Author profiles (Features → Schema & Social) to save and use these properties." />
544
+ )}
545
+ {props.bylines.length === 0 ? (
546
+ <p className="text-sm text-kumo-subtle">No bylines yet.</p>
547
+ ) : (
548
+ <Select
549
+ label="Byline"
550
+ value={selected ?? ""}
551
+ onValueChange={(value: string | null) => setSelected(value || undefined)}
552
+ items={[
553
+ { value: "", label: "Choose a byline" },
554
+ ...props.bylines.map((b) => ({ value: b.id, label: `${b.displayName} (${b.locale})${b.rows ? " ✓" : ""}` })),
555
+ ]}
556
+ />
557
+ )}
558
+ {byline && (
559
+ <>
560
+ <PropertyEditor
561
+ label={byline.displayName}
562
+ rows={rows}
563
+ onChange={setRows}
564
+ properties={props.config.catalog.person}
565
+ inputs={props.config.catalog.inputs}
566
+ profileOnly
567
+ />
568
+ <p className="text-xs text-kumo-subtle">
569
+ <strong>Profile page only</strong> properties (birthDate or email, for example) appear only on a page whose main subject is
570
+ this person (set on the Overrides tab), not on every article they wrote.
571
+ </p>
572
+ {error && <Banner variant="error" role="alert" description={error} />}
573
+ <div className="flex items-center justify-between gap-2">
574
+ <Button variant="ghost" onClick={() => setRows(defaultRows(byline))}>
575
+ Reset to byline details
576
+ </Button>
577
+ <SavedStatus status={status} dirty={dirty} />
578
+ </div>
579
+ </>
580
+ )}
581
+ </Section>
582
+ <SaveBar
583
+ dirty={dirty}
584
+ saving={pending}
585
+ canSave={props.authorsOn}
586
+ detail={props.authorsOn ? undefined : "Turn on Author profiles to save"}
587
+ onSave={() => void save()}
588
+ onDiscard={() => setRows(baseline)}
589
+ />
590
+ </div>
591
+ );
592
+ }
593
+
594
+ // ── Entry picker (overrides and preview) ─────────────────────────
595
+
596
+ function EntryPicker(props: { collections: Collection[]; onPick: (collection: string, entry: EntryHit) => void }) {
597
+ const [collection, setCollection] = React.useState(props.collections[0]?.slug ?? "");
598
+ const [query, setQuery] = React.useState("");
599
+ const [items, setItems] = React.useState<EntryHit[]>();
600
+ const [loading, setLoading] = React.useState(false);
601
+ const [error, setError] = React.useState<string>();
602
+
603
+ const find = React.useCallback(async () => {
604
+ if (!collection) return;
605
+ setLoading(true);
606
+ setError(undefined);
607
+ try {
608
+ setItems((await post<{ items: EntryHit[] }>("entries/find", { collection, query })).items);
609
+ } catch (cause) {
610
+ setError(errorText(cause, "Could not load entries"));
611
+ } finally {
612
+ setLoading(false);
613
+ }
614
+ }, [collection, query]);
615
+ React.useEffect(() => {
616
+ void find();
617
+ // Reload when the collection changes; the query is submitted explicitly.
618
+ // eslint-disable-next-line react-hooks/exhaustive-deps
619
+ }, [collection]);
620
+
621
+ return (
622
+ <div className="space-y-3">
623
+ <form
624
+ className="flex flex-col gap-2 sm:flex-row sm:items-end"
625
+ onSubmit={(e) => {
626
+ e.preventDefault();
627
+ void find();
628
+ }}
629
+ >
630
+ <div className="sm:w-64">
631
+ <Select
632
+ label="Collection"
633
+ value={collection}
634
+ onValueChange={(value: string | null) => setCollection(value ?? "")}
635
+ items={props.collections.map((c) => ({ value: c.slug, label: c.label }))}
636
+ />
637
+ </div>
638
+ <div className="flex-1">
639
+ <Input label="Find" placeholder="Title or slug" value={query} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)} />
640
+ </div>
641
+ <Button type="submit" variant="secondary" icon={<MagnifyingGlass aria-hidden="true" />}>
642
+ Search
643
+ </Button>
644
+ </form>
645
+ {error && <Banner variant="error" role="alert" description={error} />}
646
+ <div aria-live="polite">
647
+ {loading ? (
648
+ <Loader />
649
+ ) : items && items.length === 0 ? (
650
+ <p className="text-sm text-kumo-subtle">No entries match (searches the 100 most recently updated).</p>
651
+ ) : (
652
+ <ul className="max-h-64 divide-y overflow-y-auto rounded border border-kumo-line">
653
+ {items?.map((item) => (
654
+ <li key={item.id}>
655
+ <button
656
+ type="button"
657
+ className="flex w-full items-center gap-2 px-3 py-2 text-start text-sm hover:bg-kumo-tint focus:bg-kumo-tint focus:outline-none"
658
+ onClick={() => props.onPick(collection, item)}
659
+ >
660
+ <span className="min-w-0 flex-1 truncate">{item.title}</span>
661
+ {item.status !== "published" && <Badge variant="outline">{item.status}</Badge>}
662
+ </button>
663
+ </li>
664
+ ))}
665
+ </ul>
666
+ )}
667
+ </div>
668
+ </div>
669
+ );
670
+ }
671
+
672
+ // ── Overrides ────────────────────────────────────────────────────
673
+
674
+ /** The site publisher's name, for the main-subject picker. */
675
+ function publisherLabel(config: Config, bylines: Byline[] | undefined): string {
676
+ const site = config.site;
677
+ if (site.publisherType === "person") {
678
+ const name = bylines?.find((b) => b.id === site.personBylineId)?.displayName;
679
+ return name ? `Site publisher (${name})` : "Site publisher";
680
+ }
681
+ const name = site.orgRows.find((r) => r.prop === "name" && typeof r.value === "string" && r.value.trim())?.value;
682
+ return typeof name === "string" ? `Site publisher (${name.trim()})` : "Site publisher (organization)";
683
+ }
684
+
685
+ /** Main-subject choices: the entry's own bylines first, then the publisher, then every other byline. */
686
+ function subjectItems(config: Config, bylines: Byline[] | undefined, credited: string[]): Array<{ value: string; label: string }> {
687
+ const all = bylines ?? [];
688
+ const label = (b: Byline) => (all.filter((o) => o.displayName === b.displayName).length > 1 ? `${b.displayName} (${b.locale})` : b.displayName);
689
+ const own = credited.map((id) => all.find((b) => b.id === id)).filter((b): b is Byline => !!b);
690
+ const others = all.filter((b) => !credited.includes(b.id));
691
+ return [
692
+ { value: "", label: "None" },
693
+ ...own.map((b) => ({ value: `byline:${b.id}`, label: `${label(b)} (credited on this entry)` })),
694
+ { value: "publisher", label: publisherLabel(config, bylines) },
695
+ ...others.map((b) => ({ value: `byline:${b.id}`, label: label(b) })),
696
+ ];
697
+ }
698
+
699
+ function OverrideDialog(props: { config: Config; bylines: Byline[] | undefined; draft: Override | null; onClose: () => void; onSaved: () => void }) {
700
+ const [draft, setDraft] = React.useState<Override | null>(props.draft);
701
+ const [pending, setPending] = React.useState(false);
702
+ const [error, setError] = React.useState<string>();
703
+ const [credited, setCredited] = React.useState<string[]>([]);
704
+ React.useEffect(() => {
705
+ setDraft(props.draft);
706
+ setError(undefined);
707
+ }, [props.draft]);
708
+ const picking = draft !== null && !draft.entryId;
709
+ const entryKey = draft?.entryId ? `${draft.collection}:${draft.entryId}` : "";
710
+ React.useEffect(() => {
711
+ setCredited([]);
712
+ if (!draft?.entryId) return;
713
+ let live = true;
714
+ post<{ ids: string[] }>("entries/bylines", { collection: draft.collection, entryId: draft.entryId })
715
+ .then((result) => live && setCredited(result.ids))
716
+ .catch(() => {});
717
+ return () => {
718
+ live = false;
719
+ };
720
+ // Only when the entry changes, not on every edit of the draft.
721
+ // eslint-disable-next-line react-hooks/exhaustive-deps
722
+ }, [entryKey]);
723
+
724
+ const save = async () => {
725
+ if (!draft) return;
726
+ setPending(true);
727
+ setError(undefined);
728
+ try {
729
+ await post("entries/save", draft);
730
+ props.onSaved();
731
+ } catch (cause) {
732
+ setError(errorText(cause, "Could not save the override"));
733
+ } finally {
734
+ setPending(false);
735
+ }
736
+ };
737
+
738
+ return (
739
+ <Dialog.Root open={props.draft !== null} onOpenChange={(open) => !open && !pending && props.onClose()}>
740
+ <Dialog className="p-6" size="lg">
741
+ <Dialog.Title className="text-lg font-semibold">{picking ? "Choose an entry" : "Entry schema"}</Dialog.Title>
742
+ <Dialog.Description className="mt-1 text-sm text-kumo-subtle">
743
+ {picking ? "Pick the entry to override." : (draft?.title ?? draft?.entryId)}
744
+ </Dialog.Description>
745
+ <div className="mt-4 space-y-4">
746
+ {picking ? (
747
+ <EntryPicker
748
+ collections={props.config.collections}
749
+ onPick={(collection, entry) => setDraft({ collection, entryId: entry.id, title: entry.title, ...(props.config.types[collection] ?? {}) })}
750
+ />
751
+ ) : (
752
+ draft && (
753
+ <>
754
+ <Select
755
+ label="Page type"
756
+ value={draft.pageType ?? ""}
757
+ onValueChange={(value: string | null) => setDraft({ ...draft, pageType: value || undefined })}
758
+ items={[{ value: "", label: "Collection default" }, ...props.config.catalog.pageTypes.map(([value, label]) => ({ value, label }))]}
759
+ />
760
+ <Select
761
+ label="Article type"
762
+ value={draft.articleType ?? ""}
763
+ onValueChange={(value: string | null) => setDraft({ ...draft, articleType: value || undefined })}
764
+ items={[{ value: "", label: "Collection default" }, ...props.config.catalog.articleTypes.map(([value, label]) => ({ value, label }))]}
765
+ />
766
+ <div className="space-y-1">
767
+ <Select
768
+ label="Main subject"
769
+ value={draft.mainSubject ?? ""}
770
+ onValueChange={(value: string | null) => setDraft({ ...draft, mainSubject: value || undefined })}
771
+ items={subjectItems(props.config, props.bylines, credited)}
772
+ />
773
+ <p className="text-xs text-kumo-subtle">
774
+ What this page is about. For a profile page, pick the person; for About or Contact, pick the organization. It
775
+ becomes the page's mainEntity, with the person's profile-page-only properties. Profile pages usually use the
776
+ Profile page type and no Article.
777
+ </p>
778
+ </div>
779
+ </>
780
+ )
781
+ )}
782
+ {error && <Banner variant="error" role="alert" description={error} />}
783
+ <div className="flex justify-end gap-2">
784
+ <Button variant="secondary" disabled={pending} onClick={props.onClose}>
785
+ Cancel
786
+ </Button>
787
+ {!picking && (
788
+ <Button variant="primary" disabled={pending} onClick={() => void save()}>
789
+ {pending ? "Saving…" : "Save override"}
790
+ </Button>
791
+ )}
792
+ </div>
793
+ </div>
794
+ </Dialog>
795
+ </Dialog.Root>
796
+ );
797
+ }
798
+
799
+ function OverridesTab(props: { config: Config; bylines: Byline[] | undefined }) {
800
+ const [items, setItems] = React.useState<Override[]>();
801
+ const [error, setError] = React.useState<string>();
802
+ const [editing, setEditing] = React.useState<Override | null>(null);
803
+ const [notice, setNotice] = React.useState<string>();
804
+ const labels = Object.fromEntries([...props.config.catalog.pageTypes, ...props.config.catalog.articleTypes]);
805
+ const collectionLabel = (slug: string) => props.config.collections.find((c) => c.slug === slug)?.label ?? slug;
806
+ const subjectLabel = (subject: string) =>
807
+ subject === "publisher"
808
+ ? "About: site publisher"
809
+ : `About: ${props.bylines?.find((b) => `byline:${b.id}` === subject)?.displayName ?? "a byline"}`;
810
+
811
+ const load = React.useCallback(async () => {
812
+ try {
813
+ setItems((await get<{ items: Override[] }>("entries")).items);
814
+ } catch (cause) {
815
+ setError(errorText(cause, "Could not load overrides"));
816
+ }
817
+ }, []);
818
+ React.useEffect(() => {
819
+ void load();
820
+ }, [load]);
821
+
822
+ const remove = async (item: Override) => {
823
+ if (!window.confirm(`Remove the override for ${item.title ?? item.entryId}?`)) return;
824
+ try {
825
+ await post("entries/delete", { collection: item.collection, entryId: item.entryId });
826
+ setNotice("Override removed.");
827
+ await load();
828
+ } catch (cause) {
829
+ setError(errorText(cause, "Could not remove the override"));
830
+ }
831
+ };
832
+
833
+ return (
834
+ <Section
835
+ title="Per-entry overrides"
836
+ description="Give a single entry a different page or article type than its collection, or say who or what it's about (its main subject)."
837
+ >
838
+ <div className="flex justify-end">
839
+ <Button variant="primary" icon={<Plus aria-hidden="true" />} onClick={() => setEditing({ collection: "", entryId: "" })}>
840
+ New override
841
+ </Button>
842
+ </div>
843
+ <div aria-live="polite">{notice && <Banner variant="default" role="status" title={notice} />}</div>
844
+ {error && <Banner variant="error" role="alert" description={error} />}
845
+ {!items && !error ? (
846
+ <Loader />
847
+ ) : items && items.length === 0 ? (
848
+ <p className="py-6 text-center text-sm text-kumo-subtle">No overrides. Every entry uses its collection's types.</p>
849
+ ) : (
850
+ <div className="rounded-lg border">
851
+ {items?.map((item) => (
852
+ <div key={`${item.collection}:${item.entryId}`} className="flex items-center gap-4 border-b px-4 py-2 text-sm last:border-0">
853
+ <div className="min-w-0 flex-1">
854
+ <div className="truncate font-medium">{item.title || item.entryId}</div>
855
+ <div className="text-xs text-kumo-subtle">{collectionLabel(item.collection)}</div>
856
+ </div>
857
+ <div className="hidden gap-1 sm:flex">
858
+ {item.pageType && <Badge variant="secondary">{labels[item.pageType] ?? item.pageType}</Badge>}
859
+ {item.articleType && <Badge variant="outline">{labels[item.articleType] ?? item.articleType}</Badge>}
860
+ {item.mainSubject && <Badge variant="outline">{subjectLabel(item.mainSubject)}</Badge>}
861
+ </div>
862
+ <Button
863
+ variant="ghost"
864
+ shape="square"
865
+ icon={<PencilSimple aria-hidden="true" />}
866
+ aria-label={`Edit override for ${item.title || item.entryId}`}
867
+ onClick={() => setEditing(item)}
868
+ />
869
+ <Button
870
+ variant="ghost"
871
+ shape="square"
872
+ icon={<Trash aria-hidden="true" />}
873
+ aria-label={`Remove override for ${item.title || item.entryId}`}
874
+ onClick={() => void remove(item)}
875
+ />
876
+ </div>
877
+ ))}
878
+ </div>
879
+ )}
880
+ <OverrideDialog
881
+ config={props.config}
882
+ bylines={props.bylines}
883
+ draft={editing}
884
+ onClose={() => setEditing(null)}
885
+ onSaved={() => {
886
+ setEditing(null);
887
+ setNotice("Override saved.");
888
+ void load();
889
+ }}
890
+ />
891
+ </Section>
892
+ );
893
+ }
894
+
895
+ // ── Settings (search, robots, Open Graph) ────────────────────────
896
+
897
+ /** A robots limit field: blank → -1 (no limit); a number stays a number; anything else is sent as typed for the server to reject. */
898
+ const limit = (value: unknown): number | string => {
899
+ const text = String(value ?? "").trim();
900
+ if (!text) return -1;
901
+ const n = Number(text);
902
+ return Number.isFinite(n) ? n : text;
903
+ };
904
+
905
+ function SettingsTab(props: { config: Config; onSaved: (settings: Config["settings"]) => void }) {
906
+ const [values, setValues] = React.useState(props.config.settings);
907
+ const [pending, setPending] = React.useState(false);
908
+ const [status, setStatus] = React.useState<string>();
909
+ const [error, setError] = React.useState<string>();
910
+ const dirty = isDirty(values, props.config.settings);
911
+ const str = (key: string) => String(values[key] ?? "");
912
+ const set = (key: string, value: string | number | boolean) => setValues((v) => ({ ...v, [key]: value }));
913
+ const text = (key: string, label: string, placeholder?: string, description?: string) => (
914
+ <div>
915
+ <Input label={label} placeholder={placeholder} value={str(key)} onChange={(e: React.ChangeEvent<HTMLInputElement>) => set(key, e.target.value)} />
916
+ {description && <p className="mt-1 text-xs text-kumo-subtle">{description}</p>}
917
+ </div>
918
+ );
919
+
920
+ const save = async () => {
921
+ setPending(true);
922
+ setError(undefined);
923
+ try {
924
+ const body = {
925
+ ...values,
926
+ // Blank means no limit (-1), never 0 (which would forbid snippets/previews).
927
+ schemaRobotsMaxSnippet: limit(values.schemaRobotsMaxSnippet),
928
+ schemaRobotsMaxVideo: limit(values.schemaRobotsMaxVideo),
929
+ schemaRobotsNofollow: !!values.schemaRobotsNofollow,
930
+ };
931
+ const result = await post<{ settings: Config["settings"] }>("settings/save", body);
932
+ setValues(result.settings);
933
+ props.onSaved(result.settings);
934
+ setStatus("Saved.");
935
+ } catch (cause) {
936
+ setError(errorText(cause, "Could not save settings"));
937
+ } finally {
938
+ setPending(false);
939
+ }
940
+ };
941
+
942
+ return (
943
+ <div className="space-y-6">
944
+ <Section title="Graph">
945
+ {text("schemaSearchUrl", "Search URL template", "/?s={search_term_string}", "Adds a SearchAction to the WebSite node. Leave empty if the site has no search.")}
946
+ {text("schemaAuthorUrlPattern", "Author page URL", "/author/{slug}/", "Your site's author page URL with {slug} for the byline slug, e.g. /author/{slug}/. Used for an author's url and @id when the byline has no website. Leave empty if the site has no author pages.")}
947
+ {text("schemaBreadcrumbHome", "Breadcrumb home label", "Home", "First crumb when breadcrumbs are derived from the URL.")}
948
+ </Section>
949
+ <Section title="Robots" description="Applies with Robots directives on. Entries marked No index stay noindex.">
950
+ <Select
951
+ label="max-image-preview"
952
+ value={str("schemaRobotsMaxImage")}
953
+ onValueChange={(value: string | null) => set("schemaRobotsMaxImage", value ?? "")}
954
+ items={[
955
+ { value: "large", label: "Large" },
956
+ { value: "standard", label: "Standard" },
957
+ { value: "none", label: "None" },
958
+ { value: "", label: "Don't set" },
959
+ ]}
960
+ />
961
+ <div className="grid gap-4 sm:grid-cols-2">
962
+ <Input
963
+ label="max-snippet (characters; blank or -1 = no limit)"
964
+ type="number"
965
+ min={-1}
966
+ value={str("schemaRobotsMaxSnippet")}
967
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => set("schemaRobotsMaxSnippet", e.target.value)}
968
+ />
969
+ <Input
970
+ label="max-video-preview (seconds; blank or -1 = no limit)"
971
+ type="number"
972
+ min={-1}
973
+ value={str("schemaRobotsMaxVideo")}
974
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => set("schemaRobotsMaxVideo", e.target.value)}
975
+ />
976
+ </div>
977
+ <Switch className="shrink-0"
978
+ label="nofollow on every page"
979
+ checked={!!values.schemaRobotsNofollow}
980
+ onCheckedChange={(checked: boolean) => set("schemaRobotsNofollow", checked)}
981
+ />
982
+ </Section>
983
+ <Section title="Open Graph">
984
+ {text("schemaOgLocale", "og:locale", "en_US", "Leave empty to derive it from the site locale.")}
985
+ </Section>
986
+ {error && <Banner variant="error" role="alert" description={error} />}
987
+ <SavedStatus status={status} dirty={dirty} />
988
+ <SaveBar dirty={dirty} saving={pending} onSave={() => void save()} onDiscard={() => setValues(props.config.settings)} />
989
+ </div>
990
+ );
991
+ }
992
+
993
+ // ── Preview ──────────────────────────────────────────────────────
994
+
995
+ interface Contribution {
996
+ kind: string;
997
+ name?: string;
998
+ property?: string;
999
+ content?: string;
1000
+ id?: string;
1001
+ graph?: unknown;
1002
+ }
1003
+
1004
+ function PreviewTab(props: { config: Config }) {
1005
+ const [target, setTarget] = React.useState<"home" | "entry">("home");
1006
+ const [result, setResult] = React.useState<{ page: { url: string; pageType: string }; contributions: Contribution[] }>();
1007
+ const [pending, setPending] = React.useState(false);
1008
+ const [error, setError] = React.useState<string>();
1009
+
1010
+ const run = async (body: Record<string, unknown>) => {
1011
+ setPending(true);
1012
+ setError(undefined);
1013
+ try {
1014
+ setResult(await post("preview", body));
1015
+ } catch (cause) {
1016
+ setError(errorText(cause, "Preview failed"));
1017
+ } finally {
1018
+ setPending(false);
1019
+ }
1020
+ };
1021
+
1022
+ const tags = result?.contributions.filter((c) => c.kind !== "jsonld") ?? [];
1023
+ const jsonld = result?.contributions.filter((c) => c.kind === "jsonld") ?? [];
1024
+
1025
+ return (
1026
+ <Section
1027
+ title="Preview"
1028
+ description="What Schema & Social adds to a page's head with the current settings. Entries are previewed from their stored fields; the live page may pass the theme's own title, breadcrumbs or page type."
1029
+ >
1030
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-end">
1031
+ <div className="sm:w-64">
1032
+ <Select
1033
+ label="Page"
1034
+ value={target}
1035
+ onValueChange={(value: string | null) => setTarget(value === "entry" ? "entry" : "home")}
1036
+ items={[
1037
+ { value: "home", label: "Home page" },
1038
+ { value: "entry", label: "An entry" },
1039
+ ]}
1040
+ />
1041
+ </div>
1042
+ {target === "home" && (
1043
+ <Button variant="primary" disabled={pending} onClick={() => void run({ target: "home" })}>
1044
+ Preview
1045
+ </Button>
1046
+ )}
1047
+ </div>
1048
+ {target === "entry" && (
1049
+ <EntryPicker collections={props.config.collections} onPick={(collection, entry) => void run({ target: "entry", collection, entryId: entry.id })} />
1050
+ )}
1051
+ {error && <Banner variant="error" role="alert" description={error} />}
1052
+ <div aria-live="polite" aria-busy={pending}>
1053
+ {pending && <Loader />}
1054
+ {result && !pending && (
1055
+ <div className="space-y-4">
1056
+ <p className="text-sm text-kumo-subtle">
1057
+ {result.page.url} · page type “{result.page.pageType}”
1058
+ </p>
1059
+ {result.contributions.length === 0 && <p className="text-sm">Nothing: every Schema & Social feature is off.</p>}
1060
+ {tags.length > 0 && (
1061
+ <pre className="overflow-x-auto rounded bg-kumo-tint p-3 text-xs">
1062
+ {tags
1063
+ .map((t) =>
1064
+ t.kind === "property" ? `<meta property="${t.property}" content="${t.content}">` : `<meta name="${t.name}" content="${t.content}">`,
1065
+ )
1066
+ .join("\n")}
1067
+ </pre>
1068
+ )}
1069
+ {jsonld.map((j) => (
1070
+ <div key={j.id ?? "jsonld"}>
1071
+ <h3 className="mb-1 text-sm font-medium">JSON-LD ({j.id === "primary" ? "replaces EmDash's" : j.id})</h3>
1072
+ <pre className="max-h-[28rem] cw-overflow-auto rounded bg-kumo-tint p-3 text-xs">{JSON.stringify(j.graph, null, 2)}</pre>
1073
+ </div>
1074
+ ))}
1075
+ {jsonld.length > 0 && (
1076
+ <p className="text-xs text-kumo-subtle">
1077
+ Check the live page with Google's Rich Results Test or the Schema.org validator after publishing changes.
1078
+ </p>
1079
+ )}
1080
+ </div>
1081
+ )}
1082
+ </div>
1083
+ </Section>
1084
+ );
1085
+ }
1086
+
1087
+ // ── Page ─────────────────────────────────────────────────────────
1088
+
1089
+ const FEATURE_LABELS: Record<string, string> = {
1090
+ "schema.graph": "Schema.org graph",
1091
+ "schema.breadcrumbs": "Breadcrumb schema",
1092
+ "schema.robots": "Robots directives",
1093
+ "schema.openGraph": "Open Graph extras",
1094
+ "schema.authors": "Author profiles",
1095
+ };
1096
+
1097
+ /**
1098
+ * Kumo's Tabs has no panel part, so each tab gets our own id and points at the
1099
+ * one panel below, which is labeled by the selected tab (not the raw value).
1100
+ */
1101
+ const SCHEMA_PANEL_ID = "cw-schema-panel";
1102
+ const schemaTabId = (value: string) => `cw-schema-tab-${value}`;
1103
+ const SCHEMA_TABS = [
1104
+ { value: "site", label: "Site details" },
1105
+ { value: "types", label: "Types" },
1106
+ { value: "authors", label: "Authors" },
1107
+ { value: "overrides", label: "Overrides" },
1108
+ { value: "settings", label: "Robots & social" },
1109
+ { value: "preview", label: "Preview" },
1110
+ ].map((t) => ({
1111
+ ...t,
1112
+ render: (props: React.ComponentPropsWithRef<"button">) => <button {...props} id={schemaTabId(t.value)} aria-controls={SCHEMA_PANEL_ID} />,
1113
+ }));
1114
+
1115
+ export function SchemaPage() {
1116
+ const [config, setConfig] = React.useState<Config>();
1117
+ const [bylines, setBylines] = React.useState<Byline[]>();
1118
+ const [authorsOn, setAuthorsOn] = React.useState(false);
1119
+ const [error, setError] = React.useState<string>();
1120
+ const [tab, setTab] = React.useState("site");
1121
+
1122
+ const loadAuthors = React.useCallback(async () => {
1123
+ try {
1124
+ const result = await get<{ items: Byline[]; authorsOn: boolean }>("authors");
1125
+ setBylines(result.items);
1126
+ setAuthorsOn(result.authorsOn);
1127
+ } catch (cause) {
1128
+ setError(errorText(cause, "Could not load bylines"));
1129
+ }
1130
+ }, []);
1131
+
1132
+ React.useEffect(() => {
1133
+ (async () => {
1134
+ try {
1135
+ setConfig(await get<Config>("config"));
1136
+ void loadAuthors();
1137
+ } catch (cause) {
1138
+ setError(errorText(cause, "Could not load schema settings"));
1139
+ }
1140
+ })();
1141
+ }, [loadAuthors]);
1142
+
1143
+ const off = config ? Object.entries(FEATURE_LABELS).filter(([id]) => !config.features[id]) : [];
1144
+
1145
+ return (
1146
+ <div className="space-y-6">
1147
+ <header className="grid min-w-0 gap-4 border-b border-kumo-line pb-4">
1148
+ <h1 className="flex min-h-9 min-w-0 items-center gap-2 text-2xl font-semibold leading-tight">
1149
+ <TreeStructure aria-hidden="true" /> Schema
1150
+ </h1>
1151
+ <p className="text-sm leading-5 text-pretty text-kumo-subtle">
1152
+ Schema.org structured data, robots directives and Open Graph extras. The graph replaces EmDash's built-in JSON-LD and
1153
+ keeps everything it had: headline, description, image, dates, author and publisher.
1154
+ </p>
1155
+ </header>
1156
+
1157
+ <PartSwitches module="schema" />
1158
+
1159
+ {error && <Banner variant="error" role="alert" title="Something went wrong" description={error} />}
1160
+ {off.length > 0 && (
1161
+ <Banner
1162
+ variant="default"
1163
+ role="status"
1164
+ description={`Off on the Coywolf Pack page: ${off.map(([, label]) => label).join(", ")}. Settings here are kept but not used until they're on.`}
1165
+ />
1166
+ )}
1167
+
1168
+ {!config && !error ? (
1169
+ <div className="flex justify-center py-12">
1170
+ <Loader />
1171
+ </div>
1172
+ ) : config ? (
1173
+ <>
1174
+ <Tabs value={tab} onValueChange={setTab} tabs={SCHEMA_TABS} />
1175
+ <div role="tabpanel" id={SCHEMA_PANEL_ID} aria-labelledby={schemaTabId(tab)}>
1176
+ {tab === "site" && <SiteTab config={config} bylines={bylines} onSaved={(site) => setConfig({ ...config, site })} />}
1177
+ {tab === "types" && <TypesTab config={config} onSaved={(types) => setConfig({ ...config, types })} />}
1178
+ {tab === "authors" && <AuthorsTab config={config} bylines={bylines} authorsOn={authorsOn} reload={() => void loadAuthors()} />}
1179
+ {tab === "overrides" && <OverridesTab config={config} bylines={bylines} />}
1180
+ {tab === "settings" && <SettingsTab config={config} onSaved={(settings) => setConfig({ ...config, settings })} />}
1181
+ {tab === "preview" && <PreviewTab config={config} />}
1182
+ </div>
1183
+ </>
1184
+ ) : null}
1185
+ </div>
1186
+ );
1187
+ }