mdrefine 0.2.0-rc.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/CHANGELOG.md +577 -0
  2. package/CODE_OF_CONDUCT.md +30 -0
  3. package/CONTRIBUTING.md +189 -16
  4. package/README.md +100 -154
  5. package/SECURITY.md +15 -13
  6. package/config.schema.json +91 -0
  7. package/dist/cli/diff.d.ts +10 -0
  8. package/dist/cli/diff.js +247 -0
  9. package/dist/cli/diff.js.map +1 -0
  10. package/dist/cli/main.js +270 -150
  11. package/dist/cli/main.js.map +1 -1
  12. package/dist/config/load.js +67 -6
  13. package/dist/config/load.js.map +1 -1
  14. package/dist/config/problems.d.ts +7 -0
  15. package/dist/config/problems.js +208 -0
  16. package/dist/config/problems.js.map +1 -0
  17. package/dist/config/resolve.d.ts +42 -109
  18. package/dist/config/resolve.js +193 -52
  19. package/dist/config/resolve.js.map +1 -1
  20. package/dist/core/directives.d.ts +7 -0
  21. package/dist/core/directives.js +25 -13
  22. package/dist/core/directives.js.map +1 -1
  23. package/dist/core/engine.d.ts +42 -4
  24. package/dist/core/engine.js +518 -79
  25. package/dist/core/engine.js.map +1 -1
  26. package/dist/core/frozen.d.ts +6 -0
  27. package/dist/core/frozen.js +15 -0
  28. package/dist/core/frozen.js.map +1 -0
  29. package/dist/core/scope.d.ts +30 -0
  30. package/dist/core/scope.js +122 -0
  31. package/dist/core/scope.js.map +1 -0
  32. package/dist/core/types.d.ts +202 -23
  33. package/dist/core/types.js.map +1 -1
  34. package/dist/index.d.ts +2 -2
  35. package/dist/index.js.map +1 -1
  36. package/dist/node.d.ts +3 -0
  37. package/dist/node.js +4 -0
  38. package/dist/node.js.map +1 -0
  39. package/dist/rules/blocks.d.ts +34 -0
  40. package/dist/rules/blocks.js +550 -0
  41. package/dist/rules/blocks.js.map +1 -0
  42. package/dist/rules/content.d.ts +2 -0
  43. package/dist/rules/content.js +85 -0
  44. package/dist/rules/content.js.map +1 -0
  45. package/dist/rules/dialects.js +10 -6
  46. package/dist/rules/dialects.js.map +1 -1
  47. package/dist/rules/links.js +302 -6
  48. package/dist/rules/links.js.map +1 -1
  49. package/dist/rules/lists.d.ts +2 -0
  50. package/dist/rules/lists.js +924 -0
  51. package/dist/rules/lists.js.map +1 -0
  52. package/dist/rules/structure.js +127 -1
  53. package/dist/rules/structure.js.map +1 -1
  54. package/dist/rules/style.d.ts +8 -2
  55. package/dist/rules/style.js +1137 -165
  56. package/dist/rules/style.js.map +1 -1
  57. package/dist/syntax/block-identifier.d.ts +18 -0
  58. package/dist/syntax/block-identifier.js +24 -0
  59. package/dist/syntax/block-identifier.js.map +1 -0
  60. package/dist/syntax/obsidian.d.ts +29 -11
  61. package/dist/syntax/obsidian.js +9 -10
  62. package/dist/syntax/obsidian.js.map +1 -1
  63. package/dist/syntax/parse.d.ts +87 -3
  64. package/dist/syntax/parse.js +247 -19
  65. package/dist/syntax/parse.js.map +1 -1
  66. package/dist/workspace/access.d.ts +12 -3
  67. package/dist/workspace/access.js +33 -5
  68. package/dist/workspace/access.js.map +1 -1
  69. package/dist/workspace/discovery.d.ts +85 -0
  70. package/dist/workspace/discovery.js +155 -0
  71. package/dist/workspace/discovery.js.map +1 -0
  72. package/dist/workspace/encoding.d.ts +24 -0
  73. package/dist/workspace/encoding.js +113 -0
  74. package/dist/workspace/encoding.js.map +1 -0
  75. package/dist/workspace/files.d.ts +92 -6
  76. package/dist/workspace/files.js +715 -59
  77. package/dist/workspace/files.js.map +1 -1
  78. package/dist/workspace/git.d.ts +54 -0
  79. package/dist/workspace/git.js +198 -0
  80. package/dist/workspace/git.js.map +1 -0
  81. package/dist/workspace/index.d.ts +58 -3
  82. package/dist/workspace/index.js +136 -37
  83. package/dist/workspace/index.js.map +1 -1
  84. package/dist/workspace/selection.d.ts +2 -2
  85. package/dist/workspace/selection.js +3 -3
  86. package/dist/workspace/selection.js.map +1 -1
  87. package/dist/workspace/slug.js +8 -1
  88. package/dist/workspace/slug.js.map +1 -1
  89. package/dist/workspace/vault.js +3 -1
  90. package/dist/workspace/vault.js.map +1 -1
  91. package/docs/api.md +239 -0
  92. package/docs/benchmarks.md +140 -200
  93. package/docs/changelog.md +1 -0
  94. package/docs/cli.md +281 -89
  95. package/docs/configuration.md +193 -480
  96. package/docs/design.md +163 -284
  97. package/docs/dialects.md +169 -0
  98. package/docs/examples.md +151 -109
  99. package/docs/index.md +21 -37
  100. package/docs/integrations.md +144 -0
  101. package/docs/obsidian.md +91 -115
  102. package/docs/plugins.md +147 -212
  103. package/docs/quick-start.md +68 -74
  104. package/docs/rules.md +1237 -0
  105. package/docs/versions.md +56 -0
  106. package/examples/README.md +17 -14
  107. package/examples/automation/summarize-report.mjs +5 -2
  108. package/examples/library/lint-folder.mjs +5 -4
  109. package/examples/library/lint-workspace.mjs +32 -0
  110. package/examples/plugin/notes/release-checklist.md +2 -2
  111. package/examples/plugin/rules.mjs +17 -23
  112. package/examples/workspaces/obsidian-vault/after/mdtools.config.jsonc +2 -2
  113. package/examples/workspaces/obsidian-vault/before/mdtools.config.jsonc +2 -2
  114. package/examples/workspaces/repository-docs/after/README.md +2 -2
  115. package/examples/workspaces/repository-docs/after/mdtools.config.jsonc +0 -2
  116. package/examples/workspaces/repository-docs/before/README.md +2 -2
  117. package/examples/workspaces/repository-docs/before/docs/guide.md +7 -7
  118. package/examples/workspaces/repository-docs/before/docs/reference.md +3 -1
  119. package/examples/workspaces/repository-docs/before/mdtools.config.jsonc +0 -2
  120. package/package.json +38 -12
  121. package/docs/contributing.md +0 -218
  122. package/docs/releases/0.1.0-beta.1.md +0 -39
  123. package/docs/releases/0.1.0-beta.2.md +0 -83
  124. package/docs/releases/0.1.0-beta.3.md +0 -61
  125. package/docs/releases/0.1.0-beta.4.md +0 -50
  126. package/docs/releases/0.1.0-rc.1.md +0 -99
  127. package/docs/releases/0.2.0-rc.1.md +0 -141
  128. package/docs/releases/0.2.0-rc.2.md +0 -225
  129. package/docs/releases.md +0 -144
  130. package/docs/testing.md +0 -93
