tosijs-ui 1.16.10 → 1.16.11

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 CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.16.11
4
+
5
+ In-page links work, teaching examples show their test result, and "Download ePub" stops
6
+ offering a book that was never built. `dist/iife.js` grows by 1.2 kB gzip.
7
+
8
+ - **Headings have ids.** Every heading rendered from markdown now carries an `id`, so
9
+ `[text](#that-heading)` and `/page/#that-heading` go somewhere. Before this they were
10
+ links that did nothing, with no error anywhere. The id is the GitHub slug of the heading
11
+ text, with `-1`, `-2` for a repeat on the same page. Arriving at a heading scrolls it to
12
+ the top of the page.
13
+ - **The build warns about an in-page link that matches nothing** on its page, naming the
14
+ page and the anchor. Under `strict: true` it fails the build.
15
+ - **An inset's `#anchor` is matched as the heading's id**, the one the page carries:
16
+ `install.md#whats-new` now matches "What's new", and `#usage-1` names the second "Usage".
17
+ The 1.16.9 spelling still matches (`#what-s-new`).
18
+ - **Inline views show the test result** (#219). An example opened with `{"view": "code"}` or
19
+ `{"view": "console"}` has one line under its preview: the count when its tests pass, the
20
+ first failure in red when they do not. It covers the `test` block and a `tjs` example's
21
+ inline tests, and clicking it opens the tab with the detail.
22
+ - **An example in an inline view always runs its tests**, whatever the page's tests switch says (it is off
23
+ by default away from localhost). A `tjs` example with inline tests therefore transpiles in
24
+ the page when it is in an inline view on a deployed site, where it used to run from the
25
+ copy the build transpiled. Examples without a `view` option are unchanged.
26
+ - **Inline views hide empty tabs.** `html`, `css` and `DOM tests` appear only when the
27
+ example has that block. The full-screen code view still shows them all.
28
+ - **A long section title keeps its arrow.** In the sidebar, a section title that wraps no
29
+ longer leaves the disclosure triangle alone on a line above it.
30
+ - **"Download ePub" follows the build** (#218). The settings menu links to the ePub at the
31
+ URL the build wrote, shows a submenu for a site with several volumes, and has no entry on
32
+ a site that builds none. It used to appear on every site and 404 on those. A hand-written
33
+ `<tosi-doc-system config>` without the new `ebooks` key keeps the old derived link.
34
+
3
35
  ## 1.16.10
4
36
 
5
37
  Print now opens with the book cover.
@@ -978,9 +978,16 @@ export function createDocBrowser(options) {
978
978
  const scrollToHashExample = () => {
979
979
  if (memoryRouting)
980
980
  return;
981
- const hash = location.hash.replace(/^#/, '');
981
+ let hash = location.hash.replace(/^#/, '');
982
982
  if (!hash)
983
983
  return;
984
+ try {
985
+ // A heading id can hold non-ASCII letters, which arrive percent-encoded.
986
+ hash = decodeURIComponent(hash);
987
+ }
988
+ catch {
989
+ // a stray % — look it up as written
990
+ }
984
991
  requestAnimationFrame(() => {
985
992
  let el = null;
986
993
  try {
@@ -991,6 +998,12 @@ export function createDocBrowser(options) {
991
998
  }
992
999
  if (!el)
993
1000
  return;
1001
+ // A heading (they have ids as of 1.16.11) goes to the top, where a reader expects a
1002
+ // section to start; an example is centred and flashed.
1003
+ if (/^H[1-6]$/.test(el.tagName)) {
1004
+ el.scrollIntoView({ block: 'start' });
1005
+ return;
1006
+ }
994
1007
  el.scrollIntoView({ block: 'center', behavior: 'smooth' });
995
1008
  el.classList.add('example-target');
996
1009
  setTimeout(() => el && el.classList.remove('example-target'), 1600);
@@ -632,6 +632,15 @@ export function docSystemStyleSpec(theme = {}) {
632
632
  display: 'inline-block',
633
633
  // push the summary text in a bit less than a normal doc-link
634
634
  paddingLeft: 'calc(var(--spacing) * 0.5)',
635
+ /*
636
+ Room for the disclosure triangle on the same line (#219). An inline-block as wide as
637
+ its text does not fit beside the marker once the title is long, so the whole link
638
+ dropped to the next line and left the triangle alone on a row of its own, which reads
639
+ as an empty nav entry. Capped, it stays beside the triangle and wraps inside itself.
640
+ */
641
+ boxSizing: 'border-box',
642
+ maxWidth: 'calc(100% - 1.5em)',
643
+ verticalAlign: 'top',
635
644
  },
636
645
  // Declarative header link list — shown for no-JS, removed on hydration.
637
646
  '.doc-navbar': {
@@ -2,6 +2,23 @@ import { ElementCreator } from 'tosijs';
2
2
  import * as tosijsModule from 'tosijs';
3
3
  import { Doc } from '../doc-browser.js';
4
4
  import './css-var-editor.js';
5
+ export interface Ebook {
6
+ title: string;
7
+ url: string;
8
+ }
9
+ /**
10
+ * The settings menu's "Download ePub" entry, or `null` for none.
11
+ *
12
+ * The item used to be unconditional and its URL derived from the project name, so a site
13
+ * that built no ePub (the default) shipped a menu item that 404s, and a multi-volume site
14
+ * or one with `basePath` or `epub.title` linked to a file nothing wrote (#218). The site
15
+ * build now bakes the list of volumes it makes into the page config:
16
+ *
17
+ * - a list → one item (or, for several volumes, a submenu), at the build's own URLs
18
+ * - `[]` → the site makes no ePub: no item
19
+ * - no key → a hand-written `config` that predates this: the old derived link, unchanged
20
+ */
21
+ export declare function epubMenuItem(ebooks: Ebook[] | undefined, legacyUrl: string): Record<string, unknown> | null;
5
22
  export declare const CORPUS_ATTEMPTS = 3;
6
23
  export declare function fetchCorpus(url: string): Promise<Doc[]>;
7
24
  declare const TosiDocSystem_base: tosijsModule.WithAttributes<{
@@ -71,6 +71,48 @@ import { i18n, setLocale, initLocalization } from '../localize.js';
71
71
  // tweaker under an example. Part of the doc-system, NOT re-exported from tosijs-ui.
72
72
  import './css-var-editor.js';
73
73
  const { button, div } = elements;
74
+ const download = (url) => () => {
75
+ const link = document.createElement('a');
76
+ link.href = url;
77
+ link.download = '';
78
+ link.click();
79
+ };
80
+ /**
81
+ * The settings menu's "Download ePub" entry, or `null` for none.
82
+ *
83
+ * The item used to be unconditional and its URL derived from the project name, so a site
84
+ * that built no ePub (the default) shipped a menu item that 404s, and a multi-volume site
85
+ * or one with `basePath` or `epub.title` linked to a file nothing wrote (#218). The site
86
+ * build now bakes the list of volumes it makes into the page config:
87
+ *
88
+ * - a list → one item (or, for several volumes, a submenu), at the build's own URLs
89
+ * - `[]` → the site makes no ePub: no item
90
+ * - no key → a hand-written `config` that predates this: the old derived link, unchanged
91
+ */
92
+ export function epubMenuItem(ebooks, legacyUrl) {
93
+ if (!Array.isArray(ebooks))
94
+ return {
95
+ caption: 'Download ePub',
96
+ icon: 'book',
97
+ action: download(legacyUrl),
98
+ };
99
+ if (ebooks.length === 0)
100
+ return null;
101
+ if (ebooks.length === 1)
102
+ return {
103
+ caption: 'Download ePub',
104
+ icon: 'book',
105
+ action: download(ebooks[0].url),
106
+ };
107
+ return {
108
+ caption: 'Download ePub',
109
+ icon: 'book',
110
+ menuItems: ebooks.map((book) => ({
111
+ caption: book.title,
112
+ action: download(book.url),
113
+ })),
114
+ };
115
+ }
74
116
  const PREFS_KEY = 'tosi-doc-system-prefs';
75
117
  /*
76
118
  Fetch the corpus, retrying a TRANSIENT failure — without becoming the reason it stays down.
@@ -289,10 +331,12 @@ export class TosiDocSystem extends withAttributes({
289
331
  // Print / ePub of the whole corpus, before a separator + the prefs.
290
332
  let projectName = '';
291
333
  let bookVolumes;
334
+ let ebooks;
292
335
  try {
293
336
  const parsed = JSON.parse(this.config || '{}');
294
337
  projectName = parsed.projectName || '';
295
338
  bookVolumes = parsed.bookVolumes || undefined;
339
+ ebooks = parsed.ebooks;
296
340
  }
297
341
  catch {
298
342
  // ignore — fall through to document.title
@@ -345,16 +389,9 @@ export class TosiDocSystem extends withAttributes({
345
389
  void printWhenReady(win, () => highlightBlocks(win.document, { policy: 'none' }));
346
390
  },
347
391
  });
348
- menuItems.push({
349
- caption: 'Download ePub',
350
- icon: 'book',
351
- action: () => {
352
- const link = document.createElement('a');
353
- link.href = `/${slugify(bookTitle)}.epub`;
354
- link.download = '';
355
- link.click();
356
- },
357
- });
392
+ const epubItem = epubMenuItem(ebooks, `/${slugify(bookTitle)}.epub`);
393
+ if (epubItem)
394
+ menuItems.push(epubItem);
358
395
  menuItems.push(null);
359
396
  const localeOptions = i18n.localeOptions.value || [];
360
397
  if (localeOptions.length > 1) {
@@ -0,0 +1,21 @@
1
+ /** The text a reader sees in a heading: marked's inline tokens with the markup dropped. */
2
+ export declare function headingText(token: any): string;
3
+ /** GitHub-flavoured slug of some heading text. May be empty (a heading of punctuation). */
4
+ export declare function slugOfHeadingText(text: string): string;
5
+ /** The slug of a marked heading token, before any `-1` de-duplication. */
6
+ export declare const headingSlug: (token: any) => string;
7
+ /**
8
+ * Hands out ids for one page, suffixing a repeat the way GitHub does.
9
+ *
10
+ * It knows about headings and nothing else. In particular it does NOT keep headings out of
11
+ * `example-N`, the ids live examples get in the browser: a reservation here was tried for
12
+ * 1.16.11 and made `next('example')` loop forever on its second call, because every suffix
13
+ * of `example` is `example-N`. A heading "Example 2" is `example-2`, as on GitHub. On a page
14
+ * that also has a second live example the two share that id, and a link reaches whichever
15
+ * comes first in the document (a known overlap, not checked by the build).
16
+ */
17
+ export declare class HeadingIds {
18
+ private seen;
19
+ /** The id for the next heading with this slug, or '' when the slug is empty. */
20
+ next(slug: string): string;
21
+ }
@@ -0,0 +1,62 @@
1
+ /*
2
+ THE rule for a heading's id, build and client alike.
3
+
4
+ Headings rendered with no `id`, so `[text](#some-heading)` was a live-looking link that did
5
+ nothing, with no error anywhere (board #3178). The slug is the GitHub-flavoured one, because
6
+ that is what an author writing an in-page link expects and what the same file gets when it is
7
+ read on GitHub: lowercase, every character that is not a letter, a number, a space, `-` or
8
+ `_` dropped, spaces to `-`. A repeat on one page gets `-1`, `-2`, ….
9
+
10
+ The renderer stamps ids with it and an inset's `#anchor` (single-source.ts) is matched with
11
+ it. Do not write a second copy: an anchor that the build accepts and the page does not carry
12
+ is exactly the silent failure this exists to end.
13
+ */
14
+ /** The text a reader sees in a heading: marked's inline tokens with the markup dropped. */
15
+ export function headingText(token) {
16
+ if (Array.isArray(token.tokens) && token.type !== 'codespan')
17
+ return token.tokens.map(headingText).join('');
18
+ if (token.type === 'html')
19
+ return '';
20
+ if (token.type === 'br')
21
+ return ' ';
22
+ return String(token.text ?? '');
23
+ }
24
+ /** GitHub-flavoured slug of some heading text. May be empty (a heading of punctuation). */
25
+ export function slugOfHeadingText(text) {
26
+ return text
27
+ .replace(/&(?:#\d+|#x[\da-f]+|[a-z][a-z\d]*);/gi, '') // an entity is not its name
28
+ .trim()
29
+ .toLowerCase()
30
+ .replace(/[^\p{L}\p{N}\p{M}\s_-]/gu, '')
31
+ .replace(/\s/g, '-');
32
+ }
33
+ /** The slug of a marked heading token, before any `-1` de-duplication. */
34
+ export const headingSlug = (token) => slugOfHeadingText(headingText(token));
35
+ /**
36
+ * Hands out ids for one page, suffixing a repeat the way GitHub does.
37
+ *
38
+ * It knows about headings and nothing else. In particular it does NOT keep headings out of
39
+ * `example-N`, the ids live examples get in the browser: a reservation here was tried for
40
+ * 1.16.11 and made `next('example')` loop forever on its second call, because every suffix
41
+ * of `example` is `example-N`. A heading "Example 2" is `example-2`, as on GitHub. On a page
42
+ * that also has a second live example the two share that id, and a link reaches whichever
43
+ * comes first in the document (a known overlap, not checked by the build).
44
+ */
45
+ export class HeadingIds {
46
+ seen = new Map();
47
+ /** The id for the next heading with this slug, or '' when the slug is empty. */
48
+ next(slug) {
49
+ if (!slug)
50
+ return '';
51
+ let id = slug;
52
+ // A suffixed id can itself collide with an earlier literal heading ("Usage 1"). Each
53
+ // pass raises the counter, and `seen` is finite, so this ends.
54
+ while (this.seen.has(id)) {
55
+ const n = (this.seen.get(slug) ?? 0) + 1;
56
+ this.seen.set(slug, n);
57
+ id = `${slug}-${n}`;
58
+ }
59
+ this.seen.set(id, this.seen.get(id) ?? 0);
60
+ return id;
61
+ }
62
+ }
@@ -10,11 +10,20 @@ give that example a stable anchor — see the docMarked renderer below.
10
10
  */
11
11
  import { Marked, Renderer } from 'marked';
12
12
  import { parseFenceInfo } from './example-policy.js';
13
+ import { HeadingIds, headingSlug } from './heading-id.js';
13
14
  const baseRenderer = new Renderer();
14
15
  let currentBakes;
16
+ // Per-parse, reset in `preprocess` below (same pattern as the footnote state).
17
+ let headingIds = new HeadingIds();
15
18
  const docMarked = new Marked();
16
19
  docMarked.use({
17
20
  renderer: {
21
+ // Every heading gets an id, so `[text](#that-heading)` resolves — see heading-id.ts.
22
+ heading(token) {
23
+ const id = headingIds.next(headingSlug(token));
24
+ const inner = this.parser.parseInline(token.tokens);
25
+ return `<h${token.depth}${id ? ` id="${escapeAttr(id)}"` : ''}>${inner}</h${token.depth}>\n`;
26
+ },
18
27
  code(token) {
19
28
  // Fence info grammar: `<lang>` optionally with a `:<mode>` and/or `#<id>` in
20
29
  // EITHER order — `js`, `css#anchor`, `js:iframe`, `ts:ide#demo`, `ts#demo:ide`.
@@ -84,6 +93,7 @@ docMarked.use({
84
93
  preprocess(markdown) {
85
94
  footnoteDefs = new Map();
86
95
  footnoteOrder = [];
96
+ headingIds = new HeadingIds();
87
97
  return markdown;
88
98
  },
89
99
  postprocess(html) {
@@ -21,3 +21,4 @@ export declare function misconfigured(strict: boolean | undefined, message: stri
21
21
  export declare function liveFenceLanguages(docs: Array<{
22
22
  text?: string;
23
23
  }>, policy?: ExamplePolicy): Set<string>;
24
+ export declare function deadInPageAnchors(html: string): string[];
@@ -43,3 +43,64 @@ export function liveFenceLanguages(docs, policy = 'auto') {
43
43
  }
44
44
  return langs;
45
45
  }
46
+ const unescapeAttr = (s) => s
47
+ .replace(/&quot;/g, '"')
48
+ .replace(/&#39;/g, "'")
49
+ .replace(/&lt;/g, '<')
50
+ .replace(/&gt;/g, '>')
51
+ .replace(/&amp;/g, '&');
52
+ /**
53
+ * The in-page links (`href="#…"`) in a RENDERED page that point at no id on it.
54
+ *
55
+ * A dead in-page anchor is the one broken link a reader cannot tell from a working one: it
56
+ * is styled as a link, it does nothing, and nothing reports it (board #3178). Asked of the
57
+ * rendered HTML and not of the markdown, so the ids are the ones the page really carries
58
+ * (`renderDocMarkdown`, THE renderer) and no second copy of the heading-id rule exists here.
59
+ *
60
+ * Not reported, because they are not in-page anchors or resolve only in the browser:
61
+ * `#/route` and `#!…` (hash routers), a bare `#`, `#top`, and `#example-N` (live examples
62
+ * are numbered when the page hydrates). A fence's own `#id` is on the rendered page and is
63
+ * checked like any other id.
64
+ *
65
+ * Tags are read with their attributes in any order and any quoting. What it cannot see is
66
+ * an id that only exists once a script has run; such a link is reported.
67
+ */
68
+ // One HTML start tag, attributes and all. Quoted values may hold `>`, which is why this is
69
+ // not `<[^>]*>`.
70
+ const START_TAG = /<([a-z][a-z0-9-]*)((?:\s+[^\s"'<>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*)\s*\/?>/gi;
71
+ const ATTRIBUTE = /([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
72
+ export function deadInPageAnchors(html) {
73
+ const ids = new Set();
74
+ const links = [];
75
+ for (const tag of html.matchAll(START_TAG)) {
76
+ const name = tag[1].toLowerCase();
77
+ for (const attr of tag[2].matchAll(ATTRIBUTE)) {
78
+ const key = attr[1].toLowerCase();
79
+ const value = unescapeAttr(attr[2] ?? attr[3] ?? attr[4] ?? '');
80
+ if (key === 'id' || key === 'data-example-id')
81
+ ids.add(value);
82
+ // `name` is an anchor target on <a> only; on a form field it is a field name.
83
+ else if (key === 'name' && name === 'a')
84
+ ids.add(value);
85
+ else if (key === 'href' && name === 'a' && value.startsWith('#'))
86
+ links.push(value.slice(1));
87
+ }
88
+ }
89
+ const dead = new Set();
90
+ for (let target of links) {
91
+ try {
92
+ target = decodeURIComponent(target);
93
+ }
94
+ catch {
95
+ // a stray % — compare it as written
96
+ }
97
+ if (!target ||
98
+ target === 'top' ||
99
+ /^[/!]/.test(target) ||
100
+ /^example-\d+$/.test(target) ||
101
+ ids.has(target))
102
+ continue;
103
+ dead.add(target);
104
+ }
105
+ return [...dead];
106
+ }
@@ -14,12 +14,23 @@ export interface GenerateSiteConfig {
14
14
  liveExamples?: 'auto' | 'opt-in';
15
15
  /** directory to write pages into (the served web root, e.g. ./docs) */
16
16
  outputDir: string;
17
+ /** `SiteConfig.strict`: a build warning raised here fails the build */
18
+ strict?: boolean;
17
19
  projectName?: string;
18
20
  /**
19
21
  * URL of the volume manifest (`/epub-volumes.json`). Print reads the default volume's
20
22
  * cover from it; omitted when the site has no ePub or opted out with `printCover: false`.
21
23
  */
22
24
  bookVolumes?: string;
25
+ /**
26
+ * The ePubs this build makes (`listEpubVolumes`), for the "Download ePub" menu entry.
27
+ * `[]` says the site makes none, which removes the entry; leave it unset only if you do
28
+ * not know (the entry then links to the name derived from `projectName`, as it used to).
29
+ */
30
+ ebooks?: Array<{
31
+ title: string;
32
+ url: string;
33
+ }>;
23
34
  /** site-level description, used as a fallback when a doc has none */
24
35
  description?: string;
25
36
  /** <html lang>, default 'en' */
@@ -15,6 +15,7 @@ import { highlightHtml } from '../highlight.js';
15
15
  import { buildSlugMap, pathForSlug, rewriteDocLinks, withBase, } from '../routing.js';
16
16
  import { buildNavTree, navOpenPath } from '../nav-tree.js';
17
17
  import { renderDocMarkdown, docDescription, } from '../render.js';
18
+ import { deadInPageAnchors, misconfigured } from './build-warnings.js';
18
19
  const escapeAttr = (s) => s
19
20
  .replace(/&/g, '&amp;')
20
21
  .replace(/</g, '&lt;')
@@ -304,13 +305,20 @@ export async function generateSite(config) {
304
305
  projectLinks: config.projectLinks,
305
306
  logo: config.logo,
306
307
  bookVolumes: config.bookVolumes,
308
+ ebooks: config.ebooks,
307
309
  }));
308
310
  // The theme stylesheet (config.stylesUrl) is written separately by
309
311
  // ./generate-css.ts; pages here just <link> to it.
310
312
  let count = 0;
313
+ const deadAnchors = [];
311
314
  for (const doc of docs) {
312
315
  const slug = slugMap[doc.filename];
313
316
  const dir = slug === '' ? outputDir : `${outputDir}/${slug}`;
317
+ const dead = deadInPageAnchors(renderDocMarkdown(doc.text));
318
+ if (dead.length)
319
+ deadAnchors.push(` ${doc.path || doc.filename}: ${dead
320
+ .map((d) => `#${d}`)
321
+ .join(', ')}`);
314
322
  await Bun.write(`${dir}/index.html`, await pageHtml(doc, config, slugMap, configAttr));
315
323
  // The page's own source, for readers that want text rather than a page.
316
324
  if (hasMarkdownPage(doc, config)) {
@@ -318,6 +326,11 @@ export async function generateSite(config) {
318
326
  }
319
327
  count += 1;
320
328
  }
329
+ if (deadAnchors.length) {
330
+ misconfigured(config.strict, `\n⚠️ In-page links that point at nothing on their page. A heading's id is its text,\n` +
331
+ ` lowercased, punctuation dropped, spaces as "-" (a repeat gets -1, -2, …):\n` +
332
+ `${deadAnchors.join('\n')}\n`);
333
+ }
321
334
  // The corpus the component fetches for nav + client-side rendering of other pages.
322
335
  // Attach each doc's tjs bakes so client-side SPA navigation renders the same hidden
323
336
  // <script type="application/tosi-transpiled"> the pre-rendered page has, and runs
@@ -1480,6 +1480,13 @@ export async function buildSite(config, opts = {}) {
1480
1480
  outputDir: PUBLIC,
1481
1481
  projectName: config.name,
1482
1482
  bookVolumes: printCoverManifestUrl(config),
1483
+ ebooks: config.epub
1484
+ ? listEpubVolumes(docs, config).map(({ title, url }) => ({
1485
+ title,
1486
+ url,
1487
+ }))
1488
+ : [],
1489
+ strict: config.strict,
1483
1490
  description: config.description,
1484
1491
  baseUrl: config.baseUrl,
1485
1492
  lang: config.lang,
@@ -23,6 +23,7 @@ import * as fs from 'fs';
23
23
  import * as path from 'path';
24
24
  import { marked } from 'marked';
25
25
  import { buildSlugMap, resolveParent, slugify } from '../routing.js';
26
+ import { HeadingIds, headingSlug, slugOfHeadingText } from '../heading-id.js';
26
27
  import { mayHaveSingleSourceDirective } from '../book-target.js';
27
28
  export class SingleSourceError extends Error {
28
29
  constructor(message) {
@@ -110,7 +111,14 @@ export function isSingleSourceDirective(json) {
110
111
  }
111
112
  }
112
113
  const mayHaveDirective = mayHaveSingleSourceDirective;
113
- const headingSlug = (token) => slugify(String(token.text ?? '').replace(/`/g, ''));
114
+ /*
115
+ An inset's `#anchor` names a heading by the id the page gives it (`headingSlug`, THE rule).
116
+ The spelling insets shipped with in 1.16.9 is still accepted: it differs only where a heading
117
+ has punctuation ("What's new" was `what-s-new`, and is `whats-new` on the page).
118
+ */
119
+ const legacyHeadingSlug = (token) => slugify(String(token.text ?? '').replace(/`/g, ''));
120
+ const headingMatches = (token, anchor) => headingSlug(token) === slugOfHeadingText(anchor) ||
121
+ legacyHeadingSlug(token) === slugify(anchor);
114
122
  /*
115
123
  What an inset contributes: the passage, not its place in some other page's outline.
116
124
 
@@ -143,8 +151,16 @@ function bodyOf(text, anchor, ref) {
143
151
  .join('')
144
152
  .trim();
145
153
  }
146
- const want = slugify(anchor);
147
- const start = tokens.findIndex((t) => t.type === 'heading' && headingSlug(t) === want);
154
+ // By the id the page gives it first (so `#usage-1` names the second "Usage"), then by slug.
155
+ const ids = new HeadingIds();
156
+ const pageIds = tokens.map((t) => t.type === 'heading' ? ids.next(headingSlug(t)) : '');
157
+ // An anchor with nothing sluggable in it ("#???") names no heading; '' is also what every
158
+ // non-heading token has in `pageIds`, so it must not be looked up there.
159
+ const wanted = slugOfHeadingText(anchor);
160
+ const byId = wanted ? pageIds.indexOf(wanted) : -1;
161
+ const start = byId > -1
162
+ ? byId
163
+ : tokens.findIndex((t) => t.type === 'heading' && headingMatches(t, anchor));
148
164
  if (start < 0) {
149
165
  const have = tokens
150
166
  .filter((t) => t.type === 'heading')