@coywolf/emdash 0.0.0-stage → 0.35.14

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 (267) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -2
  3. package/docs/README.md +45 -0
  4. package/docs/ai.md +39 -0
  5. package/docs/backups.md +88 -0
  6. package/docs/breadcrumbs.md +59 -0
  7. package/docs/code-blocks.md +21 -0
  8. package/docs/content-urls.md +41 -0
  9. package/docs/custom-blocks.md +27 -0
  10. package/docs/discovery.md +32 -0
  11. package/docs/files.md +44 -0
  12. package/docs/form-uploads.md +59 -0
  13. package/docs/headings.md +37 -0
  14. package/docs/images.md +141 -0
  15. package/docs/internals.md +13 -0
  16. package/docs/links.md +34 -0
  17. package/docs/performance.md +72 -0
  18. package/docs/redirects.md +84 -0
  19. package/docs/releasing.md +21 -0
  20. package/docs/reviews.md +89 -0
  21. package/docs/robots.md +81 -0
  22. package/docs/schema.md +79 -0
  23. package/docs/search.md +114 -0
  24. package/docs/videos.md +48 -0
  25. package/docs/wordpress-import.md +198 -0
  26. package/package.json +79 -5
  27. package/src/admin/admin-css.ts +57 -0
  28. package/src/admin/ai.tsx +915 -0
  29. package/src/admin/backups.tsx +613 -0
  30. package/src/admin/breadcrumbs.tsx +542 -0
  31. package/src/admin/codeBlocks.tsx +204 -0
  32. package/src/admin/customBlocks.tsx +321 -0
  33. package/src/admin/dirty.ts +20 -0
  34. package/src/admin/discovery.tsx +463 -0
  35. package/src/admin/download.ts +28 -0
  36. package/src/admin/feature-switches.tsx +124 -0
  37. package/src/admin/features.tsx +142 -0
  38. package/src/admin/files.tsx +791 -0
  39. package/src/admin/formUploads.tsx +445 -0
  40. package/src/admin/guides.tsx +18 -0
  41. package/src/admin/headings.tsx +262 -0
  42. package/src/admin/images.tsx +825 -0
  43. package/src/admin/index.tsx +62 -0
  44. package/src/admin/links.tsx +980 -0
  45. package/src/admin/performance.tsx +330 -0
  46. package/src/admin/preview.tsx +63 -0
  47. package/src/admin/redirects-removed.tsx +266 -0
  48. package/src/admin/redirects.tsx +470 -0
  49. package/src/admin/reviews.tsx +232 -0
  50. package/src/admin/robots-bots.tsx +647 -0
  51. package/src/admin/robots-shared.tsx +109 -0
  52. package/src/admin/robots-wizard.tsx +748 -0
  53. package/src/admin/robots.tsx +911 -0
  54. package/src/admin/save-bar.tsx +134 -0
  55. package/src/admin/schema.tsx +1187 -0
  56. package/src/admin/search.tsx +355 -0
  57. package/src/admin/settings-ui.tsx +87 -0
  58. package/src/admin/trash-prompt-core.ts +135 -0
  59. package/src/admin/trash-prompt.tsx +156 -0
  60. package/src/admin/usage.tsx +301 -0
  61. package/src/admin/videos-display.tsx +248 -0
  62. package/src/admin/videos.tsx +1202 -0
  63. package/src/admin/wpImport.tsx +1516 -0
  64. package/src/ai/entities.ts +59 -0
  65. package/src/ai/export.ts +61 -0
  66. package/src/ai/logic.ts +606 -0
  67. package/src/ai/pack.ts +121 -0
  68. package/src/ai/prompts.ts +87 -0
  69. package/src/ai/providers.ts +276 -0
  70. package/src/ai/routes.ts +359 -0
  71. package/src/ai/store.ts +301 -0
  72. package/src/ai/wikidata.ts +87 -0
  73. package/src/ai/worker.ts +530 -0
  74. package/src/astro/astro-modules.d.ts +6 -0
  75. package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
  76. package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
  77. package/src/astro/codeBlocks/CodeBlock.astro +62 -0
  78. package/src/astro/codeBlocks/EmDashCode.astro +61 -0
  79. package/src/astro/customBlocks/CustomBlock.astro +64 -0
  80. package/src/astro/customBlocks/Details.astro +8 -0
  81. package/src/astro/customBlocks/Disclosure.astro +8 -0
  82. package/src/astro/customBlocks/Note.astro +8 -0
  83. package/src/astro/customBlocks/Podcast.astro +8 -0
  84. package/src/astro/customBlocks/Quote.astro +8 -0
  85. package/src/astro/customBlocks/Testimonial.astro +8 -0
  86. package/src/astro/files/FileCard.astro +103 -0
  87. package/src/astro/headings/HeadingBlock.astro +129 -0
  88. package/src/astro/headings/TableOfContents.astro +45 -0
  89. package/src/astro/headings/request.ts +19 -0
  90. package/src/astro/images/ResponsiveImage.astro +60 -0
  91. package/src/astro/index.ts +84 -0
  92. package/src/astro/reviews/CoywolfReview.astro +27 -0
  93. package/src/astro/reviews/Review.astro +49 -0
  94. package/src/astro/search/SearchBox.astro +61 -0
  95. package/src/astro/search/SearchBoxForm.astro +509 -0
  96. package/src/astro/tables/CaptionedTable.astro +72 -0
  97. package/src/astro/videos/CoywolfVideo.astro +448 -0
  98. package/src/backups/download.ts +112 -0
  99. package/src/backups/dump.ts +288 -0
  100. package/src/backups/module.ts +276 -0
  101. package/src/backups/pack.ts +33 -0
  102. package/src/backups/restore.ts +209 -0
  103. package/src/backups/store.ts +409 -0
  104. package/src/breadcrumbs/pack.ts +113 -0
  105. package/src/breadcrumbs/settings.ts +102 -0
  106. package/src/breadcrumbs/stamp.ts +40 -0
  107. package/src/breadcrumbs/trail.ts +123 -0
  108. package/src/codeBlocks/contrast.ts +179 -0
  109. package/src/codeBlocks/hljs.d.ts +5 -0
  110. package/src/codeBlocks/pack.ts +110 -0
  111. package/src/codeBlocks/render.ts +304 -0
  112. package/src/codeBlocks/settings.ts +18 -0
  113. package/src/codeBlocks/themes.generated.ts +304 -0
  114. package/src/codeBlocks/themes.ts +114 -0
  115. package/src/core/compose.ts +244 -0
  116. package/src/core/content-url.ts +888 -0
  117. package/src/core/csv.ts +119 -0
  118. package/src/core/d1-batch.ts +65 -0
  119. package/src/core/entry-docs.ts +175 -0
  120. package/src/core/features-module.ts +48 -0
  121. package/src/core/features.ts +392 -0
  122. package/src/core/generation.ts +88 -0
  123. package/src/core/guide-content.ts +166 -0
  124. package/src/core/module.ts +104 -0
  125. package/src/core/read-failures.ts +57 -0
  126. package/src/core/request-memo.ts +71 -0
  127. package/src/core/secrets.ts +83 -0
  128. package/src/core/settings-enhance.ts +74 -0
  129. package/src/core/site.ts +36 -0
  130. package/src/core/storage.ts +27 -0
  131. package/src/customBlocks/pack.ts +260 -0
  132. package/src/customBlocks/render.ts +424 -0
  133. package/src/customBlocks/rich.ts +313 -0
  134. package/src/customBlocks/settings.ts +46 -0
  135. package/src/discovery/build.ts +252 -0
  136. package/src/discovery/feed-build.ts +217 -0
  137. package/src/discovery/feed.ts +172 -0
  138. package/src/discovery/indexnow.ts +114 -0
  139. package/src/discovery/llms.ts +79 -0
  140. package/src/discovery/markdown.ts +294 -0
  141. package/src/discovery/memory-cache.ts +97 -0
  142. package/src/discovery/middleware.ts +186 -0
  143. package/src/discovery/module.ts +353 -0
  144. package/src/discovery/news.ts +99 -0
  145. package/src/discovery/pack.ts +58 -0
  146. package/src/discovery/settings.ts +91 -0
  147. package/src/discovery/urls.ts +35 -0
  148. package/src/env.d.ts +10 -0
  149. package/src/files/card.ts +231 -0
  150. package/src/files/counts.ts +60 -0
  151. package/src/files/export.ts +44 -0
  152. package/src/files/format.ts +163 -0
  153. package/src/files/module.ts +596 -0
  154. package/src/files/pack.ts +92 -0
  155. package/src/files/r2-rules.ts +41 -0
  156. package/src/files/range.ts +50 -0
  157. package/src/files/s3.ts +114 -0
  158. package/src/files/serve.ts +141 -0
  159. package/src/files/sigv4.ts +160 -0
  160. package/src/files/site.ts +137 -0
  161. package/src/files/walker.ts +58 -0
  162. package/src/formUploads/lib.ts +266 -0
  163. package/src/formUploads/pack.ts +55 -0
  164. package/src/formUploads/wrap.ts +592 -0
  165. package/src/forms.ts +16 -0
  166. package/src/headings/pack.ts +154 -0
  167. package/src/headings/render.ts +139 -0
  168. package/src/headings/settings.ts +117 -0
  169. package/src/headings/slug.ts +68 -0
  170. package/src/headings/stamp.ts +142 -0
  171. package/src/headings/test-hooks.mjs +14 -0
  172. package/src/headings/toc.ts +48 -0
  173. package/src/images/cloudflare.ts +329 -0
  174. package/src/images/lib.ts +221 -0
  175. package/src/images/module.ts +372 -0
  176. package/src/images/pack.ts +804 -0
  177. package/src/images/pending.ts +37 -0
  178. package/src/images/settings.ts +48 -0
  179. package/src/images/setup.ts +192 -0
  180. package/src/images/variants-estimate.ts +162 -0
  181. package/src/images/variants-store.ts +475 -0
  182. package/src/images/variants.ts +311 -0
  183. package/src/index.ts +143 -0
  184. package/src/links/check.ts +101 -0
  185. package/src/links/classify.ts +270 -0
  186. package/src/links/export.ts +59 -0
  187. package/src/links/module.ts +652 -0
  188. package/src/links/pack.ts +43 -0
  189. package/src/links/pt.ts +325 -0
  190. package/src/links/store.ts +704 -0
  191. package/src/middleware.ts +287 -0
  192. package/src/modules.ts +53 -0
  193. package/src/options.ts +66 -0
  194. package/src/pageCache/lib.ts +274 -0
  195. package/src/pageCache/pack.ts +240 -0
  196. package/src/pageCache/usage-core.ts +152 -0
  197. package/src/pageCache/usage.ts +262 -0
  198. package/src/pageCache/warm.ts +722 -0
  199. package/src/redirects/middleware.ts +169 -0
  200. package/src/redirects/module.ts +124 -0
  201. package/src/redirects/pack.ts +89 -0
  202. package/src/redirects/removed-core.ts +151 -0
  203. package/src/redirects/removed.ts +293 -0
  204. package/src/redirects/rules.ts +355 -0
  205. package/src/redirects/slug-changes.ts +207 -0
  206. package/src/redirects/slug-core.ts +49 -0
  207. package/src/redirects/transfer.ts +92 -0
  208. package/src/reviews/lib.ts +624 -0
  209. package/src/reviews/pack.ts +135 -0
  210. package/src/reviews/schema.ts +128 -0
  211. package/src/reviews/settings.ts +26 -0
  212. package/src/robots/bots.ts +321 -0
  213. package/src/robots/data/bots.json +696 -0
  214. package/src/robots/data/verified.json +582 -0
  215. package/src/robots/directory.ts +26 -0
  216. package/src/robots/explain.ts +191 -0
  217. package/src/robots/guided.ts +300 -0
  218. package/src/robots/importer.ts +263 -0
  219. package/src/robots/middleware.ts +151 -0
  220. package/src/robots/module.ts +446 -0
  221. package/src/robots/pack.ts +69 -0
  222. package/src/robots/radar.ts +129 -0
  223. package/src/robots/rep.ts +362 -0
  224. package/src/robots/rules.ts +883 -0
  225. package/src/robots/transfer.ts +45 -0
  226. package/src/robots/validate.ts +524 -0
  227. package/src/schema/catalog.ts +146 -0
  228. package/src/schema/graph.ts +791 -0
  229. package/src/schema/module.ts +1138 -0
  230. package/src/schema/og-image.ts +100 -0
  231. package/src/schema/pack.ts +58 -0
  232. package/src/search/engine.ts +578 -0
  233. package/src/search/fallback.ts +80 -0
  234. package/src/search/live-cache.ts +116 -0
  235. package/src/search/live-client.ts +770 -0
  236. package/src/search/live-serve.ts +282 -0
  237. package/src/search/module.ts +261 -0
  238. package/src/search/pack.ts +57 -0
  239. package/src/search/query.ts +132 -0
  240. package/src/search/ratelimit-core.ts +118 -0
  241. package/src/search/ratelimit.ts +79 -0
  242. package/src/search/snippet.ts +135 -0
  243. package/src/search/title-index.ts +139 -0
  244. package/src/search/title-match.ts +192 -0
  245. package/src/shared.ts +71 -0
  246. package/src/version.ts +2 -0
  247. package/src/videos/date.ts +40 -0
  248. package/src/videos/engagement.ts +75 -0
  249. package/src/videos/lib.ts +679 -0
  250. package/src/videos/module.ts +1160 -0
  251. package/src/videos/pack.ts +187 -0
  252. package/src/videos/pause.ts +42 -0
  253. package/src/videos/poster.ts +324 -0
  254. package/src/videos/render.ts +336 -0
  255. package/src/videos/store.ts +551 -0
  256. package/src/videos/stream.ts +134 -0
  257. package/src/wpImport/convert.ts +702 -0
  258. package/src/wpImport/guests.ts +329 -0
  259. package/src/wpImport/gutenberg.ts +157 -0
  260. package/src/wpImport/markers.ts +66 -0
  261. package/src/wpImport/pack.ts +451 -0
  262. package/src/wpImport/parents.ts +202 -0
  263. package/src/wpImport/prepare.ts +592 -0
  264. package/src/wpImport/redirects.ts +358 -0
  265. package/src/wpImport/sources.ts +67 -0
  266. package/src/wpImport/stream.ts +160 -0
  267. package/src/wpImport/urls.ts +354 -0