@@ -3,10 +3,22 @@ import GithubSlugger from "github-slugger";
3
3
  import { decodeNamedCharacterReference } from "decode-named-character-reference";
4
4
  import { walk } from "../syntax/walk.js";
5
5
  import { fallbackDialect } from "../config/resolve.js";
6
+ import { blockIdentifier } from "../syntax/block-identifier.js";
6
7
  import { parse, range, textContent } from "../syntax/parse.js";
7
8
  import { headingAttributes, lastLineSource } from "./heading-attributes.js";
8
9
  import { createForgejoSlugger, createGiteaSlugger, giteaAnchor } from "./slug.js";
9
10
  import { refusal } from "./access.js";
11
+ import { isNotUtf8 } from "./encoding.js";
12
+ /**
13
+ * Whether a file is Markdown by its name: one ending in `.md` or `.markdown`,
14
+ * in any letter case, both of which GitHub, Forgejo, and Gitea render.
15
+ * Discovery selects such files in folders, and their headings are link
16
+ * targets. For the Obsidian dialect only `.md` counts: Obsidian opens no
17
+ * other file as a note.
18
+ */
19
+ export function isMarkdownName(name, dialect) {
20
+ return (dialect === "obsidian" ? /\.md$/i : /\.(?:md|markdown)$/i).test(name);
21
+ }
10
22
  /** GFM's tagfilter makes GitHub show these tags as text; a hard break adds nothing. */
