@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,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
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Save-time stamping (content:beforeSave). EmDash's Portable Text renderer
|
|
3
|
+
* emits headings without ids, and a block component only sees its own node,
|
|
4
|
+
* so everything the site needs is written into the content when it's saved:
|
|
5
|
+
*
|
|
6
|
+
* - each H2–H6 heading block gets an `anchor` field (e.g. "jump-pricing"),
|
|
7
|
+
* unique within its field and kept across edits (matched to the previous
|
|
8
|
+
* save by block `_key`, then by text), because the editor drops unknown
|
|
9
|
+
* fields on heading blocks;
|
|
10
|
+
* - each `coywolf-toc` block gets `_headings`, the field's heading list.
|
|
11
|
+
*
|
|
12
|
+
* (Breadcrumbs blocks are stamped by the Breadcrumb Nav module, src/breadcrumbs/stamp.ts.)
|
|
13
|
+
*
|
|
14
|
+
* Pure: returns new objects and never mutates its input. Running it twice
|
|
15
|
+
* gives the same result (`changed: false` the second time).
|
|
16
|
+
*/
|
|
17
|
+
import { blockText, headingLevel, uniqueSlug, validAnchor } from "./slug.js";
|
|
18
|
+
import type { TocHeading } from "./toc.js";
|
|
19
|
+
|
|
20
|
+
export const TOC_BLOCK = "coywolf-toc";
|
|
21
|
+
export const ANCHOR_LEVELS = [2, 3, 4, 5, 6];
|
|
22
|
+
|
|
23
|
+
export interface StampOptions {
|
|
24
|
+
/** Stamp heading anchors (and TOC heading lists, when `toc`). */
|
|
25
|
+
anchors: boolean;
|
|
26
|
+
toc: boolean;
|
|
27
|
+
prefix: string;
|
|
28
|
+
/** The entry's stored data before this save, to keep anchors stable. */
|
|
29
|
+
previous?: Record<string, unknown> | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type Block = Record<string, unknown> & { _type: string; _key?: string };
|
|
33
|
+
|
|
34
|
+
/** An array that looks like Portable Text: every item is an object with a string `_type`. */
|
|
35
|
+
function isPortableText(value: unknown): value is Block[] {
|
|
36
|
+
return (
|
|
37
|
+
Array.isArray(value) &&
|
|
38
|
+
value.length > 0 &&
|
|
39
|
+
value.every((b) => b && typeof b === "object" && typeof (b as { _type?: unknown })._type === "string")
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sameJson(a: unknown, b: unknown): boolean {
|
|
44
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Previous anchors of one field, by block key and by heading text. */
|
|
48
|
+
function previousAnchors(previous: unknown) {
|
|
49
|
+
const byKey = new Map<string, string>();
|
|
50
|
+
const byText = new Map<string, string>();
|
|
51
|
+
if (isPortableText(previous)) {
|
|
52
|
+
for (const block of previous) {
|
|
53
|
+
if (!headingLevel(block) || !validAnchor(block.anchor)) continue;
|
|
54
|
+
if (typeof block._key === "string") byKey.set(block._key, block.anchor);
|
|
55
|
+
const text = blockText(block);
|
|
56
|
+
if (text && !byText.has(text)) byText.set(text, block.anchor);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { byKey, byText };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Stamp one Portable Text field. Returns the new array, or null when nothing changed. */
|
|
63
|
+
export function stampField(blocks: Block[], options: StampOptions, previousField: unknown): Block[] | null {
|
|
64
|
+
const out = blocks.slice();
|
|
65
|
+
let changed = false;
|
|
66
|
+
const set = (i: number, patch: Record<string, unknown>, remove?: string) => {
|
|
67
|
+
const next: Block = { ...out[i], ...patch };
|
|
68
|
+
if (remove) delete next[remove];
|
|
69
|
+
out[i] = next;
|
|
70
|
+
changed = true;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const headings: TocHeading[] = [];
|
|
74
|
+
if (options.anchors) {
|
|
75
|
+
const { byKey, byText } = previousAnchors(previousField);
|
|
76
|
+
const taken = new Set<string>();
|
|
77
|
+
const pending: { index: number; level: number; text: string; kept: string | null; byText: string | null }[] = [];
|
|
78
|
+
|
|
79
|
+
blocks.forEach((block, index) => {
|
|
80
|
+
const level = headingLevel(block);
|
|
81
|
+
if (!level || !ANCHOR_LEVELS.includes(level)) return;
|
|
82
|
+
const text = blockText(block);
|
|
83
|
+
if (!text) {
|
|
84
|
+
if ("anchor" in block) set(index, {}, "anchor");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const kept = (validAnchor(block.anchor) ? block.anchor : null) ?? (typeof block._key === "string" ? byKey.get(block._key) : undefined) ?? null;
|
|
88
|
+
pending.push({ index, level, text, kept, byText: byText.get(text) ?? null });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Claim anchors in priority order (first claim wins): the block's own or its previous
|
|
92
|
+
// anchor (by _key), then a previous anchor with the same text, then a new slug.
|
|
93
|
+
const ids = new Map<number, string>();
|
|
94
|
+
const claim = (pick: (p: (typeof pending)[number]) => string | null) => {
|
|
95
|
+
for (const p of pending) {
|
|
96
|
+
const id = ids.has(p.index) ? null : pick(p);
|
|
97
|
+
if (id && !taken.has(id)) {
|
|
98
|
+
taken.add(id);
|
|
99
|
+
ids.set(p.index, id);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
claim((p) => p.kept);
|
|
104
|
+
claim((p) => p.byText);
|
|
105
|
+
for (const p of pending) if (!ids.has(p.index)) ids.set(p.index, uniqueSlug(p.text, taken, options.prefix));
|
|
106
|
+
|
|
107
|
+
for (const p of pending) {
|
|
108
|
+
const id = ids.get(p.index) as string;
|
|
109
|
+
if (blocks[p.index].anchor !== id) set(p.index, { anchor: id });
|
|
110
|
+
headings.push({ level: p.level, id, text: p.text });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (options.toc && options.anchors) {
|
|
115
|
+
blocks.forEach((block, index) => {
|
|
116
|
+
if (block._type === TOC_BLOCK && !sameJson(block._headings, headings)) set(index, { _headings: headings });
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return changed ? out : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Stamp every Portable Text field of an entry. Returns the new content, or null when nothing changed. */
|
|
124
|
+
export function stampContent(content: Record<string, unknown>, options: StampOptions): Record<string, unknown> | null {
|
|
125
|
+
let result: Record<string, unknown> | null = null;
|
|
126
|
+
for (const [field, value] of Object.entries(content)) {
|
|
127
|
+
if (!isPortableText(value)) continue;
|
|
128
|
+
const stamped = stampField(value, options, options.previous?.[field]);
|
|
129
|
+
if (stamped) {
|
|
130
|
+
result ??= { ...content };
|
|
131
|
+
result[field] = stamped;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Whether any field has a heading without a valid anchor (so the previous version is worth reading). */
|
|
138
|
+
export function needsPrevious(content: Record<string, unknown>): boolean {
|
|
139
|
+
return Object.values(content).some(
|
|
140
|
+
(value) => isPortableText(value) && value.some((block) => headingLevel(block) && !validAnchor(block.anchor)),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Test-only resolve hook: source files import "./x.js" (bundler style); under
|
|
2
|
+
// `node --test` with type stripping, map those to the sibling .ts file.
|
|
3
|
+
import { registerHooks } from "node:module";
|
|
4
|
+
|
|
5
|
+
registerHooks({
|
|
6
|
+
resolve(specifier, context, next) {
|
|
7
|
+
try {
|
|
8
|
+
return next(specifier, context);
|
|
9
|
+
} catch (error) {
|
|
10
|
+
if (specifier.startsWith(".") && specifier.endsWith(".js")) return next(specifier.slice(0, -3) + ".ts", context);
|
|
11
|
+
throw error;
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table of contents tree: the flat heading list stamped into a TOC block,
|
|
3
|
+
* filtered to the block's levels and nested by level. Tolerant of skipped
|
|
4
|
+
* levels (an H4 straight after an H2 nests under the H2).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface TocHeading {
|
|
8
|
+
level: number;
|
|
9
|
+
id: string;
|
|
10
|
+
text: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TocNode extends TocHeading {
|
|
14
|
+
children: TocNode[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function buildTocTree(headings: readonly TocHeading[], levels: readonly number[]): TocNode[] {
|
|
18
|
+
const wanted = new Set(levels);
|
|
19
|
+
const root: TocNode[] = [];
|
|
20
|
+
const stack: TocNode[] = [];
|
|
21
|
+
for (const heading of headings) {
|
|
22
|
+
if (!wanted.has(heading.level)) continue;
|
|
23
|
+
const node: TocNode = { level: heading.level, id: heading.id, text: heading.text, children: [] };
|
|
24
|
+
while (stack.length && stack[stack.length - 1].level >= node.level) stack.pop();
|
|
25
|
+
(stack.length ? stack[stack.length - 1].children : root).push(node);
|
|
26
|
+
stack.push(node);
|
|
27
|
+
}
|
|
28
|
+
return root;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Count every node in a tree. */
|
|
32
|
+
export function countToc(nodes: readonly TocNode[]): number {
|
|
33
|
+
return nodes.reduce((n, node) => n + 1 + countToc(node.children), 0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Clean the heading list stored on a TOC block (it's content data, so treat it as untrusted). */
|
|
37
|
+
export function storedHeadings(value: unknown): TocHeading[] {
|
|
38
|
+
if (!Array.isArray(value)) return [];
|
|
39
|
+
const out: TocHeading[] = [];
|
|
40
|
+
for (const item of value) {
|
|
41
|
+
if (!item || typeof item !== "object") continue;
|
|
42
|
+
const { level, id, text } = item as Record<string, unknown>;
|
|
43
|
+
if (typeof level !== "number" || level < 1 || level > 6) continue;
|
|
44
|
+
if (typeof id !== "string" || !id || typeof text !== "string" || !text) continue;
|
|
45
|
+
out.push({ level, id, text });
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|