@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/README.md +160 -39
  3. package/dist/errors.d.ts +2 -0
  4. package/dist/highlighter.js +2 -1
  5. package/dist/meta.js +6 -9
  6. package/dist/next.d.ts +54 -14
  7. package/dist/next.js +115 -18
  8. package/dist/plugins/rehype-code-frame.d.ts +13 -1
  9. package/dist/plugins/rehype-code-frame.js +2 -1
  10. package/dist/plugins/remark-doc-links.d.ts +51 -1
  11. package/dist/plugins/remark-doc-links.js +27 -16
  12. package/dist/plugins/remark-youtube.d.ts +18 -3
  13. package/dist/plugins/remark-youtube.js +57 -9
  14. package/dist/react/callout.d.ts +13 -1
  15. package/dist/react/callout.js +2 -2
  16. package/dist/react/code-runtime.d.ts +12 -2
  17. package/dist/react/code-runtime.js +28 -4
  18. package/dist/react/doc-content.d.ts +12 -1
  19. package/dist/react/doc-content.js +2 -2
  20. package/dist/react/layout.d.ts +27 -10
  21. package/dist/react/layout.js +6 -3
  22. package/dist/react/markdown-components.d.ts +29 -1
  23. package/dist/react/markdown-components.js +69 -67
  24. package/dist/react/nav.d.ts +5 -1
  25. package/dist/react/nav.js +5 -2
  26. package/dist/react/next-nav.d.ts +5 -1
  27. package/dist/react/next-nav.js +5 -2
  28. package/dist/react/search-dialog.d.ts +92 -5
  29. package/dist/react/search-dialog.js +182 -43
  30. package/dist/react/shell-labels.d.ts +135 -21
  31. package/dist/react/shell-labels.js +47 -6
  32. package/dist/react/sidebar.d.ts +18 -1
  33. package/dist/react/sidebar.js +59 -23
  34. package/dist/react/youtube.d.ts +22 -1
  35. package/dist/react/youtube.js +22 -4
  36. package/dist/render.d.ts +11 -0
  37. package/dist/render.js +38 -11
  38. package/dist/route-path.js +7 -2
  39. package/dist/safe-href.d.ts +47 -0
  40. package/dist/safe-href.js +73 -0
  41. package/dist/search-index.js +1 -1
  42. package/dist/search-options.d.ts +64 -2
  43. package/dist/search-options.js +25 -1
  44. package/dist/semaphore.d.ts +46 -0
  45. package/dist/semaphore.js +60 -0
  46. package/dist/source.js +80 -10
  47. package/dist/styles.css +62 -13
  48. package/dist/types.d.ts +30 -0
  49. 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 { MarkdownComponents } from "./react/markdown-components.js";
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 pass it to `DocsSearch`'s
65
- * (or `SearchDialog`'s) `miniSearchOptions`. MiniSearch reads `tokenize` and
66
- * `processTerm` both when indexing and when querying, so applying one here
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
- * `hotkey`, `miniSearchOptions` and the rest of `DocsSearch`'s surface,
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
- * The four strings the shell renders itself: the navigation landmark's name,
111
- * the drawer's open and close buttons, and the skip link.
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
- * Everything else a reader sees is your markdown or your `title`. This is the
114
- * whole of what a non-English site has to sayand it is the fifth prop,
115
- * added deliberately: a documentation shell nobody can translate is not a
116
- * shell for the whole ecosystem.
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
- * The component map is built once per process.
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
- let nextComponents = null;
145
- function loadNextComponents() {
146
- if (nextComponents === null) nextComponents = buildNextComponents().catch((error) => {
147
- nextComponents = null;
148
- throw error;
149
- });
150
- return nextComponents;
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 loadNextComponents();
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
- })), doc.toc.length === 0 ? null : createElement("aside", { className: "wave-docs-layout__toc" }, createElement(DocsToc, { entries: doc.toc })));
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
- ...options.miniSearchOptions === void 0 ? {} : { miniSearchOptions: options.miniSearchOptions },
355
- ...search === true || search === void 0 ? {} : search
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
- ...labels === void 0 ? {} : { labels }
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
- /** Accessible name when a fence has no title. */
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 : `${copyLabel} from ${title}`), node);
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 `![a](./getting%20started.png)`
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 parts = HREF_PARTS.exec(href);
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, decodePath(path, href));
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 parts = HREF_PARTS.exec(href);
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(path.slice(base.length), href));
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 parts = HREF_PARTS.exec(href);
187
- const path = parts?.[1] ?? "";
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 id from a YouTube watch/short/embed URL.
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 parseYouTubeId(href: string): string | undefined;
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 { parseYouTubeId, remarkYouTube };
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
- * Extract a video id from a YouTube watch/short/embed URL.
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 parseYouTubeId(href) {
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) ? id : void 0;
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) ? id : void 0;
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) ? id : void 0;
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 id = only.type === "link" ? parseYouTubeId(only.url) : void 0;
70
- if (id === void 0) return;
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: { id }
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 { parseYouTubeId, remarkYouTube };
132
+ export { parseYouTubeRef, remarkYouTube };