@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.
- package/LICENSE +21 -0
- package/README.md +203 -2
- package/docs/README.md +45 -0
- package/docs/ai.md +39 -0
- package/docs/backups.md +88 -0
- package/docs/breadcrumbs.md +59 -0
- package/docs/code-blocks.md +21 -0
- package/docs/content-urls.md +41 -0
- package/docs/custom-blocks.md +27 -0
- package/docs/discovery.md +32 -0
- package/docs/files.md +44 -0
- package/docs/form-uploads.md +59 -0
- package/docs/headings.md +37 -0
- package/docs/images.md +141 -0
- package/docs/internals.md +13 -0
- package/docs/links.md +34 -0
- package/docs/performance.md +72 -0
- package/docs/redirects.md +84 -0
- package/docs/releasing.md +21 -0
- package/docs/reviews.md +89 -0
- package/docs/robots.md +81 -0
- package/docs/schema.md +79 -0
- package/docs/search.md +114 -0
- package/docs/videos.md +48 -0
- package/docs/wordpress-import.md +198 -0
- package/package.json +79 -5
- package/src/admin/admin-css.ts +57 -0
- package/src/admin/ai.tsx +915 -0
- package/src/admin/backups.tsx +613 -0
- package/src/admin/breadcrumbs.tsx +542 -0
- package/src/admin/codeBlocks.tsx +204 -0
- package/src/admin/customBlocks.tsx +321 -0
- package/src/admin/dirty.ts +20 -0
- package/src/admin/discovery.tsx +463 -0
- package/src/admin/download.ts +28 -0
- package/src/admin/feature-switches.tsx +124 -0
- package/src/admin/features.tsx +142 -0
- package/src/admin/files.tsx +791 -0
- package/src/admin/formUploads.tsx +445 -0
- package/src/admin/guides.tsx +18 -0
- package/src/admin/headings.tsx +262 -0
- package/src/admin/images.tsx +825 -0
- package/src/admin/index.tsx +62 -0
- package/src/admin/links.tsx +980 -0
- package/src/admin/performance.tsx +330 -0
- package/src/admin/preview.tsx +63 -0
- package/src/admin/redirects-removed.tsx +266 -0
- package/src/admin/redirects.tsx +470 -0
- package/src/admin/reviews.tsx +232 -0
- package/src/admin/robots-bots.tsx +647 -0
- package/src/admin/robots-shared.tsx +109 -0
- package/src/admin/robots-wizard.tsx +748 -0
- package/src/admin/robots.tsx +911 -0
- package/src/admin/save-bar.tsx +134 -0
- package/src/admin/schema.tsx +1187 -0
- package/src/admin/search.tsx +355 -0
- package/src/admin/settings-ui.tsx +87 -0
- package/src/admin/trash-prompt-core.ts +135 -0
- package/src/admin/trash-prompt.tsx +156 -0
- package/src/admin/usage.tsx +301 -0
- package/src/admin/videos-display.tsx +248 -0
- package/src/admin/videos.tsx +1202 -0
- package/src/admin/wpImport.tsx +1516 -0
- package/src/ai/entities.ts +59 -0
- package/src/ai/export.ts +61 -0
- package/src/ai/logic.ts +606 -0
- package/src/ai/pack.ts +121 -0
- package/src/ai/prompts.ts +87 -0
- package/src/ai/providers.ts +276 -0
- package/src/ai/routes.ts +359 -0
- package/src/ai/store.ts +301 -0
- package/src/ai/wikidata.ts +87 -0
- package/src/ai/worker.ts +530 -0
- package/src/astro/astro-modules.d.ts +6 -0
- package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
- package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
- package/src/astro/codeBlocks/CodeBlock.astro +62 -0
- package/src/astro/codeBlocks/EmDashCode.astro +61 -0
- package/src/astro/customBlocks/CustomBlock.astro +64 -0
- package/src/astro/customBlocks/Details.astro +8 -0
- package/src/astro/customBlocks/Disclosure.astro +8 -0
- package/src/astro/customBlocks/Note.astro +8 -0
- package/src/astro/customBlocks/Podcast.astro +8 -0
- package/src/astro/customBlocks/Quote.astro +8 -0
- package/src/astro/customBlocks/Testimonial.astro +8 -0
- package/src/astro/files/FileCard.astro +103 -0
- package/src/astro/headings/HeadingBlock.astro +129 -0
- package/src/astro/headings/TableOfContents.astro +45 -0
- package/src/astro/headings/request.ts +19 -0
- package/src/astro/images/ResponsiveImage.astro +60 -0
- package/src/astro/index.ts +84 -0
- package/src/astro/reviews/CoywolfReview.astro +27 -0
- package/src/astro/reviews/Review.astro +49 -0
- package/src/astro/search/SearchBox.astro +61 -0
- package/src/astro/search/SearchBoxForm.astro +509 -0
- package/src/astro/tables/CaptionedTable.astro +72 -0
- package/src/astro/videos/CoywolfVideo.astro +448 -0
- package/src/backups/download.ts +112 -0
- package/src/backups/dump.ts +288 -0
- package/src/backups/module.ts +276 -0
- package/src/backups/pack.ts +33 -0
- package/src/backups/restore.ts +209 -0
- package/src/backups/store.ts +409 -0
- package/src/breadcrumbs/pack.ts +113 -0
- package/src/breadcrumbs/settings.ts +102 -0
- package/src/breadcrumbs/stamp.ts +40 -0
- package/src/breadcrumbs/trail.ts +123 -0
- package/src/codeBlocks/contrast.ts +179 -0
- package/src/codeBlocks/hljs.d.ts +5 -0
- package/src/codeBlocks/pack.ts +110 -0
- package/src/codeBlocks/render.ts +304 -0
- package/src/codeBlocks/settings.ts +18 -0
- package/src/codeBlocks/themes.generated.ts +304 -0
- package/src/codeBlocks/themes.ts +114 -0
- package/src/core/compose.ts +244 -0
- package/src/core/content-url.ts +888 -0
- package/src/core/csv.ts +119 -0
- package/src/core/d1-batch.ts +65 -0
- package/src/core/entry-docs.ts +175 -0
- package/src/core/features-module.ts +48 -0
- package/src/core/features.ts +392 -0
- package/src/core/generation.ts +88 -0
- package/src/core/guide-content.ts +166 -0
- package/src/core/module.ts +104 -0
- package/src/core/read-failures.ts +57 -0
- package/src/core/request-memo.ts +71 -0
- package/src/core/secrets.ts +83 -0
- package/src/core/settings-enhance.ts +74 -0
- package/src/core/site.ts +36 -0
- package/src/core/storage.ts +27 -0
- package/src/customBlocks/pack.ts +260 -0
- package/src/customBlocks/render.ts +424 -0
- package/src/customBlocks/rich.ts +313 -0
- package/src/customBlocks/settings.ts +46 -0
- package/src/discovery/build.ts +252 -0
- package/src/discovery/feed-build.ts +217 -0
- package/src/discovery/feed.ts +172 -0
- package/src/discovery/indexnow.ts +114 -0
- package/src/discovery/llms.ts +79 -0
- package/src/discovery/markdown.ts +294 -0
- package/src/discovery/memory-cache.ts +97 -0
- package/src/discovery/middleware.ts +186 -0
- package/src/discovery/module.ts +353 -0
- package/src/discovery/news.ts +99 -0
- package/src/discovery/pack.ts +58 -0
- package/src/discovery/settings.ts +91 -0
- package/src/discovery/urls.ts +35 -0
- package/src/env.d.ts +10 -0
- package/src/files/card.ts +231 -0
- package/src/files/counts.ts +60 -0
- package/src/files/export.ts +44 -0
- package/src/files/format.ts +163 -0
- package/src/files/module.ts +596 -0
- package/src/files/pack.ts +92 -0
- package/src/files/r2-rules.ts +41 -0
- package/src/files/range.ts +50 -0
- package/src/files/s3.ts +114 -0
- package/src/files/serve.ts +141 -0
- package/src/files/sigv4.ts +160 -0
- package/src/files/site.ts +137 -0
- package/src/files/walker.ts +58 -0
- package/src/formUploads/lib.ts +266 -0
- package/src/formUploads/pack.ts +55 -0
- package/src/formUploads/wrap.ts +592 -0
- package/src/forms.ts +16 -0
- package/src/headings/pack.ts +154 -0
- package/src/headings/render.ts +139 -0
- package/src/headings/settings.ts +117 -0
- package/src/headings/slug.ts +68 -0
- package/src/headings/stamp.ts +142 -0
- package/src/headings/test-hooks.mjs +14 -0
- package/src/headings/toc.ts +48 -0
- package/src/images/cloudflare.ts +329 -0
- package/src/images/lib.ts +221 -0
- package/src/images/module.ts +372 -0
- package/src/images/pack.ts +804 -0
- package/src/images/pending.ts +37 -0
- package/src/images/settings.ts +48 -0
- package/src/images/setup.ts +192 -0
- package/src/images/variants-estimate.ts +162 -0
- package/src/images/variants-store.ts +475 -0
- package/src/images/variants.ts +311 -0
- package/src/index.ts +143 -0
- package/src/links/check.ts +101 -0
- package/src/links/classify.ts +270 -0
- package/src/links/export.ts +59 -0
- package/src/links/module.ts +652 -0
- package/src/links/pack.ts +43 -0
- package/src/links/pt.ts +325 -0
- package/src/links/store.ts +704 -0
- package/src/middleware.ts +287 -0
- package/src/modules.ts +53 -0
- package/src/options.ts +66 -0
- package/src/pageCache/lib.ts +274 -0
- package/src/pageCache/pack.ts +240 -0
- package/src/pageCache/usage-core.ts +152 -0
- package/src/pageCache/usage.ts +262 -0
- package/src/pageCache/warm.ts +722 -0
- package/src/redirects/middleware.ts +169 -0
- package/src/redirects/module.ts +124 -0
- package/src/redirects/pack.ts +89 -0
- package/src/redirects/removed-core.ts +151 -0
- package/src/redirects/removed.ts +293 -0
- package/src/redirects/rules.ts +355 -0
- package/src/redirects/slug-changes.ts +207 -0
- package/src/redirects/slug-core.ts +49 -0
- package/src/redirects/transfer.ts +92 -0
- package/src/reviews/lib.ts +624 -0
- package/src/reviews/pack.ts +135 -0
- package/src/reviews/schema.ts +128 -0
- package/src/reviews/settings.ts +26 -0
- package/src/robots/bots.ts +321 -0
- package/src/robots/data/bots.json +696 -0
- package/src/robots/data/verified.json +582 -0
- package/src/robots/directory.ts +26 -0
- package/src/robots/explain.ts +191 -0
- package/src/robots/guided.ts +300 -0
- package/src/robots/importer.ts +263 -0
- package/src/robots/middleware.ts +151 -0
- package/src/robots/module.ts +446 -0
- package/src/robots/pack.ts +69 -0
- package/src/robots/radar.ts +129 -0
- package/src/robots/rep.ts +362 -0
- package/src/robots/rules.ts +883 -0
- package/src/robots/transfer.ts +45 -0
- package/src/robots/validate.ts +524 -0
- package/src/schema/catalog.ts +146 -0
- package/src/schema/graph.ts +791 -0
- package/src/schema/module.ts +1138 -0
- package/src/schema/og-image.ts +100 -0
- package/src/schema/pack.ts +58 -0
- package/src/search/engine.ts +578 -0
- package/src/search/fallback.ts +80 -0
- package/src/search/live-cache.ts +116 -0
- package/src/search/live-client.ts +770 -0
- package/src/search/live-serve.ts +282 -0
- package/src/search/module.ts +261 -0
- package/src/search/pack.ts +57 -0
- package/src/search/query.ts +132 -0
- package/src/search/ratelimit-core.ts +118 -0
- package/src/search/ratelimit.ts +79 -0
- package/src/search/snippet.ts +135 -0
- package/src/search/title-index.ts +139 -0
- package/src/search/title-match.ts +192 -0
- package/src/shared.ts +71 -0
- package/src/version.ts +2 -0
- package/src/videos/date.ts +40 -0
- package/src/videos/engagement.ts +75 -0
- package/src/videos/lib.ts +679 -0
- package/src/videos/module.ts +1160 -0
- package/src/videos/pack.ts +187 -0
- package/src/videos/pause.ts +42 -0
- package/src/videos/poster.ts +324 -0
- package/src/videos/render.ts +336 -0
- package/src/videos/store.ts +551 -0
- package/src/videos/stream.ts +134 -0
- package/src/wpImport/convert.ts +702 -0
- package/src/wpImport/guests.ts +329 -0
- package/src/wpImport/gutenberg.ts +157 -0
- package/src/wpImport/markers.ts +66 -0
- package/src/wpImport/pack.ts +451 -0
- package/src/wpImport/parents.ts +202 -0
- package/src/wpImport/prepare.ts +592 -0
- package/src/wpImport/redirects.ts +358 -0
- package/src/wpImport/sources.ts +67 -0
- package/src/wpImport/stream.ts +160 -0
- package/src/wpImport/urls.ts +354 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Breadcrumb Nav module: an accessible breadcrumb trail as a Portable Text
|
|
3
|
+
* block (`coywolf-breadcrumbs`) and as a theme component (`Breadcrumbs` from
|
|
4
|
+
* "@coywolf/emdash/astro"). Until 0.6.0 this was the `headings.breadcrumbs`
|
|
5
|
+
* sub-feature of Headings & TOC; the feature switch (FeatureDef.replaces) and
|
|
6
|
+
* the settings (./settings.ts) fall back to the old stored values, and the
|
|
7
|
+
* block type, component and stored data are unchanged.
|
|
8
|
+
*/
|
|
9
|
+
import type { PluginContext } from "emdash";
|
|
10
|
+
import { definePluginRoute } from "emdash";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
|
|
13
|
+
import { registerFeatures } from "../core/features.js";
|
|
14
|
+
import type { FeatureDef, PackModule } from "../core/module.js";
|
|
15
|
+
import { parseInput } from "../shared.js";
|
|
16
|
+
import { SETTINGS_KEY, ctxBreadcrumbsSettings, invalidateBreadcrumbsSettings, normalizeSettings } from "./settings.js";
|
|
17
|
+
import { BREADCRUMBS_BLOCK, stampBreadcrumbs } from "./stamp.js";
|
|
18
|
+
import { capturePage } from "./trail.js";
|
|
19
|
+
|
|
20
|
+
/** No build-time options yet: defaults live on the Breadcrumb Nav page. */
|
|
21
|
+
export type BreadcrumbsOptions = Record<string, never>;
|
|
22
|
+
|
|
23
|
+
export const FEATURES: FeatureDef[] = [
|
|
24
|
+
{
|
|
25
|
+
id: "breadcrumbs",
|
|
26
|
+
label: "Breadcrumb Nav",
|
|
27
|
+
description: "An accessible breadcrumb trail, as a Breadcrumbs block and as a component for theme layouts.",
|
|
28
|
+
default: false,
|
|
29
|
+
replaces: ["headings.breadcrumbs"],
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
registerFeatures(FEATURES);
|
|
33
|
+
|
|
34
|
+
const BLOCKS = [
|
|
35
|
+
{
|
|
36
|
+
type: BREADCRUMBS_BLOCK,
|
|
37
|
+
label: "Breadcrumbs",
|
|
38
|
+
icon: "link",
|
|
39
|
+
category: "Sections",
|
|
40
|
+
description: "A breadcrumb trail for this page",
|
|
41
|
+
fields: [
|
|
42
|
+
{
|
|
43
|
+
type: "select",
|
|
44
|
+
action_id: "separator",
|
|
45
|
+
label: "Separator",
|
|
46
|
+
options: [
|
|
47
|
+
{ label: "Site default", value: "" },
|
|
48
|
+
{ label: "/", value: "slash" },
|
|
49
|
+
{ label: "›", value: "chevron" },
|
|
50
|
+
{ label: "»", value: "guillemet" },
|
|
51
|
+
{ label: "•", value: "bullet" },
|
|
52
|
+
{ label: "→", value: "arrow" },
|
|
53
|
+
{ label: ">", value: "gt" },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
{ type: "text_input", action_id: "homeLabel", label: "Home label", placeholder: "Site default (Home)" },
|
|
57
|
+
{
|
|
58
|
+
type: "select",
|
|
59
|
+
action_id: "showCurrent",
|
|
60
|
+
label: "Current page",
|
|
61
|
+
options: [
|
|
62
|
+
{ label: "Site default", value: "" },
|
|
63
|
+
{ label: "Show", value: "show" },
|
|
64
|
+
{ label: "Hide", value: "hide" },
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
const settingsInput = z.object({ settings: z.record(z.string(), z.unknown()) });
|
|
72
|
+
|
|
73
|
+
/** Stamp each Breadcrumbs block with the entry's title (its own hook, so it works with Headings & TOC off). */
|
|
74
|
+
export function beforeSave(event: { content: Record<string, unknown> }): Record<string, unknown> | undefined {
|
|
75
|
+
return stampBreadcrumbs(event.content) ?? undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function breadcrumbsPack(_options: BreadcrumbsOptions): PackModule {
|
|
79
|
+
return {
|
|
80
|
+
id: "breadcrumbs",
|
|
81
|
+
label: "Breadcrumb Nav",
|
|
82
|
+
features: FEATURES,
|
|
83
|
+
capabilities: ["content:write"],
|
|
84
|
+
portableTextBlocks: BLOCKS,
|
|
85
|
+
adminPages: [{ path: "/breadcrumbs", label: "Breadcrumb Nav", icon: "path" }],
|
|
86
|
+
hooks: {
|
|
87
|
+
"content:beforeSave": beforeSave,
|
|
88
|
+
// Remember the theme's breadcrumb trail for the Breadcrumbs block in the body.
|
|
89
|
+
"page:metadata": (event: { page: Parameters<typeof capturePage>[0] }) => {
|
|
90
|
+
capturePage(event.page);
|
|
91
|
+
return [];
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
routes: {
|
|
95
|
+
"breadcrumbs/settings": {
|
|
96
|
+
permission: "plugins:manage" as const,
|
|
97
|
+
handler: async (ctx: PluginContext) => ({ settings: await ctxBreadcrumbsSettings(ctx) }),
|
|
98
|
+
},
|
|
99
|
+
"breadcrumbs/settings/save": definePluginRoute({
|
|
100
|
+
permission: "plugins:manage",
|
|
101
|
+
methods: ["POST"],
|
|
102
|
+
request: { body: "json" },
|
|
103
|
+
handler: async (ctx) => {
|
|
104
|
+
const settings = normalizeSettings(parseInput(settingsInput, ctx.input).settings);
|
|
105
|
+
await ctx.settings.set(SETTINGS_KEY, settings);
|
|
106
|
+
invalidateBreadcrumbsSettings();
|
|
107
|
+
ctx.log.info("Breadcrumb settings saved");
|
|
108
|
+
return { settings };
|
|
109
|
+
},
|
|
110
|
+
}),
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Breadcrumb Nav site defaults: one plugin setting ("breadcrumbs"). Before
|
|
3
|
+
* 0.6.0 they lived inside the Headings & TOC setting ("headings", under
|
|
4
|
+
* `breadcrumbs`), so reads fall back to that sub-object until the Breadcrumb
|
|
5
|
+
* Nav page saves its own. Hooks and routes read through the plugin context;
|
|
6
|
+
* Astro components read the option rows from D1 in the feature switches'
|
|
7
|
+
* query and per-isolate cache.
|
|
8
|
+
*/
|
|
9
|
+
import { invalidateFeatures, readSiteSetting, registerSiteSetting } from "../core/features.js";
|
|
10
|
+
|
|
11
|
+
export const SETTINGS_KEY = "breadcrumbs";
|
|
12
|
+
/** The setting that held these values before Breadcrumb Nav was its own module. */
|
|
13
|
+
export const LEGACY_SETTINGS_KEY = "headings";
|
|
14
|
+
|
|
15
|
+
export const SEPARATORS = {
|
|
16
|
+
slash: "/",
|
|
17
|
+
chevron: "›",
|
|
18
|
+
guillemet: "»",
|
|
19
|
+
bullet: "•",
|
|
20
|
+
arrow: "→",
|
|
21
|
+
gt: ">",
|
|
22
|
+
} as const;
|
|
23
|
+
export type SeparatorPreset = keyof typeof SEPARATORS;
|
|
24
|
+
|
|
25
|
+
export interface BreadcrumbsSettings {
|
|
26
|
+
separator: SeparatorPreset;
|
|
27
|
+
/** Overrides the preset when not empty (max 8 characters). */
|
|
28
|
+
customSeparator: string;
|
|
29
|
+
homeLabel: string;
|
|
30
|
+
showHome: boolean;
|
|
31
|
+
showCurrent: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const DEFAULT_SETTINGS: BreadcrumbsSettings = {
|
|
35
|
+
separator: "slash",
|
|
36
|
+
customSeparator: "",
|
|
37
|
+
homeLabel: "Home",
|
|
38
|
+
showHome: true,
|
|
39
|
+
showCurrent: true,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const asObject = (value: unknown) => (value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null);
|
|
43
|
+
const bool = (value: unknown, fallback: boolean) => (typeof value === "boolean" ? value : fallback);
|
|
44
|
+
const text = (value: unknown, max: number, fallback: string) =>
|
|
45
|
+
typeof value === "string" ? value.replace(/[\u0000-\u001f<>]/g, "").trim().slice(0, max) : fallback;
|
|
46
|
+
|
|
47
|
+
/** Fill in and clamp stored settings. Never throws. */
|
|
48
|
+
export function normalizeSettings(raw: unknown): BreadcrumbsSettings {
|
|
49
|
+
const r = asObject(raw) ?? {};
|
|
50
|
+
const d = DEFAULT_SETTINGS;
|
|
51
|
+
return {
|
|
52
|
+
separator: Object.hasOwn(SEPARATORS, r.separator as string) ? (r.separator as SeparatorPreset) : d.separator,
|
|
53
|
+
customSeparator: [...text(r.customSeparator, 32, "")].slice(0, 8).join(""),
|
|
54
|
+
homeLabel: text(r.homeLabel, 60, d.homeLabel) || d.homeLabel,
|
|
55
|
+
showHome: bool(r.showHome, d.showHome),
|
|
56
|
+
showCurrent: bool(r.showCurrent, d.showCurrent),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The effective settings from the stored "breadcrumbs" setting, else the
|
|
62
|
+
* `breadcrumbs` sub-object of the legacy "headings" setting, else defaults.
|
|
63
|
+
*/
|
|
64
|
+
export function resolveSettings(stored: unknown, legacyHeadings: unknown): BreadcrumbsSettings {
|
|
65
|
+
return normalizeSettings(asObject(stored) ?? asObject(asObject(legacyHeadings)?.breadcrumbs));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface SettingsCtx {
|
|
69
|
+
settings: { get<T>(key: string): Promise<T | null | undefined> };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Settings inside the plugin context (hooks, routes). */
|
|
73
|
+
export async function ctxBreadcrumbsSettings(ctx: SettingsCtx): Promise<BreadcrumbsSettings> {
|
|
74
|
+
const stored = await ctx.settings.get<unknown>(SETTINGS_KEY);
|
|
75
|
+
if (asObject(stored)) return normalizeSettings(stored);
|
|
76
|
+
return resolveSettings(null, await ctx.settings.get<unknown>(LEGACY_SETTINGS_KEY));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Outside the plugin context (Astro components) ────────────────
|
|
80
|
+
|
|
81
|
+
registerSiteSetting(SETTINGS_KEY);
|
|
82
|
+
registerSiteSetting(LEGACY_SETTINGS_KEY);
|
|
83
|
+
|
|
84
|
+
/** The last stored values and their resolved settings, so a cache hit doesn't resolve again. */
|
|
85
|
+
let memo: { stored: unknown; legacy: unknown; settings: BreadcrumbsSettings } | null = null;
|
|
86
|
+
|
|
87
|
+
export function invalidateBreadcrumbsSettings(): void {
|
|
88
|
+
invalidateFeatures();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The settings from D1 (both rows, in the feature switches' query and cache). Falls back to defaults if the database can't be read. */
|
|
92
|
+
export async function siteBreadcrumbsSettings(database = "DB"): Promise<BreadcrumbsSettings> {
|
|
93
|
+
const [stored, legacy] = await Promise.all([readSiteSetting(SETTINGS_KEY, database), readSiteSetting(LEGACY_SETTINGS_KEY, database)]);
|
|
94
|
+
if (!stored || !legacy) {
|
|
95
|
+
console.error("coywolf-pack: could not read breadcrumb settings");
|
|
96
|
+
return DEFAULT_SETTINGS;
|
|
97
|
+
}
|
|
98
|
+
if (memo && memo.stored === stored.value && memo.legacy === legacy.value) return memo.settings;
|
|
99
|
+
const settings = resolveSettings(stored.value, legacy.value);
|
|
100
|
+
memo = { stored: stored.value, legacy: legacy.value, settings };
|
|
101
|
+
return settings;
|
|
102
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Save-time stamping for Breadcrumbs blocks (content:beforeSave). A block
|
|
3
|
+
* component only sees its own node, so each `coywolf-breadcrumbs` block gets
|
|
4
|
+
* `_title`, the entry's title, for the last crumb of a trail derived from the
|
|
5
|
+
* URL. Pure: returns new objects, never mutates its input, and is idempotent.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const BREADCRUMBS_BLOCK = "coywolf-breadcrumbs";
|
|
9
|
+
|
|
10
|
+
type Block = Record<string, unknown> & { _type: string };
|
|
11
|
+
|
|
12
|
+
/** An array that looks like Portable Text: every item is an object with a string `_type`. */
|
|
13
|
+
function isPortableText(value: unknown): value is Block[] {
|
|
14
|
+
return (
|
|
15
|
+
Array.isArray(value) &&
|
|
16
|
+
value.length > 0 &&
|
|
17
|
+
value.every((b) => b && typeof b === "object" && typeof (b as { _type?: unknown })._type === "string")
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Stamp the title into every Breadcrumbs block. Returns the new content, or null when nothing changed. */
|
|
22
|
+
export function stampBreadcrumbs(content: Record<string, unknown>): Record<string, unknown> | null {
|
|
23
|
+
const title = typeof content.title === "string" ? content.title.trim() || null : null;
|
|
24
|
+
if (title === null) return null;
|
|
25
|
+
let result: Record<string, unknown> | null = null;
|
|
26
|
+
for (const [field, value] of Object.entries(content)) {
|
|
27
|
+
if (!isPortableText(value)) continue;
|
|
28
|
+
let out: Block[] | null = null;
|
|
29
|
+
value.forEach((block, i) => {
|
|
30
|
+
if (block._type !== BREADCRUMBS_BLOCK || block._title === title) return;
|
|
31
|
+
out ??= value.slice();
|
|
32
|
+
out[i] = { ...block, _title: title };
|
|
33
|
+
});
|
|
34
|
+
if (out) {
|
|
35
|
+
result ??= { ...content };
|
|
36
|
+
result[field] = out;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
@@ -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,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
|
+
}
|