@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.
- package/LICENSE +21 -0
- package/README.md +201 -2
- package/package.json +70 -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.test.ts +187 -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/queue.test.ts +97 -0
- package/src/ai/routes.ts +359 -0
- package/src/ai/store.ts +301 -0
- package/src/ai/wikidata.test.ts +184 -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.test.mjs +287 -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.test.mjs +103 -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.test.ts +152 -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.test.ts +114 -0
- package/src/files/counts.ts +60 -0
- package/src/files/export.ts +44 -0
- package/src/files/format.test.ts +93 -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.test.ts +30 -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.test.ts +64 -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/headings.test.ts +159 -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.test.mjs +224 -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.test.mjs +670 -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.test.ts +50 -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.test.ts +335 -0
- package/src/robots/rep.ts +362 -0
- package/src/robots/rules.test.ts +251 -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.test.mjs +522 -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,159 @@
|
|
|
1
|
+
// Run: node --import ./src/headings/test-hooks.mjs --test src/headings/headings.test.ts
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
|
|
5
|
+
import { blockText, slugify, uniqueSlug, validAnchor } from "./slug.js";
|
|
6
|
+
import { stampContent } from "./stamp.js";
|
|
7
|
+
import { buildTocTree, countToc } from "./toc.js";
|
|
8
|
+
import { normalizeSettings, withLegacyBreadcrumbs } from "./settings.js";
|
|
9
|
+
|
|
10
|
+
const h = (key: string, style: string, text: string, extra: Record<string, unknown> = {}) => ({
|
|
11
|
+
_type: "block",
|
|
12
|
+
_key: key,
|
|
13
|
+
style,
|
|
14
|
+
children: [{ _type: "span", _key: `${key}s`, text, marks: [] }],
|
|
15
|
+
markDefs: [],
|
|
16
|
+
...extra,
|
|
17
|
+
});
|
|
18
|
+
const p = (key: string, text: string) => h(key, "normal", text);
|
|
19
|
+
const opts = { anchors: true, toc: true, prefix: "jump-" };
|
|
20
|
+
|
|
21
|
+
test("slugify mirrors sanitize_title", () => {
|
|
22
|
+
assert.equal(slugify("Hello, World!"), "hello-world");
|
|
23
|
+
assert.equal(slugify("Don't Panic"), "dont-panic");
|
|
24
|
+
assert.equal(slugify(" Café au lait "), "cafe-au-lait");
|
|
25
|
+
assert.equal(slugify("Price & Plans — 2026"), "price-plans-2026");
|
|
26
|
+
assert.equal(slugify("snake_case words"), "snake-case-words");
|
|
27
|
+
assert.equal(slugify("日本語 見出し"), "日本語-見出し");
|
|
28
|
+
assert.equal(slugify("!!!"), "");
|
|
29
|
+
assert.ok(slugify("a".repeat(200)).length <= 80);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("uniqueSlug prefixes and de-duplicates", () => {
|
|
33
|
+
const taken = new Set<string>();
|
|
34
|
+
assert.equal(uniqueSlug("Intro", taken), "jump-intro");
|
|
35
|
+
assert.equal(uniqueSlug("Intro", taken), "jump-intro-2");
|
|
36
|
+
assert.equal(uniqueSlug("Intro", taken), "jump-intro-3");
|
|
37
|
+
assert.equal(uniqueSlug("???", taken), "jump-section");
|
|
38
|
+
assert.equal(uniqueSlug("2026 plans", new Set(), ""), "h-2026-plans");
|
|
39
|
+
assert.equal(uniqueSlug("Intro", new Set(), "sec-"), "sec-intro");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("validAnchor rejects unsafe ids", () => {
|
|
43
|
+
assert.ok(validAnchor("jump-intro"));
|
|
44
|
+
assert.ok(!validAnchor('x" onclick="y'));
|
|
45
|
+
assert.ok(!validAnchor("1abc"));
|
|
46
|
+
assert.ok(!validAnchor(""));
|
|
47
|
+
assert.ok(!validAnchor(42));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("blockText reads nested marks trees", () => {
|
|
51
|
+
const node = { children: [{ _type: "@span", children: [{ _type: "@text", text: "Bold " }] }, { _type: "@text", text: "tail" }] };
|
|
52
|
+
assert.equal(blockText(node), "Bold tail");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("stamping adds unique anchors and TOC headings (breadcrumb blocks are left to Breadcrumb Nav)", () => {
|
|
56
|
+
const content = {
|
|
57
|
+
title: "My Post",
|
|
58
|
+
body: [
|
|
59
|
+
{ _type: "coywolf-toc", _key: "t" },
|
|
60
|
+
{ _type: "coywolf-breadcrumbs", _key: "b" },
|
|
61
|
+
h("a", "h2", "Intro"),
|
|
62
|
+
p("x", "text"),
|
|
63
|
+
h("b2", "h3", "Intro"),
|
|
64
|
+
h("c", "h1", "Title"),
|
|
65
|
+
h("d", "h2", ""),
|
|
66
|
+
],
|
|
67
|
+
other: "not portable text",
|
|
68
|
+
};
|
|
69
|
+
const out = stampContent(content, opts);
|
|
70
|
+
assert.ok(out);
|
|
71
|
+
const body = out.body as Record<string, unknown>[];
|
|
72
|
+
assert.equal(body[2].anchor, "jump-intro");
|
|
73
|
+
assert.equal(body[4].anchor, "jump-intro-2");
|
|
74
|
+
assert.equal(body[5].anchor, undefined, "H1 is not anchored");
|
|
75
|
+
assert.equal(body[6].anchor, undefined, "empty heading is skipped");
|
|
76
|
+
assert.deepEqual(body[0]._headings, [
|
|
77
|
+
{ level: 2, id: "jump-intro", text: "Intro" },
|
|
78
|
+
{ level: 3, id: "jump-intro-2", text: "Intro" },
|
|
79
|
+
]);
|
|
80
|
+
assert.equal(body[1]._title, undefined);
|
|
81
|
+
assert.equal(out.other, "not portable text");
|
|
82
|
+
// Input untouched.
|
|
83
|
+
assert.equal((content.body[2] as Record<string, unknown>).anchor, undefined);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("stamping is idempotent", () => {
|
|
87
|
+
const content = { title: "T", body: [{ _type: "coywolf-toc", _key: "t" }, h("a", "h2", "One"), h("b", "h2", "One")] };
|
|
88
|
+
const once = stampContent(content, opts);
|
|
89
|
+
assert.ok(once);
|
|
90
|
+
assert.equal(stampContent(once, opts), null);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("anchors stay stable when the editor drops them and the heading is reworded", () => {
|
|
94
|
+
const saved = stampContent({ body: [h("a", "h2", "Pricing"), h("b", "h2", "FAQ")] }, opts);
|
|
95
|
+
assert.ok(saved);
|
|
96
|
+
// Editor round trip: anchor field gone, first heading renamed, a new heading inserted before it.
|
|
97
|
+
const edited = { body: [h("n", "h2", "FAQ"), h("a", "h2", "Plans and pricing"), h("b", "h2", "FAQ")] };
|
|
98
|
+
const out = stampContent(edited, { ...opts, previous: saved });
|
|
99
|
+
assert.ok(out);
|
|
100
|
+
const body = out.body as Record<string, unknown>[];
|
|
101
|
+
assert.equal(body[1].anchor, "jump-pricing", "kept by _key despite new text");
|
|
102
|
+
assert.equal(body[2].anchor, "jump-faq", "kept by _key");
|
|
103
|
+
assert.equal(body[0].anchor, "jump-faq-2", "new heading slugs around existing anchors");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("anchors fall back to text matching when keys change", () => {
|
|
107
|
+
const saved = stampContent({ body: [h("a", "h2", "Setup")] }, { ...opts, prefix: "old-" });
|
|
108
|
+
assert.ok(saved);
|
|
109
|
+
const out = stampContent({ body: [h("z", "h2", "Setup")] }, { ...opts, previous: saved });
|
|
110
|
+
assert.equal((out?.body as Record<string, unknown>[])[0].anchor, "old-setup");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("invalid stored anchors are replaced", () => {
|
|
114
|
+
const out = stampContent({ body: [h("a", "h2", "Hi", { anchor: '"><script>' })] }, opts);
|
|
115
|
+
assert.equal((out?.body as Record<string, unknown>[])[0].anchor, "jump-hi");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("TOC tree nests by level and tolerates skipped levels", () => {
|
|
119
|
+
const flat = [
|
|
120
|
+
{ level: 2, id: "a", text: "A" },
|
|
121
|
+
{ level: 4, id: "a1", text: "A1" },
|
|
122
|
+
{ level: 3, id: "a2", text: "A2" },
|
|
123
|
+
{ level: 2, id: "b", text: "B" },
|
|
124
|
+
{ level: 5, id: "deep", text: "Deep" },
|
|
125
|
+
{ level: 3, id: "b1", text: "B1" },
|
|
126
|
+
];
|
|
127
|
+
const tree = buildTocTree(flat, [2, 3, 4]);
|
|
128
|
+
assert.deepEqual(
|
|
129
|
+
tree.map((n) => [n.id, n.children.map((c) => [c.id, c.children.map((g) => g.id)])]),
|
|
130
|
+
[
|
|
131
|
+
["a", [["a1", []], ["a2", []]]],
|
|
132
|
+
["b", [["b1", []]]],
|
|
133
|
+
],
|
|
134
|
+
);
|
|
135
|
+
assert.equal(countToc(tree), 5);
|
|
136
|
+
assert.deepEqual(
|
|
137
|
+
buildTocTree(flat, [3]).map((n) => n.id),
|
|
138
|
+
["a2", "b1"],
|
|
139
|
+
);
|
|
140
|
+
assert.deepEqual(
|
|
141
|
+
buildTocTree([{ level: 3, id: "x", text: "X" }, { level: 2, id: "y", text: "Y" }], [2, 3]).map((n) => n.id),
|
|
142
|
+
["x", "y"],
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("TOC title can be hidden; levels can be H2 only", () => {
|
|
147
|
+
assert.equal(normalizeSettings({}).toc.showTitle, true);
|
|
148
|
+
assert.equal(normalizeSettings({ toc: { showTitle: false } }).toc.showTitle, false);
|
|
149
|
+
assert.deepEqual(normalizeSettings({ toc: { levels: [2] } }).toc.levels, [2]);
|
|
150
|
+
assert.equal("breadcrumbs" in normalizeSettings({ breadcrumbs: { homeLabel: "Start" } }), false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("saving Headings settings keeps the legacy breadcrumb sub-object for Breadcrumb Nav", () => {
|
|
154
|
+
const legacy = { separator: "chevron", homeLabel: "Start" };
|
|
155
|
+
const next = normalizeSettings({ prefix: "sec-" });
|
|
156
|
+
assert.deepEqual(withLegacyBreadcrumbs({ prefix: "jump-", breadcrumbs: legacy }, next), { ...next, breadcrumbs: legacy });
|
|
157
|
+
assert.deepEqual(withLegacyBreadcrumbs({ prefix: "jump-" }, next), next);
|
|
158
|
+
assert.deepEqual(withLegacyBreadcrumbs(null, next), next);
|
|
159
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headings & TOC module: heading anchors and a Table of Contents block,
|
|
3
|
+
* ported from Coywolf SEO. Anchors and TOC heading lists are stamped into the
|
|
4
|
+
* content on save (see ./stamp.ts); the Astro components in
|
|
5
|
+
* src/astro/headings render them. Breadcrumbs moved to their own module
|
|
6
|
+
* (Breadcrumb Nav, src/breadcrumbs) in 0.6.0.
|
|
7
|
+
*/
|
|
8
|
+
import type { PluginContext } from "emdash";
|
|
9
|
+
import { definePluginRoute } from "emdash";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
|
|
12
|
+
import { ctxFeatures, isOn, registerFeatures } from "../core/features.js";
|
|
13
|
+
import type { PackModule } from "../core/module.js";
|
|
14
|
+
import { parseInput } from "../shared.js";
|
|
15
|
+
import { SETTINGS_KEY, invalidateHeadingsSettings, normalizeSettings, withLegacyBreadcrumbs } from "./settings.js";
|
|
16
|
+
import { TOC_BLOCK, needsPrevious, stampContent } from "./stamp.js";
|
|
17
|
+
|
|
18
|
+
/** No build-time options yet: defaults live on the Headings & TOC settings page. */
|
|
19
|
+
export type HeadingsOptions = Record<string, never>;
|
|
20
|
+
|
|
21
|
+
export const FEATURES = [
|
|
22
|
+
{
|
|
23
|
+
id: "headings",
|
|
24
|
+
label: "Headings & TOC",
|
|
25
|
+
description: "Linkable headings and a Table of Contents block.",
|
|
26
|
+
default: false,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "headings.anchors",
|
|
30
|
+
label: "Heading anchors",
|
|
31
|
+
description: "Give every H2–H6 a stable id (e.g. #jump-pricing) so sections can be linked, with an optional copy-link button.",
|
|
32
|
+
default: false,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: "headings.toc",
|
|
36
|
+
label: "Table of Contents block",
|
|
37
|
+
description: "A Table of Contents block built from the entry's headings, with numbered or bulleted lists and an optional collapsible panel.",
|
|
38
|
+
default: false,
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
registerFeatures(FEATURES);
|
|
42
|
+
|
|
43
|
+
const LEVEL_OPTIONS = [2, 3, 4, 5, 6].map((n) => ({ label: `H${n}`, value: String(n) }));
|
|
44
|
+
|
|
45
|
+
const BLOCKS = [
|
|
46
|
+
{
|
|
47
|
+
type: TOC_BLOCK,
|
|
48
|
+
label: "Table of Contents",
|
|
49
|
+
icon: "list",
|
|
50
|
+
category: "Sections",
|
|
51
|
+
description: "A linked list of this entry's headings",
|
|
52
|
+
fields: [
|
|
53
|
+
{ type: "text_input", action_id: "title", label: "Title", placeholder: "Site default (Table of contents)" },
|
|
54
|
+
{
|
|
55
|
+
type: "select",
|
|
56
|
+
action_id: "showTitle",
|
|
57
|
+
label: "Show title",
|
|
58
|
+
options: [
|
|
59
|
+
{ label: "Site default", value: "" },
|
|
60
|
+
{ label: "Show", value: "show" },
|
|
61
|
+
{ label: "Hide (collapsible tables keep it as the toggle)", value: "hide" },
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
type: "select",
|
|
66
|
+
action_id: "titleTag",
|
|
67
|
+
label: "Title heading level (always-open tables)",
|
|
68
|
+
options: [
|
|
69
|
+
{ label: "Heading 2", value: "h2" },
|
|
70
|
+
{ label: "Heading 3", value: "h3" },
|
|
71
|
+
{ label: "Heading 4", value: "h4" },
|
|
72
|
+
{ label: "Heading 5", value: "h5" },
|
|
73
|
+
{ label: "Heading 6", value: "h6" },
|
|
74
|
+
],
|
|
75
|
+
initial_value: "h2",
|
|
76
|
+
},
|
|
77
|
+
{ type: "checkbox", action_id: "levels", label: "Heading levels (none checked: site default)", options: LEVEL_OPTIONS },
|
|
78
|
+
{
|
|
79
|
+
type: "select",
|
|
80
|
+
action_id: "listStyle",
|
|
81
|
+
label: "List style",
|
|
82
|
+
options: [
|
|
83
|
+
{ label: "Site default", value: "" },
|
|
84
|
+
{ label: "Plain", value: "none" },
|
|
85
|
+
{ label: "Bulleted", value: "bulleted" },
|
|
86
|
+
{ label: "Numbered", value: "numbered" },
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
type: "select",
|
|
91
|
+
action_id: "display",
|
|
92
|
+
label: "Display",
|
|
93
|
+
options: [
|
|
94
|
+
{ label: "Site default", value: "" },
|
|
95
|
+
{ label: "Always open", value: "open" },
|
|
96
|
+
{ label: "Collapsible, open", value: "collapsible" },
|
|
97
|
+
{ label: "Collapsible, collapsed", value: "collapsed" },
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
const settingsInput = z.object({ settings: z.record(z.string(), z.unknown()) });
|
|
105
|
+
|
|
106
|
+
async function beforeSave(event: { content: Record<string, unknown>; collection: string; isNew: boolean; id?: string }, ctx: PluginContext) {
|
|
107
|
+
const features = await ctxFeatures(ctx);
|
|
108
|
+
const toc = isOn(features, "headings.toc");
|
|
109
|
+
const anchors = isOn(features, "headings.anchors") || toc;
|
|
110
|
+
if (!anchors) return undefined;
|
|
111
|
+
|
|
112
|
+
const settings = normalizeSettings(await ctx.settings.get(SETTINGS_KEY));
|
|
113
|
+
let previous: Record<string, unknown> | null = null;
|
|
114
|
+
if (anchors && !event.isNew && event.id && ctx.content && needsPrevious(event.content)) {
|
|
115
|
+
try {
|
|
116
|
+
previous = (await ctx.content.get(event.collection, event.id))?.data ?? null;
|
|
117
|
+
} catch (error) {
|
|
118
|
+
ctx.log.warn("headings: could not read the previous version; anchors follow the heading text", { error: String(error) });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return stampContent(event.content, { anchors, toc, prefix: settings.prefix, previous }) ?? undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function headingsPack(_options: HeadingsOptions): PackModule {
|
|
125
|
+
return {
|
|
126
|
+
id: "headings",
|
|
127
|
+
label: "Headings & TOC",
|
|
128
|
+
features: FEATURES,
|
|
129
|
+
capabilities: ["content:read", "content:write"],
|
|
130
|
+
portableTextBlocks: BLOCKS,
|
|
131
|
+
adminPages: [{ path: "/headings", label: "Headings & TOC", icon: "list" }],
|
|
132
|
+
hooks: {
|
|
133
|
+
"content:beforeSave": beforeSave,
|
|
134
|
+
},
|
|
135
|
+
routes: {
|
|
136
|
+
"headings/settings": {
|
|
137
|
+
permission: "plugins:manage" as const,
|
|
138
|
+
handler: async (ctx: PluginContext) => ({ settings: normalizeSettings(await ctx.settings.get(SETTINGS_KEY)) }),
|
|
139
|
+
},
|
|
140
|
+
"headings/settings/save": definePluginRoute({
|
|
141
|
+
permission: "plugins:manage",
|
|
142
|
+
methods: ["POST"],
|
|
143
|
+
request: { body: "json" },
|
|
144
|
+
handler: async (ctx) => {
|
|
145
|
+
const settings = normalizeSettings(parseInput(settingsInput, ctx.input).settings);
|
|
146
|
+
await ctx.settings.set(SETTINGS_KEY, withLegacyBreadcrumbs(await ctx.settings.get(SETTINGS_KEY), settings));
|
|
147
|
+
invalidateHeadingsSettings();
|
|
148
|
+
ctx.log.info("Heading settings saved");
|
|
149
|
+
return { settings };
|
|
150
|
+
},
|
|
151
|
+
}),
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table of Contents markup and CSS, shared by the coywolf-toc block
|
|
3
|
+
* (src/astro/headings/TableOfContents.astro) and the live preview on the
|
|
4
|
+
* Headings & TOC admin page, so the preview is exactly what the site prints.
|
|
5
|
+
* Only type imports, so `node --test` can load it.
|
|
6
|
+
*/
|
|
7
|
+
import type { TocDisplay, TocListStyle } from "./settings.js";
|
|
8
|
+
import type { TocNode } from "./toc.js";
|
|
9
|
+
|
|
10
|
+
/** Heading levels the TOC title can use when it's shown as a heading (always open). */
|
|
11
|
+
export const TOC_TITLE_TAGS = ["h2", "h3", "h4", "h5", "h6"] as const;
|
|
12
|
+
export type TocTitleTag = (typeof TOC_TITLE_TAGS)[number];
|
|
13
|
+
|
|
14
|
+
/** A block's titleTag, or h2. */
|
|
15
|
+
export function tocTitleTag(value: unknown): TocTitleTag {
|
|
16
|
+
return TOC_TITLE_TAGS.includes(value as TocTitleTag) ? (value as TocTitleTag) : "h2";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface TocRenderOptions {
|
|
20
|
+
title: string;
|
|
21
|
+
showTitle: boolean;
|
|
22
|
+
/** Heading level of a shown title in an always-open TOC (default h2), so it fits the page's outline. */
|
|
23
|
+
titleTag?: TocTitleTag;
|
|
24
|
+
listStyle: TocListStyle;
|
|
25
|
+
display: TocDisplay;
|
|
26
|
+
smooth: boolean;
|
|
27
|
+
/** Id for the title element (unique per block on a page). */
|
|
28
|
+
titleId: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const escapeHtml = (text: string) =>
|
|
32
|
+
String(text).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] as string);
|
|
33
|
+
|
|
34
|
+
function listHtml(nodes: readonly TocNode[], ordered: boolean, root: boolean): string {
|
|
35
|
+
const tag = ordered ? "ol" : "ul";
|
|
36
|
+
const items = nodes
|
|
37
|
+
.map(
|
|
38
|
+
(n) =>
|
|
39
|
+
`<li><a href="#${escapeHtml(n.id)}">${escapeHtml(n.text)}</a>${n.children.length ? listHtml(n.children, ordered, false) : ""}</li>`,
|
|
40
|
+
)
|
|
41
|
+
.join("");
|
|
42
|
+
return `<${tag}${root ? ' class="cw-toc__list"' : ""}>${items}</${tag}>`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The <nav> for a TOC tree. */
|
|
46
|
+
export function renderTocHtml(tree: readonly TocNode[], o: TocRenderOptions): string {
|
|
47
|
+
const id = escapeHtml(o.titleId);
|
|
48
|
+
const title = escapeHtml(o.title);
|
|
49
|
+
const list = listHtml(tree, o.listStyle === "numbered", true);
|
|
50
|
+
const labelled = o.display !== "open" || o.showTitle;
|
|
51
|
+
const tag = tocTitleTag(o.titleTag);
|
|
52
|
+
const attrs = [
|
|
53
|
+
`class="cw-toc cw-toc--${o.listStyle}"`,
|
|
54
|
+
labelled ? `aria-labelledby="${id}"` : `aria-label="${title}"`,
|
|
55
|
+
o.smooth ? "data-smooth" : "",
|
|
56
|
+
]
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.join(" ");
|
|
59
|
+
const body =
|
|
60
|
+
o.display === "open"
|
|
61
|
+
? `${o.showTitle ? `<${tag} id="${id}" class="cw-toc__title">${title}</${tag}>` : ""}${list}`
|
|
62
|
+
: `<details class="cw-toc__details"${o.display === "collapsible" ? " open" : ""}><summary class="cw-toc__summary"><span id="${id}" class="cw-toc__title">${title}</span></summary>${list}</details>`;
|
|
63
|
+
return `<nav ${attrs}>${body}</nav>`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Sample headings for the admin preview (H2–H4, so level choices show). */
|
|
67
|
+
export const SAMPLE_TOC_HEADINGS = [
|
|
68
|
+
{ level: 2, id: "jump-getting-started", text: "Getting started" },
|
|
69
|
+
{ level: 3, id: "jump-what-you-need", text: "What you need" },
|
|
70
|
+
{ level: 3, id: "jump-first-steps", text: "First steps" },
|
|
71
|
+
{ level: 4, id: "jump-a-quick-tip", text: "A quick tip" },
|
|
72
|
+
{ level: 2, id: "jump-how-it-works", text: "How it works" },
|
|
73
|
+
{ level: 3, id: "jump-the-details", text: "The details" },
|
|
74
|
+
{ level: 2, id: "jump-final-thoughts", text: "Final thoughts" },
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
export const TOC_CSS = `.cw-toc {
|
|
78
|
+
--cw-toc-line: color-mix(in srgb, currentColor 18%, transparent);
|
|
79
|
+
--cw-toc-bg: color-mix(in srgb, currentColor 4%, transparent);
|
|
80
|
+
margin-block: 1.5em;
|
|
81
|
+
padding: 1em 1.25em;
|
|
82
|
+
border: 1px solid var(--cw-toc-line);
|
|
83
|
+
border-radius: 0.5em;
|
|
84
|
+
background: var(--cw-toc-bg);
|
|
85
|
+
}
|
|
86
|
+
@media (prefers-color-scheme: dark) {
|
|
87
|
+
.cw-toc {
|
|
88
|
+
--cw-toc-bg: color-mix(in srgb, currentColor 7%, transparent);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
.cw-toc .cw-toc__title {
|
|
92
|
+
margin: 0 0 0.5em;
|
|
93
|
+
font-size: 1.1em;
|
|
94
|
+
}
|
|
95
|
+
.cw-toc ul,
|
|
96
|
+
.cw-toc ol {
|
|
97
|
+
margin: 0;
|
|
98
|
+
}
|
|
99
|
+
.cw-toc li {
|
|
100
|
+
margin: 0.25em 0;
|
|
101
|
+
}
|
|
102
|
+
.cw-toc--none ul,
|
|
103
|
+
.cw-toc--numbered ol {
|
|
104
|
+
list-style: none;
|
|
105
|
+
padding-inline-start: 0;
|
|
106
|
+
}
|
|
107
|
+
.cw-toc--none li > ul,
|
|
108
|
+
.cw-toc--bulleted ul {
|
|
109
|
+
padding-inline-start: 1.25em;
|
|
110
|
+
}
|
|
111
|
+
.cw-toc--numbered ol {
|
|
112
|
+
counter-reset: cw-toc;
|
|
113
|
+
}
|
|
114
|
+
.cw-toc--numbered li > ol {
|
|
115
|
+
padding-inline-start: 1.5em;
|
|
116
|
+
}
|
|
117
|
+
.cw-toc--numbered li {
|
|
118
|
+
counter-increment: cw-toc;
|
|
119
|
+
}
|
|
120
|
+
.cw-toc--numbered li > a::before {
|
|
121
|
+
content: counters(cw-toc, ".") ". ";
|
|
122
|
+
}
|
|
123
|
+
.cw-toc__summary {
|
|
124
|
+
cursor: pointer;
|
|
125
|
+
}
|
|
126
|
+
.cw-toc__summary .cw-toc__title {
|
|
127
|
+
display: inline;
|
|
128
|
+
margin: 0;
|
|
129
|
+
font-size: inherit;
|
|
130
|
+
font-weight: 600;
|
|
131
|
+
}
|
|
132
|
+
.cw-toc__details[open] > .cw-toc__summary {
|
|
133
|
+
margin-bottom: 0.5em;
|
|
134
|
+
}
|
|
135
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
136
|
+
html:has(.cw-toc[data-smooth]) {
|
|
137
|
+
scroll-behavior: smooth;
|
|
138
|
+
}
|
|
139
|
+
}`;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headings & TOC site defaults: one plugin setting ("headings"). Hooks and
|
|
3
|
+
* routes read it through the plugin context; Astro components read the same
|
|
4
|
+
* option row from D1 in the feature switches' query and per-isolate cache.
|
|
5
|
+
*/
|
|
6
|
+
import { invalidateFeatures, readSiteSetting, registerSiteSetting } from "../core/features.js";
|
|
7
|
+
import { validPrefix } from "./slug.js";
|
|
8
|
+
|
|
9
|
+
export const SETTINGS_KEY = "headings";
|
|
10
|
+
|
|
11
|
+
export type TocListStyle = "none" | "bulleted" | "numbered";
|
|
12
|
+
export type TocDisplay = "open" | "collapsible" | "collapsed";
|
|
13
|
+
|
|
14
|
+
export interface HeadingsSettings {
|
|
15
|
+
/** Prefix for generated heading ids ("jump-"). */
|
|
16
|
+
prefix: string;
|
|
17
|
+
/** Show a "copy link to section" link on hover/focus. */
|
|
18
|
+
copyLink: boolean;
|
|
19
|
+
/** scroll-margin-top for anchored headings, so a sticky header doesn't cover them. 0 = none. */
|
|
20
|
+
scrollOffset: number;
|
|
21
|
+
scrollUnit: "px" | "rem";
|
|
22
|
+
toc: {
|
|
23
|
+
title: string;
|
|
24
|
+
/** Show the title above the list. A collapsible table always shows it (it's the toggle). */
|
|
25
|
+
showTitle: boolean;
|
|
26
|
+
levels: number[];
|
|
27
|
+
listStyle: TocListStyle;
|
|
28
|
+
display: TocDisplay;
|
|
29
|
+
/** Hide the table when it would list fewer headings than this. */
|
|
30
|
+
minHeadings: number;
|
|
31
|
+
/** Smooth scrolling on TOC pages (always off for visitors who prefer reduced motion). */
|
|
32
|
+
smoothScroll: boolean;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const DEFAULT_SETTINGS: HeadingsSettings = {
|
|
37
|
+
prefix: "jump-",
|
|
38
|
+
copyLink: false,
|
|
39
|
+
scrollOffset: 0,
|
|
40
|
+
scrollUnit: "px",
|
|
41
|
+
toc: { title: "Table of contents", showTitle: true, levels: [2, 3], listStyle: "none", display: "open", minHeadings: 2, smoothScroll: true },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const pick = <T extends string>(value: unknown, allowed: readonly T[], fallback: T): T =>
|
|
45
|
+
allowed.includes(value as T) ? (value as T) : fallback;
|
|
46
|
+
const bool = (value: unknown, fallback: boolean) => (typeof value === "boolean" ? value : fallback);
|
|
47
|
+
const int = (value: unknown, min: number, max: number, fallback: number) =>
|
|
48
|
+
typeof value === "number" && Number.isFinite(value) ? Math.min(max, Math.max(min, Math.round(value))) : fallback;
|
|
49
|
+
const text = (value: unknown, max: number, fallback: string) =>
|
|
50
|
+
typeof value === "string" ? value.replace(/[\u0000-\u001f<>]/g, "").trim().slice(0, max) : fallback;
|
|
51
|
+
|
|
52
|
+
/** Heading levels from block or setting input (numbers or "2"/"h2" strings), sorted, 2–6. */
|
|
53
|
+
export function parseLevels(value: unknown): number[] {
|
|
54
|
+
if (!Array.isArray(value)) return [];
|
|
55
|
+
const levels = value
|
|
56
|
+
.map((v) => (typeof v === "number" ? v : Number(String(v).replace(/^h/i, ""))))
|
|
57
|
+
.filter((n) => Number.isInteger(n) && n >= 2 && n <= 6);
|
|
58
|
+
return [...new Set(levels)].sort((a, b) => a - b);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Fill in and clamp stored settings. Never throws. */
|
|
62
|
+
export function normalizeSettings(raw: unknown): HeadingsSettings {
|
|
63
|
+
const r = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
|
|
64
|
+
const toc = (r.toc && typeof r.toc === "object" ? r.toc : {}) as Record<string, unknown>;
|
|
65
|
+
const d = DEFAULT_SETTINGS;
|
|
66
|
+
const levels = parseLevels(toc.levels);
|
|
67
|
+
return {
|
|
68
|
+
prefix: validPrefix(r.prefix) ? r.prefix : d.prefix,
|
|
69
|
+
copyLink: bool(r.copyLink, d.copyLink),
|
|
70
|
+
scrollOffset: int(r.scrollOffset, 0, 500, d.scrollOffset),
|
|
71
|
+
scrollUnit: pick(r.scrollUnit, ["px", "rem"], d.scrollUnit),
|
|
72
|
+
toc: {
|
|
73
|
+
title: text(toc.title, 100, d.toc.title) || d.toc.title,
|
|
74
|
+
showTitle: bool(toc.showTitle, d.toc.showTitle),
|
|
75
|
+
levels: levels.length ? levels : d.toc.levels,
|
|
76
|
+
listStyle: pick(toc.listStyle, ["none", "bulleted", "numbered"], d.toc.listStyle),
|
|
77
|
+
display: pick(toc.display, ["open", "collapsible", "collapsed"], d.toc.display),
|
|
78
|
+
minHeadings: int(toc.minHeadings, 1, 10, d.toc.minHeadings),
|
|
79
|
+
smoothScroll: bool(toc.smoothScroll, d.toc.smoothScroll),
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The value to store when Headings & TOC settings are saved. Breadcrumb
|
|
86
|
+
* defaults used to live in this setting (`breadcrumbs`); Breadcrumb Nav
|
|
87
|
+
* falls back to that sub-object until it saves its own setting, so it's
|
|
88
|
+
* carried over unchanged rather than dropped.
|
|
89
|
+
*/
|
|
90
|
+
export function withLegacyBreadcrumbs(previous: unknown, settings: HeadingsSettings): HeadingsSettings & { breadcrumbs?: unknown } {
|
|
91
|
+
const legacy = previous && typeof previous === "object" ? (previous as Record<string, unknown>).breadcrumbs : undefined;
|
|
92
|
+
return legacy && typeof legacy === "object" ? { ...settings, breadcrumbs: legacy } : settings;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Outside the plugin context (Astro components) ────────────────
|
|
96
|
+
|
|
97
|
+
registerSiteSetting(SETTINGS_KEY);
|
|
98
|
+
|
|
99
|
+
/** The last stored value and its normalized settings, so a cache hit doesn't normalize again. */
|
|
100
|
+
let memo: { raw: unknown; settings: HeadingsSettings } | null = null;
|
|
101
|
+
|
|
102
|
+
export function invalidateHeadingsSettings(): void {
|
|
103
|
+
invalidateFeatures();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The settings from D1 (in the feature switches' query and cache). Falls back to defaults if the database can't be read. */
|
|
107
|
+
export async function siteHeadingsSettings(database = "DB"): Promise<HeadingsSettings> {
|
|
108
|
+
const read = await readSiteSetting(SETTINGS_KEY, database);
|
|
109
|
+
if (!read) {
|
|
110
|
+
console.error("coywolf-pack: could not read heading settings");
|
|
111
|
+
return DEFAULT_SETTINGS;
|
|
112
|
+
}
|
|
113
|
+
if (memo && memo.raw === read.value) return memo.settings;
|
|
114
|
+
const settings = normalizeSettings(read.value);
|
|
115
|
+
memo = { raw: read.value, settings };
|
|
116
|
+
return settings;
|
|
117
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heading anchor slugs, ported from Coywolf SEO (WordPress `sanitize_title`
|
|
3
|
+
* plus a `jump-` prefix): lowercase, accents removed, punctuation dropped,
|
|
4
|
+
* spaces to hyphens, unique within the document.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** A Portable Text heading level from a block's style ("h2" → 2), or null. */
|
|
8
|
+
export function headingLevel(block: unknown): number | null {
|
|
9
|
+
if (!block || typeof block !== "object") return null;
|
|
10
|
+
const b = block as { _type?: unknown; style?: unknown };
|
|
11
|
+
if (b._type !== "block" || typeof b.style !== "string") return null;
|
|
12
|
+
const match = /^h([1-6])$/.exec(b.style);
|
|
13
|
+
return match ? Number(match[1]) : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The plain text of a Portable Text block. Walks nested `children`, so it
|
|
18
|
+
* also works on the marks tree astro-portabletext hands block components.
|
|
19
|
+
*/
|
|
20
|
+
export function blockText(block: unknown): string {
|
|
21
|
+
const parts: string[] = [];
|
|
22
|
+
const walk = (node: unknown) => {
|
|
23
|
+
if (!node || typeof node !== "object") return;
|
|
24
|
+
const { text, children } = node as { text?: unknown; children?: unknown };
|
|
25
|
+
if (typeof text === "string") parts.push(text);
|
|
26
|
+
else if (Array.isArray(children)) children.forEach(walk);
|
|
27
|
+
};
|
|
28
|
+
const children = (block as { children?: unknown })?.children;
|
|
29
|
+
if (Array.isArray(children)) children.forEach(walk);
|
|
30
|
+
return parts.join("").replace(/\s+/g, " ").trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const MAX_SLUG = 80;
|
|
34
|
+
|
|
35
|
+
/** A URL-safe slug for heading text ("Don't Panic!" → "dont-panic"). Empty text gives "". */
|
|
36
|
+
export function slugify(text: string): string {
|
|
37
|
+
const slug = text
|
|
38
|
+
.normalize("NFKD")
|
|
39
|
+
.replace(/\p{M}+/gu, "")
|
|
40
|
+
.toLowerCase()
|
|
41
|
+
.replace(/&[a-z0-9#]+;/g, "")
|
|
42
|
+
.replace(/[^\p{L}\p{N}\s_-]+/gu, "")
|
|
43
|
+
.trim()
|
|
44
|
+
.replace(/[\s_]+/g, "-")
|
|
45
|
+
.replace(/-{2,}/g, "-")
|
|
46
|
+
.replace(/^-+|-+$/g, "");
|
|
47
|
+
return slug.length > MAX_SLUG ? slug.slice(0, MAX_SLUG).replace(/-+$/, "") : slug;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A prefix is empty or a short id-safe string starting with a letter. */
|
|
51
|
+
export function validPrefix(prefix: unknown): prefix is string {
|
|
52
|
+
return typeof prefix === "string" && /^(?:[A-Za-z][A-Za-z0-9_-]{0,19})?$/.test(prefix);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Whether a stored anchor is safe to emit as an HTML id and in a fragment link. */
|
|
56
|
+
export function validAnchor(anchor: unknown): anchor is string {
|
|
57
|
+
return typeof anchor === "string" && anchor.length <= 120 && /^[\p{L}][\p{L}\p{N}_-]*$/u.test(anchor);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `prefix + slug(text)`, with -2, -3… appended until it's not in `taken`. Adds the result to `taken`. */
|
|
61
|
+
export function uniqueSlug(text: string, taken: Set<string>, prefix = "jump-"): string {
|
|
62
|
+
let base = `${prefix}${slugify(text) || "section"}`;
|
|
63
|
+
if (!/^\p{L}/u.test(base)) base = `h-${base}`;
|
|
64
|
+
let slug = base;
|
|
65
|
+
for (let i = 2; taken.has(slug); i++) slug = `${base}-${i}`;
|
|
66
|
+
taken.add(slug);
|
|
67
|
+
return slug;
|
|
68
|
+
}
|