create-thally-docs 0.10.122 → 0.10.124

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/README.md CHANGED
@@ -75,7 +75,7 @@ runs retain subprocess output for scripts and CI. These options also work with
75
75
  | --- | --- |
76
76
  | `create-thally-docs <dir>` | Scaffold a new project |
77
77
  | `create-thally-docs migrate <github-or-docs-url-or-directory> [dir]` | Import a docs repository or public docs site through the shared migration engine |
78
- | `create-thally-docs check [dir] [--fix] [--ci] [--drift] [--external]` | Lint content for broken links, orphan pages, and missing frontmatter; `--external` also checks links to public hosts (bounded, warnings only) |
78
+ | `create-thally-docs check [dir] [--fix] [--ci] [--drift] [--external]` | Lint content for MDX syntax errors (pages and snippets that cannot be parsed), broken links, orphan pages, and missing frontmatter; `--external` also checks links to public hosts (bounded, warnings only) |
79
79
  | `create-thally-docs translate --locale <code>` | Translate content into another locale |
80
80
 
81
81
  Public URL imports default to 1,000 pages. The web migration flow supports up to
@@ -492,7 +492,7 @@ async function discoverMigration(options) {
492
492
  const cloneDir = join5(temporaryRoot, "repository");
493
493
  try {
494
494
  const cloneWarnings = [];
495
- await terminal.step(`Cloning ${source.owner}/${source.repo}`, () => cloneGitHubRepository(source, cloneDir, cloneWarnings), "Source repository cloned");
495
+ await terminal.step(`Cloning ${source.owner}/${source.repo}`, () => cloneGitHubRepository(source, cloneDir, cloneWarnings, { docsDir: options.docsDir }), "Source repository cloned");
496
496
  const sourceRefs = options.sourceRefs?.length ? await terminal.step("Importing sourceRef repositories", () => fetchSourceRefs(options.sourceRefs ?? [], cloneWarnings), "sourceRef repositories imported") : [];
497
497
  const bundle = await terminal.step("Converting repository documentation", () => convertRepository({
498
498
  sourceRefs,
@@ -13,6 +13,7 @@ import {
13
13
  import { existsSync, readFileSync, readdirSync, statSync } from "fs";
14
14
  import { join, extname, relative } from "path";
15
15
  import { execFileSync } from "child_process";
16
+ import { compile, pathToRegexp, regexpToFunction } from "path-to-regexp";
16
17
 
17
18
  // src/frontmatter.ts
18
19
  import { parse as parseYaml } from "yaml";
@@ -39,6 +40,7 @@ function parseFrontmatter(raw) {
39
40
 
40
41
  // src/check.ts
41
42
  import { parse as parseYaml2 } from "yaml";
43
+ import remarkGfm from "remark-gfm";
42
44
  import remarkMdx2 from "remark-mdx";
43
45
  import remarkParse2 from "remark-parse";
44
46
  import { unified as unified2 } from "unified";
@@ -303,8 +305,9 @@ function manualApiProblems(file, data, hasOpenApiReference, mdx, docsApi) {
303
305
  }
304
306
 
305
307
  // src/check.ts
308
+ import { parseMdxTree } from "@thallylabs/core/markdown";
306
309
  import { projectNavigationContract } from "@thallylabs/core/navigation";
307
- import { plainInlineText, slugify } from "@thallylabs/core/slugify";
310
+ import { componentIds, constantString, fieldAnchorBase, fieldAnchorName, numberFieldIds, numberHeadingIds, plainInlineText, slugify } from "@thallylabs/core/slugify";
308
311
  import { validateIntegrations } from "@thallylabs/migrate";
309
312
 
310
313
  // src/external-links.ts
@@ -499,6 +502,19 @@ function scanMdx(dir, results) {
499
502
  }
500
503
  }
501
504
  }
505
+ function compiledMdx(directory, results) {
506
+ let entries;
507
+ try {
508
+ entries = readdirSync(directory, { withFileTypes: true });
509
+ } catch {
510
+ return;
511
+ }
512
+ for (const entry of entries) {
513
+ const fullPath = join(directory, entry.name);
514
+ if (entry.isDirectory()) compiledMdx(fullPath, results);
515
+ else if (entry.isFile() && entry.name.endsWith(".mdx")) results.add(fullPath);
516
+ }
517
+ }
502
518
  function addOrphanToNav(projectDir, pageId) {
503
519
  const config = readDocsJson(projectDir);
504
520
  const tab = config.tabs.find((candidate) => !candidate.href && !candidate.api && (candidate.pages?.length || candidate.groups?.length));
@@ -532,70 +548,132 @@ function forEachNonFencedLine(content, visit) {
532
548
  if (!fence) visit(line, index + 1);
533
549
  }
534
550
  }
535
- function renderedHeadingText(source) {
536
- let visible = "";
537
- let inTag = false;
538
- let quote = null;
539
- for (let index = 0; index < source.length; index++) {
540
- const character = source[index];
541
- if (!inTag) {
542
- if (character === "<") inTag = true;
543
- else visible += character;
544
- continue;
545
- }
546
- if (quote) {
547
- if (character === quote && source[index - 1] !== "\\") quote = null;
548
- } else if (character === '"' || character === "'") {
549
- quote = character;
550
- } else if (character === ">") {
551
- inTag = false;
551
+ var mdxParser = unified2().use(remarkParse2).use(remarkMdx2).use(remarkGfm);
552
+ var SNIPPET_IMPORT = /^\s*import\s+\{([^}]+)\}\s+from\s+['"]\/snippets\/[^'"]+['"];?\s*$/gm;
553
+ var LINE_BREAK = /\r\n|\r|\n/g;
554
+ function countAtMost(ascending, value) {
555
+ let low = 0;
556
+ let high = ascending.length;
557
+ while (low < high) {
558
+ const middle = low + high >> 1;
559
+ if (ascending[middle] <= value) low = middle + 1;
560
+ else high = middle;
561
+ }
562
+ return low;
563
+ }
564
+ function parseMdxPage(content) {
565
+ const removedAt = [];
566
+ const removedTotal = [];
567
+ let removed = 0;
568
+ const source = content.replace(SNIPPET_IMPORT, (match, _names, start) => {
569
+ removedAt.push(start - removed);
570
+ removed += match.length;
571
+ removedTotal.push(removed);
572
+ return "";
573
+ });
574
+ let newlines;
575
+ const lineAt = (offset) => {
576
+ const before = countAtMost(removedAt, offset);
577
+ const original = before === 0 ? offset : offset + removedTotal[before - 1];
578
+ if (!newlines) {
579
+ newlines = [];
580
+ for (const lineBreak of content.matchAll(LINE_BREAK)) newlines.push(lineBreak.index + lineBreak[0].length - 1);
552
581
  }
582
+ return 1 + countAtMost(newlines, original - 1);
583
+ };
584
+ try {
585
+ return { tree: mdxParser.parse(source), lineAt };
586
+ } catch (error) {
587
+ const { reason, message, line, place } = error;
588
+ const offset = place?.offset ?? place?.start?.offset;
589
+ return {
590
+ error: {
591
+ reason: (reason ?? message ?? "The page could not be parsed").split("\n")[0].replace(/\.$/, ""),
592
+ line: offset === void 0 ? line ?? 1 : lineAt(offset)
593
+ },
594
+ lineAt
595
+ };
553
596
  }
554
- return visible;
555
597
  }
556
- var mdxParser = unified2().use(remarkParse2).use(remarkMdx2);
557
598
  function stringAttribute(node, name) {
558
599
  const attribute = node.attributes?.find((item) => item.type === "mdxJsxAttribute" && item.name === name);
559
600
  if (!attribute) return void 0;
560
601
  return typeof attribute.value === "string" ? attribute.value : null;
561
602
  }
562
- function extractStepHeadings(content) {
563
- if (!content.includes("<Step")) return [];
564
- let tree;
565
- try {
566
- tree = mdxParser.parse(content);
567
- } catch {
568
- return [];
569
- }
570
- const steps = [];
571
- const visit = (node, stepSize) => {
603
+ function constantAttribute(node, name) {
604
+ const attribute = node.attributes?.find((item) => item.type === "mdxJsxAttribute" && item.name === name);
605
+ if (!attribute) return void 0;
606
+ return typeof attribute.value === "string" ? attribute.value : constantString(attribute.value);
607
+ }
608
+ var HEADING_ID_MARKER = /^\s*\/\*\s*#(\S+?)\s*\*\/\s*$/;
609
+ function mdxSyntaxIssue(page, file, bodyLineOffset2, consequence) {
610
+ if (!page.error) return null;
611
+ return {
612
+ severity: "error",
613
+ message: `MDX syntax error: ${page.error.reason}. ${consequence}`,
614
+ file,
615
+ line: page.error.line + bodyLineOffset2
616
+ };
617
+ }
618
+ function bodyLineOffset(raw, content) {
619
+ return raw.slice(0, raw.length - content.length).split(LINE_BREAK).length - 1;
620
+ }
621
+ function headingText(node, components, footnoteNumber) {
622
+ if (node.type === "text" || node.type === "inlineCode") return node.value ?? "";
623
+ if (node.type === "break") return "\n";
624
+ if (node.type === "footnoteReference") return String(footnoteNumber(node.identifier ?? ""));
625
+ if (node.type === "mdxTextExpression") return components ? constantString(node) ?? "" : "";
626
+ if (node.type === "mdxJsxTextElement" && !components) return "";
627
+ return (node.children ?? []).map((child) => headingText(child, components, footnoteNumber)).join("");
628
+ }
629
+ var STEP_HEADING_SIZES = ["h2", "h3", "h4"];
630
+ function readPageIds(content, parsed) {
631
+ const tree = parsed.tree ?? parseMdxTree(content);
632
+ const footnotes = /* @__PURE__ */ new Map();
633
+ const footnoteNumber = (identifier) => {
634
+ if (!footnotes.has(identifier)) footnotes.set(identifier, footnotes.size + 1);
635
+ return footnotes.get(identifier);
636
+ };
637
+ const page = { headings: [], fieldBases: [], reserved: /* @__PURE__ */ new Set() };
638
+ const visit = (node, stepSize, inNote) => {
572
639
  let childSize = stepSize;
573
- if (node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") {
640
+ if (node.type === "footnoteReference" && !inNote) footnoteNumber(node.identifier ?? "");
641
+ if (node.type === "heading" && (node.depth ?? 0) >= 2) {
642
+ const children = node.children ?? [];
643
+ const marker = children.findIndex((child) => child.type === "mdxTextExpression" && HEADING_ID_MARKER.test(child.value ?? ""));
644
+ const given = marker < 0 ? void 0 : HEADING_ID_MARKER.exec(children[marker].value ?? "")[1];
645
+ const heading = marker < 0 ? node : { ...node, children: children.filter((_, index) => index !== marker) };
646
+ const base = given ?? (slugify(headingText(heading, false, footnoteNumber)) || slugify(headingText(heading, true, footnoteNumber)));
647
+ page.headings.push({ base, explicit: given !== void 0 });
648
+ } else if (node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") {
649
+ const authored = constantAttribute(node, "id")?.trim();
650
+ if (authored) page.reserved.add(authored);
651
+ for (const id of componentIds(node.name ?? "", (attribute) => constantAttribute(node, attribute))) page.reserved.add(id);
574
652
  if (node.name === "Steps") childSize = stringAttribute(node, "titleSize") ?? void 0;
575
653
  if (node.name === "Step") {
576
654
  const own = stringAttribute(node, "titleSize");
577
655
  const size = own === void 0 ? stepSize : own;
578
656
  const title = stringAttribute(node, "title")?.trim();
579
- if (title && size && ["h2", "h3", "h4"].includes(size)) {
580
- const id = stringAttribute(node, "id")?.trim();
581
- steps.push({ line: node.position?.start.line ?? 0, base: id || slugify(plainInlineText(title)), explicit: !!id });
657
+ const stepId = constantAttribute(node, "id");
658
+ if (title && size && STEP_HEADING_SIZES.includes(size) && stepId !== null) {
659
+ const given = stepId?.trim();
660
+ page.headings.push({ base: given || slugify(plainInlineText(title)), explicit: !!given });
582
661
  }
583
662
  }
663
+ if (node.name === "ParamField" || node.name === "ResponseField") {
664
+ const name = fieldAnchorName(node.name, (attribute) => constantAttribute(node, attribute));
665
+ const base = name === void 0 ? void 0 : fieldAnchorBase(name);
666
+ if (base && constantAttribute(node, "id") === void 0) page.fieldBases.push(base);
667
+ }
584
668
  }
585
- for (const child of node.children ?? []) visit(child, childSize);
669
+ for (const child of node.children ?? []) visit(child, childSize, inNote || node.type === "footnoteDefinition");
586
670
  };
587
- visit(tree, void 0);
588
- return steps;
671
+ visit(tree, void 0, false);
672
+ return page;
589
673
  }
590
- function extractHeadingAnchors(content) {
674
+ function extractHeadingAnchors(content, parsed = parseMdxPage(content)) {
591
675
  const anchors = /* @__PURE__ */ new Set();
592
- const headings = [];
593
- forEachNonFencedLine(content, (line, lineNumber) => {
594
- const heading = /^ {0,3}#{1,6}\s+(.+?)\s*#*\s*$/.exec(line);
595
- if (heading) {
596
- const explicit = /\{\/\*\s*#(\S+?)\s*\*\/\}$/.exec(heading[1]);
597
- headings.push({ base: explicit?.[1] ?? slugify(renderedHeadingText(heading[1])), explicit: !!explicit, line: lineNumber });
598
- }
676
+ forEachNonFencedLine(content, (line) => {
599
677
  for (const idMatch of line.matchAll(EXPLICIT_ID_ATTRIBUTE)) {
600
678
  const id = idMatch[1] ?? idMatch[2] ?? idMatch[3];
601
679
  if (id) anchors.add(id);
@@ -605,26 +683,11 @@ function extractHeadingAnchors(content) {
605
683
  if (name) anchors.add(name);
606
684
  }
607
685
  });
608
- headings.push(...extractStepHeadings(content));
609
- headings.sort((left, right) => left.line - right.line);
610
- const reserved = new Set(headings.filter((heading) => heading.explicit).map((heading) => heading.base));
611
- const used = /* @__PURE__ */ new Set();
612
- const occurrences = /* @__PURE__ */ new Map();
613
- for (const { base, explicit } of headings) {
614
- let id = base;
615
- let n = 1;
616
- if (explicit) {
617
- while (used.has(id)) id = `${base}-${++n}`;
618
- } else {
619
- n = occurrences.get(base) ?? 0;
620
- do
621
- id = ++n === 1 ? base : `${base}-${n}`;
622
- while (used.has(id) || reserved.has(id));
623
- occurrences.set(base, n);
624
- }
625
- used.add(id);
626
- anchors.add(id);
627
- }
686
+ const page = readPageIds(content, parsed);
687
+ const { ids, used } = numberHeadingIds(page.headings);
688
+ for (const id of ids) if (id) anchors.add(id);
689
+ for (const id of numberFieldIds(page.fieldBases, used, page.reserved)) anchors.add(id);
690
+ for (const id of page.reserved) anchors.add(id);
628
691
  return anchors;
629
692
  }
630
693
  function decodedFragment(fragment) {
@@ -672,34 +735,45 @@ function pageIdToPath(pageId, secondaryLocales) {
672
735
  const basePath = navPageId === "introduction" ? "/" : `/${navPageId}`;
673
736
  return locale ? `/${locale}${basePath === "/" ? "" : basePath}` : basePath;
674
737
  }
675
- function compileDynamicRedirectSource(source, destination) {
676
- if (!source.includes(":")) return null;
677
- const paramNames = [];
678
- let pattern = "";
679
- source.split("/").forEach((segment, index) => {
680
- const match = segment.match(/^:([A-Za-z_][A-Za-z0-9_]*)([*+?]?)$/);
681
- const separator = index === 0 ? "" : "/";
682
- if (!match) {
683
- pattern += separator + segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
684
- return;
685
- }
686
- const [, name, quantifier] = match;
687
- paramNames.push(name);
688
- if (quantifier === "*") pattern += `(?:${separator}(.*))?`;
689
- else if (quantifier === "?") pattern += `(?:${separator}([^/]+))?`;
690
- else pattern += `${separator}(${quantifier === "+" ? ".+" : "[^/]+"})`;
691
- });
692
- return { regex: new RegExp(`^${pattern}$`), paramNames, destination };
738
+ function compileRedirectSource(source, destination) {
739
+ const bare = source.replace(/\/$/, "") || "/";
740
+ const keys = [];
741
+ let match = null;
742
+ try {
743
+ const regexp = pathToRegexp(bare, keys, { delimiter: "/", sensitive: false, strict: true });
744
+ const matcher = regexpToFunction(new RegExp(regexp.source.replace(/\^/, "^(?!\\/_next)").replace(/\$$/, "(?:\\/)?$"), regexp.flags), keys);
745
+ match = (path) => {
746
+ const found = matcher(path);
747
+ if (!found) return false;
748
+ for (const key of keys) if (typeof key.name === "number") delete found.params[key.name];
749
+ return found;
750
+ };
751
+ } catch {
752
+ }
753
+ return { match, destination, literal: !/[:*+?(){}\\]/.test(bare) };
693
754
  }
694
- function resolveDynamicRedirectDestination(redirect, match) {
695
- return redirect.destination.replace(/:([A-Za-z_][A-Za-z0-9_]*)[*+?]?/g, (whole, name) => {
696
- const index = redirect.paramNames.indexOf(name);
697
- return index === -1 ? whole : match[index + 1] ?? "";
698
- });
755
+ function applyRedirect(rule, path) {
756
+ const found = rule.match?.(path);
757
+ if (!found) return void 0;
758
+ if (/^(?:https?:)?\/\//i.test(rule.destination)) return rule.destination;
759
+ try {
760
+ return compile(rule.destination.replace(/\?[^#]*/, ""), { validate: false })(found.params);
761
+ } catch {
762
+ return null;
763
+ }
764
+ }
765
+ function matchRedirect(rules, path) {
766
+ for (const rule of rules) {
767
+ if (!rule.match) return "unsure";
768
+ const to = applyRedirect(rule, path);
769
+ if (to === null) return "unsure";
770
+ if (to !== void 0) return { to };
771
+ }
772
+ return void 0;
699
773
  }
700
774
  var MAX_REDIRECT_HOPS = 20;
701
775
  var MISSING_DESCRIPTION = 'Missing "description" in frontmatter';
702
- function resolveLink(target, redirects, dynamicRedirects = []) {
776
+ function resolveLink(target, redirects) {
703
777
  let current = target;
704
778
  let anchor;
705
779
  const seen = /* @__PURE__ */ new Set();
@@ -711,14 +785,10 @@ function resolveLink(target, redirects, dynamicRedirects = []) {
711
785
  const path = beforeHash.split("?", 1)[0].replace(/\/$/, "") || "/";
712
786
  if (seen.has(path) || seen.size >= MAX_REDIRECT_HOPS) return { path, anchor, cycle: true };
713
787
  seen.add(path);
714
- const literalRedirect = redirects.get(path);
715
- if (literalRedirect) {
716
- current = literalRedirect;
717
- continue;
718
- }
719
- const dynamicMatch = dynamicRedirects.map((redirect) => ({ redirect, match: path.match(redirect.regex) })).find((candidate) => candidate.match !== null);
720
- if (!dynamicMatch) return { path, anchor };
721
- current = resolveDynamicRedirectDestination(dynamicMatch.redirect, dynamicMatch.match);
788
+ const found = matchRedirect(redirects, path);
789
+ if (found === "unsure") return { path, anchor, unsure: true };
790
+ if (!found) return { path, anchor };
791
+ current = found.to;
722
792
  }
723
793
  }
724
794
  function checkPublicSpecs(projectDir, tabs, issues) {
@@ -898,16 +968,7 @@ async function runCheck(projectDir, options) {
898
968
  "/api",
899
969
  ...Array.from(secondaryLocales, (locale) => `/${locale}/api`)
900
970
  ]);
901
- const redirectDestinations = new Map(
902
- (config.redirects ?? []).filter((redirect) => !redirect.source.includes(":")).map((redirect) => [
903
- redirect.source.replace(/\/$/, "") || "/",
904
- redirect.destination
905
- ])
906
- );
907
- const dynamicRedirects = (config.redirects ?? []).flatMap((redirect) => {
908
- const compiled = compileDynamicRedirectSource(redirect.source, redirect.destination);
909
- return compiled ? [compiled] : [];
910
- });
971
+ const redirectRules = (config.redirects ?? []).map(({ source, destination }) => compileRedirectSource(source, destination));
911
972
  for (const message of validateIntegrations(config.integrations)) {
912
973
  issues.push({ severity: "warning", message, file: "docs.json" });
913
974
  }
@@ -927,11 +988,16 @@ async function runCheck(projectDir, options) {
927
988
  }
928
989
  const allFiles = [];
929
990
  if (existsSync(contentDir)) scanMdx(contentDir, allFiles);
991
+ const compiledPages = /* @__PURE__ */ new Set();
992
+ compiledMdx(contentDir, compiledPages);
993
+ const compiledSnippets = /* @__PURE__ */ new Set();
994
+ compiledMdx(join(projectDir, "snippets"), compiledSnippets);
930
995
  const fixedOrphans = [];
931
996
  const operationPages = [];
932
997
  const validPaths = /* @__PURE__ */ new Set(["/"]);
933
998
  const anchorsByPath = /* @__PURE__ */ new Map();
934
999
  const linksByFile = [];
1000
+ const linkStubs = [];
935
1001
  for (const filePath of allFiles) {
936
1002
  const rel = filePath.slice(contentDir.length + 1).replace(/\.mdx$/, "").replace(/\\/g, "/");
937
1003
  const pageId = rel.endsWith("/index") ? rel.slice(0, -6) : rel;
@@ -943,7 +1009,7 @@ async function runCheck(projectDir, options) {
943
1009
  const parsed = parseFrontmatter(raw);
944
1010
  data = parsed.data;
945
1011
  content = parsed.content;
946
- lineOffset = raw.slice(0, raw.indexOf(content)).split("\n").length - 1;
1012
+ lineOffset = bodyLineOffset(raw, content);
947
1013
  } catch {
948
1014
  issues.push({ severity: "error", message: `Could not parse frontmatter`, file: relative(projectDir, filePath) });
949
1015
  continue;
@@ -960,23 +1026,38 @@ async function runCheck(projectDir, options) {
960
1026
  const rel2 = relative(projectDir, filePath);
961
1027
  if (!data.title) issues.push({ severity: "warning", message: `Missing "title" in frontmatter`, file: rel2 });
962
1028
  const reference = parseDocReference(data.openapi);
963
- if (!data.description && !reference) issues.push({ severity: "warning", message: MISSING_DESCRIPTION, file: rel2 });
1029
+ const path = pageIdToPath(pageId, secondaryLocales);
1030
+ const isLinkStub = data.noindex === true && redirectRules.some((rule) => rule.literal && rule.match?.(path));
1031
+ const notices = isLinkStub ? [] : issues;
1032
+ if (!data.description && !reference) notices.push({ severity: "warning", message: MISSING_DESCRIPTION, file: rel2 });
964
1033
  if (typeof data.openapi !== "string" && content.trim().length < 50) {
965
- issues.push({ severity: "warning", message: `Very short body (${content.trim().length} chars) \u2014 page may be empty`, file: rel2 });
1034
+ notices.push({ severity: "warning", message: `Very short body (${content.trim().length} chars) \u2014 page may be empty`, file: rel2 });
966
1035
  }
1036
+ if (isLinkStub) linkStubs.push({ file: rel2, path, issues: notices });
967
1037
  if (reference) operationPages.push({ file: rel2, reference, hasDescription: Boolean(data.description) });
968
1038
  for (const message of manualApiProblems(rel2, data, Boolean(reference), content, config.api)) {
969
1039
  issues.push({ severity: "warning", file: rel2, message });
970
1040
  }
971
1041
  if (options.drift) checkDrift(projectDir, rel2, data, issues);
972
- const path = pageIdToPath(pageId, secondaryLocales);
973
- const anchors = extractHeadingAnchors(content);
1042
+ const page = parseMdxPage(content);
1043
+ const syntaxIssue = compiledPages.has(filePath) ? mdxSyntaxIssue(page, rel2, lineOffset, "The page cannot render until this is fixed.") : null;
1044
+ if (syntaxIssue) issues.push(syntaxIssue);
1045
+ const anchors = extractHeadingAnchors(content, page);
974
1046
  validPaths.add(path);
975
1047
  validPaths.add(`/${pageId}`);
976
1048
  anchorsByPath.set(path, anchors);
977
1049
  anchorsByPath.set(`/${pageId}`, anchors);
978
1050
  linksByFile.push({ file: rel2, path, anchors, links: extractLinks(content), images: extractImageRefs(content), offset: lineOffset });
979
1051
  }
1052
+ for (const filePath of compiledSnippets) {
1053
+ try {
1054
+ const raw = readFileSync(filePath, "utf8");
1055
+ const { content } = parseFrontmatter(raw);
1056
+ const issue = mdxSyntaxIssue(parseMdxPage(content), relative(projectDir, filePath), bodyLineOffset(raw, content), "Pages that use this snippet cannot render until this is fixed.");
1057
+ if (issue) issues.push(issue);
1058
+ } catch {
1059
+ }
1060
+ }
980
1061
  for (const [path, anchors] of [...anchorsByPath]) {
981
1062
  if (secondaryLocales.has(path.split("/")[1])) continue;
982
1063
  for (const locale of secondaryLocales) {
@@ -986,6 +1067,16 @@ async function runCheck(projectDir, options) {
986
1067
  anchorsByPath.set(localized, anchors);
987
1068
  }
988
1069
  }
1070
+ const isGeneratedPath = (path) => Array.from(generatedApiPaths).some((prefix) => path === prefix || path.startsWith(`${prefix}/`)) || path.startsWith("/_next") || /\.[a-z0-9]+$/i.test(path);
1071
+ for (const { file, path, issues: notices } of linkStubs) {
1072
+ const { path: destination, cycle, external, unsure } = resolveLink(path, redirectRules);
1073
+ if (external || unsure || !cycle && (isGeneratedPath(destination) || validPaths.has(destination))) continue;
1074
+ issues.push(...notices, {
1075
+ severity: "error",
1076
+ message: cycle ? `Broken redirect: "${path}" redirects in a loop, so the page it stands in for cannot be reached` : `Broken redirect: "${path}" redirects to "${destination}", where there is no page`,
1077
+ file
1078
+ });
1079
+ }
989
1080
  const externalLinks = [];
990
1081
  for (const { file, anchors, links, offset } of linksByFile) {
991
1082
  for (const { target, line: contentLine } of links) {
@@ -1002,12 +1093,9 @@ async function runCheck(projectDir, options) {
1002
1093
  continue;
1003
1094
  }
1004
1095
  if (!target.startsWith("/")) continue;
1005
- const { path, anchor, cycle, external } = resolveLink(target, redirectDestinations, dynamicRedirects);
1006
- if (external) continue;
1007
- const isGeneratedApiPath = Array.from(generatedApiPaths).some(
1008
- (prefix) => path === prefix || path.startsWith(`${prefix}/`)
1009
- );
1010
- if (!cycle && (isGeneratedApiPath || path.startsWith("/_next") || /\.[a-z0-9]+$/i.test(path))) continue;
1096
+ const { path, anchor, cycle, external, unsure } = resolveLink(target, redirectRules);
1097
+ if (external || unsure) continue;
1098
+ if (!cycle && isGeneratedPath(path)) continue;
1011
1099
  if (cycle || !validPaths.has(path)) {
1012
1100
  issues.push({ severity: "error", message: `Broken link: "${target}" \u2014 no page at "${path}"`, file, line });
1013
1101
  } else if (anchor && !anchorsByPath.get(path)?.has(decodedFragment(anchor))) {
package/dist/index.js CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  migrateDocs
4
- } from "./chunk-3WMUNLRV.js";
4
+ } from "./chunk-NITY7H3R.js";
5
5
  import {
6
6
  parseFrontmatter,
7
7
  runCheck
8
- } from "./chunk-HSPM676Z.js";
8
+ } from "./chunk-WKJN3RIC.js";
9
9
  import "./chunk-KDLP27DS.js";
10
10
  import {
11
11
  logo,
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  migrateDocs
4
- } from "../chunk-3WMUNLRV.js";
4
+ } from "../chunk-NITY7H3R.js";
5
5
  import "../chunk-KDLP27DS.js";
6
6
  import "../chunk-BHJYHH67.js";
7
7
  import "../chunk-UG3OXVLE.js";
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runCheck
4
- } from "./chunk-HSPM676Z.js";
4
+ } from "./chunk-WKJN3RIC.js";
5
5
  import "./chunk-KDLP27DS.js";
6
6
  import "./chunk-J7255UQA.js";
7
7
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-thally-docs",
3
- "version": "0.10.122",
3
+ "version": "0.10.124",
4
4
  "description": "Scaffold an open-source Thally docs site for people, search engines, and AI tools.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -53,9 +53,11 @@
53
53
  "@anthropic-ai/sdk": "^0.36.0",
54
54
  "@clack/prompts": "^0.11.0",
55
55
  "@inquirer/prompts": "^7.0.0",
56
- "@thallylabs/core": "0.2.17",
57
- "@thallylabs/migrate": "0.2.43",
56
+ "@thallylabs/core": "0.2.18",
57
+ "@thallylabs/migrate": "0.2.45",
58
58
  "p-limit": "^6.1.0",
59
+ "path-to-regexp": "6.3.0",
60
+ "remark-gfm": "^4.0.1",
59
61
  "remark-mdx": "^3.1.1",
60
62
  "remark-parse": "^11.0.0",
61
63
  "tar": "^7.5.22",