@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,123 @@
1
+ /**
2
+ * Breadcrumb trails. The theme's trail (PublicPageContext.breadcrumbs) wins
3
+ * when it has one; otherwise the trail is derived from the URL path, with
4
+ * the page title for the last crumb and humanized path segments for the
5
+ * ancestors.
6
+ */
7
+
8
+ export interface Crumb {
9
+ name: string;
10
+ url: string;
11
+ }
12
+
13
+ export interface TrailOptions {
14
+ /** Current URL path, e.g. "/guides/setup/". */
15
+ path: string;
16
+ /** The theme's trail, root first. `[]` means "no breadcrumbs on this page". */
17
+ items?: readonly Crumb[] | null;
18
+ /** Title of the current page (last crumb when deriving). */
19
+ title?: string | null;
20
+ homeLabel: string;
21
+ showHome: boolean;
22
+ showCurrent: boolean;
23
+ }
24
+
25
+ /** "getting-started_now" → "Getting started now". */
26
+ export function humanize(segment: string): string {
27
+ let decoded = segment;
28
+ try {
29
+ decoded = decodeURIComponent(segment);
30
+ } catch {
31
+ // Keep the raw segment.
32
+ }
33
+ const words = decoded.replace(/\.[a-z0-9]+$/i, "").replace(/[-_+]+/g, " ").trim();
34
+ return words ? words.charAt(0).toUpperCase() + words.slice(1) : segment;
35
+ }
36
+
37
+ const normalizePath = (path: string) => (path.length > 1 ? path.replace(/\/+$/, "") : path) || "/";
38
+
39
+ /** Root-relative path of a crumb URL (absolute URLs are reduced to their path). */
40
+ function crumbPath(url: string): string {
41
+ try {
42
+ return normalizePath(new URL(url, "https://x.invalid").pathname);
43
+ } catch {
44
+ return url;
45
+ }
46
+ }
47
+
48
+ /** The trail to render: home first, current page last (unless hidden). Empty when there's nothing useful to show. */
49
+ export function resolveTrail(options: TrailOptions): Crumb[] {
50
+ const current = normalizePath(options.path.split(/[?#]/)[0] || "/");
51
+ let trail: Crumb[];
52
+
53
+ if (options.items) {
54
+ trail = options.items.filter((c) => c && typeof c.name === "string" && c.name.trim() && typeof c.url === "string").map((c) => ({ name: c.name.trim(), url: c.url }));
55
+ if (!options.showHome && trail.length && crumbPath(trail[0].url) === "/") trail = trail.slice(1);
56
+ else if (options.showHome && trail.length && crumbPath(trail[0].url) !== "/") trail.unshift({ name: options.homeLabel, url: "/" });
57
+ } else {
58
+ if (current === "/") return [];
59
+ const trailingSlash = options.path.split(/[?#]/)[0].endsWith("/");
60
+ const segments = current.split("/").filter(Boolean);
61
+ trail = options.showHome ? [{ name: options.homeLabel, url: "/" }] : [];
62
+ segments.forEach((segment, i) => {
63
+ const url = `/${segments.slice(0, i + 1).join("/")}${trailingSlash ? "/" : ""}`;
64
+ const last = i === segments.length - 1;
65
+ trail.push({ name: last && options.title?.trim() ? options.title.trim() : humanize(segment), url });
66
+ });
67
+ }
68
+
69
+ if (!options.showCurrent && trail.length && crumbPath(trail[trail.length - 1].url) === current) trail = trail.slice(0, -1);
70
+ // A lone crumb (just Home, or just the page) isn't a useful trail.
71
+ return trail.length >= 2 ? trail : [];
72
+ }
73
+
74
+ /** Escape a separator for a CSS string (`content: "…"`). */
75
+ export function cssString(value: string): string {
76
+ return `"${value.replace(/[\\"]/g, (c) => `\\${c}`).replace(/[\u0000-\u001f<>]/g, "")}"`;
77
+ }
78
+
79
+ // ── Theme trails captured from page:metadata ─────────────────────
80
+ //
81
+ // EmDashHead runs the page:metadata hook (with the theme's
82
+ // PublicPageContext) before the body renders. The hook records the page's
83
+ // breadcrumbs and title here so a Breadcrumbs block in the body can use the
84
+ // theme's trail without the theme wiring anything. This relies on the theme
85
+ // rendering <EmDashHead page={page}> with an accurate `url`; without it the
86
+ // block derives the trail from the URL path. Keyed by the page URL (path plus
87
+ // query, so locales and variants don't mix); short-lived.
88
+
89
+ interface Captured {
90
+ items?: Crumb[];
91
+ title: string | null;
92
+ at: number;
93
+ }
94
+
95
+ const CAPTURE_TTL_MS = 60_000;
96
+ const CAPTURE_MAX = 200;
97
+ const captured = new Map<string, Captured>();
98
+
99
+ /** Cache key for a page URL: normalized path plus query string, ignoring origin and fragment. */
100
+ export function captureKey(url: string): string {
101
+ try {
102
+ const parsed = new URL(url, "https://x.invalid");
103
+ return `${normalizePath(parsed.pathname)}${parsed.search}`;
104
+ } catch {
105
+ return url;
106
+ }
107
+ }
108
+
109
+ export function capturePage(page: { path?: string; url?: string; locale?: string | null; breadcrumbs?: Crumb[]; pageTitle?: string | null; title?: string | null }): void {
110
+ const source = page.url ?? page.path;
111
+ if (!source) return;
112
+ const key = captureKey(source);
113
+ captured.delete(key);
114
+ captured.set(key, { items: page.breadcrumbs, title: page.pageTitle ?? page.title ?? null, at: Date.now() });
115
+ while (captured.size > CAPTURE_MAX) captured.delete(captured.keys().next().value as string);
116
+ }
117
+
118
+ /** The trail captured for this request URL, if EmDashHead published one in the last minute. */
119
+ export function capturedPage(url: string): Omit<Captured, "at"> | null {
120
+ const entry = captured.get(captureKey(url));
121
+ if (!entry || Date.now() - entry.at > CAPTURE_TTL_MS) return null;
122
+ return entry;
123
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Readable code block themes (WCAG 1.4.3). Several highlight.js themes color
3
+ * comments, and a few their body text, below 4.5:1 against their own
4
+ * background. Rather than hand-edit the generated themes, each theme's CSS is
5
+ * checked once and any color under the minimum is moved in lightness only
6
+ * (same hue and saturation) until it reaches it, so the theme keeps its look.
7
+ */
8
+
9
+ export const MIN_CONTRAST = 4.5;
10
+ /** Aim a little above the minimum so rounding to hex never lands under it. */
11
+ const TARGET = 4.6;
12
+
13
+ type Rgba = [number, number, number, number];
14
+
15
+ const NAMED: Record<string, string> = { black: "#000000", white: "#ffffff", gold: "#ffd700", gray: "#808080", grey: "#808080", red: "#ff0000", green: "#008000", blue: "#0000ff", navy: "#000080", purple: "#800080", maroon: "#800000", teal: "#008080", olive: "#808000", silver: "#c0c0c0" };
16
+
17
+ /** A CSS color as RGBA (0–255, alpha 0–1), or null for anything else (url(), gradients, inherit…). */
18
+ export function parseColor(value: string): Rgba | null {
19
+ const v = value.trim().toLowerCase().replace(/\s*!important$/, "");
20
+ const hex = NAMED[v] ?? v;
21
+ let m = /^#([0-9a-f]{3,4})$/.exec(hex);
22
+ if (m) {
23
+ const [r, g, b, a = "f"] = m[1].split("");
24
+ return [parseInt(r + r, 16), parseInt(g + g, 16), parseInt(b + b, 16), parseInt(a + a, 16) / 255];
25
+ }
26
+ m = /^#([0-9a-f]{6})([0-9a-f]{2})?$/.exec(hex);
27
+ if (m) return [parseInt(m[1].slice(0, 2), 16), parseInt(m[1].slice(2, 4), 16), parseInt(m[1].slice(4, 6), 16), m[2] ? parseInt(m[2], 16) / 255 : 1];
28
+ m = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)$/.exec(v);
29
+ if (m) return [Number(m[1]), Number(m[2]), Number(m[3]), m[4] === undefined ? 1 : Number(m[4])];
30
+ return null;
31
+ }
32
+
33
+ /** `top` drawn over an opaque `bottom`. */
34
+ function over(top: Rgba, bottom: Rgba): Rgba {
35
+ const a = top[3];
36
+ return [top[0] * a + bottom[0] * (1 - a), top[1] * a + bottom[1] * (1 - a), top[2] * a + bottom[2] * (1 - a), 1];
37
+ }
38
+
39
+ function luminance([r, g, b]: Rgba): number {
40
+ const lin = (c: number) => {
41
+ const s = c / 255;
42
+ return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
43
+ };
44
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
45
+ }
46
+
47
+ export function contrastRatio(a: Rgba, b: Rgba): number {
48
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
49
+ return (hi + 0.05) / (lo + 0.05);
50
+ }
51
+
52
+ function toHsl([r, g, b]: Rgba): [number, number, number] {
53
+ const [rr, gg, bb] = [r / 255, g / 255, b / 255];
54
+ const max = Math.max(rr, gg, bb);
55
+ const min = Math.min(rr, gg, bb);
56
+ const l = (max + min) / 2;
57
+ if (max === min) return [0, 0, l];
58
+ const d = max - min;
59
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
60
+ const h = max === rr ? (gg - bb) / d + (gg < bb ? 6 : 0) : max === gg ? (bb - rr) / d + 2 : (rr - gg) / d + 4;
61
+ return [h / 6, s, l];
62
+ }
63
+
64
+ function fromHsl(h: number, s: number, l: number): Rgba {
65
+ if (s === 0) return [l * 255, l * 255, l * 255, 1];
66
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
67
+ const p = 2 * l - q;
68
+ const ch = (t: number) => {
69
+ const x = t < 0 ? t + 1 : t > 1 ? t - 1 : t;
70
+ if (x < 1 / 6) return p + (q - p) * 6 * x;
71
+ if (x < 1 / 2) return q;
72
+ if (x < 2 / 3) return p + (q - p) * (2 / 3 - x) * 6;
73
+ return p;
74
+ };
75
+ return [ch(h + 1 / 3) * 255, ch(h) * 255, ch(h - 1 / 3) * 255, 1];
76
+ }
77
+
78
+ const toHex = (c: Rgba) => `#${c.slice(0, 3).map((x) => Math.round(Math.min(255, Math.max(0, x))).toString(16).padStart(2, "0")).join("")}`;
79
+
80
+ /**
81
+ * `color` (any alpha, drawn over `background`) made readable on `background`:
82
+ * unchanged when it already passes, else the nearest lightness that does
83
+ * (lighter on dark backgrounds, darker on light ones). Returns an opaque hex.
84
+ */
85
+ export function readableOn(color: Rgba, background: Rgba): Rgba {
86
+ const solid = over(color, background);
87
+ if (contrastRatio(solid, background) >= MIN_CONTRAST) return solid;
88
+ const [h, s, l] = toHsl(solid);
89
+ const lighter = luminance(background) < 0.18;
90
+ let [lo, hi] = lighter ? [l, 1] : [0, l];
91
+ for (let i = 0; i < 24; i++) {
92
+ const mid = (lo + hi) / 2;
93
+ const ok = contrastRatio(fromHsl(h, s, mid), background) >= TARGET;
94
+ if (lighter) ok ? (hi = mid) : (lo = mid);
95
+ else ok ? (lo = mid) : (hi = mid);
96
+ }
97
+ const fixed = parseColor(toHex(fromHsl(h, s, lighter ? hi : lo))) as Rgba;
98
+ return contrastRatio(fixed, background) >= MIN_CONTRAST ? fixed : lighter ? [255, 255, 255, 1] : [0, 0, 0, 1];
99
+ }
100
+
101
+ interface Rule {
102
+ selectors: string[];
103
+ body: string;
104
+ }
105
+
106
+ const RULE = /([^{}]+)\{([^{}]*)\}/g;
107
+ const DECL = (name: string) => new RegExp(`(^|;)(\\s*${name}\\s*:\\s*)([^;]+)`, "i");
108
+
109
+ function parseRules(css: string): Rule[] {
110
+ return [...css.matchAll(RULE)].map((m) => ({ selectors: m[1].split(",").map((s) => s.trim()), body: m[2] }));
111
+ }
112
+
113
+ const declared = (body: string, name: string) => DECL(name).exec(body)?.[3] ?? null;
114
+ const backgroundOf = (body: string) => parseColor(declared(body, "background-color") ?? declared(body, "background") ?? "");
115
+
116
+ /** The theme's own background and text color (the rules for `.cw-code` itself). */
117
+ export function themeBase(css: string): { background: Rgba; color: Rgba } {
118
+ const rules = parseRules(css).filter((r) => r.selectors.includes(".cw-code"));
119
+ let background: Rgba | null = null;
120
+ let color: Rgba | null = null;
121
+ for (const r of rules) {
122
+ background = backgroundOf(r.body) ?? background;
123
+ color = parseColor(declared(r.body, "color") ?? "") ?? color;
124
+ }
125
+ const bg = background ?? [255, 255, 255, 1];
126
+ return { background: bg, color: color ?? (luminance(bg) < 0.18 ? [255, 255, 255, 1] : [0, 0, 0, 1]) };
127
+ }
128
+
129
+ /** Every text color a theme's rules set, each with the background it's read on. */
130
+ export function themeTextColors(css: string): Array<{ selector: string; color: Rgba; background: Rgba }> {
131
+ const base = themeBase(css);
132
+ const out: Array<{ selector: string; color: Rgba; background: Rgba }> = [];
133
+ for (const r of parseRules(css)) {
134
+ const own = backgroundOf(r.body);
135
+ const background = own ? over(own, base.background) : base.background;
136
+ const color = parseColor(declared(r.body, "color") ?? "");
137
+ if (color) out.push({ selector: r.selectors.join(","), color: over(color, background), background });
138
+ else if (own) out.push({ selector: r.selectors.join(","), color: base.color, background });
139
+ }
140
+ return out;
141
+ }
142
+
143
+ /** Opacity line numbers had (now a color per theme, --cw-line). */
144
+ export const LINE_OPACITY = 0.5;
145
+ /** Opacity the language label had (now a color per theme, --cw-label). */
146
+ export const LABEL_OPACITY = 0.75;
147
+
148
+ /**
149
+ * `color` at `opacity` over `background`, as an opaque hex: exactly what the
150
+ * opacity looked like when that already reaches 4.5:1, else the nearest
151
+ * lightness that does (readableOn).
152
+ */
153
+ export function fadedColor(color: Rgba, background: Rgba, opacity: number): string {
154
+ return toHex(readableOn([color[0], color[1], color[2], color[3] * opacity], background));
155
+ }
156
+
157
+ /**
158
+ * A muted text color for a theme (line numbers by default): its text color at
159
+ * `opacity` over its background, as before, raised to 4.5:1 only where that's
160
+ * too faint.
161
+ */
162
+ export function mutedColor(css: string, opacity = LINE_OPACITY): string {
163
+ const { color, background } = themeBase(css);
164
+ return fadedColor(color, background, opacity);
165
+ }
166
+
167
+ /** The theme CSS with every text color under 4.5:1 replaced by a readable one of the same hue. */
168
+ export function readableThemeCss(css: string): string {
169
+ const base = themeBase(css);
170
+ return css.replace(RULE, (whole, selectors: string, body: string) => {
171
+ const own = backgroundOf(body);
172
+ const background = own ? over(own, base.background) : base.background;
173
+ const value = declared(body, "color");
174
+ const color = value ? parseColor(value) : own ? base.color : null;
175
+ if (!color || contrastRatio(over(color, background), background) >= MIN_CONTRAST) return whole;
176
+ const hex = toHex(readableOn(color, background));
177
+ return value ? `${selectors}{${body.replace(DECL("color"), `$1$2${hex}`)}}` : `${selectors}{${body};color:${hex}}`;
178
+ });
179
+ }
@@ -0,0 +1,5 @@
1
+ declare module "highlight.js/lib/languages/*" {
2
+ import type { LanguageFn } from "highlight.js";
3
+ const language: LanguageFn;
4
+ export default language;
5
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Code Blocks module: server-side syntax highlighting, themes, language
3
+ * label, copy button and line numbers for EmDash's built-in code block
4
+ * (rendered by src/astro/codeBlocks/CodeBlock.astro). Port of Coywolf Code
5
+ * Block Enhancer.
6
+ */
7
+ import { PluginRouteError, definePluginRoute } from "emdash";
8
+ import { z } from "zod";
9
+
10
+ import { registerFeatures } from "../core/features.js";
11
+ import type { PackModule } from "../core/module.js";
12
+ import { parseInput } from "../shared.js";
13
+ import { invalidateTheme } from "./settings.js";
14
+ import { DEFAULT_THEME, THEME_SETTING, isTheme, themeCss, themeOptions } from "./themes.js";
15
+
16
+ // biome-ignore lint/suspicious/noEmptyInterface: reserved for future options.
17
+ export interface CodeBlocksOptions {}
18
+
19
+ export const FEATURES = [
20
+ {
21
+ id: "codeBlocks",
22
+ label: "Code blocks",
23
+ description: "Syntax highlighting and themes for code blocks on the site, rendered on the server (no highlighting script for visitors).",
24
+ default: false,
25
+ },
26
+ {
27
+ id: "codeBlocks.label",
28
+ label: "Language label",
29
+ description: "Show the code's language (e.g. TypeScript) above each block.",
30
+ default: false,
31
+ },
32
+ {
33
+ id: "codeBlocks.copy",
34
+ label: "Copy button",
35
+ description: "An accessible copy-to-clipboard button on each block (one small inline script, only on pages with code).",
36
+ default: false,
37
+ },
38
+ {
39
+ id: "codeBlocks.lineNumbers",
40
+ label: "Line numbers",
41
+ description: "Number each line. Numbers aren't selected or copied with the code.",
42
+ default: false,
43
+ },
44
+ ];
45
+ registerFeatures(FEATURES);
46
+
47
+ const SAMPLE = {
48
+ language: "typescript",
49
+ code: `/** Say hello to someone. */
50
+ export function greet(name: string, times = 1): string {
51
+ const words = ["Hello", name].join(", ");
52
+ // Repeat it if asked
53
+ return \`\${words}!\`.repeat(times);
54
+ }
55
+
56
+ console.log(greet("Coywolf", 2));
57
+ `,
58
+ };
59
+
60
+ export function codeBlocksPack(_options: CodeBlocksOptions): PackModule {
61
+ return {
62
+ id: "codeBlocks",
63
+ label: "Code Blocks",
64
+ features: FEATURES,
65
+ adminPages: [{ path: "/code-blocks", label: "Code Blocks", icon: "code" }],
66
+ routes: {
67
+ "codeBlocks/settings": {
68
+ permission: "plugins:manage" as const,
69
+ // biome-ignore lint/suspicious/noExplicitAny: plugin context.
70
+ handler: async (ctx: any) => {
71
+ const stored = await ctx.settings.get(THEME_SETTING);
72
+ return { theme: isTheme(stored) ? stored : DEFAULT_THEME, themes: themeOptions() };
73
+ },
74
+ },
75
+
76
+ "codeBlocks/save": definePluginRoute({
77
+ permission: "plugins:manage",
78
+ methods: ["POST"],
79
+ request: { body: "json" },
80
+ handler: async (ctx) => {
81
+ const { theme } = parseInput(z.object({ theme: z.string().max(100) }), ctx.input);
82
+ if (!isTheme(theme)) throw PluginRouteError.badRequest("Unknown theme.");
83
+ await ctx.settings.set(THEME_SETTING, theme);
84
+ invalidateTheme();
85
+ ctx.log.info("Code block theme saved", { theme });
86
+ return { theme };
87
+ },
88
+ }),
89
+
90
+ /** Preview markup + CSS for the admin page (same renderer as the site). */
91
+ "codeBlocks/preview": definePluginRoute({
92
+ permission: "plugins:manage",
93
+ methods: ["POST"],
94
+ request: { body: "json" },
95
+ handler: async (ctx) => {
96
+ const input = parseInput(
97
+ z.object({ theme: z.string().max(100), label: z.boolean(), copy: z.boolean(), lineNumbers: z.boolean() }),
98
+ ctx.input,
99
+ );
100
+ // Lazy: keeps lowlight and its grammars out of the plugin's startup path.
101
+ const { CHROME_CSS, renderBlock } = await import("./render.js");
102
+ return {
103
+ css: CHROME_CSS + themeCss(input.theme),
104
+ html: renderBlock(SAMPLE, { label: input.label, copy: input.copy, lineNumbers: input.lineNumbers }),
105
+ };
106
+ },
107
+ }),
108
+ },
109
+ };
110
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Run: node --test src/codeBlocks/render.test.ts (Node 22.18+ strips types)
3
+ */
4
+ import assert from "node:assert/strict";
5
+ import { test } from "node:test";
6
+
7
+ // A literal ".ts" specifier would trip tsc (no allowImportingTsExtensions); Node needs the real file name.
8
+ const path = "./render" + ".ts";
9
+ const r: typeof import("./render.js") = await import(path);
10
+
11
+ test("escapes text in highlighted and plain output", () => {
12
+ const html = r.renderBlock({ code: `<script>alert("x")</script> & </code>`, language: "zig" }, { label: true, copy: false, lineNumbers: false });
13
+ assert.ok(!html.includes("<script>"));
14
+ assert.ok(html.includes("&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt; &amp; &lt;/code&gt;"));
15
+ const hl = r.highlightLines(`const a = "<b>";`, "javascript").join("\n");
16
+ assert.ok(hl.includes("&lt;b&gt;"));
17
+ assert.ok(!hl.includes("<b>"));
18
+ });
19
+
20
+ test("shows the language, not the file name, and escapes an unknown language label", () => {
21
+ const html = r.renderBlock({ code: "x", language: `"><img src=x>`, filename: `<i>a.ts</i>` }, { label: true, copy: false, lineNumbers: false });
22
+ assert.ok(!html.includes("<img"));
23
+ assert.ok(!html.includes("a.ts"));
24
+ assert.match(html, /class="language-img-src-x"/);
25
+ const ts = r.renderBlock({ code: "let a = 1;", language: "typescript", filename: "greet.ts" }, { label: true, copy: true, lineNumbers: false });
26
+ assert.match(ts, /<div class="cw-code-head"><span class="cw-code-label">TypeScript<\/span><\/div>/);
27
+ assert.ok(!ts.includes("greet.ts"));
28
+ });
29
+
30
+ test("only hljs classes survive hast → HTML", () => {
31
+ const lines = r.hastToLines({
32
+ type: "root",
33
+ children: [
34
+ {
35
+ type: "element",
36
+ tagName: "span",
37
+ properties: { className: ["hljs-title", "function_", "evil\" onclick=\"x", "other"] },
38
+ children: [{ type: "text", value: "f" }],
39
+ },
40
+ { type: "element", tagName: "img", properties: { src: "x", onerror: "alert(1)" }, children: [{ type: "text", value: "y" }] },
41
+ { type: "comment", value: "<!-- no -->" },
42
+ ],
43
+ });
44
+ assert.deepEqual(lines, ['<span class="hljs-title function_">f</span>y']);
45
+ });
46
+
47
+ test("unknown languages fall back to plain text", () => {
48
+ const lang = r.resolveLanguage("klingon");
49
+ assert.equal(lang.grammar, null);
50
+ assert.equal(lang.id, "klingon");
51
+ const html = r.renderBlock({ code: "if (x) { y }", language: "klingon" }, { label: false, copy: false, lineNumbers: false });
52
+ assert.ok(!html.includes("hljs-"));
53
+ assert.ok(html.includes("if (x) { y }"));
54
+ assert.equal(r.resolveLanguage("").id, null);
55
+ assert.equal(r.resolveLanguage("plaintext").label, null);
56
+ });
57
+
58
+ test("aliases and editor ids map to grammars", () => {
59
+ assert.equal(r.resolveLanguage("ts").grammar, "typescript");
60
+ assert.equal(r.resolveLanguage("TSX").grammar, "typescript");
61
+ assert.equal(r.resolveLanguage("html").grammar, "xml");
62
+ assert.equal(r.resolveLanguage("html").label, "HTML");
63
+ assert.equal(r.resolveLanguage("dockerfile").grammar, "dockerfile");
64
+ assert.equal(r.resolveLanguage("zig").grammar, null);
65
+ assert.equal(r.resolveLanguage("zig").label, "Zig");
66
+ });
67
+
68
+ test("spans crossing newlines are closed and reopened per line", () => {
69
+ const lines = r.highlightLines("/* one\n two */\nx = 1", "javascript");
70
+ assert.equal(lines.length, 3);
71
+ assert.equal(lines[0], '<span class="hljs-comment">/* one</span>');
72
+ assert.equal(lines[1], '<span class="hljs-comment"> two */</span>');
73
+ for (const line of lines) {
74
+ assert.equal((line.match(/<span/g) ?? []).length, (line.match(/<\/span>/g) ?? []).length, line);
75
+ }
76
+ });
77
+
78
+ test("nested spans split across lines keep their nesting", () => {
79
+ const lines = r.hastToLines({
80
+ type: "root",
81
+ children: [
82
+ {
83
+ type: "element",
84
+ tagName: "span",
85
+ properties: { className: ["hljs-string"] },
86
+ children: [
87
+ { type: "text", value: "`a\n" },
88
+ { type: "element", tagName: "span", properties: { className: ["hljs-subst"] }, children: [{ type: "text", value: "${b\nc}" }] },
89
+ { type: "text", value: "`" },
90
+ ],
91
+ },
92
+ ],
93
+ });
94
+ assert.deepEqual(lines, [
95
+ '<span class="hljs-string">`a</span>',
96
+ '<span class="hljs-string"><span class="hljs-subst">${b</span></span>',
97
+ '<span class="hljs-string"><span class="hljs-subst">c}</span>`</span>',
98
+ ]);
99
+ });
100
+
101
+ test("line numbers wrap each line; trailing newline adds no empty line", () => {
102
+ const html = r.renderBlock({ code: "a\nb\n", language: "plaintext" }, { label: true, copy: false, lineNumbers: true });
103
+ assert.equal((html.match(/class="cw-line"/g) ?? []).length, 2);
104
+ assert.ok(!html.includes("cw-code-head"), "plain text has no label");
105
+ });
106
+
107
+ test("copy button is accessible and optional", () => {
108
+ const on = r.renderBlock({ code: "x", language: "js" }, { label: true, copy: true, lineNumbers: false });
109
+ assert.ok(on.includes('aria-label="Copy code to clipboard"'));
110
+ assert.ok(on.includes('role="status" aria-live="polite"'));
111
+ assert.ok(on.includes('<span class="cw-code-label">JavaScript</span>'));
112
+ const off = r.renderBlock({ code: "x", language: "js" }, { label: false, copy: false, lineNumbers: false });
113
+ assert.ok(!off.includes("button"));
114
+ assert.ok(!off.includes("cw-code-label"));
115
+ });
116
+
117
+ test("very large blocks skip highlighting", () => {
118
+ const code = "const x = 1;\n".repeat(Math.ceil(r.MAX_HIGHLIGHT_CHARS / 13) + 1);
119
+ assert.ok(!r.highlightLines(code, "javascript").some((l) => l.includes("<span")));
120
+ });
121
+
122
+ test("any line over the limit skips highlighting, quickly", () => {
123
+ const long = `const s = "${"a".repeat(r.MAX_HIGHLIGHT_LINE)}";`;
124
+ const started = performance.now();
125
+ const lines = r.highlightLines(`let a = 1;\n${long}\nlet b = 2;`, "javascript");
126
+ assert.ok(performance.now() - started < 50);
127
+ assert.equal(lines.length, 3);
128
+ assert.ok(!lines.some((l) => l.includes("<span")));
129
+ assert.ok(lines[1]!.includes("&quot;"));
130
+ const ok = `const s = "${"a".repeat(r.MAX_HIGHLIGHT_LINE - 20)}";`;
131
+ assert.ok(r.highlightLines(ok, "javascript")[0]!.includes("<span"));
132
+ const huge = `x = "${"b".repeat(40_000)}"`;
133
+ const t2 = performance.now();
134
+ r.highlightLines(huge, "typescript");
135
+ assert.ok(performance.now() - t2 < 50);
136
+ });
137
+
138
+ test("rendered blocks are cached and keyed by options and code", () => {
139
+ const node = { code: "let a = 1;", language: "js" };
140
+ const a = r.renderBlock(node, { label: true, copy: false, lineNumbers: false });
141
+ assert.equal(r.renderBlock({ ...node }, { label: true, copy: false, lineNumbers: false }), a);
142
+ assert.notEqual(r.renderBlock(node, { label: true, copy: false, lineNumbers: true }), a);
143
+ assert.notEqual(r.renderBlock({ ...node, code: "let a = 2;" }, { label: true, copy: false, lineNumbers: false }), a);
144
+ });
145
+
146
+ test("unknown language ids are capped at 40 characters", () => {
147
+ const lang = r.resolveLanguage("x".repeat(100));
148
+ assert.equal(lang.id!.length, 40);
149
+ const html = r.renderBlock({ code: "y", language: "z".repeat(100) }, { label: false, copy: false, lineNumbers: false });
150
+ assert.ok(html.includes(`data-language="${"z".repeat(40)}"`));
151
+ assert.ok(!html.includes("z".repeat(41)));
152
+ });