@trackunit/repo-checks 0.0.0-stage → 0.0.3

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 +7 -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,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
@@ -0,0 +1,72 @@
1
+ /** A 1-based source location, as mdast reports it. */
2
+ export interface MarkdownPoint {
3
+ readonly line: number;
4
+ readonly column: number;
5
+ readonly offset?: number;
6
+ }
7
+ /**
8
+ * The structural subset of an mdast node the doc-reference checks read. Declared here rather than
9
+ * imported from `mdast` so the library doesn't depend on the parser's type packages.
10
+ */
11
+ export interface MarkdownNode {
12
+ readonly type: string;
13
+ /** Text content: `code`, `inlineCode`, `yaml`, `html`, `text`, and similar leaves. */
14
+ readonly value?: string;
15
+ /** Destination of `link`, `image`, and `definition`. */
16
+ readonly url?: string;
17
+ readonly children?: ReadonlyArray<MarkdownNode>;
18
+ readonly position?: {
19
+ readonly start: MarkdownPoint;
20
+ readonly end: MarkdownPoint;
21
+ };
22
+ /** The label as written, on `footnoteDefinition` and `footnoteReference` (and other reference nodes). */
23
+ readonly label?: string | null;
24
+ /** The normalised label, on the same nodes as `label`. */
25
+ readonly identifier?: string;
26
+ /** An MDX JSX element's name (`div`, `Meta`, `Docs.Block`), or `null` for a fragment. */
27
+ readonly name?: string | null;
28
+ /** On MDX JSX elements only. */
29
+ readonly attributes?: ReadonlyArray<{
30
+ readonly type: string;
31
+ readonly name?: string;
32
+ readonly value?: unknown;
33
+ }>;
34
+ /** On MDX ESM and `{expression}` nodes: the program the parser built from `value`. */
35
+ readonly data?: {
36
+ readonly estree?: {
37
+ readonly body: ReadonlyArray<unknown>;
38
+ } | null;
39
+ };
40
+ }
41
+ /**
42
+ * Whether `node` is an MDX `{expression}` holding no code, only comments if anything (`{/* note *\/}`,
43
+ * `{// note}`), so it renders nothing; its source text, comments included, is its `value`. Decided
44
+ * from the program the parser built, so `{/* a *\/ x /* b *\/}` is code; an expression without one is.
45
+ */
46
+ export declare const isCommentExpression: (node: MarkdownNode) => boolean;
47
+ /** A document that could not be parsed, located at the 1-based line and column the parser stopped at. */
48
+ export declare class DocumentParseError extends Error {
49
+ readonly file: string;
50
+ readonly line: number;
51
+ readonly column: number;
52
+ readonly reason: string;
53
+ /**
54
+ * @param file The document, as it was passed to the parser.
55
+ * @param line 1-based line the parser stopped at.
56
+ * @param column 1-based column the parser stopped at.
57
+ * @param reason The parser's message.
58
+ */
59
+ constructor(file: string, line: number, column: number, reason: string);
60
+ }
61
+ /** Whether `file` is parsed as MDX: its extension is `.mdx`, in any case. */
62
+ export declare const isMdxFile: (file: string) => boolean;
63
+ /**
64
+ * Parse a markdown document into mdast with GFM and YAML frontmatter, the way every document this
65
+ * library checks is read: a leading `---` block becomes a `yaml` node rather than content, while `+++` fences stay plain
66
+ * markdown, as on GitHub. A `.mdx` `file` is parsed with the MDX extensions too, as Storybook's MDX 3
67
+ * compiler reads it: ESM becomes `mdxjsEsm`, JSX `mdxJsxFlowElement`/`mdxJsxTextElement` (markdown in
68
+ * their children stays mdast; attributes are not children), and `{expressions}`, comments included,
69
+ * `mdxFlowExpression`/`mdxTextExpression` leaves. Plain markdown always parses; invalid MDX throws a
70
+ * {@link DocumentParseError} naming `file` and the location.
71
+ */
72
+ export declare const parseMarkdown: (source: string, file: string) => MarkdownNode;
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseMarkdown = exports.isMdxFile = exports.DocumentParseError = exports.isCommentExpression = void 0;
4
+ const mdast_util_from_markdown_1 = require("mdast-util-from-markdown");
5
+ const mdast_util_frontmatter_1 = require("mdast-util-frontmatter");
6
+ const mdast_util_gfm_1 = require("mdast-util-gfm");
7
+ const mdast_util_mdx_1 = require("mdast-util-mdx");
8
+ const micromark_extension_frontmatter_1 = require("micromark-extension-frontmatter");
9
+ const micromark_extension_gfm_1 = require("micromark-extension-gfm");
10
+ const micromark_extension_mdxjs_1 = require("micromark-extension-mdxjs");
11
+ const MDX_EXPRESSION_TYPES = new Set(["mdxFlowExpression", "mdxTextExpression"]);
12
+ /**
13
+ * Whether `node` is an MDX `{expression}` holding no code, only comments if anything (`{/* note *\/}`,
14
+ * `{// note}`), so it renders nothing; its source text, comments included, is its `value`. Decided
15
+ * from the program the parser built, so `{/* a *\/ x /* b *\/}` is code; an expression without one is.
16
+ */
17
+ const isCommentExpression = (node) => MDX_EXPRESSION_TYPES.has(node.type) && node.data?.estree?.body.length === 0;
18
+ exports.isCommentExpression = isCommentExpression;
19
+ /** A document that could not be parsed, located at the 1-based line and column the parser stopped at. */
20
+ class DocumentParseError extends Error {
21
+ /**
22
+ * @param file The document, as it was passed to the parser.
23
+ * @param line 1-based line the parser stopped at.
24
+ * @param column 1-based column the parser stopped at.
25
+ * @param reason The parser's message.
26
+ */
27
+ constructor(file, line, column, reason) {
28
+ super(`${file}:${line}:${column}: ${reason}`);
29
+ this.file = file;
30
+ this.line = line;
31
+ this.column = column;
32
+ this.reason = reason;
33
+ this.name = "DocumentParseError";
34
+ }
35
+ }
36
+ exports.DocumentParseError = DocumentParseError;
37
+ const positiveInteger = (value) => typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
38
+ const toParseError = (error, file) => {
39
+ const message = typeof error === "object" && error !== null ? error : {};
40
+ const reason = typeof message.reason === "string"
41
+ ? message.reason
42
+ : typeof message.message === "string"
43
+ ? message.message
44
+ : String(error);
45
+ return new DocumentParseError(file, positiveInteger(message.line) ?? 1, positiveInteger(message.column) ?? 1, reason);
46
+ };
47
+ /** Whether `file` is parsed as MDX: its extension is `.mdx`, in any case. */
48
+ const isMdxFile = (file) => file.toLowerCase().endsWith(".mdx");
49
+ exports.isMdxFile = isMdxFile;
50
+ /**
51
+ * Parse a markdown document into mdast with GFM and YAML frontmatter, the way every document this
52
+ * library checks is read: a leading `---` block becomes a `yaml` node rather than content, while `+++` fences stay plain
53
+ * markdown, as on GitHub. A `.mdx` `file` is parsed with the MDX extensions too, as Storybook's MDX 3
54
+ * compiler reads it: ESM becomes `mdxjsEsm`, JSX `mdxJsxFlowElement`/`mdxJsxTextElement` (markdown in
55
+ * their children stays mdast; attributes are not children), and `{expressions}`, comments included,
56
+ * `mdxFlowExpression`/`mdxTextExpression` leaves. Plain markdown always parses; invalid MDX throws a
57
+ * {@link DocumentParseError} naming `file` and the location.
58
+ */
59
+ const parseMarkdown = (source, file) => {
60
+ const mdx = (0, exports.isMdxFile)(file);
61
+ try {
62
+ return (0, mdast_util_from_markdown_1.fromMarkdown)(source, {
63
+ extensions: [(0, micromark_extension_gfm_1.gfm)(), (0, micromark_extension_frontmatter_1.frontmatter)(["yaml"]), ...(mdx ? [(0, micromark_extension_mdxjs_1.mdxjs)()] : [])],
64
+ mdastExtensions: [(0, mdast_util_gfm_1.gfmFromMarkdown)(), (0, mdast_util_frontmatter_1.frontmatterFromMarkdown)(["yaml"]), ...(mdx ? [(0, mdast_util_mdx_1.mdxFromMarkdown)()] : [])],
65
+ });
66
+ }
67
+ catch (error) {
68
+ throw toParseError(error, file);
69
+ }
70
+ };
71
+ exports.parseMarkdown = parseMarkdown;
72
+ //# sourceMappingURL=parse.js.map
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Placeholder conventions in documented paths, such as `docs/<skill>/SKILL.md`.
3
+ *
4
+ * A reference's path part is compiled segment by segment (split on `/`). A segment without a
5
+ * placeholder is `literal`; one with placeholders is a `pattern` of text and named slots. Each slot
6
+ * matches one or more characters of a single directory entry name, so it never matches across `/`
7
+ * and never matches the empty string. Slots with the same name, in any segment and from any
8
+ * convention, must bind to the same value. Text beside a slot (`eval-` in `eval-<skill>`) is matched
9
+ * case-exactly, so a case difference there reads as no match rather than a case mismatch.
10
+ *
11
+ * Named conventions:
12
+ * - `angle`: `<name>`.
13
+ * - `curly`: `{name}`, but not a brace pair that is part of `{{name}}`.
14
+ * - `double-curly`: `{{name}}`.
15
+ * - `colon`: `:name` (a letter or `_`, then word characters), only at the start of a segment, so
16
+ * `/:id/` and `:id.md` are placeholders but `app:build` is literal.
17
+ * - `path-to`: a leading `path/to/` (after any `./`) followed by at least one segment marks the
18
+ * whole reference as illustrative, so it is never checked (see `isIllustrativeReference`).
19
+ *
20
+ * Any other string is a custom regex, applied within a segment, whose single capture group is the
21
+ * placeholder name, e.g. `\[(\w+)\]` for `[slug]`. Where conventions overlap at a position, the
22
+ * longest match wins. `*` is not a placeholder: globs are skipped before resolution.
23
+ */
24
+ export declare const NAMED_PLACEHOLDERS: readonly ["angle", "curly", "double-curly", "colon", "path-to"];
25
+ export type NamedPlaceholder = (typeof NAMED_PLACEHOLDERS)[number];
26
+ /** Every named convention; a single constant so measurement can narrow the defaults in one place. */
27
+ export declare const DEFAULT_PLACEHOLDERS: ReadonlyArray<string>;
28
+ export type PatternPart = {
29
+ readonly kind: "text";
30
+ readonly value: string;
31
+ } | {
32
+ readonly kind: "slot";
33
+ readonly name: string;
34
+ };
35
+ export type SegmentPattern = {
36
+ readonly kind: "literal";
37
+ readonly value: string;
38
+ } | {
39
+ readonly kind: "pattern";
40
+ readonly parts: ReadonlyArray<PatternPart>;
41
+ };
42
+ export interface CompiledReference {
43
+ readonly segments: ReadonlyArray<SegmentPattern>;
44
+ }
45
+ /** Placeholder name to the entry-name substring it matched. */
46
+ export type Bindings = ReadonlyMap<string, string>;
47
+ /** The number of capture groups in a regex source, or `undefined` when it doesn't compile. */
48
+ export declare const countCaptureGroups: (source: string) => number | undefined;
49
+ /**
50
+ * Whether `pathPart` starts with `path/to/` (after any `./`) and names something below it, with
51
+ * `path-to` enabled. Such a reference stands for "some file in your project", not a real path.
52
+ */
53
+ export declare const isIllustrativeReference: (pathPart: string, conventions: ReadonlyArray<string>) => boolean;
54
+ /**
55
+ * Compile a reference's path part (fragment stripped, URL-decoded, leading `/` removed) into
56
+ * per-segment matchers. Returns `undefined` when it has no placeholder under `conventions`, so the
57
+ * literal resolution applies unchanged. `.`, `..`, and empty segments stay literal; the resolver
58
+ * applies them lexically.
59
+ */
60
+ export declare const compileReference: (pathPart: string, conventions: ReadonlyArray<string>) => CompiledReference | undefined;
61
+ /**
62
+ * Whether some segment has two placeholders with no text between them (`<a><b>.md`). Such a split
63
+ * is arbitrary and its regex backtracks polynomially on names that don't match, so it is skipped.
64
+ */
65
+ export declare const hasAdjacentSlots: ({ segments }: CompiledReference) => boolean;
66
+ /**
67
+ * A matcher for one directory entry name against `segment`, given the names bound by earlier
68
+ * segments. It returns the extended bindings, or `undefined` when the name doesn't match. Already
69
+ * bound names must reappear verbatim. When a segment has several unbound slots, only the regex
70
+ * engine's first split is tried (e.g. `<a>-<b>` on `x-y-z` binds `a=x-y`).
71
+ */
72
+ export declare const createSegmentMatcher: (segment: Extract<SegmentPattern, {
73
+ kind: "pattern";
74
+ }>, bindings: Bindings) => ((name: string) => Bindings | undefined);
@@ -0,0 +1,176 @@
1
+ "use strict";
2
+ /**
3
+ * Placeholder conventions in documented paths, such as `docs/<skill>/SKILL.md`.
4
+ *
5
+ * A reference's path part is compiled segment by segment (split on `/`). A segment without a
6
+ * placeholder is `literal`; one with placeholders is a `pattern` of text and named slots. Each slot
7
+ * matches one or more characters of a single directory entry name, so it never matches across `/`
8
+ * and never matches the empty string. Slots with the same name, in any segment and from any
9
+ * convention, must bind to the same value. Text beside a slot (`eval-` in `eval-<skill>`) is matched
10
+ * case-exactly, so a case difference there reads as no match rather than a case mismatch.
11
+ *
12
+ * Named conventions:
13
+ * - `angle`: `<name>`.
14
+ * - `curly`: `{name}`, but not a brace pair that is part of `{{name}}`.
15
+ * - `double-curly`: `{{name}}`.
16
+ * - `colon`: `:name` (a letter or `_`, then word characters), only at the start of a segment, so
17
+ * `/:id/` and `:id.md` are placeholders but `app:build` is literal.
18
+ * - `path-to`: a leading `path/to/` (after any `./`) followed by at least one segment marks the
19
+ * whole reference as illustrative, so it is never checked (see `isIllustrativeReference`).
20
+ *
21
+ * Any other string is a custom regex, applied within a segment, whose single capture group is the
22
+ * placeholder name, e.g. `\[(\w+)\]` for `[slug]`. Where conventions overlap at a position, the
23
+ * longest match wins. `*` is not a placeholder: globs are skipped before resolution.
24
+ */
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.createSegmentMatcher = exports.hasAdjacentSlots = exports.compileReference = exports.isIllustrativeReference = exports.countCaptureGroups = exports.DEFAULT_PLACEHOLDERS = exports.NAMED_PLACEHOLDERS = void 0;
27
+ exports.NAMED_PLACEHOLDERS = ["angle", "curly", "double-curly", "colon", "path-to"];
28
+ /** Every named convention; a single constant so measurement can narrow the defaults in one place. */
29
+ exports.DEFAULT_PLACEHOLDERS = exports.NAMED_PLACEHOLDERS;
30
+ /** `path-to` marks a whole reference, so it has no in-segment token. */
31
+ const NAMED_TOKEN_SOURCES = {
32
+ angle: "<([^<>]+)>",
33
+ curly: "(?<!\\{)\\{([^{}]+)\\}(?!\\})",
34
+ "double-curly": "\\{\\{([^{}]+)\\}\\}",
35
+ colon: ":([A-Za-z_]\\w*)",
36
+ "path-to": undefined,
37
+ };
38
+ const isNamedPlaceholder = (convention) => exports.NAMED_PLACEHOLDERS.some(named => named === convention);
39
+ /** The number of capture groups in a regex source, or `undefined` when it doesn't compile. */
40
+ const countCaptureGroups = (source) => {
41
+ try {
42
+ return (new RegExp(`${source}|`).exec("")?.length ?? 1) - 1;
43
+ }
44
+ catch {
45
+ return undefined;
46
+ }
47
+ };
48
+ exports.countCaptureGroups = countCaptureGroups;
49
+ const scannerFor = (convention) => {
50
+ const source = isNamedPlaceholder(convention) ? NAMED_TOKEN_SOURCES[convention] : convention;
51
+ return source === undefined
52
+ ? undefined
53
+ : { regex: new RegExp(source, "y"), segmentStartOnly: convention === "colon" };
54
+ };
55
+ const compiledScanners = new WeakMap();
56
+ const scannersFor = (conventions) => {
57
+ let scanners = compiledScanners.get(conventions);
58
+ if (!scanners) {
59
+ scanners = conventions.flatMap(convention => scannerFor(convention) ?? []);
60
+ compiledScanners.set(conventions, scanners);
61
+ }
62
+ return scanners;
63
+ };
64
+ /** The longest placeholder token starting at `index`, if any. */
65
+ const tokenAt = (segment, index, scanners) => {
66
+ let best;
67
+ for (const { regex, segmentStartOnly } of scanners) {
68
+ if (segmentStartOnly && index !== 0)
69
+ continue;
70
+ regex.lastIndex = index;
71
+ const match = regex.exec(segment);
72
+ const name = match?.[1];
73
+ if (match && name && match[0].length > (best?.length ?? 0)) {
74
+ best = { length: match[0].length, name };
75
+ }
76
+ }
77
+ return best;
78
+ };
79
+ const compileSegment = (segment, scanners) => {
80
+ const parts = [];
81
+ let text = "";
82
+ let index = 0;
83
+ while (index < segment.length) {
84
+ const token = tokenAt(segment, index, scanners);
85
+ if (token) {
86
+ if (text !== "")
87
+ parts.push({ kind: "text", value: text });
88
+ text = "";
89
+ parts.push({ kind: "slot", name: token.name });
90
+ index += token.length;
91
+ }
92
+ else {
93
+ text += segment[index];
94
+ index++;
95
+ }
96
+ }
97
+ if (!parts.some(part => part.kind === "slot")) {
98
+ return { kind: "literal", value: segment };
99
+ }
100
+ if (text !== "")
101
+ parts.push({ kind: "text", value: text });
102
+ return { kind: "pattern", parts };
103
+ };
104
+ /**
105
+ * Whether `pathPart` starts with `path/to/` (after any `./`) and names something below it, with
106
+ * `path-to` enabled. Such a reference stands for "some file in your project", not a real path.
107
+ */
108
+ const isIllustrativeReference = (pathPart, conventions) => {
109
+ if (!conventions.includes("path-to"))
110
+ return false;
111
+ const segments = pathPart.split("/");
112
+ let start = 0;
113
+ while (segments[start] === ".")
114
+ start++;
115
+ return segments[start] === "path" && segments[start + 1] === "to" && segments.slice(start + 2).join("/") !== "";
116
+ };
117
+ exports.isIllustrativeReference = isIllustrativeReference;
118
+ /**
119
+ * Compile a reference's path part (fragment stripped, URL-decoded, leading `/` removed) into
120
+ * per-segment matchers. Returns `undefined` when it has no placeholder under `conventions`, so the
121
+ * literal resolution applies unchanged. `.`, `..`, and empty segments stay literal; the resolver
122
+ * applies them lexically.
123
+ */
124
+ const compileReference = (pathPart, conventions) => {
125
+ const scanners = scannersFor(conventions);
126
+ const segments = pathPart.split("/").map(segment => compileSegment(segment, scanners));
127
+ return segments.some(segment => segment.kind !== "literal") ? { segments } : undefined;
128
+ };
129
+ exports.compileReference = compileReference;
130
+ /**
131
+ * Whether some segment has two placeholders with no text between them (`<a><b>.md`). Such a split
132
+ * is arbitrary and its regex backtracks polynomially on names that don't match, so it is skipped.
133
+ */
134
+ const hasAdjacentSlots = ({ segments }) => segments.some(segment => segment.kind === "pattern" &&
135
+ segment.parts.some((part, index) => part.kind === "slot" && segment.parts[index + 1]?.kind === "slot"));
136
+ exports.hasAdjacentSlots = hasAdjacentSlots;
137
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
138
+ /**
139
+ * A matcher for one directory entry name against `segment`, given the names bound by earlier
140
+ * segments. It returns the extended bindings, or `undefined` when the name doesn't match. Already
141
+ * bound names must reappear verbatim. When a segment has several unbound slots, only the regex
142
+ * engine's first split is tried (e.g. `<a>-<b>` on `x-y-z` binds `a=x-y`).
143
+ */
144
+ const createSegmentMatcher = (segment, bindings) => {
145
+ const groups = new Map();
146
+ const source = segment.parts
147
+ .map(part => {
148
+ if (part.kind === "text")
149
+ return escapeRegExp(part.value);
150
+ const bound = bindings.get(part.name);
151
+ if (bound !== undefined)
152
+ return escapeRegExp(bound);
153
+ const group = groups.get(part.name);
154
+ if (group !== undefined)
155
+ return `\\k<${group}>`;
156
+ const newGroup = `p${groups.size}`;
157
+ groups.set(part.name, newGroup);
158
+ return `(?<${newGroup}>[^/]+)`;
159
+ })
160
+ .join("");
161
+ const regex = new RegExp(`^${source}$`);
162
+ return name => {
163
+ const match = regex.exec(name);
164
+ if (!match)
165
+ return undefined;
166
+ const next = new Map(bindings);
167
+ for (const [placeholder, group] of groups) {
168
+ const value = match.groups?.[group];
169
+ if (value !== undefined)
170
+ next.set(placeholder, value);
171
+ }
172
+ return next;
173
+ };
174
+ };
175
+ exports.createSegmentMatcher = createSegmentMatcher;
176
+ //# sourceMappingURL=placeholders.js.map
@@ -0,0 +1,18 @@
1
+ export interface RepoRootOptions {
2
+ /** Explicit repository root; a relative value resolves against `cwd`. */
3
+ readonly rootDir?: string;
4
+ /** Working directory a relative `rootDir` resolves against. Defaults to `process.cwd()`. */
5
+ readonly cwd?: string;
6
+ }
7
+ /**
8
+ * Find the repository root for a directory: `rootDir` when configured, otherwise `startDir` or its
9
+ * nearest ancestor containing a `.git` directory or a `.git` file (a git worktree or submodule).
10
+ * Returns `undefined` when no repository encloses `startDir`.
11
+ */
12
+ export declare const findRepoRootFromDir: (startDir: string, { rootDir, cwd }?: RepoRootOptions) => string | undefined;
13
+ /**
14
+ * Find the repository root for a checked document, as {@link findRepoRootFromDir} does from the
15
+ * document's directory. Walks up from the document rather than from `process.cwd()`, so checking
16
+ * from a subdirectory or a worktree resolves the same root.
17
+ */
18
+ export declare const findRepoRoot: (documentPath: string, options?: RepoRootOptions) => string | undefined;