@waveso/docs 0.3.0 → 0.5.0
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/CHANGELOG.md +201 -0
- package/README.md +160 -39
- package/dist/errors.d.ts +2 -0
- package/dist/highlighter.js +2 -1
- package/dist/meta.js +6 -9
- package/dist/next.d.ts +54 -14
- package/dist/next.js +115 -18
- package/dist/plugins/rehype-code-frame.d.ts +13 -1
- package/dist/plugins/rehype-code-frame.js +2 -1
- package/dist/plugins/remark-doc-links.d.ts +51 -1
- package/dist/plugins/remark-doc-links.js +27 -16
- package/dist/plugins/remark-youtube.d.ts +18 -3
- package/dist/plugins/remark-youtube.js +57 -9
- package/dist/react/callout.d.ts +13 -1
- package/dist/react/callout.js +2 -2
- package/dist/react/code-runtime.d.ts +12 -2
- package/dist/react/code-runtime.js +28 -4
- package/dist/react/doc-content.d.ts +12 -1
- package/dist/react/doc-content.js +2 -2
- package/dist/react/layout.d.ts +27 -10
- package/dist/react/layout.js +6 -3
- package/dist/react/markdown-components.d.ts +29 -1
- package/dist/react/markdown-components.js +69 -67
- package/dist/react/nav.d.ts +5 -1
- package/dist/react/nav.js +5 -2
- package/dist/react/next-nav.d.ts +5 -1
- package/dist/react/next-nav.js +5 -2
- package/dist/react/search-dialog.d.ts +92 -5
- package/dist/react/search-dialog.js +182 -43
- package/dist/react/shell-labels.d.ts +135 -21
- package/dist/react/shell-labels.js +47 -6
- package/dist/react/sidebar.d.ts +18 -1
- package/dist/react/sidebar.js +59 -23
- package/dist/react/youtube.d.ts +22 -1
- package/dist/react/youtube.js +22 -4
- package/dist/render.d.ts +11 -0
- package/dist/render.js +38 -11
- package/dist/route-path.js +7 -2
- package/dist/safe-href.d.ts +47 -0
- package/dist/safe-href.js +73 -0
- package/dist/search-index.js +1 -1
- package/dist/search-options.d.ts +64 -2
- package/dist/search-options.js +25 -1
- package/dist/semaphore.d.ts +46 -0
- package/dist/semaphore.js +60 -0
- package/dist/source.js +80 -10
- package/dist/styles.css +62 -13
- package/dist/types.d.ts +30 -0
- package/package.json +1 -1
package/dist/next.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { DocFile, DocFrontmatter, DocsConfig, ImageResolver, LinkResolver, RenderedDoc, SearchRecord } from "./types.js";
|
|
2
2
|
import { DocsHighlighter, DocsLang, DocsTheme, DocsThemes } from "./highlighter.js";
|
|
3
|
-
import {
|
|
3
|
+
import { SerializableSearchOptions } from "./search-options.js";
|
|
4
4
|
import { DocsLabels } from "./react/shell-labels.js";
|
|
5
|
+
import { MarkdownComponents } from "./react/markdown-components.js";
|
|
5
6
|
import { DocsLayoutSearchProps } from "./react/layout.js";
|
|
6
7
|
import { DocsSource } from "./source.js";
|
|
7
8
|
import { ReactNode } from "react";
|
|
@@ -61,13 +62,36 @@ interface DocsRouteOptions<TFrontmatter extends DocFrontmatter = DocFrontmatter>
|
|
|
61
62
|
/**
|
|
62
63
|
* MiniSearch overrides for the index {@link DocsRoute.searchIndex} builds.
|
|
63
64
|
*
|
|
64
|
-
* ⚠️ THE IDENTICAL OBJECT MUST REACH THE DIALOG
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* and not there produces an index whose terms no query can spell: zero
|
|
65
|
+
* ⚠️ THE IDENTICAL OBJECT MUST REACH THE DIALOG. MiniSearch reads `tokenize`
|
|
66
|
+
* and `processTerm` both when indexing and when querying, so applying one
|
|
67
|
+
* here and not there produces an index whose terms no query can spell: zero
|
|
68
68
|
* results, no error, nothing in the console.
|
|
69
|
+
*
|
|
70
|
+
* {@link DocsRoute.Layout} forwards this for you, which covers every
|
|
71
|
+
* serialisable override — `storeFields`, `boost`, `searchOptions.fuzzy`. It
|
|
72
|
+
* cannot forward a **function**: the dialog is a Client Component and props
|
|
73
|
+
* crossing that boundary are serialised, so `tokenize`, `processTerm` and
|
|
74
|
+
* their kind make `next build` fail. Rather than drop them silently — which
|
|
75
|
+
* is the zero-results failure above, with the warning turned off — `Layout`
|
|
76
|
+
* throws `invalid-config` and names the remedy: pass `search={false}`, render
|
|
77
|
+
* `DocsSearch` from a `'use client'` module of your own, and import the same
|
|
78
|
+
* function there. See {@link SerializableSearchOptions}.
|
|
69
79
|
*/
|
|
70
80
|
miniSearchOptions?: Partial<Options<SearchRecord>> | undefined;
|
|
81
|
+
/**
|
|
82
|
+
* Every string this package renders that is not your content.
|
|
83
|
+
*
|
|
84
|
+
* ⚠️ THE ROUTE IS WHERE THEY BELONG, BECAUSE THEY DO NOT ALL LIVE IN ONE
|
|
85
|
+
* RUNTIME. Four are rendered by the shell, two by the table of contents, nine
|
|
86
|
+
* by the markdown component map, two by a client-side copy runtime — and two
|
|
87
|
+
* are baked into the HTML by a rehype plugin at build time. `docs.Layout` can
|
|
88
|
+
* reach the first four and no more, which is exactly why its own `labels` prop
|
|
89
|
+
* documented itself as the whole set and covered less than a fifth of it.
|
|
90
|
+
*
|
|
91
|
+
* `docs.Layout` forwards these for you, and its `labels` prop still overrides
|
|
92
|
+
* them per-layout. Set them once here.
|
|
93
|
+
*/
|
|
94
|
+
labels?: DocsLabels | undefined;
|
|
71
95
|
}
|
|
72
96
|
/**
|
|
73
97
|
* Props for {@link DocsRoute.Layout}.
|
|
@@ -97,23 +121,39 @@ interface DocsLayoutProps {
|
|
|
97
121
|
* The search trigger. Defaults to on, and the URL is always derived.
|
|
98
122
|
*
|
|
99
123
|
* `false` omits it. An object configures the dialog — `placeholder`,
|
|
100
|
-
* `
|
|
101
|
-
* minus `indexUrl`.
|
|
124
|
+
* `pageSize`, `minQueryLength`, the state messages and the rest of
|
|
125
|
+
* `DocsSearch`'s surface, minus `indexUrl`.
|
|
126
|
+
*
|
|
127
|
+
* (It used to say `hotkey`. There is no such prop and there never was: the
|
|
128
|
+
* shortcut is ⌘K / Ctrl-K and is not configurable. A docstring naming an
|
|
129
|
+
* option that does not exist is worse than one naming none.)
|
|
102
130
|
*
|
|
103
131
|
* You do not need to pass `miniSearchOptions` here to match what
|
|
104
132
|
* `createDocsRoute` was given: the route's own value is forwarded, so the
|
|
105
133
|
* object that built the index is the object that queries it. Pass one only
|
|
106
134
|
* to override that.
|
|
135
|
+
*
|
|
136
|
+
* Serialisable overrides only, in both directions. This is a Server
|
|
137
|
+
* Component handing props to a Client one, so a `tokenize` or a
|
|
138
|
+
* `processTerm` here does not compile and one on the route throws rather
|
|
139
|
+
* than being quietly dropped — {@link SerializableSearchOptions} has the
|
|
140
|
+
* `'use client'` recipe for those.
|
|
107
141
|
*/
|
|
108
142
|
search?: boolean | DocsLayoutSearchProps | undefined;
|
|
109
143
|
/**
|
|
110
|
-
*
|
|
111
|
-
*
|
|
144
|
+
* Overrides for the strings the shell renders, over the route's.
|
|
145
|
+
*
|
|
146
|
+
* ⚠️ THIS DOCSTRING USED TO CLAIM TO BE "THE WHOLE OF WHAT A NON-ENGLISH SITE
|
|
147
|
+
* HAS TO SAY" AND REACHED FOUR STRINGS OF TWENTY-TWO. It could not have
|
|
148
|
+
* reached the rest: the table of contents is rendered by `docs.Page`, the
|
|
149
|
+
* callout headings by a component map built when the route is created, and the
|
|
150
|
+
* copy button is baked into the HTML by a rehype plugin at build time. None of
|
|
151
|
+
* those is downstream of a layout prop.
|
|
112
152
|
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
153
|
+
* So the set lives on {@link DocsRouteOptions.labels}, which is upstream of
|
|
154
|
+
* all four runtimes, and this overrides it key by key — for a site with two
|
|
155
|
+
* shells, or a section in another language. Whole-object replacement would
|
|
156
|
+
* mean naming one string cost you the other twenty-one.
|
|
117
157
|
*/
|
|
118
158
|
labels?: DocsLabels | undefined;
|
|
119
159
|
}
|
|
@@ -400,4 +440,4 @@ interface DocsRedirect {
|
|
|
400
440
|
*/
|
|
401
441
|
declare function createDocsRedirects(config: DocsConfig): Promise<DocsRedirect[]>;
|
|
402
442
|
//#endregion
|
|
403
|
-
export { type DocsLang, DocsLayoutProps, DocsPageMetadata, DocsPageProps, DocsRedirect, DocsRoute, DocsRouteOptions, DocsSitemapEntry, DocsSitemapOptions, type DocsTheme, type DocsThemes, createDocsRedirects, createDocsRoute, createDocsSitemap };
|
|
443
|
+
export { type DocsLang, DocsLayoutProps, type DocsLayoutSearchProps, DocsPageMetadata, DocsPageProps, DocsRedirect, DocsRoute, DocsRouteOptions, DocsSitemapEntry, DocsSitemapOptions, type DocsTheme, type DocsThemes, type SerializableSearchOptions, createDocsRedirects, createDocsRoute, createDocsSitemap };
|
package/dist/next.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { docsError } from "./docs-error.js";
|
|
2
2
|
import { DOCS_CONTENT_ID } from "./docs-content-id.js";
|
|
3
3
|
import { mapPooled } from "./map-pooled.js";
|
|
4
|
+
import { findFunctionValuedOptions } from "./search-options.js";
|
|
4
5
|
import { createMarkdownComponents } from "./react/markdown-components.js";
|
|
5
6
|
import { DocContent } from "./react/doc-content.js";
|
|
6
7
|
import { DocsToc } from "./react/toc.js";
|
|
@@ -119,9 +120,14 @@ async function loadNotFound() {
|
|
|
119
120
|
* as `number | \`${number}\``, and spreading a `ComponentProps<'img'>`-shaped
|
|
120
121
|
* object into it fails with TS2322 because the DOM types allow a bare `string`.
|
|
121
122
|
* The build-time {@link ImageResolver} has already produced real numbers here.
|
|
123
|
+
*
|
|
124
|
+
* ⚠️ SO THE LIST IS THE CONTRACT, AND IT HAS TO MATCH `DocsImageProps`. A prop
|
|
125
|
+
* added there and forgotten here is dropped silently — which is what happened to
|
|
126
|
+
* `decoding` and `fetchPriority`, under a comment in `createImage` promising
|
|
127
|
+
* they survived.
|
|
122
128
|
*/
|
|
123
129
|
function wrapNextImage(NextImage) {
|
|
124
|
-
return function DocsNextImage({ src, alt, width, height, title, className, sizes, loading }) {
|
|
130
|
+
return function DocsNextImage({ src, alt, width, height, title, className, sizes, loading, decoding, fetchPriority }) {
|
|
125
131
|
return createElement(NextImage, {
|
|
126
132
|
src,
|
|
127
133
|
alt,
|
|
@@ -130,35 +136,92 @@ function wrapNextImage(NextImage) {
|
|
|
130
136
|
...title === void 0 ? {} : { title },
|
|
131
137
|
...className === void 0 ? {} : { className },
|
|
132
138
|
...sizes === void 0 ? {} : { sizes },
|
|
133
|
-
...loading === void 0 ? {} : { loading }
|
|
139
|
+
...loading === void 0 ? {} : { loading },
|
|
140
|
+
...decoding === void 0 ? {} : { decoding },
|
|
141
|
+
...fetchPriority === void 0 ? {} : { fetchPriority }
|
|
134
142
|
});
|
|
135
143
|
};
|
|
136
144
|
}
|
|
137
145
|
/**
|
|
138
|
-
*
|
|
146
|
+
* A memo for the component map, one per route.
|
|
139
147
|
*
|
|
140
148
|
* `createMarkdownComponents` returns fresh component identities on every call,
|
|
141
149
|
* and a new identity for `a` remounts every link in the document on every
|
|
142
150
|
* render — so this memo is correctness, not micro-optimisation.
|
|
151
|
+
*
|
|
152
|
+
* ⚠️ PER ROUTE, NOT PER PROCESS, SINCE THE MAP CLOSES OVER THE ROUTE'S LABELS.
|
|
153
|
+
* A single process-wide memo would hand the second route the first route's
|
|
154
|
+
* language. Nothing is lost: `import()` caches the `next/link` and `next/image`
|
|
155
|
+
* modules itself, so all a second route pays for is two wrapper identities —
|
|
156
|
+
* and a route's identities only ever have to be stable against themselves,
|
|
157
|
+
* because the pages that use them come from that same route.
|
|
143
158
|
*/
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
159
|
+
function createComponentsMemo(labels) {
|
|
160
|
+
let memo = null;
|
|
161
|
+
return () => {
|
|
162
|
+
if (memo === null) memo = buildNextComponents(labels).catch((error) => {
|
|
163
|
+
memo = null;
|
|
164
|
+
throw error;
|
|
165
|
+
});
|
|
166
|
+
return memo;
|
|
167
|
+
};
|
|
151
168
|
}
|
|
152
|
-
async function buildNextComponents() {
|
|
169
|
+
async function buildNextComponents(labels) {
|
|
153
170
|
const [linkMod, imageMod] = await Promise.all([importNext(() => import("next/link"), "next/link"), importNext(() => import("next/image"), "next/image")]);
|
|
154
171
|
const NextLink = readDefaultExport(linkMod, "next/link");
|
|
155
172
|
const NextImage = readDefaultExport(imageMod, "next/image");
|
|
156
173
|
return createMarkdownComponents({
|
|
157
174
|
Link: wrapNextLink(NextLink),
|
|
158
|
-
Image: wrapNextImage(NextImage)
|
|
175
|
+
Image: wrapNextImage(NextImage),
|
|
176
|
+
...labels === void 0 ? {} : { labels }
|
|
159
177
|
});
|
|
160
178
|
}
|
|
161
179
|
/**
|
|
180
|
+
* The named subset of `labels`, or `undefined` when none of it is set.
|
|
181
|
+
*
|
|
182
|
+
* `undefined` rather than `{}` is the point: every forwarding site here spreads
|
|
183
|
+
* with `...(x === undefined ? {} : { x })`, so an empty object would still add a
|
|
184
|
+
* prop — and for the two groups that cross a client boundary that is a prop in
|
|
185
|
+
* every page's payload, forever, saying nothing.
|
|
186
|
+
*
|
|
187
|
+
* `map` goes target-key → `DocsLabels` key, so the rename is visible at the call
|
|
188
|
+
* site rather than hidden in a component.
|
|
189
|
+
*/
|
|
190
|
+
function pickLabels(labels, map) {
|
|
191
|
+
if (labels === void 0) return void 0;
|
|
192
|
+
const picked = {};
|
|
193
|
+
let found = false;
|
|
194
|
+
for (const [target, source] of Object.entries(map)) {
|
|
195
|
+
const value = labels[source];
|
|
196
|
+
if (value !== void 0) {
|
|
197
|
+
picked[target] = value;
|
|
198
|
+
found = true;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return found ? picked : void 0;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* `candidate`, once it is known to hold no functions.
|
|
205
|
+
*
|
|
206
|
+
* The cast at the end is the whole point of the function, and
|
|
207
|
+
* {@link findFunctionValuedOptions} is what earns it: a structural walk over
|
|
208
|
+
* the values, so it answers for options MiniSearch has not shipped yet as well
|
|
209
|
+
* as the five it has. `SerializableSearchOptions` narrows the same thing at
|
|
210
|
+
* compile time, which is friendlier and strictly weaker — a JavaScript caller
|
|
211
|
+
* has no types at all, and an `Omit` list goes stale the minor MiniSearch adds
|
|
212
|
+
* a callback.
|
|
213
|
+
*
|
|
214
|
+
* Throwing beats dropping. Silently forwarding the serialisable half would
|
|
215
|
+
* rebuild the original defect this channel exists to close — an index built
|
|
216
|
+
* with a `processTerm` the query does not share returns nothing, reports
|
|
217
|
+
* nothing, and looks like an empty corpus.
|
|
218
|
+
*/
|
|
219
|
+
function serializableSearchOptions(candidate) {
|
|
220
|
+
const functions = findFunctionValuedOptions(candidate);
|
|
221
|
+
if (functions.length > 0) throw docsError("invalid-config", `the search dialog cannot be given MiniSearch functions from a server component: ${functions.map((name) => `\`miniSearchOptions.${name}\``).join(", ")}. \`docs.Layout\` renders the dialog as a client component, so its props are serialised on the way across and React rejects a function with "Functions cannot be passed directly to Client Components" while prerendering. Keep the function on \`createDocsRoute\` so the index is still built with it, pass \`search={false}\` to \`docs.Layout\`, and render the dialog yourself from a \`'use client'\` module that imports the same function — \`<DocsSearch indexUrl={docs.searchIndexUrl} miniSearchOptions={{ processTerm }} />\` — putting that component in \`actions\`. Serialisable overrides (\`storeFields\`, \`boost\`, \`searchOptions.fuzzy\`) need none of this and are forwarded as before.`);
|
|
222
|
+
return candidate;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
162
225
|
* Create the route handlers for a documentation tree.
|
|
163
226
|
*
|
|
164
227
|
* Call it once at module scope in each of the two route files. The filesystem
|
|
@@ -170,6 +233,28 @@ function createDocsRoute(options) {
|
|
|
170
233
|
const source = createDocsSource(options);
|
|
171
234
|
const siteUrl = normalizeSiteUrl(options.siteUrl);
|
|
172
235
|
const rescanPerRequest = process.env.NODE_ENV !== "production";
|
|
236
|
+
const routeLabels = options.labels;
|
|
237
|
+
const codeLabels = pickLabels(routeLabels, {
|
|
238
|
+
copyLabel: "copyCode",
|
|
239
|
+
copyFromLabel: "copyCodeFrom"
|
|
240
|
+
});
|
|
241
|
+
const contentLabels = pickLabels(routeLabels, {
|
|
242
|
+
externalLink: "externalLink",
|
|
243
|
+
table: "table",
|
|
244
|
+
calloutNote: "calloutNote",
|
|
245
|
+
calloutTip: "calloutTip",
|
|
246
|
+
calloutImportant: "calloutImportant",
|
|
247
|
+
calloutWarning: "calloutWarning",
|
|
248
|
+
calloutCaution: "calloutCaution",
|
|
249
|
+
youtubeTitle: "youtubeTitle",
|
|
250
|
+
youtubePlay: "youtubePlay",
|
|
251
|
+
youtubeHide: "youtubeHide"
|
|
252
|
+
});
|
|
253
|
+
const copyLabels = pickLabels(routeLabels, {
|
|
254
|
+
copied: "copied",
|
|
255
|
+
copyFailed: "copyFailed"
|
|
256
|
+
});
|
|
257
|
+
const loadComponents = createComponentsMemo(contentLabels);
|
|
173
258
|
let renderer = null;
|
|
174
259
|
const knownRoutes = /* @__PURE__ */ new Set();
|
|
175
260
|
const draftRoutes = /* @__PURE__ */ new Set();
|
|
@@ -232,6 +317,7 @@ function createDocsRoute(options) {
|
|
|
232
317
|
...options.langs === void 0 ? {} : { langs: options.langs },
|
|
233
318
|
...options.themes === void 0 ? {} : { themes: options.themes },
|
|
234
319
|
...options.excludeLangs === void 0 ? {} : { excludeLangs: options.excludeLangs },
|
|
320
|
+
...codeLabels === void 0 ? {} : { codeLabels },
|
|
235
321
|
...options.titleHeading === void 0 ? {} : { titleHeading: options.titleHeading },
|
|
236
322
|
...options.remarkPlugins === void 0 ? {} : { remarkPlugins: options.remarkPlugins },
|
|
237
323
|
...options.rehypePlugins === void 0 ? {} : { rehypePlugins: options.rehypePlugins },
|
|
@@ -321,7 +407,7 @@ function createDocsRoute(options) {
|
|
|
321
407
|
async function renderRoute(segments) {
|
|
322
408
|
const doc = await getPage(segments);
|
|
323
409
|
if (doc === void 0) return (await loadNotFound())();
|
|
324
|
-
const components = await
|
|
410
|
+
const components = await loadComponents();
|
|
325
411
|
return createElement(Fragment, null, createElement("main", {
|
|
326
412
|
className: "wave-docs-layout__main",
|
|
327
413
|
id: DOCS_CONTENT_ID,
|
|
@@ -331,8 +417,13 @@ function createDocsRoute(options) {
|
|
|
331
417
|
components: {
|
|
332
418
|
...components,
|
|
333
419
|
...options.components
|
|
334
|
-
}
|
|
335
|
-
|
|
420
|
+
},
|
|
421
|
+
...copyLabels === void 0 ? {} : { labels: copyLabels }
|
|
422
|
+
})), doc.toc.length === 0 ? null : createElement("aside", { className: "wave-docs-layout__toc" }, createElement(DocsToc, {
|
|
423
|
+
entries: doc.toc,
|
|
424
|
+
...routeLabels?.toc === void 0 ? {} : { label: routeLabels.toc },
|
|
425
|
+
...routeLabels?.backToTop === void 0 ? {} : { topLabel: routeLabels.backToTop }
|
|
426
|
+
})));
|
|
336
427
|
}
|
|
337
428
|
return {
|
|
338
429
|
source: requestScopedSource,
|
|
@@ -350,9 +441,15 @@ function createDocsRoute(options) {
|
|
|
350
441
|
},
|
|
351
442
|
async Layout({ children, title, actions, search, labels }) {
|
|
352
443
|
const { DocsLayoutShell } = await import("./react/layout.js");
|
|
444
|
+
const host = search === true || search === void 0 || search === false ? void 0 : search;
|
|
445
|
+
const requestedOptions = host?.miniSearchOptions ?? options.miniSearchOptions;
|
|
353
446
|
const searchProps = search === false ? false : {
|
|
354
|
-
...
|
|
355
|
-
...
|
|
447
|
+
...host,
|
|
448
|
+
...requestedOptions === void 0 ? {} : { miniSearchOptions: serializableSearchOptions(requestedOptions) }
|
|
449
|
+
};
|
|
450
|
+
const shellLabels = options.labels === void 0 && labels === void 0 ? void 0 : {
|
|
451
|
+
...options.labels,
|
|
452
|
+
...labels
|
|
356
453
|
};
|
|
357
454
|
return createElement(DocsLayoutShell, {
|
|
358
455
|
children,
|
|
@@ -361,7 +458,7 @@ function createDocsRoute(options) {
|
|
|
361
458
|
search: searchProps,
|
|
362
459
|
...title === void 0 ? {} : { title },
|
|
363
460
|
...actions === void 0 ? {} : { actions },
|
|
364
|
-
...
|
|
461
|
+
...shellLabels === void 0 ? {} : { labels: shellLabels }
|
|
365
462
|
});
|
|
366
463
|
},
|
|
367
464
|
async generateStaticParams() {
|
|
@@ -2,8 +2,20 @@ import { Plugin } from "unified";
|
|
|
2
2
|
import { Root } from "hast";
|
|
3
3
|
//#region src/plugins/rehype-code-frame.d.ts
|
|
4
4
|
interface RehypeCodeFrameOptions {
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* Accessible name of the copy button on a fence with no title.
|
|
7
|
+
* Default `'Copy code'`.
|
|
8
|
+
*/
|
|
6
9
|
copyLabel?: string | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* The same button on a titled fence. Default `'Copy code from {title}'`.
|
|
12
|
+
*
|
|
13
|
+
* `{title}` is replaced with the fence's own `title="…"`. Two controls both
|
|
14
|
+
* called "Copy code" are indistinguishable in a screen reader's element list,
|
|
15
|
+
* which is why the titled form exists at all — and a translator needs to be
|
|
16
|
+
* able to move the title within the sentence, which concatenation forbade.
|
|
17
|
+
*/
|
|
18
|
+
copyFromLabel?: string | undefined;
|
|
7
19
|
}
|
|
8
20
|
declare const rehypeCodeFrame: Plugin<[RehypeCodeFrameOptions?], Root>;
|
|
9
21
|
//#endregion
|
|
@@ -6,6 +6,7 @@ import { CONTINUE, SKIP, visit } from "unist-util-visit";
|
|
|
6
6
|
const LANGUAGE_CLASS = /^language-(.+)$/;
|
|
7
7
|
const rehypeCodeFrame = (options = {}) => {
|
|
8
8
|
const copyLabel = options.copyLabel ?? "Copy code";
|
|
9
|
+
const copyFromLabel = options.copyFromLabel ?? "Copy code from {title}";
|
|
9
10
|
return (tree, file) => {
|
|
10
11
|
const path = file.data.docLinkContext?.relativePath ?? file.path ?? "a document";
|
|
11
12
|
visit(tree, "element", (node, index, parent) => {
|
|
@@ -27,7 +28,7 @@ const rehypeCodeFrame = (options = {}) => {
|
|
|
27
28
|
value: title
|
|
28
29
|
}]
|
|
29
30
|
});
|
|
30
|
-
children.push(copyButton(title === void 0 ? copyLabel :
|
|
31
|
+
children.push(copyButton(title === void 0 ? copyLabel : copyFromLabel.replace("{title}", title)), node);
|
|
31
32
|
parent.children[index] = {
|
|
32
33
|
type: "element",
|
|
33
34
|
tagName: "figure",
|
|
@@ -56,6 +56,56 @@ interface RemarkDocLinksOptions {
|
|
|
56
56
|
* are contained by one implementation rather than two.
|
|
57
57
|
*/
|
|
58
58
|
declare function foldSegments(from: readonly string[], path: string): string[] | undefined;
|
|
59
|
+
/**
|
|
60
|
+
* Decode BEFORE folding, never after: `%2E%2E%2F` is `../` in disguise, and
|
|
61
|
+
* `foldSegments` is the only thing that refuses a chain climbing out of the
|
|
62
|
+
* content root.
|
|
63
|
+
*
|
|
64
|
+
* Exported for `route-path.ts`, which needs the decode without the split:
|
|
65
|
+
* an alias may carry a literal `#` or `?` — `c# guide` is a page name — and
|
|
66
|
+
* {@link splitHref} would cut the string there and throw the rest away.
|
|
67
|
+
*/
|
|
68
|
+
declare function decodePath(path: string, href: string): string;
|
|
69
|
+
/**
|
|
70
|
+
* An href, split at `?` and `#`, with the path decoded and the rest left alone.
|
|
71
|
+
*
|
|
72
|
+
* ⚠️ ONE IMPLEMENTATION, BECAUSE THE THIRD COPY WAS WRONG. Four call sites need
|
|
73
|
+
* this exact sequence — split, decode the path, fold, re-attach — and three of
|
|
74
|
+
* them had it inline while `foldImageSrc` in `render.ts` had none of it. An
|
|
75
|
+
* author dragging a file into GitHub's editor gets ``
|
|
76
|
+
* written for them, and that reached the resolver undecoded: `readFile` on a
|
|
77
|
+
* filename with a literal `%20` in it, `ENOENT`, and a build failed on an image
|
|
78
|
+
* that is plainly on disk and that GitHub renders. `./diagram.png?v=2` and
|
|
79
|
+
* `./sprite.svg#icon` baked the query and the fragment into the filename the
|
|
80
|
+
* same way.
|
|
81
|
+
*
|
|
82
|
+
* A query and a fragment are never decoded: `?q=a%26b` carries a literal
|
|
83
|
+
* ampersand that decoding would turn into a separator, and neither is part of
|
|
84
|
+
* any filename.
|
|
85
|
+
*/
|
|
86
|
+
interface HrefParts {
|
|
87
|
+
/** Path, percent-decoded and NFC-normalised — what {@link foldSegments} needs. */
|
|
88
|
+
path: string;
|
|
89
|
+
/**
|
|
90
|
+
* The same span exactly as authored.
|
|
91
|
+
*
|
|
92
|
+
* One caller needs it: {@link normalizeInternalRoute} strips `basePath` off
|
|
93
|
+
* the front by length, and doing that to a decoded path would cut at the
|
|
94
|
+
* wrong offset for any href that percent-encoded part of the prefix.
|
|
95
|
+
*/
|
|
96
|
+
rawPath: string;
|
|
97
|
+
/** `?query` as authored, or `''`. */
|
|
98
|
+
query: string;
|
|
99
|
+
/** `#hash` as authored, or `''`. */
|
|
100
|
+
hash: string;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Split an href into its path, query and fragment; decode only the path.
|
|
104
|
+
*
|
|
105
|
+
* Throws a {@link URIError} if the path is not valid percent-encoding, with the
|
|
106
|
+
* whole href in the message — see {@link decodeSegment}.
|
|
107
|
+
*/
|
|
108
|
+
declare function splitHref(href: string): HrefParts;
|
|
59
109
|
/**
|
|
60
110
|
* The built-in {@link LinkResolver}: markdown file path in, route out.
|
|
61
111
|
*
|
|
@@ -71,4 +121,4 @@ declare function resolveMarkdownLink(href: string, fromDir: readonly string[], b
|
|
|
71
121
|
*/
|
|
72
122
|
declare const remarkDocLinks: Plugin<[RemarkDocLinksOptions], Root>;
|
|
73
123
|
//#endregion
|
|
74
|
-
export { type DocLinkContext, DocLinkRef, RemarkDocLinksOptions, foldSegments, remarkDocLinks, resolveMarkdownLink };
|
|
124
|
+
export { type DocLinkContext, DocLinkRef, HrefParts, RemarkDocLinksOptions, decodePath, foldSegments, remarkDocLinks, resolveMarkdownLink, splitHref };
|
|
@@ -107,11 +107,31 @@ function decodeSegment(segment, href) {
|
|
|
107
107
|
* Decode BEFORE folding, never after: `%2E%2E%2F` is `../` in disguise, and
|
|
108
108
|
* `foldSegments` is the only thing that refuses a chain climbing out of the
|
|
109
109
|
* content root.
|
|
110
|
+
*
|
|
111
|
+
* Exported for `route-path.ts`, which needs the decode without the split:
|
|
112
|
+
* an alias may carry a literal `#` or `?` — `c# guide` is a page name — and
|
|
113
|
+
* {@link splitHref} would cut the string there and throw the rest away.
|
|
110
114
|
*/
|
|
111
115
|
function decodePath(path, href) {
|
|
112
116
|
return path.split("/").map((segment) => decodeSegment(segment, href)).join("/");
|
|
113
117
|
}
|
|
114
118
|
/**
|
|
119
|
+
* Split an href into its path, query and fragment; decode only the path.
|
|
120
|
+
*
|
|
121
|
+
* Throws a {@link URIError} if the path is not valid percent-encoding, with the
|
|
122
|
+
* whole href in the message — see {@link decodeSegment}.
|
|
123
|
+
*/
|
|
124
|
+
function splitHref(href) {
|
|
125
|
+
const parts = HREF_PARTS.exec(href);
|
|
126
|
+
const rawPath = parts?.[1] ?? "";
|
|
127
|
+
return {
|
|
128
|
+
path: decodePath(rawPath, href),
|
|
129
|
+
rawPath,
|
|
130
|
+
query: parts?.[2] ?? "",
|
|
131
|
+
hash: parts?.[3] ?? ""
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
115
135
|
* The built-in {@link LinkResolver}: markdown file path in, route out.
|
|
116
136
|
*
|
|
117
137
|
* Exported for reuse by hosts that want to wrap rather than replace it. Throws
|
|
@@ -119,12 +139,9 @@ function decodePath(path, href) {
|
|
|
119
139
|
* reported as `undefined`.
|
|
120
140
|
*/
|
|
121
141
|
function resolveMarkdownLink(href, fromDir, basePath) {
|
|
122
|
-
const
|
|
123
|
-
const path = parts?.[1] ?? "";
|
|
124
|
-
const query = parts?.[2] ?? "";
|
|
125
|
-
const hash = parts?.[3] ?? "";
|
|
142
|
+
const { path, query, hash } = splitHref(href);
|
|
126
143
|
if (path === "") return;
|
|
127
|
-
const segments = foldSegments(fromDir,
|
|
144
|
+
const segments = foldSegments(fromDir, path);
|
|
128
145
|
if (segments === void 0) return;
|
|
129
146
|
const last = segments.at(-1);
|
|
130
147
|
if (last !== void 0) {
|
|
@@ -149,12 +166,9 @@ function resolveMarkdownLink(href, fromDir, basePath) {
|
|
|
149
166
|
* browser resolves happily and `knownRoutes` has never heard of.
|
|
150
167
|
*/
|
|
151
168
|
function normalizeInternalRoute(href, basePath) {
|
|
152
|
-
const
|
|
153
|
-
const path = parts?.[1] ?? "";
|
|
154
|
-
const query = parts?.[2] ?? "";
|
|
155
|
-
const hash = parts?.[3] ?? "";
|
|
169
|
+
const { rawPath, query, hash } = splitHref(href);
|
|
156
170
|
const base = basePath.replace(/\/+$/, "");
|
|
157
|
-
const segments = foldSegments([], decodePath(
|
|
171
|
+
const segments = foldSegments([], decodePath(rawPath.slice(base.length), href));
|
|
158
172
|
if (segments === void 0) return;
|
|
159
173
|
return `${toRoute(basePath, segments)}${query}${hash}`;
|
|
160
174
|
}
|
|
@@ -183,11 +197,8 @@ function isAssetLink(href) {
|
|
|
183
197
|
* the exception.
|
|
184
198
|
*/
|
|
185
199
|
function resolveAssetLink(href, fromDir, basePath) {
|
|
186
|
-
const
|
|
187
|
-
const
|
|
188
|
-
const query = parts?.[2] ?? "";
|
|
189
|
-
const hash = parts?.[3] ?? "";
|
|
190
|
-
const segments = foldSegments(fromDir, decodePath(path, href));
|
|
200
|
+
const { path, query, hash } = splitHref(href);
|
|
201
|
+
const segments = foldSegments(fromDir, path);
|
|
191
202
|
if (segments === void 0) return;
|
|
192
203
|
return `${toRoute(basePath, segments)}${query}${hash}`;
|
|
193
204
|
}
|
|
@@ -246,4 +257,4 @@ const remarkDocLinks = (options) => {
|
|
|
246
257
|
};
|
|
247
258
|
};
|
|
248
259
|
//#endregion
|
|
249
|
-
export { foldSegments, remarkDocLinks, resolveMarkdownLink };
|
|
260
|
+
export { decodePath, foldSegments, remarkDocLinks, resolveMarkdownLink, splitHref };
|
|
@@ -1,13 +1,28 @@
|
|
|
1
1
|
import { Plugin } from "unified";
|
|
2
2
|
import { Root } from "mdast";
|
|
3
3
|
//#region src/plugins/remark-youtube.d.ts
|
|
4
|
+
/** What a YouTube URL says, beyond which video it is. */
|
|
5
|
+
interface YouTubeRef {
|
|
6
|
+
id: string;
|
|
7
|
+
/** Seconds to start at, from `t` or `start`. */
|
|
8
|
+
start?: number | undefined;
|
|
9
|
+
/** Playlist the video was linked inside, from `list`. */
|
|
10
|
+
list?: string | undefined;
|
|
11
|
+
}
|
|
4
12
|
/**
|
|
5
|
-
* Extract a video
|
|
13
|
+
* Extract a video reference from a YouTube watch/short/embed URL.
|
|
6
14
|
*
|
|
7
15
|
* Returns `undefined` for anything else, including YouTube URLs that are not a
|
|
8
16
|
* single video (channels, playlists) — those stay ordinary links.
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ THE TIMESTAMP AND THE PLAYLIST ARE PART OF THE LINK, AND WERE DROPPED. Only
|
|
19
|
+
* the id survived, so `https://youtu.be/x?t=754` — a link to one specific moment
|
|
20
|
+
* in a two-hour talk, which is most of why anyone deep-links a video — opened at
|
|
21
|
+
* zero. And because the facade passes `autoplay=1`, it did not merely start in
|
|
22
|
+
* the wrong place: it started *playing* in the wrong place, so the reader had to
|
|
23
|
+
* work out that the author had meant somewhere else.
|
|
9
24
|
*/
|
|
10
|
-
declare function
|
|
25
|
+
declare function parseYouTubeRef(href: string): YouTubeRef | undefined;
|
|
11
26
|
/**
|
|
12
27
|
* remark plugin. Replaces the whole PARAGRAPH, not the link inside it — which
|
|
13
28
|
* is the point: leaving the paragraph is what nested a block element in
|
|
@@ -19,4 +34,4 @@ declare function parseYouTubeId(href: string): string | undefined;
|
|
|
19
34
|
*/
|
|
20
35
|
declare const remarkYouTube: Plugin<[], Root>;
|
|
21
36
|
//#endregion
|
|
22
|
-
export {
|
|
37
|
+
export { YouTubeRef, parseYouTubeRef, remarkYouTube };
|
|
@@ -4,13 +4,40 @@ import { SKIP, visit } from "unist-util-visit";
|
|
|
4
4
|
const VIDEO_ID = /^[A-Za-z0-9_-]{11}$/;
|
|
5
5
|
/** `https://` or `http://`, for comparing a label against its own href. */
|
|
6
6
|
const HTTP_SCHEME = /^https?:\/\//i;
|
|
7
|
+
/** A playlist id, conservatively: what YouTube uses and nothing else. */
|
|
8
|
+
const PLAYLIST_ID = /^[A-Za-z0-9_-]{2,64}$/;
|
|
7
9
|
/**
|
|
8
|
-
*
|
|
10
|
+
* `1h2m3s`, `90s`, `90` — every spelling YouTube's own `t` parameter takes.
|
|
11
|
+
*/
|
|
12
|
+
const TIMESTAMP = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s?)?$/;
|
|
13
|
+
/**
|
|
14
|
+
* Seconds from a `t`/`start` value, or `undefined` if it is not one.
|
|
15
|
+
*
|
|
16
|
+
* YouTube accepts `t=90`, `t=90s` and `t=1m30s`, and an author linking to a
|
|
17
|
+
* moment in a talk pastes whichever the share dialog gave them.
|
|
18
|
+
*/
|
|
19
|
+
function parseTimestamp(value) {
|
|
20
|
+
if (value === null || value === "") return void 0;
|
|
21
|
+
const match = TIMESTAMP.exec(value);
|
|
22
|
+
if (match === null) return void 0;
|
|
23
|
+
const [, hours, minutes, seconds] = match;
|
|
24
|
+
const total = Number(hours ?? 0) * 3600 + Number(minutes ?? 0) * 60 + Number(seconds ?? 0);
|
|
25
|
+
return Number.isFinite(total) && total > 0 ? total : void 0;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Extract a video reference from a YouTube watch/short/embed URL.
|
|
9
29
|
*
|
|
10
30
|
* Returns `undefined` for anything else, including YouTube URLs that are not a
|
|
11
31
|
* single video (channels, playlists) — those stay ordinary links.
|
|
32
|
+
*
|
|
33
|
+
* ⚠️ THE TIMESTAMP AND THE PLAYLIST ARE PART OF THE LINK, AND WERE DROPPED. Only
|
|
34
|
+
* the id survived, so `https://youtu.be/x?t=754` — a link to one specific moment
|
|
35
|
+
* in a two-hour talk, which is most of why anyone deep-links a video — opened at
|
|
36
|
+
* zero. And because the facade passes `autoplay=1`, it did not merely start in
|
|
37
|
+
* the wrong place: it started *playing* in the wrong place, so the reader had to
|
|
38
|
+
* work out that the author had meant somewhere else.
|
|
12
39
|
*/
|
|
13
|
-
function
|
|
40
|
+
function parseYouTubeRef(href) {
|
|
14
41
|
let url;
|
|
15
42
|
try {
|
|
16
43
|
url = new URL(href, "https://example.invalid");
|
|
@@ -19,17 +46,34 @@ function parseYouTubeId(href) {
|
|
|
19
46
|
}
|
|
20
47
|
const host = url.hostname.replace(/^(www|m)\./, "");
|
|
21
48
|
const segments = url.pathname.split("/").filter(Boolean);
|
|
49
|
+
const extras = () => {
|
|
50
|
+
const start = parseTimestamp(url.searchParams.get("t")) ?? parseTimestamp(url.searchParams.get("start")) ?? parseTimestamp(url.hash.replace(/^#t=/, "") || null);
|
|
51
|
+
const list = url.searchParams.get("list");
|
|
52
|
+
return {
|
|
53
|
+
...start === void 0 ? {} : { start },
|
|
54
|
+
...list !== null && PLAYLIST_ID.test(list) ? { list } : {}
|
|
55
|
+
};
|
|
56
|
+
};
|
|
22
57
|
if (host === "youtu.be") {
|
|
23
58
|
const [id] = segments;
|
|
24
|
-
return id !== void 0 && VIDEO_ID.test(id) ?
|
|
59
|
+
return id !== void 0 && VIDEO_ID.test(id) ? {
|
|
60
|
+
id,
|
|
61
|
+
...extras()
|
|
62
|
+
} : void 0;
|
|
25
63
|
}
|
|
26
64
|
if (host !== "youtube.com" && host !== "youtube-nocookie.com") return;
|
|
27
65
|
if (url.pathname === "/watch") {
|
|
28
66
|
const id = url.searchParams.get("v");
|
|
29
|
-
return id !== null && VIDEO_ID.test(id) ?
|
|
67
|
+
return id !== null && VIDEO_ID.test(id) ? {
|
|
68
|
+
id,
|
|
69
|
+
...extras()
|
|
70
|
+
} : void 0;
|
|
30
71
|
}
|
|
31
72
|
const [prefix, id] = segments;
|
|
32
|
-
if ((prefix === "embed" || prefix === "shorts") && id !== void 0) return VIDEO_ID.test(id) ?
|
|
73
|
+
if ((prefix === "embed" || prefix === "shorts") && id !== void 0) return VIDEO_ID.test(id) ? {
|
|
74
|
+
id,
|
|
75
|
+
...extras()
|
|
76
|
+
} : void 0;
|
|
33
77
|
}
|
|
34
78
|
/** Whitespace-only text is what separates two links on consecutive lines. */
|
|
35
79
|
function isIgnorable(node) {
|
|
@@ -66,14 +110,18 @@ const remarkYouTube = () => {
|
|
|
66
110
|
const meaningful = node.children.filter((child) => !isIgnorable(child));
|
|
67
111
|
const only = meaningful[0];
|
|
68
112
|
if (meaningful.length !== 1 || only === void 0 || !isBareUrl(only)) return;
|
|
69
|
-
const
|
|
70
|
-
if (
|
|
113
|
+
const ref = only.type === "link" ? parseYouTubeRef(only.url) : void 0;
|
|
114
|
+
if (ref === void 0) return;
|
|
71
115
|
parent.children[index] = {
|
|
72
116
|
type: "paragraph",
|
|
73
117
|
children: [],
|
|
74
118
|
data: {
|
|
75
119
|
hName: "youtube",
|
|
76
|
-
hProperties: {
|
|
120
|
+
hProperties: {
|
|
121
|
+
id: ref.id,
|
|
122
|
+
...ref.start === void 0 ? {} : { start: ref.start },
|
|
123
|
+
...ref.list === void 0 ? {} : { list: ref.list }
|
|
124
|
+
}
|
|
77
125
|
}
|
|
78
126
|
};
|
|
79
127
|
return [SKIP, index + 1];
|
|
@@ -81,4 +129,4 @@ const remarkYouTube = () => {
|
|
|
81
129
|
};
|
|
82
130
|
};
|
|
83
131
|
//#endregion
|
|
84
|
-
export {
|
|
132
|
+
export { parseYouTubeRef, remarkYouTube };
|