11
23
  const github = {
12
24
  shownTags: /^<\/?(?:iframe|noembed|noframes|plaintext|script|style|title|textarea|xmp)(?=[\t\n\f\r />])/i,
@@ -250,33 +262,57 @@ function htmlParts(document) {
250
262
  });
251
263
  return parts;
252
264
  }
253
- /** A target's anchors for Obsidian; none for a target that is not Markdown source. */
265
+ /**
266
+ * Whether the outline holds a chain of headings that `parts` name, each one
267
+ * below the one before, within its section: `Setup#Mobile` is a heading
268
+ * `Mobile` of any lower level after a heading `Setup` and before the next
269
+ * heading of `Setup`'s level or higher. Every heading that a part names is
270
+ * tried, so the link is missing only when no such chain exists.
271
+ */
272
+ function hasHeadingPath(outline, parts) {
273
+ const named = (names, part) => names.some((name) => name === part || name.toLowerCase() === part.toLowerCase());
274
+ const below = (part, from, depth) => {
275
+ for (let at = from; at < outline.length && outline[at].depth > depth; at++)
276
+ if (named(outline[at].names, parts[part]) &&
277
+ (part === parts.length - 1 || below(part + 1, at + 1, outline[at].depth)))
278
+ return true;
279
+ return false;
280
+ };
281
+ return below(0, 0, 0);
282
+ }
283
+ /** A target's anchors for Obsidian. */
254
284
  function obsidianAnchors(document) {
255
285
  const anchors = {
256
286
  headings: new Set(),
257
287
  foldedHeadings: new Set(),
288
+ outline: [],
258
289
  blocks: new Set(),
259
290
  };
260
- if (!document)
261
- return anchors;
262
- walk(document.tree, "heading", (node) => {
263
- const text = textContent(node);
264
- anchors.headings.add(text);
265
- anchors.foldedHeadings.add(text.toLowerCase());
266
- });
267
- walk(document.tree, "text", (node) => {
268
- const match = /(?:^|\s)\^([A-Za-z0-9-]+)\s*$/.exec(node.value);
269
- if (match)
270
- anchors.blocks.add(match[1]);
291
+ const { source } = document;
292
+ walk(document.tree, (node) => {
293
+ const block = blockIdentifier(source, node);
294
+ if (block)
295
+ anchors.blocks.add(block.id);
296
+ if (node.type !== "heading")
297
+ return;
298
+ // A link may name a heading by the text it shows or as its source writes
299
+ // it: the heading `` `move` `` is both `move` and `` `move` ``.
300
+ const first = node.children[0];
301
+ const written = first ? source.slice(range(first)[0], range(node.children.at(-1))[1]) : "";
302
+ const names = [...new Set([textContent(node), written])].map(detach);
303
+ for (const name of names) {
304
+ anchors.headings.add(name);
305
+ anchors.foldedHeadings.add(name.toLowerCase());
306
+ }
307
+ anchors.outline.push({ depth: node.depth, names });
271
308
  });
272
- detachAll(anchors.headings, anchors.foldedHeadings, anchors.blocks);
309
+ // The names are detached already, and so is their lowercase form.
310
+ detachAll(anchors.blocks);
273
311
  return anchors;
274
312
  }
275
- /** A target's anchors for GitHub and CommonMark; none for a target that is not Markdown source. */
313
+ /** A target's anchors for GitHub and CommonMark. */
276
314
  function githubAnchors(document) {
277
315
  const anchors = { slugs: new Set(), explicit: new Set() };
278
- if (!document)
279
- return anchors;
280
316
  const slugger = new GithubSlugger();
281
317
  const parts = htmlParts(document);
282
318
  // GitHub numbers the anchors of Markdown and HTML headings together, in
@@ -298,15 +334,13 @@ function githubAnchors(document) {
298
334
  detachAll(anchors.slugs, anchors.explicit);
299
335
  return anchors;
300
336
  }
301
- /** A target's anchors for Forgejo and Gitea; none for a target that is not Markdown source. */
337
+ /** A target's anchors for Forgejo and Gitea. */
302
338
  function forgeAnchors(document) {
303
339
  const anchors = {
304
340
  forgejoSlugs: new Set(),
305
341
  giteaSlugs: new Set(),
306
342
  explicit: new Set(),
307
343
  };
308
- if (!document)
309
- return anchors;
310
344
  const { source } = document;
311
345
  const forgejoSlugger = createForgejoSlugger();
312
346
  const giteaSlugger = createGiteaSlugger();
@@ -380,6 +414,13 @@ export function splitDestination(destination) {
380
414
  fragment: decode(hash < 0 ? "" : destination.slice(hash + 1)),
381
415
  };
382
416
  }
417
+ /**
418
+ * The workspace of `files`, which maps each path, relative to the workspace
419
+ * root with forward slashes, to a `WorkspaceSource`. Link checks, link
420
+ * rewriting, and Obsidian wrapping need one. A Markdown target is parsed in
421
+ * `options.dialect` when a link's fragment needs its anchors, which are kept
422
+ * and its text is not.
423
+ */
383
424
  export function createWorkspace(files, options = {}) {
384
425
  const sources = new Map(Object.entries(files).map(([name, source]) => [
385
426
  name.replaceAll("\\", "/").replace(/^\.\//, ""),
@@ -480,25 +521,39 @@ export function createWorkspace(files, options = {}) {
480
521
  return foldedDirectories.has(name.toLowerCase());
481
522
  }
482
523
  const targets = new Map();
524
+ /** What the host's lookup found, by the matching asked for and the path. */
525
+ const lookedUp = new Map();
526
+ /** The sources of the files that the lookup found, by path. */
527
+ const foundSources = new Map();
528
+ /** What the host finds at a path that the index does not hold, asked once per path. */
529
+ function lookup(name, folded) {
530
+ if (!options.lookup)
531
+ return undefined;
532
+ const key = `${folded ? "i" : "s"}:${name}`;
533
+ if (lookedUp.has(key))
534
+ return lookedUp.get(key);
535
+ const result = options.lookup(name, folded);
536
+ lookedUp.set(key, result);
537
+ if (result?.type === "file")
538
+ foundSources.set(result.path, result.source);
539
+ return result;
540
+ }
483
541
  /**
484
- * One group of a target's anchors, from the target read and parsed for it;
485
- * nothing keeps the parsed document. A target that is not Markdown source has
486
- * no anchors. Undefined for a target that the system refuses to let be read,
487
- * which is marked and not read again.
542
+ * One group of a Markdown target's anchors, from the target read and parsed
543
+ * for it; nothing keeps the parsed document. Undefined for a target that the
544
+ * system refuses to let be read, or whose text is not UTF-8, which is marked
545
+ * and not read again.
488
546
  */
489
- function compute(target, name, anchors) {
547
+ function compute(target, value, name, anchors) {
490
548
  if (target.unreadable)
491
549
  return undefined;
492
- const value = sources.get(name);
493
- if (value === null || value === undefined || !/\.md$/i.test(name))
494
- return anchors(null);
495
550
  let source;
496
551
  try {
497
552
  source = typeof value === "function" ? value() : value;
498
553
  }
499
554
  catch (error) {
500
555
  // The file exists, but its anchors are unknown; any other error is a fault.
501
- if (!refusal(error))
556
+ if (!refusal(error) && !isNotUtf8(error))
502
557
  throw error;
503
558
  target.unreadable = true;
504
559
  return undefined;
@@ -515,20 +570,30 @@ export function createWorkspace(files, options = {}) {
515
570
  * page script also retries a fragment lowercased, and its anchors are.
516
571
  */
517
572
  function hasAnchor(name, dialect, fragment, anchor) {
573
+ const value = sources.has(name) ? sources.get(name) : foundSources.get(name);
574
+ // Only a Markdown file's anchors are known. Other files take fragments of
575
+ // their own, such as an image's `#gh-dark-mode-only`, a PDF's `#page=2`, or
576
+ // a forge's line range `#L10-L20` in source code, which are not checked; so
577
+ // is a file that is linted as Markdown only because it was named.
578
+ if (value === null || value === undefined || !isMarkdownName(name, dialect))
579
+ return true;
518
580
  let target = targets.get(name);
519
581
  if (!target)
520
582
  targets.set(name, (target = {}));
521
583
  if (dialect === "obsidian") {
522
- const anchors = target.obsidian ?? compute(target, name, obsidianAnchors);
584
+ const anchors = target.obsidian ?? compute(target, value, name, obsidianAnchors);
523
585
  if (!anchors)
524
586
  return undefined;
525
587
  target.obsidian = anchors;
526
- return fragment.startsWith("^")
527
- ? anchors.blocks.has(fragment.slice(1))
528
- : anchors.headings.has(fragment) || anchors.foldedHeadings.has(fragment.toLowerCase());
588
+ if (fragment.startsWith("^"))
589
+ return anchors.blocks.has(fragment.slice(1));
590
+ if (anchors.headings.has(fragment) || anchors.foldedHeadings.has(fragment.toLowerCase()))
591
+ return true;
592
+ // A path of headings, each below the one before, such as `Setup#Mobile`.
593
+ return fragment.includes("#") && hasHeadingPath(anchors.outline, fragment.split("#"));
529
594
  }
530
595
  if (dialect === "forgejo" || dialect === "gitea") {
531
- const forge = target.forge ?? compute(target, name, forgeAnchors);
596
+ const forge = target.forge ?? compute(target, value, name, forgeAnchors);
532
597
  if (!forge)
533
598
  return undefined;
534
599
  target.forge = forge;
@@ -537,7 +602,7 @@ export function createWorkspace(files, options = {}) {
537
602
  return true;
538
603
  return forge.explicit.has(anchor) || forge.explicit.has(fragment);
539
604
  }
540
- const anchors = target.github ?? compute(target, name, githubAnchors);
605
+ const anchors = target.github ?? compute(target, value, name, githubAnchors);
541
606
  if (!anchors)
542
607
  return undefined;
543
608
  target.github = anchors;
@@ -639,7 +704,7 @@ export function createWorkspace(files, options = {}) {
639
704
  ...(options.strictLineBreaks !== undefined
640
705
  ? { strictLineBreaks: options.strictLineBreaks }
641
706
  : {}),
642
- resolve(source, destination, dialect) {
707
+ resolve(source, destination, dialect, wiki = false) {
643
708
  if (/^[a-z][a-z\d+.-]*:/i.test(destination) || destination.startsWith("//"))
644
709
  return { status: "external" };
645
710
  source = source.replaceAll("\\", "/");
@@ -664,6 +729,22 @@ export function createWorkspace(files, options = {}) {
664
729
  const partial = folded && !vault && rootFolders.length > 0;
665
730
  // Whether Obsidian would look in a place of that vault outside the workspace.
666
731
  let beyond = false;
732
+ // A link on the disk that the path leads through to outside the workspace root.
733
+ let leaves;
734
+ /** What the host finds at a path that the index does not hold. */
735
+ const look = (name, file = true) => {
736
+ const found = lookup(name, folded);
737
+ if (found?.type === "file") {
738
+ if (file)
739
+ candidates.add(found.path);
740
+ }
741
+ else if (found?.type === "directory")
742
+ directory ||= !wiki;
743
+ else if (found?.type === "outside")
744
+ leaves ??= found.through;
745
+ else if (found?.type === "unreadable")
746
+ blocking.add(found.at);
747
+ };
667
748
  const add = (candidate) => {
668
749
  const normalized = path.posix.normalize(candidate);
669
750
  if (normalized === ".." || normalized.startsWith("../")) {
@@ -681,10 +762,11 @@ export function createWorkspace(files, options = {}) {
681
762
  outside ??=
682
763
  namesMatching(bare)[0] ??
683
764
  (path.posix.extname(bare) ? undefined : namesMatching(`${bare}.md`)[0]) ??
684
- (isDirectory(bare, true) ? bare : undefined);
765
+ (!wiki && isDirectory(bare, true) ? bare : undefined);
685
766
  return;
686
767
  }
687
- if (isDirectory(bare, folded))
768
+ // Obsidian links a wikilink to a note or a file, never to a folder.
769
+ if (!wiki && isDirectory(bare, folded))
688
770
  directory = true;
689
771
  const above = unreadableAbove(bare, folded);
690
772
  if (above !== undefined)
@@ -692,14 +774,23 @@ export function createWorkspace(files, options = {}) {
692
774
  if (!folded) {
693
775
  if (sources.has(normalized))
694
776
  candidates.add(normalized);
777
+ // A path that ends with a slash names a folder only.
778
+ else if (!directory)
779
+ look(bare, bare === normalized);
695
780
  return;
696
781
  }
697
782
  // Names differing only by case all count, so such vaults report ambiguity.
783
+ const before = candidates.size;
698
784
  for (const name of namesMatching(normalized))
699
785
  candidates.add(name);
700
786
  if (!path.posix.extname(normalized))
701
787
  for (const name of namesMatching(`${normalized}.md`))
702
788
  candidates.add(name);
789
+ if (candidates.size === before && !directory) {
790
+ look(bare);
791
+ if (!path.posix.extname(bare))
792
+ look(`${bare}.md`);
793
+ }
703
794
  };
704
795
  if (!targetPath)
705
796
  add(source);
@@ -750,6 +841,14 @@ export function createWorkspace(files, options = {}) {
750
841
  // A match is uncertain when a place that could not be looked in may hold a
751
842
  // note that Obsidian would choose instead, or as well.
752
843
  const uncertain = [...(unsearched ? hidden : obstructed ? blocking : []), ...unseen];
844
+ // The tool reads nothing outside the workspace root, where the link leads.
845
+ if (candidates.size === 0 && leaves !== undefined)
846
+ return {
847
+ status: "unreadable",
848
+ unreadable: [leaves],
849
+ leavesRoot: leaves,
850
+ ...(vault ? { vault } : {}),
851
+ };
753
852
  if (candidates.size === 0 && (blocking.size > 0 || unsearched || unseen.length > 0))
754
853
  return {
755
854
  status: "unreadable",