@trackunit/repo-checks 0.0.0-stage → 0.0.2

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 (46) hide show
  1. package/CHANGELOG.md +3 -0
  2. package/LICENSE.txt +191 -0
  3. package/README.md +319 -2
  4. package/bin/index.d.ts +2 -0
  5. package/bin/index.js +34 -0
  6. package/migrations/entry.js +3 -0
  7. package/migrations.json +3 -0
  8. package/package.json +34 -3
  9. package/src/cli.d.ts +27 -0
  10. package/src/cli.js +109 -0
  11. package/src/index.d.ts +8 -0
  12. package/src/index.js +21 -0
  13. package/src/lib/config-error.d.ts +8 -0
  14. package/src/lib/config-error.js +16 -0
  15. package/src/lib/config.d.ts +22 -0
  16. package/src/lib/config.js +61 -0
  17. package/src/lib/doc-references/case-fix.d.ts +21 -0
  18. package/src/lib/doc-references/case-fix.js +66 -0
  19. package/src/lib/doc-references/check-doc-references.d.ts +28 -0
  20. package/src/lib/doc-references/check-doc-references.js +147 -0
  21. package/src/lib/doc-references/classify.d.ts +49 -0
  22. package/src/lib/doc-references/classify.js +59 -0
  23. package/src/lib/doc-references/discover.d.ts +6 -0
  24. package/src/lib/doc-references/discover.js +84 -0
  25. package/src/lib/doc-references/extract.d.ts +29 -0
  26. package/src/lib/doc-references/extract.js +46 -0
  27. package/src/lib/doc-references/finding.d.ts +24 -0
  28. package/src/lib/doc-references/finding.js +11 -0
  29. package/src/lib/doc-references/fs-errors.d.ts +14 -0
  30. package/src/lib/doc-references/fs-errors.js +25 -0
  31. package/src/lib/doc-references/headings.d.ts +79 -0
  32. package/src/lib/doc-references/headings.js +219 -0
  33. package/src/lib/doc-references/parse.d.ts +72 -0
  34. package/src/lib/doc-references/parse.js +72 -0
  35. package/src/lib/doc-references/placeholders.d.ts +74 -0
  36. package/src/lib/doc-references/placeholders.js +176 -0
  37. package/src/lib/doc-references/repo-root.d.ts +18 -0
  38. package/src/lib/doc-references/repo-root.js +36 -0
  39. package/src/lib/doc-references/resolver.d.ts +96 -0
  40. package/src/lib/doc-references/resolver.js +430 -0
  41. package/src/lib/doc-references/settings.d.ts +163 -0
  42. package/src/lib/doc-references/settings.js +167 -0
  43. package/src/lib/doc-references/suppressions.d.ts +8 -0
  44. package/src/lib/doc-references/suppressions.js +24 -0
  45. package/src/lib/format.d.ts +13 -0
  46. package/src/lib/format.js +33 -0
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.classifyReference = void 0;
4
+ const resolver_1 = require("./resolver");
5
+ const settings_1 = require("./settings");
6
+ /**
7
+ * A registry host (has a dot, or is `localhost`, optionally with a port), a repository path, and a
8
+ * `:tag` or `@sha256:` digest, e.g. `ghcr.io/org/img:1.2` or `123.dkr.ecr.eu-west-1.amazonaws.com/app:latest`.
9
+ * Checked before the URL-scheme test because `localhost:5000/...` would otherwise read as a scheme.
10
+ */
11
+ const REGISTRY_IMAGE_RE = /^(?:[a-z0-9-]+(?:\.[a-z0-9-]+)+|localhost)(?::\d+)?\/[^\s:@]+(?::\w[\w.-]*|@sha256:[a-f0-9]+)$/i;
12
+ const URL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
13
+ const STORYBOOK_PATH_QUERY_RE = /[?&]path=/u;
14
+ const DEFAULT_OPTIONS = { directories: settings_1.DEFAULT_DIRECTORIES, bareFilenames: settings_1.DEFAULT_BARE_FILENAMES };
15
+ const skip = (reason) => ({ check: false, reason });
16
+ const isCheckedShape = (base, referenceKind, { directories, bareFilenames }) => {
17
+ const hasExtension = (0, resolver_1.hasFileExtension)(base);
18
+ if (base.includes("/"))
19
+ return hasExtension || directories;
20
+ // A bare *named* file (has an extension, e.g. `README.md`) in a URL (link/image/definition) is
21
+ // unambiguously a relative path, so it is always checked. The same shape in inline code is often
22
+ // a package name or a version (`eslint@9`, `v1.2`), so it stays gated by `bareFilenames` there.
23
+ // An extensionless bare word (`README`) is ambiguous either way and keeps needing both
24
+ // `bareFilenames` and `directories`.
25
+ if (referenceKind === "url" && hasExtension)
26
+ return true;
27
+ return bareFilenames && (hasExtension || directories);
28
+ };
29
+ /**
30
+ * Whether a reference is checked as a repo path, or the reason it is skipped: empty, anchor-only,
31
+ * URL-, shell- or glob-shaped, a registry image, a Storybook path, or not shaped like a file path.
32
+ */
33
+ const classifyReference = (reference, referenceKind, options = DEFAULT_OPTIONS) => {
34
+ if (!reference)
35
+ return skip("empty");
36
+ if (reference.startsWith("#"))
37
+ return skip("anchorOnly");
38
+ if (/\s/.test(reference))
39
+ return skip("multiWord");
40
+ if (REGISTRY_IMAGE_RE.test(reference))
41
+ return skip("registryImage");
42
+ // `//host/path` is a protocol-relative URL, not a repo-root path.
43
+ if (URL_SCHEME_RE.test(reference) || reference.startsWith("//"))
44
+ return skip("urlScheme");
45
+ if (reference.startsWith("~"))
46
+ return skip("homePath");
47
+ if (reference.includes("$"))
48
+ return skip("shellVariable");
49
+ if (reference.includes("*"))
50
+ return skip("glob");
51
+ // A Storybook link (`?path=/docs/...`) addresses a page of the running Storybook, whatever path precedes it.
52
+ if (STORYBOOK_PATH_QUERY_RE.test(reference))
53
+ return skip("storybookPath");
54
+ if (!isCheckedShape((0, resolver_1.referenceTarget)(reference, referenceKind), referenceKind, options))
55
+ return skip("notAFilePath");
56
+ return { check: true };
57
+ };
58
+ exports.classifyReference = classifyReference;
59
+ //# sourceMappingURL=classify.js.map
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Every `.md` and `.mdx` document (extension in any case) under `rootDir`, as sorted absolute paths. Inside a git work tree
3
+ * these are git's tracked and untracked, non-ignored files that exist on disk; outside one, a
4
+ * filesystem walk that skips `.git` and conventional generated output.
5
+ */
6
+ export declare const discoverDocuments: (rootDir: string) => Array<string>;
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.discoverDocuments = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const child_process_1 = require("child_process");
6
+ const fs = tslib_1.__importStar(require("fs"));
7
+ const path = tslib_1.__importStar(require("path"));
8
+ const fs_errors_1 = require("./fs-errors");
9
+ /** Document extensions discovered and checked, in any case (`.MD`), as `parseMarkdown` and the anchor check read them. */
10
+ const DOCUMENT_PATHSPECS = [":(icase)*.md", ":(icase)*.mdx"];
11
+ /** Directories the filesystem walk never enters: VCS metadata and conventional generated output. */
12
+ const WALK_SKIPPED_DIRECTORIES = new Set([".git", "node_modules", "dist", "build", "coverage"]);
13
+ /** Set by git hooks; inherited, they would point `git ls-files` at the hook's repository instead of `rootDir`'s. */
14
+ const GIT_LOCATION_VARIABLES = new Set(["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"]);
15
+ const isDocument = (name) => /\.mdx?$/iu.test(name);
16
+ /**
17
+ * Tracked and untracked, non-ignored documents under `rootDir`, as absolute paths, or `undefined`
18
+ * when `rootDir` is not inside a git work tree (or git is unavailable).
19
+ */
20
+ const listWithGit = (rootDir) => {
21
+ const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !GIT_LOCATION_VARIABLES.has(name)));
22
+ const result = (0, child_process_1.spawnSync)("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", ...DOCUMENT_PATHSPECS], { cwd: rootDir, encoding: "utf8", maxBuffer: 256 * 1024 * 1024, env: { ...env, LC_ALL: "C", LANGUAGE: "C" } });
23
+ if (result.error) {
24
+ if ("code" in result.error && result.error.code === "ENOENT")
25
+ return undefined;
26
+ throw new fs_errors_1.ReadError(`Failed to list documents with git in ${rootDir}: ${result.error.message}`);
27
+ }
28
+ if (result.status !== 0) {
29
+ if (/not a git repository/i.test(result.stderr))
30
+ return undefined;
31
+ throw new fs_errors_1.ReadError(`git ls-files failed in ${rootDir}: ${result.stderr.trim()}`);
32
+ }
33
+ const files = new Set(result.stdout.split("\0").filter(file => file !== ""));
34
+ return [...files].map(file => path.join(rootDir, file));
35
+ };
36
+ const readDirectory = (directory) => {
37
+ try {
38
+ return fs.readdirSync(directory, { withFileTypes: true });
39
+ }
40
+ catch (error) {
41
+ throw new fs_errors_1.ReadError(`Cannot read directory ${directory}: ${(0, fs_errors_1.errorMessage)(error)}`);
42
+ }
43
+ };
44
+ const walk = (directory, documents) => {
45
+ for (const entry of readDirectory(directory)) {
46
+ const entryPath = path.join(directory, entry.name);
47
+ if (entry.isDirectory()) {
48
+ if (!WALK_SKIPPED_DIRECTORIES.has(entry.name))
49
+ walk(entryPath, documents);
50
+ }
51
+ else if (isDocument(entry.name)) {
52
+ documents.push(entryPath);
53
+ }
54
+ }
55
+ };
56
+ /**
57
+ * Whether `file` is a regular file on disk; `git ls-files --cached` still lists tracked files deleted from the work tree.
58
+ *
59
+ * @throws {ReadError} when `file` can't be stat'ed for any reason other than not existing
60
+ */
61
+ const existsAsFile = (file) => {
62
+ try {
63
+ return fs.statSync(file).isFile();
64
+ }
65
+ catch (error) {
66
+ if ((0, fs_errors_1.isNotFoundError)(error))
67
+ return false;
68
+ throw new fs_errors_1.ReadError(`Cannot read ${file}: ${(0, fs_errors_1.errorMessage)(error)}`);
69
+ }
70
+ };
71
+ /**
72
+ * Every `.md` and `.mdx` document (extension in any case) under `rootDir`, as sorted absolute paths. Inside a git work tree
73
+ * these are git's tracked and untracked, non-ignored files that exist on disk; outside one, a
74
+ * filesystem walk that skips `.git` and conventional generated output.
75
+ */
76
+ const discoverDocuments = (rootDir) => {
77
+ const listed = listWithGit(rootDir);
78
+ const documents = listed ?? [];
79
+ if (listed === undefined)
80
+ walk(rootDir, documents);
81
+ return documents.filter(existsAsFile).sort();
82
+ };
83
+ exports.discoverDocuments = discoverDocuments;
84
+ //# sourceMappingURL=discover.js.map
@@ -0,0 +1,29 @@
1
+ import type { MarkdownNode } from "./parse";
2
+ import type { ReferenceKind } from "./resolver";
3
+ import type { DocsSettings } from "./settings";
4
+ /** The markdown construct a reference was written in; the keys of {@link ConstructToggles}, singular. */
5
+ export type ReferenceConstruct = "link" | "image" | "definition" | "inlineCode";
6
+ /** A reference as written in a document, before it is classified or resolved. */
7
+ export interface ExtractedReference {
8
+ readonly kind: ReferenceConstruct;
9
+ /** The link, image, or definition URL as the parser decoded it, or the inline-code span's text. */
10
+ readonly target: string;
11
+ /** 1-based line of the construct's start. */
12
+ readonly line: number;
13
+ /** 1-based column of the construct's start. */
14
+ readonly column: number;
15
+ }
16
+ /** The resolver's reading of a construct: inline code is resolved as a bare path, everything else as a URL. */
17
+ export declare const referenceKindOf: (construct: ReferenceConstruct) => ReferenceKind;
18
+ /**
19
+ * Every reference in `tree` whose construct `settings.constructs` enables, in document order.
20
+ * Code blocks (fenced or indented) and frontmatter are leaves, so nothing inside them is extracted;
21
+ * so are MDX ESM and `{expressions}`, and a JSX element's attributes aren't children, so only the
22
+ * markdown in its children is.
23
+ * A link reference (`[x][r]`) is reported through its definition only, so each target is reported
24
+ * once. Autolinks and GFM literal URLs are links like any other; deciding which targets to check is
25
+ * the caller's job (see `classify.ts`).
26
+ * References starting on the line after an ignore-next-line comment are dropped (see
27
+ * `suppressions.ts`); a link reference's is decided on its definition's line.
28
+ */
29
+ export declare const extractReferences: (tree: MarkdownNode, settings: Pick<DocsSettings, "constructs">) => Array<ExtractedReference>;
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.extractReferences = exports.referenceKindOf = void 0;
4
+ const suppressions_1 = require("./suppressions");
5
+ const CONSTRUCT_TOGGLE = {
6
+ link: "links",
7
+ image: "images",
8
+ definition: "definitions",
9
+ inlineCode: "inlineCode",
10
+ };
11
+ const isReferenceConstruct = (type) => Object.hasOwn(CONSTRUCT_TOGGLE, type);
12
+ /** The resolver's reading of a construct: inline code is resolved as a bare path, everything else as a URL. */
13
+ const referenceKindOf = (construct) => construct === "inlineCode" ? "inline" : "url";
14
+ exports.referenceKindOf = referenceKindOf;
15
+ /**
16
+ * Every reference in `tree` whose construct `settings.constructs` enables, in document order.
17
+ * Code blocks (fenced or indented) and frontmatter are leaves, so nothing inside them is extracted;
18
+ * so are MDX ESM and `{expressions}`, and a JSX element's attributes aren't children, so only the
19
+ * markdown in its children is.
20
+ * A link reference (`[x][r]`) is reported through its definition only, so each target is reported
21
+ * once. Autolinks and GFM literal URLs are links like any other; deciding which targets to check is
22
+ * the caller's job (see `classify.ts`).
23
+ * References starting on the line after an ignore-next-line comment are dropped (see
24
+ * `suppressions.ts`); a link reference's is decided on its definition's line.
25
+ */
26
+ const extractReferences = (tree, settings) => {
27
+ const references = [];
28
+ const ignoredLines = new Set();
29
+ const visit = (node) => {
30
+ const ignoredLine = (0, suppressions_1.ignoredNextLine)(node);
31
+ if (ignoredLine !== undefined)
32
+ ignoredLines.add(ignoredLine);
33
+ if (isReferenceConstruct(node.type) && settings.constructs[CONSTRUCT_TOGGLE[node.type]]) {
34
+ const target = node.type === "inlineCode" ? node.value : node.url;
35
+ if (target !== undefined) {
36
+ const start = node.position?.start;
37
+ references.push({ kind: node.type, target, line: start?.line ?? 1, column: start?.column ?? 1 });
38
+ }
39
+ }
40
+ node.children?.forEach(visit);
41
+ };
42
+ visit(tree);
43
+ return references.filter(reference => !ignoredLines.has(reference.line));
44
+ };
45
+ exports.extractReferences = extractReferences;
46
+ //# sourceMappingURL=extract.js.map
@@ -0,0 +1,24 @@
1
+ import type { ReferenceConstruct } from "./extract";
2
+ /**
3
+ * Why a reference was reported: its target doesn't exist on disk (`missing`), exists only under
4
+ * different letter case (`caseMismatch`), or exists but its `#fragment` names no heading or anchor
5
+ * in it (`missingAnchor`).
6
+ */
7
+ export type FindingReason = "missing" | "caseMismatch" | "missingAnchor";
8
+ /** One reported doc reference. */
9
+ export interface Finding {
10
+ /** The document, as a POSIX path relative to the repository root. */
11
+ readonly file: string;
12
+ /** 1-based line of the reference's construct. */
13
+ readonly line: number;
14
+ /** 1-based column of the reference's construct. */
15
+ readonly column: number;
16
+ /** The reference as the parser read it: a link, image, or definition URL, or an inline-code span. */
17
+ readonly target: string;
18
+ readonly kind: ReferenceConstruct;
19
+ readonly reason: FindingReason;
20
+ /** A replacement for `target` that would resolve, when exactly one is known: the on-disk casing of a `caseMismatch`. */
21
+ readonly suggestion?: string;
22
+ }
23
+ /** Order findings by file, then line, then column, so output is the same on every host. */
24
+ export declare const compareFindings: (a: Finding, b: Finding) => number;
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.compareFindings = void 0;
4
+ /** Order findings by file, then line, then column, so output is the same on every host. */
5
+ const compareFindings = (a, b) => {
6
+ if (a.file !== b.file)
7
+ return a.file < b.file ? -1 : 1;
8
+ return a.line - b.line || a.column - b.column;
9
+ };
10
+ exports.compareFindings = compareFindings;
11
+ //# sourceMappingURL=finding.js.map
@@ -0,0 +1,14 @@
1
+ /** Whether a filesystem error means the path doesn't exist, as opposed to existing but being unreadable. */
2
+ export declare const isNotFoundError: (error: unknown) => boolean;
3
+ /** Whether `error` is a Node system error (`EACCES`, `ELOOP`, ...) from a failed system call. */
4
+ export declare const isSystemError: (error: unknown) => boolean;
5
+ /** An error's message, or the thrown value as a string when it isn't an `Error`. */
6
+ export declare const errorMessage: (error: unknown) => string;
7
+ /**
8
+ * A document, directory, or git's document listing that couldn't be read: the environment, not the
9
+ * documents or the config, stopped the check.
10
+ */
11
+ export declare class ReadError extends Error {
12
+ /** @param message What couldn't be read, and why. */
13
+ constructor(message: string);
14
+ }
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ReadError = exports.errorMessage = exports.isSystemError = exports.isNotFoundError = void 0;
4
+ /** Whether a filesystem error means the path doesn't exist, as opposed to existing but being unreadable. */
5
+ const isNotFoundError = (error) => error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
6
+ exports.isNotFoundError = isNotFoundError;
7
+ /** Whether `error` is a Node system error (`EACCES`, `ELOOP`, ...) from a failed system call. */
8
+ const isSystemError = (error) => error instanceof Error && "code" in error && typeof error.code === "string" && "syscall" in error;
9
+ exports.isSystemError = isSystemError;
10
+ /** An error's message, or the thrown value as a string when it isn't an `Error`. */
11
+ const errorMessage = (error) => (error instanceof Error ? error.message : String(error));
12
+ exports.errorMessage = errorMessage;
13
+ /**
14
+ * A document, directory, or git's document listing that couldn't be read: the environment, not the
15
+ * documents or the config, stopped the check.
16
+ */
17
+ class ReadError extends Error {
18
+ /** @param message What couldn't be read, and why. */
19
+ constructor(message) {
20
+ super(message);
21
+ this.name = "ReadError";
22
+ }
23
+ }
24
+ exports.ReadError = ReadError;
25
+ //# sourceMappingURL=fs-errors.js.map
@@ -0,0 +1,79 @@
1
+ import { type MarkdownNode } from "./parse";
2
+ /**
3
+ * The fragment ids a markdown document defines: upstream's anchors plus GitHub's. Upstream's are
4
+ * those `@eslint/markdown`'s `no-missing-link-fragments` computes for in-document `#anchor` links,
5
+ * so every fragment that passes there passes here too. GitHub's only add anchors, so they can only
6
+ * remove false positives.
7
+ *
8
+ * The document is parsed with GFM and YAML frontmatter, as `parseMarkdown` parses it (so a
9
+ * leading `---` block never yields a heading, while `+++` fences are plain markdown, as on GitHub),
10
+ * and one `github-slugger` per document slugs, in document order:
11
+ * - every heading (ATX or setext): the concatenated `value` of its non-HTML descendants, so
12
+ * inline code, emphasis, and link text count while image alt text and inline HTML don't; a
13
+ * trailing custom `{#id}` replaces the text;
14
+ * - in every HTML node, outside `<!-- comments -->`: each `id=`/`name=` attribute value, and the
15
+ * tag-stripped text of each `<h1>`-`<h6>` element.
16
+ * The slugger suffixes repeats (`setup`, `setup-1`, ...) across all of these, and `top` is always
17
+ * defined. `.mdx` targets are parsed with the MDX extensions too (`parseMarkdown` decides by the
18
+ * file extension): ESM and `{expressions}` yield no headings, markdown inside JSX children does, and
19
+ * a JSX element's string `id`/`name` attribute counts like the HTML one. An `{expression}` inside a
20
+ * heading adds no text, as in the ids Storybook's `rehype-slug` gives the rendered heading, so
21
+ * `# Hi {name}` defines `hi-` (the space before the expression stays), not `hi` or `hi-name`. A
22
+ * target that is not valid MDX, has MDX whose rendering parsing can't see (`knownDocumentAnchors`),
23
+ * or is larger than `MAX_ANCHOR_DOCUMENT_BYTES`, has no known anchors.
24
+ *
25
+ * GitHub's anchors, on top of those:
26
+ * - each explicit `id`/`name` value as written (lower-cased), since GitHub keeps `.` and `:`
27
+ * (`v1.2.0`, `a:b`) that slugging drops;
28
+ * - every markdown heading slugged by a second `github-slugger` of its own, over its full text
29
+ * with emoji shortcodes removed: GitHub has no `{#id}` (`## Setup {#install}` is `setup-install`)
30
+ * and renders `:rocket:` as an emoji the slug drops (`## :rocket: Deploy` is `-deploy`);
31
+ * - footnote ids as GitHub renders them: `fn-<label>` per definition, and `fnref-<label>`,
32
+ * `fnref-<label>-2`, ... per reference.
33
+ * `hasAnchor` also accepts each of these behind GitHub's `user-content-` prefix.
34
+ *
35
+ * `.mdx` documents get the same GFM and frontmatter reading, which deliberately differs from a
36
+ * Storybook build without those plugins: there `# Title[^1]` keeps `[^1]` as text (`title1`, not
37
+ * `title`), and a leading `---` block renders as a rule and a setext heading. So a `.mdx` document
38
+ * with frontmatter, or with a footnote reference inside a heading, has no known anchors
39
+ * (`knownDocumentAnchors`).
40
+ */
41
+ /**
42
+ * Documents above this size are skipped rather than parsed: a 1.1 MB CHANGELOG took 2 s and 417 MB
43
+ * of heap. A skipped target has no known anchors, so it is never reported.
44
+ */
45
+ export declare const MAX_ANCHOR_DOCUMENT_BYTES: number;
46
+ /**
47
+ * The fragment ids an already-parsed document defines (see the module comment): upstream's anchors
48
+ * (every HTML node's, and every heading's once its own HTML is collected), plus GitHub's.
49
+ */
50
+ export declare const documentAnchors: (root: MarkdownNode) => Set<string>;
51
+ /**
52
+ * The fragment ids a parsed document defines, as `documentAnchors` computes them, or `undefined`
53
+ * when MDX in it may render anchors that parsing can't see, so a caller never reports a fragment
54
+ * that the rendered page might define. That is any ESM (an import brings in components or content,
55
+ * an `export default` wraps the page in a layout); any JSX element that is a component (`<Meta>`,
56
+ * Storybook's `<Title />` and `<Stories />`, `<Markdown>{...}</Markdown>`) or a custom element
57
+ * (`<doc-toc>`); a JSX `<h1>`-`<h6>`, whose rendered id may differ between renderers (GitHub,
58
+ * Storybook); a spread or any `{expression}` attribute value; and any `{expression}` holding code
59
+ * (see `isCommentExpression`), which covers a heading like `# Hi {name}`. In a `.mdx` `file`,
60
+ * frontmatter and a footnote reference inside a heading count too (see the module comment).
61
+ * Built-in HTML elements with string attributes and comment-only expressions are seen in full.
62
+ * Plain markdown has none of these nodes, and its frontmatter and footnotes read as on GitHub, so a
63
+ * `.md` document's anchors are always known.
64
+ */
65
+ export declare const knownDocumentAnchors: (root: MarkdownNode, file: string) => Set<string> | undefined;
66
+ /**
67
+ * The fragment ids `absolutePath` defines (see the module comment), or `undefined` when it can't be
68
+ * read or parsed, is larger than `MAX_ANCHOR_DOCUMENT_BYTES`, or has MDX whose rendered anchors
69
+ * can't be seen (see `knownDocumentAnchors`), so a caller never reports against a document it
70
+ * didn't fully inspect.
71
+ */
72
+ export declare const readDocumentAnchors: (absolutePath: string) => ReadonlySet<string> | undefined;
73
+ /**
74
+ * Whether a written fragment names one of `anchors`, compared as `no-missing-link-fragments` does by
75
+ * default: percent-decoded (a malformed escape as written) and case-insensitively. GitHub line
76
+ * references (`L10`, `L10C2-L12C5`) always match, and so does an anchor behind the `user-content-`
77
+ * prefix GitHub gives the ids it renders (`#user-content-setup`).
78
+ */
79
+ export declare const hasAnchor: (anchors: ReadonlySet<string>, fragment: string) => boolean;
@@ -0,0 +1,219 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.hasAnchor = exports.readDocumentAnchors = exports.knownDocumentAnchors = exports.documentAnchors = exports.MAX_ANCHOR_DOCUMENT_BYTES = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const fs = tslib_1.__importStar(require("fs"));
6
+ const github_slugger_1 = tslib_1.__importDefault(require("github-slugger"));
7
+ const parse_1 = require("./parse");
8
+ const resolver_1 = require("./resolver");
9
+ /**
10
+ * The fragment ids a markdown document defines: upstream's anchors plus GitHub's. Upstream's are
11
+ * those `@eslint/markdown`'s `no-missing-link-fragments` computes for in-document `#anchor` links,
12
+ * so every fragment that passes there passes here too. GitHub's only add anchors, so they can only
13
+ * remove false positives.
14
+ *
15
+ * The document is parsed with GFM and YAML frontmatter, as `parseMarkdown` parses it (so a
16
+ * leading `---` block never yields a heading, while `+++` fences are plain markdown, as on GitHub),
17
+ * and one `github-slugger` per document slugs, in document order:
18
+ * - every heading (ATX or setext): the concatenated `value` of its non-HTML descendants, so
19
+ * inline code, emphasis, and link text count while image alt text and inline HTML don't; a
20
+ * trailing custom `{#id}` replaces the text;
21
+ * - in every HTML node, outside `<!-- comments -->`: each `id=`/`name=` attribute value, and the
22
+ * tag-stripped text of each `<h1>`-`<h6>` element.
23
+ * The slugger suffixes repeats (`setup`, `setup-1`, ...) across all of these, and `top` is always
24
+ * defined. `.mdx` targets are parsed with the MDX extensions too (`parseMarkdown` decides by the
25
+ * file extension): ESM and `{expressions}` yield no headings, markdown inside JSX children does, and
26
+ * a JSX element's string `id`/`name` attribute counts like the HTML one. An `{expression}` inside a
27
+ * heading adds no text, as in the ids Storybook's `rehype-slug` gives the rendered heading, so
28
+ * `# Hi {name}` defines `hi-` (the space before the expression stays), not `hi` or `hi-name`. A
29
+ * target that is not valid MDX, has MDX whose rendering parsing can't see (`knownDocumentAnchors`),
30
+ * or is larger than `MAX_ANCHOR_DOCUMENT_BYTES`, has no known anchors.
31
+ *
32
+ * GitHub's anchors, on top of those:
33
+ * - each explicit `id`/`name` value as written (lower-cased), since GitHub keeps `.` and `:`
34
+ * (`v1.2.0`, `a:b`) that slugging drops;
35
+ * - every markdown heading slugged by a second `github-slugger` of its own, over its full text
36
+ * with emoji shortcodes removed: GitHub has no `{#id}` (`## Setup {#install}` is `setup-install`)
37
+ * and renders `:rocket:` as an emoji the slug drops (`## :rocket: Deploy` is `-deploy`);
38
+ * - footnote ids as GitHub renders them: `fn-<label>` per definition, and `fnref-<label>`,
39
+ * `fnref-<label>-2`, ... per reference.
40
+ * `hasAnchor` also accepts each of these behind GitHub's `user-content-` prefix.
41
+ *
42
+ * `.mdx` documents get the same GFM and frontmatter reading, which deliberately differs from a
43
+ * Storybook build without those plugins: there `# Title[^1]` keeps `[^1]` as text (`title1`, not
44
+ * `title`), and a leading `---` block renders as a rule and a setext heading. So a `.mdx` document
45
+ * with frontmatter, or with a footnote reference inside a heading, has no known anchors
46
+ * (`knownDocumentAnchors`).
47
+ */
48
+ /**
49
+ * Documents above this size are skipped rather than parsed: a 1.1 MB CHANGELOG took 2 s and 417 MB
50
+ * of heap. A skipped target has no known anchors, so it is never reported.
51
+ */
52
+ exports.MAX_ANCHOR_DOCUMENT_BYTES = 1024 * 1024;
53
+ const CUSTOM_HEADING_ID_RE = /\{#(?<id>[^}\s]+)\}\s*$/u;
54
+ const HTML_HEADING_RE = /<h(?<depth>[1-6])[^>]*>(?<children>[\s\S]*?)<\/h\k<depth>\s*>/giu;
55
+ const HTML_ID_NAME_RE = /(?<!<)<[^>]+\s(?:id|name)\s*=\s*["']?(?<id>[^"'\s>]+)["']?/giu;
56
+ const HTML_TAG_RE = /<\/?[a-z0-9]+(?:-[a-z0-9]+)*(?:\s(?:[^>"']|"[^"]*"|'[^']*')*)?(?:\/\s*)?>/giu;
57
+ const HTML_COMMENT_RE = /<!--[\s\S]*?-->/gu;
58
+ const GITHUB_LINE_REFERENCE_RE = /^L\d+(?:C\d+)?(?:-L\d+(?:C\d+)?)?$/u;
59
+ const EMOJI_SHORTCODE_RE = /:[a-z0-9_+-]+:/giu;
60
+ const GITHUB_USER_CONTENT_PREFIX = "user-content-";
61
+ const JSX_ELEMENT_TYPES = new Set(["mdxJsxFlowElement", "mdxJsxTextElement"]);
62
+ /** The string `id`/`name` attribute values of a JSX element, the MDX counterpart of HTML's `id=`/`name=`. */
63
+ const jsxAnchorAttributes = (node) => JSX_ELEMENT_TYPES.has(node.type)
64
+ ? (node.attributes ?? []).flatMap(attribute => attribute.type === "mdxJsxAttribute" &&
65
+ (attribute.name === "id" || attribute.name === "name") &&
66
+ typeof attribute.value === "string"
67
+ ? [attribute.value]
68
+ : [])
69
+ : [];
70
+ const HEADING_TEXT_SKIPPED_TYPES = new Set(["html", "mdxTextExpression"]);
71
+ const headingText = (node) => (node.children ?? [])
72
+ .map(child => (HEADING_TEXT_SKIPPED_TYPES.has(child.type) ? "" : (child.value ?? "") + headingText(child)))
73
+ .join("");
74
+ /**
75
+ * The fragment ids an already-parsed document defines (see the module comment): upstream's anchors
76
+ * (every HTML node's, and every heading's once its own HTML is collected), plus GitHub's.
77
+ */
78
+ const documentAnchors = (root) => {
79
+ const anchors = new Set(["top"]);
80
+ const slugger = new github_slugger_1.default();
81
+ const githubSlugger = new github_slugger_1.default();
82
+ const footnoteReferences = new Map();
83
+ const addExplicit = (id) => {
84
+ anchors.add(slugger.slug(id));
85
+ anchors.add(id.toLowerCase());
86
+ };
87
+ const visit = (node) => {
88
+ if (node.type === "html") {
89
+ const html = (node.value ?? "").replace(HTML_COMMENT_RE, "");
90
+ for (const match of html.matchAll(HTML_ID_NAME_RE))
91
+ addExplicit(match.groups?.id ?? "");
92
+ for (const match of html.matchAll(HTML_HEADING_RE)) {
93
+ anchors.add(slugger.slug((match.groups?.children ?? "").replace(HTML_TAG_RE, "")));
94
+ }
95
+ }
96
+ for (const id of jsxAnchorAttributes(node))
97
+ addExplicit(id);
98
+ const footnoteLabel = (node.label ?? node.identifier)?.toLowerCase();
99
+ if (footnoteLabel !== undefined && node.type === "footnoteDefinition")
100
+ anchors.add(`fn-${footnoteLabel}`);
101
+ if (footnoteLabel !== undefined && node.type === "footnoteReference") {
102
+ const count = (footnoteReferences.get(footnoteLabel) ?? 0) + 1;
103
+ footnoteReferences.set(footnoteLabel, count);
104
+ anchors.add(count === 1 ? `fnref-${footnoteLabel}` : `fnref-${footnoteLabel}-${count}`);
105
+ }
106
+ node.children?.forEach(visit);
107
+ if (node.type === "heading") {
108
+ const text = headingText(node);
109
+ anchors.add(slugger.slug(CUSTOM_HEADING_ID_RE.exec(text)?.groups?.id ?? text));
110
+ anchors.add(githubSlugger.slug(text.replace(EMOJI_SHORTCODE_RE, "")));
111
+ }
112
+ };
113
+ visit(root);
114
+ return anchors;
115
+ };
116
+ exports.documentAnchors = documentAnchors;
117
+ const MDX_EXPRESSION_TYPES = new Set(["mdxFlowExpression", "mdxTextExpression"]);
118
+ /**
119
+ * A built-in HTML element MDX renders as itself; anything else is a component (`Meta`,
120
+ * `Docs.Block`) or a custom element (`doc-toc`), either of which may render anything.
121
+ */
122
+ const INTRINSIC_JSX_NAME_RE = /^[a-z][a-z0-9]*$/u;
123
+ const JSX_HEADING_NAME_RE = /^h[1-6]$/u;
124
+ /** A spread, or any `{expression}` value (`children={...}`, `dangerouslySetInnerHTML={...}`), may render anchors. */
125
+ const isOpaqueJsxAttribute = (attribute) => attribute.type !== "mdxJsxAttribute" || (typeof attribute.value === "object" && attribute.value !== null);
126
+ /** Whether `node` may render anchors, or headings with ids, that parsing the document can't see. */
127
+ const isOpaqueMdxNode = (node) => {
128
+ if (node.type === "mdxjsEsm")
129
+ return true;
130
+ if (MDX_EXPRESSION_TYPES.has(node.type))
131
+ return !(0, parse_1.isCommentExpression)(node);
132
+ if (!JSX_ELEMENT_TYPES.has(node.type) || node.name === null || node.name === undefined)
133
+ return false;
134
+ return (!INTRINSIC_JSX_NAME_RE.test(node.name) ||
135
+ JSX_HEADING_NAME_RE.test(node.name) ||
136
+ (node.attributes ?? []).some(isOpaqueJsxAttribute));
137
+ };
138
+ const hasOpaqueMdx = (node) => isOpaqueMdxNode(node) || (node.children ?? []).some(hasOpaqueMdx);
139
+ const hasFootnoteReference = (node) => node.type === "footnoteReference" || (node.children ?? []).some(hasFootnoteReference);
140
+ /** GFM and frontmatter readings Storybook may not share (see the module comment), so an `.mdx`'s anchors there are unknown. */
141
+ const hasStorybookReadingGap = (node) => node.type === "yaml" ||
142
+ (node.type === "heading" && hasFootnoteReference(node)) ||
143
+ (node.children ?? []).some(hasStorybookReadingGap);
144
+ /**
145
+ * The fragment ids a parsed document defines, as `documentAnchors` computes them, or `undefined`
146
+ * when MDX in it may render anchors that parsing can't see, so a caller never reports a fragment
147
+ * that the rendered page might define. That is any ESM (an import brings in components or content,
148
+ * an `export default` wraps the page in a layout); any JSX element that is a component (`<Meta>`,
149
+ * Storybook's `<Title />` and `<Stories />`, `<Markdown>{...}</Markdown>`) or a custom element
150
+ * (`<doc-toc>`); a JSX `<h1>`-`<h6>`, whose rendered id may differ between renderers (GitHub,
151
+ * Storybook); a spread or any `{expression}` attribute value; and any `{expression}` holding code
152
+ * (see `isCommentExpression`), which covers a heading like `# Hi {name}`. In a `.mdx` `file`,
153
+ * frontmatter and a footnote reference inside a heading count too (see the module comment).
154
+ * Built-in HTML elements with string attributes and comment-only expressions are seen in full.
155
+ * Plain markdown has none of these nodes, and its frontmatter and footnotes read as on GitHub, so a
156
+ * `.md` document's anchors are always known.
157
+ */
158
+ const knownDocumentAnchors = (root, file) => hasOpaqueMdx(root) || ((0, parse_1.isMdxFile)(file) && hasStorybookReadingGap(root)) ? undefined : (0, exports.documentAnchors)(root);
159
+ exports.knownDocumentAnchors = knownDocumentAnchors;
160
+ /** Per-process cache keyed by absolute path, so each target is parsed once per run; revalidated by mtime and size. */
161
+ const documentCache = new Map();
162
+ const parseAnchors = (absolutePath, text) => {
163
+ try {
164
+ return (0, exports.knownDocumentAnchors)((0, parse_1.parseMarkdown)(text, absolutePath), absolutePath);
165
+ }
166
+ catch {
167
+ return undefined;
168
+ }
169
+ };
170
+ /**
171
+ * The fragment ids `absolutePath` defines (see the module comment), or `undefined` when it can't be
172
+ * read or parsed, is larger than `MAX_ANCHOR_DOCUMENT_BYTES`, or has MDX whose rendered anchors
173
+ * can't be seen (see `knownDocumentAnchors`), so a caller never reports against a document it
174
+ * didn't fully inspect.
175
+ */
176
+ const readDocumentAnchors = (absolutePath) => {
177
+ try {
178
+ const { mtimeMs, size } = fs.statSync(absolutePath);
179
+ if (size > exports.MAX_ANCHOR_DOCUMENT_BYTES) {
180
+ documentCache.delete(absolutePath);
181
+ return undefined;
182
+ }
183
+ const cached = documentCache.get(absolutePath);
184
+ if (cached?.mtimeMs === mtimeMs && cached.size === size && cached.readAtMs - mtimeMs > resolver_1.RACY_WINDOW_MS) {
185
+ return cached.anchors;
186
+ }
187
+ const readAtMs = Date.now();
188
+ const anchors = parseAnchors(absolutePath, fs.readFileSync(absolutePath, "utf8"));
189
+ documentCache.set(absolutePath, { mtimeMs, size, readAtMs, anchors });
190
+ return anchors;
191
+ }
192
+ catch {
193
+ documentCache.delete(absolutePath);
194
+ return undefined;
195
+ }
196
+ };
197
+ exports.readDocumentAnchors = readDocumentAnchors;
198
+ /**
199
+ * Whether a written fragment names one of `anchors`, compared as `no-missing-link-fragments` does by
200
+ * default: percent-decoded (a malformed escape as written) and case-insensitively. GitHub line
201
+ * references (`L10`, `L10C2-L12C5`) always match, and so does an anchor behind the `user-content-`
202
+ * prefix GitHub gives the ids it renders (`#user-content-setup`).
203
+ */
204
+ const hasAnchor = (anchors, fragment) => {
205
+ let decoded = fragment;
206
+ try {
207
+ decoded = decodeURIComponent(fragment);
208
+ }
209
+ catch {
210
+ // checked as written
211
+ }
212
+ const lowerCase = decoded.toLowerCase();
213
+ return (GITHUB_LINE_REFERENCE_RE.test(decoded) ||
214
+ anchors.has(lowerCase) ||
215
+ (lowerCase.startsWith(GITHUB_USER_CONTENT_PREFIX) &&
216
+ anchors.has(lowerCase.slice(GITHUB_USER_CONTENT_PREFIX.length))));
217
+ };
218
+ exports.hasAnchor = hasAnchor;
219
+ //# sourceMappingURL=headings.js.map