@@ -0,0 +1,542 @@
1
+ /**
2
+ * Breadcrumb Nav: site defaults for the Breadcrumbs block and theme
3
+ * component, with a live preview, plus a step-by-step guide for adding the
4
+ * component to an Astro theme.
5
+ */
6
+ import { Banner, Button, Checkbox, Input, Loader, Select } from "@cloudflare/kumo";
7
+ import { Check, Copy } 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
+
13
+ const API = "/_emdash/api/plugins/coywolf-pack/breadcrumbs";
14
+
15
+ interface Settings {
16
+ separator: string;
17
+ customSeparator: string;
18
+ homeLabel: string;
19
+ showHome: boolean;
20
+ showCurrent: boolean;
21
+ }
22
+
23
+ const SEPARATORS: Record<string, string> = {
24
+ slash: "/",
25
+ chevron: "›",
26
+ guillemet: "»",
27
+ bullet: "•",
28
+ arrow: "→",
29
+ gt: ">",
30
+ };
31
+ const SEPARATOR_ITEMS = [
32
+ { value: "slash", label: "/ Slash" },
33
+ { value: "chevron", label: "› Chevron" },
34
+ { value: "guillemet", label: "» Guillemet" },
35
+ { value: "bullet", label: "• Bullet" },
36
+ { value: "arrow", label: "→ Arrow" },
37
+ { value: "gt", label: "> Greater-than" },
38
+ ];
39
+
40
+ const errorText = (cause: unknown, fallback: string) => (cause instanceof Error && cause.message ? cause.message : fallback);
41
+
42
+ function Section(props: { title: string; description: React.ReactNode; children: React.ReactNode; collapsed?: boolean }) {
43
+ const id = `cw-breadcrumbs-${props.title.toLowerCase().replace(/[^a-z]+/g, "-")}`;
44
+ if (props.collapsed)
45
+ // Native <details>: the browser's own disclosure triangle and keyboard support; closed until opened.
46
+ return (
47
+ <details className="rounded-lg border border-kumo-line">
48
+ <summary className="cursor-pointer p-4" style={{ display: "list-item" }}>
49
+ <h2 id={id} className="text-base font-semibold" style={{ display: "inline" }}>
50
+ {props.title}
51
+ </h2>
52
+ <span className="mt-1 block text-sm text-kumo-subtle">{props.description}</span>
53
+ </summary>
54
+ <div className="space-y-4 border-t border-kumo-line p-4">{props.children}</div>
55
+ </details>
56
+ );
57
+ return (
58
+ <section className="rounded-lg border border-kumo-line" aria-labelledby={id}>
59
+ <div className="border-b border-kumo-line p-4">
60
+ <h2 id={id} className="text-base font-semibold">
61
+ {props.title}
62
+ </h2>
63
+ <p className="mt-1 text-sm text-kumo-subtle">{props.description}</p>
64
+ </div>
65
+ <div className="space-y-4 p-4">{props.children}</div>
66
+ </section>
67
+ );
68
+ }
69
+
70
+ /** A trail the way the component renders it, with the current settings. */
71
+ function Preview({ settings }: { settings: Settings }) {
72
+ const separator = settings.customSeparator || SEPARATORS[settings.separator] || "/";
73
+ const trail = [
74
+ ...(settings.showHome ? [{ name: settings.homeLabel.trim() || "Home", current: false }] : []),
75
+ { name: "Recipes", current: false },
76
+ ...(settings.showCurrent ? [{ name: "Overnight oats", current: true }] : []),
77
+ ];
78
+ return (
79
+ <div>
80
+ <p className="mb-2 text-sm font-medium">Preview</p>
81
+ <div className="rounded-md border border-kumo-line bg-kumo-tint px-4 py-3 text-sm" aria-live="polite">
82
+ {trail.length < 2 ? (
83
+ <span className="text-kumo-subtle">No trail: a single crumb isn't shown.</span>
84
+ ) : (
85
+ <ol className="m-0 flex list-none flex-wrap items-center p-0" aria-label="Breadcrumb preview">
86
+ {trail.map((crumb, i) => (
87
+ <li key={crumb.name + i} className="inline-flex items-center">
88
+ {i > 0 && (
89
+ <span className="px-2 opacity-60" aria-hidden="true">
90
+ {separator}
91
+ </span>
92
+ )}
93
+ {crumb.current ? <span className="font-semibold">{crumb.name}</span> : <span className="text-kumo-link underline">{crumb.name}</span>}
94
+ </li>
95
+ ))}
96
+ </ol>
97
+ )}
98
+ </div>
99
+ </div>
100
+ );
101
+ }
102
+
103
+ /** A code sample with a Copy button. */
104
+ function Code({ code, label }: { code: string; label: string }) {
105
+ const [status, setStatus] = React.useState<"" | "copied" | "failed">("");
106
+ const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
107
+ React.useEffect(() => () => {
108
+ if (timer.current) clearTimeout(timer.current);
109
+ }, []);
110
+ async function copy() {
111
+ try {
112
+ await navigator.clipboard.writeText(code);
113
+ setStatus("copied");
114
+ } catch {
115
+ setStatus("failed");
116
+ }
117
+ if (timer.current) clearTimeout(timer.current);
118
+ timer.current = setTimeout(() => setStatus(""), 2000);
119
+ }
120
+ return (
121
+ <div className="relative min-w-0">
122
+ <pre className="overflow-x-auto rounded-md border border-kumo-line bg-kumo-tint p-3 pe-24 text-xs leading-5" aria-label={label}>
123
+ <code>{code}</code>
124
+ </pre>
125
+ <div className="absolute right-2 top-2 flex items-center gap-2">
126
+ <span className="sr-only" aria-live="polite">
127
+ {status === "copied" ? `${label} copied` : status === "failed" ? "Couldn't copy. Select the code and copy it instead." : ""}
128
+ </span>
129
+ <Button variant="secondary" size="sm" icon={status === "copied" ? <Check /> : <Copy />} onClick={() => void copy()} aria-label={`Copy ${label}`}>
130
+ {status === "copied" ? "Copied" : status === "failed" ? "Failed" : "Copy"}
131
+ </Button>
132
+ </div>
133
+ </div>
134
+ );
135
+ }
136
+
137
+ function Step(props: { n: number; title: string; children: React.ReactNode }) {
138
+ return (
139
+ <li className="space-y-3">
140
+ <h3 className="text-sm font-semibold">
141
+ <span className="mr-2 inline-flex size-6 items-center justify-center rounded-full border border-kumo-line text-xs" aria-hidden="true">
142
+ {props.n}
143
+ </span>
144
+ {props.title}
145
+ </h3>
146
+ <div className="space-y-3 text-sm">{props.children}</div>
147
+ </li>
148
+ );
149
+ }
150
+
151
+ const C = (props: { children: React.ReactNode }) => <code className="rounded bg-kumo-tint px-1 py-0.5 text-xs">{props.children}</code>;
152
+
153
+ const IMPORT_CODE = `import { Breadcrumbs } from "@coywolf/emdash/astro";`;
154
+
155
+ const LAYOUT_CODE = `---
156
+ // src/layouts/Base.astro
157
+ import { EmDashHead } from "emdash/ui";
158
+ import { createPublicPageContext } from "emdash/page";
159
+ import { Breadcrumbs } from "@coywolf/emdash/astro";
160
+
161
+ const { title, breadcrumbs } = Astro.props;
162
+
163
+ const page = createPublicPageContext({
164
+ Astro,
165
+ kind: "custom",
166
+ title,
167
+ pageTitle: title,
168
+ breadcrumbs, // optional: see step 3
169
+ });
170
+ ---
171
+ <html lang="en">
172
+ <head>
173
+ <EmDashHead page={page} />
174
+ </head>
175
+ <body>
176
+ <header><!-- site header --></header>
177
+ <Breadcrumbs page={page} />
178
+ <main><slot /></main>
179
+ </body>
180
+ </html>`;
181
+
182
+ const TRAIL_CODE = `---
183
+ // A post page, e.g. src/pages/[category]/[slug].astro,
184
+ // after loading the entry and its category the way your theme does.
185
+ import { createPublicPageContext } from "emdash/page";
186
+
187
+ const page = createPublicPageContext({
188
+ Astro,
189
+ kind: "content",
190
+ content: { collection: "posts", id: entry.id, slug: entry.slug },
191
+ title: \`\${entry.data.title} | My Site\`,
192
+ pageTitle: entry.data.title,
193
+ // Root first, current page last.
194
+ breadcrumbs: [
195
+ { name: "Home", url: "/" },
196
+ { name: category.label, url: \`/\${category.slug}/\` },
197
+ { name: entry.data.title, url: Astro.url.pathname },
198
+ ],
199
+ });
200
+ ---
201
+ <!-- Renders: Home › Breakfast › Overnight oats -->`;
202
+
203
+ const LAYOUT_PROP_CODE = `<Base
204
+ title={entry.data.title}
205
+ breadcrumbs={[
206
+ { name: "Home", url: "/" },
207
+ { name: category.label, url: \`/\${category.slug}/\` },
208
+ { name: entry.data.title, url: Astro.url.pathname },
209
+ ]}
210
+ >`;
211
+
212
+ const HIDE_CODE = `// Home page (or any page that shouldn't show a trail)
213
+ const page = createPublicPageContext({ Astro, kind: "custom", breadcrumbs: [] });`;
214
+
215
+ const PROPS_CODE = `<Breadcrumbs page={page} separator="chevron" showHome={false} class="site-crumbs" />
216
+
217
+ <!-- Or a trail of your own, without a page context: -->
218
+ <Breadcrumbs
219
+ items={[{ name: "Docs", url: "/docs/" }, { name: "Install", url: "/docs/install/" }]}
220
+ separator="|"
221
+ label="You are here"
222
+ />`;
223
+
224
+ const MARKUP_CODE = `<nav class="cw-breadcrumbs cw-breadcrumbs--sep-chevron" aria-label="Breadcrumb">
225
+ <ol class="cw-breadcrumbs__list">
226
+ <li class="cw-breadcrumbs__item"><a href="/">Home</a></li>
227
+ <li class="cw-breadcrumbs__item"><a href="/breakfast/">Breakfast</a></li>
228
+ <li class="cw-breadcrumbs__item">
229
+ <span class="cw-breadcrumbs__current" aria-current="page">Overnight oats</span>
230
+ </li>
231
+ </ol>
232
+ </nav>`;
233
+
234
+ const CSS_CODE = `/* In your theme's stylesheet. Prefix with a parent (or use the class prop)
235
+ so these win over the component's single-class rules in any order. */
236
+ .site-header .cw-breadcrumbs {
237
+ font-size: 0.8125rem;
238
+ margin-block: 1rem;
239
+ }
240
+ .site-header .cw-breadcrumbs a {
241
+ color: inherit;
242
+ text-decoration: none;
243
+ }
244
+ .site-header .cw-breadcrumbs a:hover {
245
+ text-decoration: underline;
246
+ }
247
+ .site-header .cw-breadcrumbs__current {
248
+ font-weight: 400;
249
+ opacity: 0.8;
250
+ }
251
+ /* Separator from CSS (the separator setting or prop is simpler;
252
+ a custom separator is set inline and wins over this) */
253
+ .site-header .cw-breadcrumbs {
254
+ --cw-bc-sep: "\\2014";
255
+ }`;
256
+
257
+ const PROPS: { name: string; type: string; description: React.ReactNode }[] = [
258
+ {
259
+ name: "page",
260
+ type: "PublicPageContext",
261
+ description: (
262
+ <>
263
+ The context you pass to <C>{"<EmDashHead page={page} />"}</C>. Its <C>breadcrumbs</C> is the trail and its <C>pageTitle</C> (or{" "}
264
+ <C>title</C>) names the current page when the trail is derived from the URL.
265
+ </>
266
+ ),
267
+ },
268
+ { name: "items", type: "{ name, url }[]", description: <>An explicit trail, root first. Wins over <C>page</C>. <C>[]</C> renders nothing.</> },
269
+ {
270
+ name: "separator",
271
+ type: "string",
272
+ description: (
273
+ <>
274
+ A preset (<C>slash</C>, <C>chevron</C>, <C>guillemet</C>, <C>bullet</C>, <C>arrow</C>, <C>gt</C>) or any string up to 8 characters. Default: the
275
+ Separator setting.
276
+ </>
277
+ ),
278
+ },
279
+ { name: "homeLabel", type: "string", description: <>Name of the Home crumb the component adds (to derived trails, or when a trail doesn't start at <C>/</C>). Default: the Home label setting.</> },
280
+ { name: "showHome", type: "boolean", description: "Start the trail with the home page. Default: the setting." },
281
+ { name: "showCurrent", type: "boolean", description: "End the trail with the current page. Default: the setting." },
282
+ { name: "class", type: "string", description: <>Extra classes on the <C>{"<nav>"}</C>.</> },
283
+ { name: "label", type: "string", description: <>The nav's accessible name (<C>aria-label</C>). Default: “Breadcrumb”.</> },
284
+ { name: "title", type: "string", description: <>Current page title for a derived trail when neither <C>items</C> nor <C>page</C> gives one.</> },
285
+ ];
286
+
287
+ const CLASSES: { name: string; description: React.ReactNode }[] = [
288
+ { name: ".cw-breadcrumbs", description: <>The <C>{"<nav>"}</C>. Sets <C>font-size: 0.875em</C> and <C>line-height: 1.4</C>.</> },
289
+ { name: ".cw-breadcrumbs--sep-<preset>", description: <>Added for a preset separator, e.g. <C>.cw-breadcrumbs--sep-chevron</C>.</> },
290
+ { name: ".cw-breadcrumbs__list", description: <>The <C>{"<ol>"}</C>: a wrapping flex row with no list styling.</> },
291
+ { name: ".cw-breadcrumbs__item", description: <>Each <C>{"<li>"}</C>. The separator is its <C>::before</C> (0.5em padding each side, 60% opacity), hidden from screen readers.</> },
292
+ { name: ".cw-breadcrumbs__current", description: <>The current page: a <C>{"<span aria-current=\"page\">"}</C>, semibold. Ancestors are plain <C>{"<a>"}</C> links in your theme's link color.</> },
293
+ { name: "--cw-bc-sep", description: <>Custom property holding the separator as a CSS string, e.g. <C>{'"›"'}</C>. Custom separators set it inline.</> },
294
+ ];
295
+
296
+ export function BreadcrumbsPage() {
297
+ const [settings, setSettings] = React.useState<Settings | null>(null);
298
+ const [saved, setSaved] = React.useState<Settings | null>(null);
299
+ const [error, setError] = React.useState<string | null>(null);
300
+ const [notice, setNotice] = React.useState<string | null>(null);
301
+ const [saving, setSaving] = React.useState(false);
302
+
303
+ React.useEffect(() => {
304
+ void (async () => {
305
+ try {
306
+ const response = await apiFetch(`${API}/settings`);
307
+ const loaded = (await parseApiResponse<{ settings: Settings }>(response, "Couldn't load settings")).settings;
308
+ setSettings(loaded);
309
+ setSaved(loaded);
310
+ } catch (cause) {
311
+ setError(errorText(cause, "Couldn't load settings"));
312
+ }
313
+ })();
314
+ }, []);
315
+
316
+ const set = (patch: Partial<Settings>) => setSettings((s) => (s ? { ...s, ...patch } : s));
317
+
318
+ const dirty = isDirty(settings, saved);
319
+
320
+ async function save() {
321
+ if (!settings) return;
322
+ setSaving(true);
323
+ setError(null);
324
+ setNotice(null);
325
+ try {
326
+ const response = await apiFetch(`${API}/settings/save`, {
327
+ method: "POST",
328
+ headers: { "Content-Type": "application/json" },
329
+ body: JSON.stringify({ settings }),
330
+ });
331
+ const next = (await parseApiResponse<{ settings: Settings }>(response, "Couldn't save")).settings;
332
+ setSettings(next);
333
+ setSaved(next);
334
+ setNotice("Settings saved. Pages pick them up within a minute.");
335
+ } catch (cause) {
336
+ setError(errorText(cause, "Couldn't save"));
337
+ } finally {
338
+ setSaving(false);
339
+ }
340
+ }
341
+
342
+ return (
343
+ <div className="space-y-6">
344
+ <header className="grid min-w-0 gap-4 border-b border-kumo-line pb-4">
345
+ <h1 className="flex min-h-9 min-w-0 items-center text-2xl font-semibold leading-tight">Breadcrumb Nav</h1>
346
+ <p className="text-sm leading-5 text-pretty text-kumo-subtle">
347
+ An accessible breadcrumb trail for your pages, as a component in your theme's layout or as a Breadcrumbs block in content.
348
+ These defaults apply to both; the component's props and each block can override them.
349
+ </p>
350
+ </header>
351
+
352
+ <div aria-live="polite">{notice && <Banner variant="default" role="status" title={notice} />}</div>
353
+ {error && <Banner variant="error" role="alert" description={error} />}
354
+
355
+ {!settings && !error && (
356
+ <div className="flex justify-center py-12">
357
+ <Loader />
358
+ </div>
359
+ )}
360
+
361
+ {settings && (
362
+ <form
363
+ id="cw-breadcrumbs-form"
364
+ className="space-y-6"
365
+ onSubmit={(e) => {
366
+ e.preventDefault();
367
+ void save();
368
+ }}
369
+ >
370
+ <Section title="Settings" description="How trails look by default.">
371
+ <Preview settings={settings} />
372
+ <Select
373
+ label="Separator"
374
+ value={settings.separator}
375
+ onValueChange={(value: string | null) => set({ separator: value ?? "slash" })}
376
+ items={SEPARATOR_ITEMS}
377
+ />
378
+ <Input
379
+ label="Custom separator"
380
+ description="Up to 8 characters, e.g. | or ::. Overrides the separator above when set."
381
+ value={settings.customSeparator}
382
+ maxLength={8}
383
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => set({ customSeparator: e.target.value })}
384
+ />
385
+ <Input
386
+ label="Home label"
387
+ description="Name of the first crumb when the trail starts with the home page."
388
+ value={settings.homeLabel}
389
+ maxLength={60}
390
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => set({ homeLabel: e.target.value })}
391
+ />
392
+ <fieldset className="min-w-0">
393
+ <legend className="mb-2 text-sm font-medium">Trail</legend>
394
+ <div className="flex flex-col items-start gap-3">
395
+ <Checkbox
396
+ label="Start the trail with the home page"
397
+ checked={settings.showHome}
398
+ onCheckedChange={(checked: boolean) => set({ showHome: checked })}
399
+ />
400
+ <Checkbox
401
+ label="End the trail with the current page"
402
+ checked={settings.showCurrent}
403
+ onCheckedChange={(checked: boolean) => set({ showCurrent: checked })}
404
+ />
405
+ </div>
406
+ </fieldset>
407
+ </Section>
408
+ </form>
409
+ )}
410
+
411
+ <Section
412
+ collapsed
413
+ title="Add the breadcrumb nav to your Astro theme"
414
+ description="Put the Breadcrumbs component in a layout once and every page that uses the layout gets a trail. Nothing renders while Breadcrumb Nav is turned off under Plugins → Coywolf Pack."
415
+ >
416
+ <ol className="grid gap-8">
417
+ <Step n={1} title="Import the component">
418
+ <p>In the layout (or page) where the trail should appear:</p>
419
+ <Code label="Import" code={IMPORT_CODE} />
420
+ </Step>
421
+
422
+ <Step n={2} title="Place it in your layout">
423
+ <p>
424
+ Pass it the same <C>page</C> object you give <C>{"<EmDashHead page={page} />"}</C> (the <C>PublicPageContext</C> from{" "}
425
+ <C>createPublicPageContext</C>). Put it where the trail should show, usually just above the main content.
426
+ </p>
427
+ <Code label="Layout example" code={LAYOUT_CODE} />
428
+ <p className="text-kumo-subtle">
429
+ Without a trail of your own, the component derives one from the URL: Home, one crumb per path segment with a readable
430
+ name (<C>/healthy-recipes/</C> becomes “Healthy recipes”), then the page title from <C>pageTitle</C>.
431
+ </p>
432
+ </Step>
433
+
434
+ <Step n={3} title="Pass an explicit trail (recommended)">
435
+ <p>
436
+ Derived ancestors link to whatever the path segments are, which may not be real pages. Give each page its real trail with{" "}
437
+ <C>breadcrumbs</C>: <C>{"{ name, url }"}</C> items, root first, current page last.
438
+ </p>
439
+ <Code label="Post page example" code={TRAIL_CODE} />
440
+ <p>If your layout builds the context, pass the trail to it as a prop instead:</p>
441
+ <Code label="Layout prop example" code={LAYOUT_PROP_CODE} />
442
+ <ul className="list-disc space-y-1 ps-5">
443
+ <li>
444
+ <C>breadcrumbs: []</C> hides the trail on that page (the home page, a landing page):
445
+ </li>
446
+ </ul>
447
+ <Code label="Hide example" code={HIDE_CODE} />
448
+ <ul className="list-disc space-y-1 ps-5">
449
+ <li>Leave <C>breadcrumbs</C> out to derive the trail from the URL.</li>
450
+ <li>URLs can be root-relative (<C>/breakfast/</C>) or absolute.</li>
451
+ <li>“Start with the home page” and “End with the current page” trim or add those crumbs when the trail renders.</li>
452
+ </ul>
453
+ </Step>
454
+
455
+ <Step n={4} title="Adjust it with props (optional)">
456
+ <p>Props override the settings above for that one placement.</p>
457
+ <div className="overflow-x-auto">
458
+ <table className="w-full text-left text-sm">
459
+ <thead className="text-kumo-subtle">
460
+ <tr>
461
+ <th scope="col" className="py-1 pr-4 font-medium">Prop</th>
462
+ <th scope="col" className="py-1 pr-4 font-medium">Type</th>
463
+ <th scope="col" className="py-1 font-medium">What it does</th>
464
+ </tr>
465
+ </thead>
466
+ <tbody className="divide-y divide-kumo-line">
467
+ {PROPS.map((p) => (
468
+ <tr key={p.name} className="align-top">
469
+ <td className="whitespace-nowrap py-2 pr-4"><C>{p.name}</C></td>
470
+ <td className="whitespace-nowrap py-2 pr-4 text-kumo-subtle">{p.type}</td>
471
+ <td className="py-2">{p.description}</td>
472
+ </tr>
473
+ ))}
474
+ </tbody>
475
+ </table>
476
+ </div>
477
+ <p>
478
+ The trail comes from, in order: <C>items</C>, <C>page.breadcrumbs</C>, the trail the theme gave <C>EmDashHead</C> for the
479
+ same URL, then the URL path.
480
+ </p>
481
+ <Code label="Props example" code={PROPS_CODE} />
482
+ </Step>
483
+
484
+ <Step n={5} title="Style it (optional)">
485
+ <p>
486
+ The component outputs this markup (separators are CSS, so screen readers skip them) with a small global stylesheet that
487
+ inherits your theme's colors and font:
488
+ </p>
489
+ <Code label="Markup" code={MARKUP_CODE} />
490
+ <div className="overflow-x-auto">
491
+ <table className="w-full text-left text-sm">
492
+ <thead className="text-kumo-subtle">
493
+ <tr>
494
+ <th scope="col" className="py-1 pr-4 font-medium">Class or property</th>
495
+ <th scope="col" className="py-1 font-medium">What it is</th>
496
+ </tr>
497
+ </thead>
498
+ <tbody className="divide-y divide-kumo-line">
499
+ {CLASSES.map((c) => (
500
+ <tr key={c.name} className="align-top">
501
+ <td className="whitespace-nowrap py-2 pr-4"><C>{c.name}</C></td>
502
+ <td className="py-2">{c.description}</td>
503
+ </tr>
504
+ ))}
505
+ </tbody>
506
+ </table>
507
+ </div>
508
+ <Code label="CSS override example" code={CSS_CODE} />
509
+ </Step>
510
+
511
+ <Step n={6} title="Get matching breadcrumb schema">
512
+ <p>
513
+ With Schema &amp; Social's <strong>Breadcrumb schema</strong> feature on, the page's <C>BreadcrumbList</C> is built from the
514
+ same <C>page.breadcrumbs</C>, so passing the trail once (step 3) keeps the visible nav and the structured data in agreement.
515
+ </p>
516
+ <ul className="list-disc space-y-1 ps-5">
517
+ <li>The schema uses the trail as given (relative URLs made absolute), so include Home first and the current page last.</li>
518
+ <li><C>breadcrumbs: []</C> removes both the nav and the BreadcrumbList on that page.</li>
519
+ <li>
520
+ Without a trail, both derive from the URL. The schema's home label is set on the Schema page; keep it the same as the
521
+ Home label here.
522
+ </li>
523
+ </ul>
524
+ </Step>
525
+
526
+ <Step n={7} title="Or insert a Breadcrumbs block in content">
527
+ <p>
528
+ No theme changes needed: in the editor, add a <strong>Breadcrumbs</strong> block (Sections) where the trail should
529
+ appear. Each block can override the separator, home label, and whether the current page shows.
530
+ </p>
531
+ <p className="text-kumo-subtle">
532
+ The block uses the trail the theme gave <C>EmDashHead</C> for the page (when the theme passes its real URL), otherwise
533
+ one derived from the URL, with the entry's title (saved with the block) as the last crumb.
534
+ </p>
535
+ </Step>
536
+ </ol>
537
+ </Section>
538
+
539
+ <SaveBar form="cw-breadcrumbs-form" dirty={dirty} saving={saving} onDiscard={() => setSettings(saved)} />
540
+ </div>
541
+ );
542
+ }