nimbus-docs 0.1.4 → 0.1.5
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/dist/cli/index.js +610 -7
- package/dist/cli/index.js.map +1 -1
- package/dist/client.js.map +1 -1
- package/dist/diagnostic-DZf0z79l.d.ts +123 -0
- package/dist/diagnostic-DZf0z79l.d.ts.map +1 -0
- package/dist/index.d.ts +85 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +743 -161
- package/dist/index.js.map +1 -1
- package/dist/rules-DnAP-j89.js +5836 -0
- package/dist/rules-DnAP-j89.js.map +1 -0
- package/dist/schemas.d.ts +74 -1
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +8 -3
- package/dist/schemas.js.map +1 -1
- package/dist/{strict-keys-D06tc9YZ.js → strict-keys-BiXiT3pq.js} +1 -1
- package/dist/{strict-keys-D06tc9YZ.js.map → strict-keys-BiXiT3pq.js.map} +1 -1
- package/dist/types.d.ts +9 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +20 -4
package/dist/cli/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { a as resolveRuleForCollection, i as parseSource, n as RULES, o as validateLintOptions, s as isRuleCode, t as IMPLEMENTED_CODES } from "../rules-DnAP-j89.js";
|
|
2
3
|
import { spawn } from "node:child_process";
|
|
3
|
-
import {
|
|
4
|
+
import fs, { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import path, { dirname, join, relative } from "node:path";
|
|
4
6
|
import mri from "mri";
|
|
5
7
|
import * as p from "@clack/prompts";
|
|
6
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
8
|
import { determineAgent } from "@vercel/detect-agent";
|
|
8
9
|
|
|
9
10
|
//#region src/cli/_registry.generated.ts
|
|
@@ -209,6 +210,12 @@ const BUNDLED_INDEX = {
|
|
|
209
210
|
"title": "Component showcase",
|
|
210
211
|
"description": "Add a /components grid landing plus per-component showcase pages at /components/<slug>, driven by a dedicated content collection. For sites documenting their own UI library."
|
|
211
212
|
},
|
|
213
|
+
"lint-prose-textlint": {
|
|
214
|
+
"name": "lint-prose-textlint",
|
|
215
|
+
"type": "registry:feature",
|
|
216
|
+
"title": "Prose linting with textlint",
|
|
217
|
+
"description": "Add textlint with write-good, alex, and terminology rules — npm-native prose linting that runs alongside nimbus-docs lint."
|
|
218
|
+
},
|
|
212
219
|
"new-collection": {
|
|
213
220
|
"name": "new-collection",
|
|
214
221
|
"type": "registry:feature",
|
|
@@ -560,6 +567,579 @@ function printHumanInstructions(slug) {
|
|
|
560
567
|
stream.write(` Run "${cmd} --print" and follow the instructions.\n`);
|
|
561
568
|
}
|
|
562
569
|
|
|
570
|
+
//#endregion
|
|
571
|
+
//#region src/lint/disables.ts
|
|
572
|
+
/**
|
|
573
|
+
* Per-file and per-line disable directives — both designed so the disable
|
|
574
|
+
* itself is greppable:
|
|
575
|
+
*
|
|
576
|
+
* - Frontmatter `nimbusDisableRules: ["nimbus/internal-link"]` disables
|
|
577
|
+
* the listed codes for the whole file.
|
|
578
|
+
* - An inline `{/* nimbus-rule-disable-next-line nimbus/bare-url */}`
|
|
579
|
+
* comment disables the named code on the next non-blank line.
|
|
580
|
+
*
|
|
581
|
+
* Both require a rule code: an empty `nimbusDisableRules` array is a
|
|
582
|
+
* reported error, so the reason for a disable is always visible.
|
|
583
|
+
*/
|
|
584
|
+
const INLINE_DISABLE = /\{\/\*\s*nimbus-rule-disable-next-line\s+(\S+)\s*\*\/\}/;
|
|
585
|
+
function collectDisables(frontmatter, frontmatterRaw, frontmatterStartLine, lines) {
|
|
586
|
+
const fileDisabled = /* @__PURE__ */ new Set();
|
|
587
|
+
const lineDisabled = /* @__PURE__ */ new Map();
|
|
588
|
+
const problems = [];
|
|
589
|
+
if (frontmatter && "nimbusDisableRules" in frontmatter) {
|
|
590
|
+
const raw = frontmatter.nimbusDisableRules;
|
|
591
|
+
const at = locateFrontmatterKey(frontmatterRaw, "nimbusDisableRules", frontmatterStartLine);
|
|
592
|
+
if (!Array.isArray(raw)) problems.push({
|
|
593
|
+
message: "\"nimbusDisableRules\" must be an array of rule codes, e.g. [\"nimbus/internal-link\"].",
|
|
594
|
+
line: at.line,
|
|
595
|
+
column: at.column
|
|
596
|
+
});
|
|
597
|
+
else if (raw.length === 0) problems.push({
|
|
598
|
+
message: "\"nimbusDisableRules\" is empty — remove it, or name the rule code(s) you mean to disable so the reason stays greppable.",
|
|
599
|
+
line: at.line,
|
|
600
|
+
column: at.column
|
|
601
|
+
});
|
|
602
|
+
else for (const entry of raw) {
|
|
603
|
+
if (typeof entry !== "string") {
|
|
604
|
+
problems.push({
|
|
605
|
+
message: `"nimbusDisableRules" entry ${JSON.stringify(entry)} must be a string rule code.`,
|
|
606
|
+
line: at.line,
|
|
607
|
+
column: at.column
|
|
608
|
+
});
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
if (!isRuleCode(entry)) {
|
|
612
|
+
problems.push({
|
|
613
|
+
message: `"nimbusDisableRules" lists "${entry}", which is not a known rule code — typos here silently no-op, so we surface them.`,
|
|
614
|
+
line: at.line,
|
|
615
|
+
column: at.column
|
|
616
|
+
});
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
fileDisabled.add(entry);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
for (let i = 0; i < lines.length; i++) {
|
|
623
|
+
const match = lines[i].match(INLINE_DISABLE);
|
|
624
|
+
if (!match) continue;
|
|
625
|
+
const code = match[1];
|
|
626
|
+
if (!isRuleCode(code)) {
|
|
627
|
+
problems.push({
|
|
628
|
+
message: `inline disable references "${code}", which is not a known rule code — typos here silently no-op, so we surface them.`,
|
|
629
|
+
line: i + 1,
|
|
630
|
+
column: (match.index ?? 0) + 1
|
|
631
|
+
});
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
let target = -1;
|
|
635
|
+
for (let j = i + 1; j < lines.length; j++) if (lines[j].trim() !== "") {
|
|
636
|
+
target = j + 1;
|
|
637
|
+
break;
|
|
638
|
+
}
|
|
639
|
+
if (target === -1) continue;
|
|
640
|
+
const set = lineDisabled.get(target) ?? /* @__PURE__ */ new Set();
|
|
641
|
+
set.add(code);
|
|
642
|
+
lineDisabled.set(target, set);
|
|
643
|
+
}
|
|
644
|
+
return {
|
|
645
|
+
fileDisabled,
|
|
646
|
+
lineDisabled,
|
|
647
|
+
problems
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
/** Is a diagnostic for `code` on `line` suppressed by a disable directive? */
|
|
651
|
+
function isDisabled(info, code, line) {
|
|
652
|
+
if (info.fileDisabled.has(code)) return true;
|
|
653
|
+
return info.lineDisabled.get(line)?.has(code) ?? false;
|
|
654
|
+
}
|
|
655
|
+
function locateFrontmatterKey(frontmatterRaw, key, startLine) {
|
|
656
|
+
if (frontmatterRaw) {
|
|
657
|
+
const rawLines = frontmatterRaw.split("\n");
|
|
658
|
+
for (let i = 0; i < rawLines.length; i++) {
|
|
659
|
+
const m = rawLines[i].match(new RegExp(`^(\\s*)${key}\\s*:`));
|
|
660
|
+
if (m) return {
|
|
661
|
+
line: startLine + i,
|
|
662
|
+
column: m[1].length + 1
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
return {
|
|
667
|
+
line: startLine,
|
|
668
|
+
column: 1
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
//#endregion
|
|
673
|
+
//#region src/lint/fix.ts
|
|
674
|
+
function applyFixes(source, diagnostics) {
|
|
675
|
+
const items = diagnostics.filter((d) => d.fix !== void 0 && d.fix.edits.length > 0).map((d) => {
|
|
676
|
+
const edits = d.fix.edits;
|
|
677
|
+
return {
|
|
678
|
+
diagnostic: d,
|
|
679
|
+
edits,
|
|
680
|
+
start: Math.min(...edits.map((e) => e.range[0])),
|
|
681
|
+
end: Math.max(...edits.map((e) => e.range[1]))
|
|
682
|
+
};
|
|
683
|
+
}).sort((a, b) => b.start - a.start);
|
|
684
|
+
let output = source;
|
|
685
|
+
let frontier = Number.POSITIVE_INFINITY;
|
|
686
|
+
const applied = /* @__PURE__ */ new Set();
|
|
687
|
+
for (const item of items) {
|
|
688
|
+
if (item.end > frontier) continue;
|
|
689
|
+
const ordered = [...item.edits].sort((a, b) => b.range[0] - a.range[0]);
|
|
690
|
+
for (const edit of ordered) output = output.slice(0, edit.range[0]) + edit.text + output.slice(edit.range[1]);
|
|
691
|
+
frontier = item.start;
|
|
692
|
+
applied.add(item.diagnostic);
|
|
693
|
+
}
|
|
694
|
+
return {
|
|
695
|
+
output,
|
|
696
|
+
fixed: applied.size,
|
|
697
|
+
applied
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
//#endregion
|
|
702
|
+
//#region src/lint/engine.ts
|
|
703
|
+
/**
|
|
704
|
+
* The lint engine. Runs the registered rules over parsed files, resolves
|
|
705
|
+
* each rule's severity from config, applies per-file and per-line disables,
|
|
706
|
+
* and collects everything into the one `Diagnostic` envelope.
|
|
707
|
+
*
|
|
708
|
+
* Pure and synchronous: `lintFile` takes a `ParsedFile` and returns
|
|
709
|
+
* `Diagnostic[]`, which is what the test harness drives directly. The
|
|
710
|
+
* disk-walking entry points (`lintPaths`) sit on top.
|
|
711
|
+
*/
|
|
712
|
+
/** Lint one already-parsed file. */
|
|
713
|
+
function lintFile(file, opts = {}) {
|
|
714
|
+
const forceEnable = opts.only !== void 0 && opts.rules?.[opts.only] === void 0;
|
|
715
|
+
const rules = forceEnable ? {
|
|
716
|
+
...opts.rules ?? {},
|
|
717
|
+
[opts.only]: "error"
|
|
718
|
+
} : opts.rules ?? {};
|
|
719
|
+
const collections = forceEnable ? stripCodeFromCollections(opts.collections ?? {}, opts.only) : opts.collections ?? {};
|
|
720
|
+
const out = [];
|
|
721
|
+
if (file.parseError) return [{
|
|
722
|
+
code: "nimbus/mdx-syntax",
|
|
723
|
+
severity: "error",
|
|
724
|
+
source: "docs-compiler",
|
|
725
|
+
file: file.path,
|
|
726
|
+
message: `MDX failed to parse: ${file.parseError.message}`,
|
|
727
|
+
line: file.parseError.line,
|
|
728
|
+
column: file.parseError.column
|
|
729
|
+
}];
|
|
730
|
+
const disables = collectDisables(file.frontmatter, file.frontmatterRaw, file.frontmatterStartLine, file.lines);
|
|
731
|
+
for (const problem of disables.problems) out.push({
|
|
732
|
+
code: "nimbus/frontmatter-shape",
|
|
733
|
+
severity: "error",
|
|
734
|
+
source: "docs-compiler",
|
|
735
|
+
file: file.path,
|
|
736
|
+
message: problem.message,
|
|
737
|
+
line: problem.line,
|
|
738
|
+
column: problem.column
|
|
739
|
+
});
|
|
740
|
+
for (const rule of RULES) {
|
|
741
|
+
if (opts.only && rule.code !== opts.only) continue;
|
|
742
|
+
const resolved = resolveRuleForCollection(rule.code, rules, collections, file.collection);
|
|
743
|
+
if (resolved.severity === "off") continue;
|
|
744
|
+
const severity = resolved.severity;
|
|
745
|
+
const reports = [];
|
|
746
|
+
try {
|
|
747
|
+
rule.run({
|
|
748
|
+
file,
|
|
749
|
+
options: resolved.options,
|
|
750
|
+
site: opts.site,
|
|
751
|
+
report: (report) => reports.push(report)
|
|
752
|
+
});
|
|
753
|
+
} catch (err) {
|
|
754
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
755
|
+
process.stderr.write(`nimbus-docs: rule \`${rule.code}\` threw on ${file.path}: ${detail}\n`);
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
for (const report of reports) {
|
|
759
|
+
if (isDisabled(disables, rule.code, report.line)) continue;
|
|
760
|
+
out.push({
|
|
761
|
+
code: rule.code,
|
|
762
|
+
severity,
|
|
763
|
+
source: "docs-compiler",
|
|
764
|
+
file: file.path,
|
|
765
|
+
message: report.message,
|
|
766
|
+
line: report.line,
|
|
767
|
+
column: report.column,
|
|
768
|
+
endLine: report.endLine,
|
|
769
|
+
endColumn: report.endColumn,
|
|
770
|
+
fix: report.fix
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
out.sort((a, b) => a.line - b.line || a.column - b.column || a.code.localeCompare(b.code));
|
|
775
|
+
return out;
|
|
776
|
+
}
|
|
777
|
+
/** Lint a set of absolute file paths, reading + parsing each one. */
|
|
778
|
+
function lintPaths(absPaths, projectRoot, opts = {}) {
|
|
779
|
+
const out = [];
|
|
780
|
+
for (const abs of absPaths) {
|
|
781
|
+
if (opts.signal?.aborted) break;
|
|
782
|
+
const rel = path.relative(projectRoot, abs);
|
|
783
|
+
try {
|
|
784
|
+
const parsed = parseSource(fs.readFileSync(abs, "utf8"), {
|
|
785
|
+
path: rel,
|
|
786
|
+
absPath: abs,
|
|
787
|
+
collection: inferCollection(rel)
|
|
788
|
+
});
|
|
789
|
+
out.push(...lintFile(parsed, opts));
|
|
790
|
+
} catch (err) {
|
|
791
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
792
|
+
process.stderr.write(`nimbus-docs: skipped ${rel}: ${detail}\n`);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
return out;
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* Hard cap on per-file fix passes — a runaway rule that keeps emitting
|
|
799
|
+
* convergence-breaking fixes won't hang the CLI. 10 is well above any
|
|
800
|
+
* realistic chain (the longest in practice is 2–3: a fix unmasks a
|
|
801
|
+
* second-tier finding the first pass shadowed).
|
|
802
|
+
*/
|
|
803
|
+
const MAX_FIX_PASSES = 10;
|
|
804
|
+
/**
|
|
805
|
+
* Lint + apply auto-fixes in place. Each file is read, linted, fixed, and
|
|
806
|
+
* (when content changed) atomically rewritten via tmp-file + rename — so a
|
|
807
|
+
* crash or SIGINT mid-write can't truncate the user's content. We iterate
|
|
808
|
+
* each file until the output stabilizes or `MAX_FIX_PASSES` is hit; this
|
|
809
|
+
* picks up diagnostics that were skipped on pass 1 due to overlap with
|
|
810
|
+
* another applied fix, plus diagnostics a fix on pass 1 unmasked.
|
|
811
|
+
*
|
|
812
|
+
* A diagnostic stays in the report when it wasn't *actually* applied —
|
|
813
|
+
* which includes the advisory-only case (a rule emits a `fix` with no
|
|
814
|
+
* `edits`, like the did-you-mean hint on `internal-link`) and the
|
|
815
|
+
* skipped-overlap case after the convergence cap. Both are real,
|
|
816
|
+
* unresolved issues; suppressing them just because the diagnostic carries
|
|
817
|
+
* a `fix` field would silently hide broken links and other
|
|
818
|
+
* un-auto-fixable problems.
|
|
819
|
+
*
|
|
820
|
+
* Files that fail to read, parse, or write are skipped with a stderr
|
|
821
|
+
* message and the run continues — one bad file shouldn't leave the rest
|
|
822
|
+
* of the working tree half-fixed.
|
|
823
|
+
*/
|
|
824
|
+
function fixPaths(absPaths, projectRoot, opts = {}) {
|
|
825
|
+
let fixed = 0;
|
|
826
|
+
let filesChanged = 0;
|
|
827
|
+
const remaining = [];
|
|
828
|
+
for (const abs of absPaths) {
|
|
829
|
+
if (opts.signal?.aborted) break;
|
|
830
|
+
const rel = path.relative(projectRoot, abs);
|
|
831
|
+
try {
|
|
832
|
+
const original = fs.readFileSync(abs, "utf8");
|
|
833
|
+
let current = original;
|
|
834
|
+
let lastDiagnostics = [];
|
|
835
|
+
let lastApplied = /* @__PURE__ */ new Set();
|
|
836
|
+
for (let pass = 0; pass < MAX_FIX_PASSES; pass++) {
|
|
837
|
+
const diagnostics = lintFile(parseSource(current, {
|
|
838
|
+
path: rel,
|
|
839
|
+
absPath: abs,
|
|
840
|
+
collection: inferCollection(rel)
|
|
841
|
+
}), opts);
|
|
842
|
+
const result = applyFixes(current, diagnostics);
|
|
843
|
+
fixed += result.fixed;
|
|
844
|
+
lastDiagnostics = diagnostics;
|
|
845
|
+
lastApplied = result.applied;
|
|
846
|
+
if (result.output === current) break;
|
|
847
|
+
current = result.output;
|
|
848
|
+
}
|
|
849
|
+
if (current !== original) {
|
|
850
|
+
writeFileAtomicSync(abs, current);
|
|
851
|
+
filesChanged++;
|
|
852
|
+
}
|
|
853
|
+
for (const d of lastDiagnostics) if (!lastApplied.has(d)) remaining.push(d);
|
|
854
|
+
} catch (err) {
|
|
855
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
856
|
+
process.stderr.write(`nimbus-docs: skipped ${rel}: ${detail}\n`);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
return {
|
|
860
|
+
diagnostics: remaining,
|
|
861
|
+
fixed,
|
|
862
|
+
filesChanged
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Write atomically: serialize to a sibling tmp file, fsync, rename over the
|
|
867
|
+
* target. A crash mid-write leaves the original intact instead of a
|
|
868
|
+
* truncated .mdx. The tmp file lives next to the target so the rename is
|
|
869
|
+
* a same-filesystem atomic op.
|
|
870
|
+
*/
|
|
871
|
+
function writeFileAtomicSync(abs, content) {
|
|
872
|
+
const tmp = `${abs}.nimbus-tmp-${process.pid}`;
|
|
873
|
+
const fd = fs.openSync(tmp, "w");
|
|
874
|
+
try {
|
|
875
|
+
fs.writeFileSync(fd, content, "utf8");
|
|
876
|
+
fs.fsyncSync(fd);
|
|
877
|
+
} finally {
|
|
878
|
+
fs.closeSync(fd);
|
|
879
|
+
}
|
|
880
|
+
try {
|
|
881
|
+
fs.renameSync(tmp, abs);
|
|
882
|
+
} catch (err) {
|
|
883
|
+
try {
|
|
884
|
+
fs.unlinkSync(tmp);
|
|
885
|
+
} catch {}
|
|
886
|
+
throw err;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
function summarize(diagnostics, files) {
|
|
890
|
+
let errors = 0;
|
|
891
|
+
let warnings = 0;
|
|
892
|
+
for (const d of diagnostics) if (d.severity === "error") errors++;
|
|
893
|
+
else warnings++;
|
|
894
|
+
return {
|
|
895
|
+
errors,
|
|
896
|
+
warnings,
|
|
897
|
+
total: diagnostics.length,
|
|
898
|
+
files
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
/** Infer the collection name from a `src/content/<name>/…` path. */
|
|
902
|
+
function inferCollection(relPath) {
|
|
903
|
+
const match = relPath.replace(/\\/g, "/").match(/(?:^|\/)src\/content\/([^/]+)\//);
|
|
904
|
+
return match ? match[1] : null;
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* Return a new collections config with `code` removed from every per-
|
|
908
|
+
* collection `rules` block. Used by `lintFile`'s `--rule` force-enable so
|
|
909
|
+
* a per-collection "off" doesn't silently shadow the CLI flag — the
|
|
910
|
+
* user explicitly asked to see this rule's findings.
|
|
911
|
+
*/
|
|
912
|
+
function stripCodeFromCollections(collections, code) {
|
|
913
|
+
const out = {};
|
|
914
|
+
for (const [name, cfg] of Object.entries(collections)) {
|
|
915
|
+
if (!cfg.rules || !(code in cfg.rules)) {
|
|
916
|
+
out[name] = cfg;
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
const { [code]: _stripped, ...remaining } = cfg.rules;
|
|
920
|
+
out[name] = {
|
|
921
|
+
...cfg,
|
|
922
|
+
rules: remaining
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
return out;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
//#endregion
|
|
929
|
+
//#region src/lint/discover.ts
|
|
930
|
+
/**
|
|
931
|
+
* File discovery for the CLI — walk the configured content directories for
|
|
932
|
+
* `.mdx` files, skipping `node_modules` and dotfolders. Mirrors the walk
|
|
933
|
+
* the MDX validator already uses, kept here so the lint CLI doesn't depend
|
|
934
|
+
* on integration internals.
|
|
935
|
+
*/
|
|
936
|
+
function findMdxFiles(dirs) {
|
|
937
|
+
const out = [];
|
|
938
|
+
for (const dir of dirs) walk(dir, out);
|
|
939
|
+
out.sort();
|
|
940
|
+
return out;
|
|
941
|
+
}
|
|
942
|
+
function walk(dir, out) {
|
|
943
|
+
let entries;
|
|
944
|
+
try {
|
|
945
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
946
|
+
} catch (err) {
|
|
947
|
+
if (err.code === "ENOENT") return;
|
|
948
|
+
throw err;
|
|
949
|
+
}
|
|
950
|
+
for (const entry of entries) {
|
|
951
|
+
const full = path.join(dir, entry.name);
|
|
952
|
+
if (entry.isDirectory()) {
|
|
953
|
+
if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
|
|
954
|
+
walk(full, out);
|
|
955
|
+
} else if (entry.isFile() && entry.name.endsWith(".mdx")) out.push(full);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
//#endregion
|
|
960
|
+
//#region src/lint/format.ts
|
|
961
|
+
const COLORS = {
|
|
962
|
+
reset: "\x1B[0m",
|
|
963
|
+
dim: "\x1B[2m",
|
|
964
|
+
red: "\x1B[31m",
|
|
965
|
+
yellow: "\x1B[33m",
|
|
966
|
+
green: "\x1B[32m",
|
|
967
|
+
bold: "\x1B[1m"
|
|
968
|
+
};
|
|
969
|
+
function formatPretty(diagnostics, summary, opts) {
|
|
970
|
+
const paint = (code, text) => opts.color ? `${code}${text}${COLORS.reset}` : text;
|
|
971
|
+
const shown = opts.quiet ? diagnostics.filter((d) => d.severity === "error") : diagnostics;
|
|
972
|
+
const lines = [];
|
|
973
|
+
for (const d of shown) {
|
|
974
|
+
const sev = d.severity === "error" ? paint(COLORS.red, "error") : paint(COLORS.yellow, "warn");
|
|
975
|
+
const loc = paint(COLORS.dim, `${d.file}:${d.line}:${d.column}`);
|
|
976
|
+
lines.push(`${loc} ${sev} ${paint(COLORS.dim, d.code)}`);
|
|
977
|
+
lines.push(` ${d.message}`);
|
|
978
|
+
if (d.fix && d.fix.description) lines.push(` ${paint(COLORS.dim, `fix: ${d.fix.description}`)}`);
|
|
979
|
+
}
|
|
980
|
+
if (summary.total === 0) return paint(COLORS.green, `✓ ${summary.files} file(s) lint clean.`);
|
|
981
|
+
const tally = `${summary.errors} error(s), ${summary.warnings} warning(s) across ${summary.files} file(s)`;
|
|
982
|
+
lines.push("");
|
|
983
|
+
lines.push(summary.errors > 0 ? paint(COLORS.red, `✗ ${tally}`) : paint(COLORS.yellow, `${tally}`));
|
|
984
|
+
return lines.join("\n");
|
|
985
|
+
}
|
|
986
|
+
function formatJson(diagnostics, summary) {
|
|
987
|
+
return JSON.stringify({
|
|
988
|
+
version: 1,
|
|
989
|
+
summary: {
|
|
990
|
+
errors: summary.errors,
|
|
991
|
+
warnings: summary.warnings,
|
|
992
|
+
total: summary.total,
|
|
993
|
+
files: summary.files
|
|
994
|
+
},
|
|
995
|
+
diagnostics
|
|
996
|
+
}, null, 2);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
//#endregion
|
|
1000
|
+
//#region src/cli/lint.ts
|
|
1001
|
+
/**
|
|
1002
|
+
* `nimbus-docs lint` — the authoring-quality verdict for MDX content.
|
|
1003
|
+
*
|
|
1004
|
+
* Walks the content directories, runs the registered rules, prints
|
|
1005
|
+
* diagnostics, and exits non-zero when any `error`-severity finding
|
|
1006
|
+
* survives. The build is never gated by this command — drafts that fail
|
|
1007
|
+
* lint still render under `astro dev`.
|
|
1008
|
+
*
|
|
1009
|
+
* Severity overrides live with the integration
|
|
1010
|
+
* (`nimbus(config, { rules })`), which materializes them to
|
|
1011
|
+
* `.nimbus/lint.json` at build/dev time; this command reads that file when
|
|
1012
|
+
* present and otherwise runs every authoring rule at its default. In-file
|
|
1013
|
+
* disables (`nimbusDisableRules`, inline comments) work with no config.
|
|
1014
|
+
*/
|
|
1015
|
+
async function lintCommand(flags) {
|
|
1016
|
+
const cwd = process.cwd();
|
|
1017
|
+
const contentDir = path.join(cwd, "src", "content");
|
|
1018
|
+
if (flags.rule) {
|
|
1019
|
+
if (!isRuleCode(flags.rule)) {
|
|
1020
|
+
process.stderr.write(`Unknown rule code: \`${flags.rule}\`. See https://nimbus-docs.com/lint for the rule list.\n`);
|
|
1021
|
+
process.exit(1);
|
|
1022
|
+
}
|
|
1023
|
+
if (!IMPLEMENTED_CODES.has(flags.rule)) {
|
|
1024
|
+
process.stderr.write(`Rule \`${flags.rule}\` is not implemented by \`nimbus-docs lint\`. Build validators run inside \`astro build\`, not here; planned rules haven't shipped yet. Implemented authoring rules: ${[...IMPLEMENTED_CODES].sort().join(", ")}.\n`);
|
|
1025
|
+
process.exit(1);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
const files = findMdxFiles([contentDir]);
|
|
1029
|
+
if (files.length === 0) {
|
|
1030
|
+
process.stderr.write(`No .mdx files found under ${path.relative(cwd, contentDir) || "."}. Run from your project root.
|
|
1031
|
+
`);
|
|
1032
|
+
process.exit(0);
|
|
1033
|
+
}
|
|
1034
|
+
const { rules, collections, site } = loadMaterializedConfig(cwd);
|
|
1035
|
+
const opts = {
|
|
1036
|
+
rules,
|
|
1037
|
+
collections,
|
|
1038
|
+
site,
|
|
1039
|
+
only: flags.rule
|
|
1040
|
+
};
|
|
1041
|
+
let diagnostics;
|
|
1042
|
+
let interrupted = false;
|
|
1043
|
+
if (flags.fix) {
|
|
1044
|
+
const ac = new AbortController();
|
|
1045
|
+
const onSigint = () => {
|
|
1046
|
+
if (interrupted) {
|
|
1047
|
+
process.stderr.write("\nnimbus-docs: forced exit.\n");
|
|
1048
|
+
process.exit(130);
|
|
1049
|
+
}
|
|
1050
|
+
interrupted = true;
|
|
1051
|
+
process.stderr.write("\nnimbus-docs: interrupted — finishing current file, then stopping. Press Ctrl-C again to force.\n");
|
|
1052
|
+
ac.abort();
|
|
1053
|
+
};
|
|
1054
|
+
process.on("SIGINT", onSigint);
|
|
1055
|
+
try {
|
|
1056
|
+
const result = fixPaths(files, cwd, {
|
|
1057
|
+
...opts,
|
|
1058
|
+
signal: ac.signal
|
|
1059
|
+
});
|
|
1060
|
+
if (result.fixed > 0) process.stderr.write(`Fixed ${result.fixed} issue(s) across ${result.filesChanged} file(s).\n`);
|
|
1061
|
+
if (interrupted) process.stderr.write(`nimbus-docs: stopped early — ${result.filesChanged} file(s) changed before interrupt.\n`);
|
|
1062
|
+
diagnostics = result.diagnostics.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column);
|
|
1063
|
+
} finally {
|
|
1064
|
+
process.off("SIGINT", onSigint);
|
|
1065
|
+
}
|
|
1066
|
+
} else diagnostics = lintPaths(files, cwd, opts);
|
|
1067
|
+
const summary = summarize(diagnostics, files.length);
|
|
1068
|
+
if (flags.format === "json") process.stdout.write(formatJson(diagnostics, summary) + "\n");
|
|
1069
|
+
else process.stdout.write(formatPretty(diagnostics, summary, {
|
|
1070
|
+
color: shouldUseColor(flags.color),
|
|
1071
|
+
quiet: flags.quiet
|
|
1072
|
+
}) + "\n");
|
|
1073
|
+
if (interrupted) process.exit(130);
|
|
1074
|
+
process.exit(summary.errors > 0 ? 1 : 0);
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* Resolve whether to emit ANSI escapes, in standard CLI precedence:
|
|
1078
|
+
* 1. Explicit `--color` / `--no-color` flag.
|
|
1079
|
+
* 2. `FORCE_COLOR` env (Node ecosystem convention) — any non-empty,
|
|
1080
|
+
* non-zero value forces color on.
|
|
1081
|
+
* 3. `NO_COLOR` env (no-color.org) — any non-empty value forces color off.
|
|
1082
|
+
* 4. Auto-detect via `process.stdout.isTTY`.
|
|
1083
|
+
*/
|
|
1084
|
+
function shouldUseColor(explicit) {
|
|
1085
|
+
if (explicit !== void 0) return explicit;
|
|
1086
|
+
const force = process.env.FORCE_COLOR;
|
|
1087
|
+
if (force !== void 0 && force !== "" && force !== "0") return true;
|
|
1088
|
+
if (process.env.NO_COLOR !== void 0 && process.env.NO_COLOR !== "") return false;
|
|
1089
|
+
return process.stdout.isTTY === true;
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* Read the integration's materialized lint config from `.nimbus/lint.json`.
|
|
1093
|
+
* Returns empty defaults when the file is absent or unreadable (lint must
|
|
1094
|
+
* work before the first build).
|
|
1095
|
+
*
|
|
1096
|
+
* **Re-validates the parsed config** against `validateLintOptions`, the
|
|
1097
|
+
* same validator the integration ran at config-setup time. The materialized
|
|
1098
|
+
* file is normally machine-written, so failures here typically mean a
|
|
1099
|
+
* hand-edit or a stale schema. The CLI surfaces the validation error and
|
|
1100
|
+
* exits — silently ignoring a typo'd rule code in `lint.json` contradicts
|
|
1101
|
+
* the anti-silent-typo invariant the rest of the codebase enforces.
|
|
1102
|
+
*/
|
|
1103
|
+
function loadMaterializedConfig(cwd) {
|
|
1104
|
+
const file = path.join(cwd, ".nimbus", "lint.json");
|
|
1105
|
+
let raw;
|
|
1106
|
+
try {
|
|
1107
|
+
raw = fs.readFileSync(file, "utf8");
|
|
1108
|
+
} catch {
|
|
1109
|
+
return {
|
|
1110
|
+
rules: {},
|
|
1111
|
+
collections: {}
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
let parsed;
|
|
1115
|
+
try {
|
|
1116
|
+
parsed = JSON.parse(raw);
|
|
1117
|
+
} catch (err) {
|
|
1118
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1119
|
+
process.stderr.write(`nimbus-docs: ${path.relative(cwd, file) || file} is not valid JSON — ${detail}. Delete the file (it'll be regenerated by \`astro build\`) or fix the syntax.
|
|
1120
|
+
`);
|
|
1121
|
+
process.exit(1);
|
|
1122
|
+
}
|
|
1123
|
+
let validated;
|
|
1124
|
+
try {
|
|
1125
|
+
validated = validateLintOptions({
|
|
1126
|
+
rules: parsed.rules,
|
|
1127
|
+
collections: parsed.collections
|
|
1128
|
+
}, IMPLEMENTED_CODES);
|
|
1129
|
+
} catch (err) {
|
|
1130
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1131
|
+
process.stderr.write(`${detail}\n\nThis shape lives in ${path.relative(cwd, file) || file} — usually machine-written by the Nimbus integration at \`astro build\`. If you've hand-edited it, fix or delete the file. Otherwise, re-run \`astro build\` to regenerate it.
|
|
1132
|
+
`);
|
|
1133
|
+
process.exit(1);
|
|
1134
|
+
}
|
|
1135
|
+
const site = typeof parsed.site === "string" ? parsed.site : void 0;
|
|
1136
|
+
return {
|
|
1137
|
+
rules: validated.rules,
|
|
1138
|
+
collections: validated.collections,
|
|
1139
|
+
site
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
|
|
563
1143
|
//#endregion
|
|
564
1144
|
//#region src/cli/index.ts
|
|
565
1145
|
/**
|
|
@@ -591,20 +1171,25 @@ const HELP = `
|
|
|
591
1171
|
list [--type ui|lib|feature] List available registry items
|
|
592
1172
|
add Same as \`list\`
|
|
593
1173
|
add <slug> Install a component or hand off a feature
|
|
1174
|
+
lint Lint .mdx content for authoring-quality issues
|
|
594
1175
|
|
|
595
1176
|
Flags:
|
|
596
1177
|
--yes, -y Component: overwrite conflicts without prompting
|
|
597
1178
|
--print Feature: print markdown to stdout (skip agent detect)
|
|
598
1179
|
--type <ui|lib|feature> \`list\`: filter by type
|
|
1180
|
+
--format <json> \`lint\`: machine-readable output
|
|
1181
|
+
--rule <nimbus/...> \`lint\`: run a single rule
|
|
1182
|
+
--fix \`lint\`: apply auto-fixes in place
|
|
1183
|
+
--quiet \`lint\`: errors only, suppress warnings
|
|
599
1184
|
--help, -h
|
|
600
1185
|
--version, -v
|
|
601
1186
|
|
|
602
1187
|
Examples:
|
|
603
1188
|
nimbus-docs add dialog # component: resolve + install
|
|
604
|
-
nimbus-docs add 404-page # feature: detect agent or print
|
|
605
|
-
# pipe instructions for humans
|
|
606
1189
|
nimbus-docs add 404-page --print | claude # explicit pipe to claude
|
|
607
|
-
nimbus-docs
|
|
1190
|
+
nimbus-docs lint # pretty output, exit non-zero on error
|
|
1191
|
+
nimbus-docs lint --format=json # agent-readable diagnostics
|
|
1192
|
+
nimbus-docs lint --rule=nimbus/single-h1 # one rule
|
|
608
1193
|
`;
|
|
609
1194
|
async function main() {
|
|
610
1195
|
const args = mri(process.argv.slice(2), {
|
|
@@ -612,9 +1197,17 @@ async function main() {
|
|
|
612
1197
|
"yes",
|
|
613
1198
|
"print",
|
|
614
1199
|
"help",
|
|
615
|
-
"version"
|
|
1200
|
+
"version",
|
|
1201
|
+
"quiet",
|
|
1202
|
+
"color",
|
|
1203
|
+
"fix"
|
|
616
1204
|
],
|
|
617
|
-
string: [
|
|
1205
|
+
string: [
|
|
1206
|
+
"type",
|
|
1207
|
+
"format",
|
|
1208
|
+
"rule"
|
|
1209
|
+
],
|
|
1210
|
+
default: { color: void 0 },
|
|
618
1211
|
alias: {
|
|
619
1212
|
y: "yes",
|
|
620
1213
|
h: "help",
|
|
@@ -630,6 +1223,16 @@ async function main() {
|
|
|
630
1223
|
return;
|
|
631
1224
|
}
|
|
632
1225
|
const [command, slug] = args._;
|
|
1226
|
+
if (command === "lint") {
|
|
1227
|
+
await lintCommand({
|
|
1228
|
+
format: args.format,
|
|
1229
|
+
quiet: args.quiet,
|
|
1230
|
+
rule: args.rule,
|
|
1231
|
+
color: args.color,
|
|
1232
|
+
fix: args.fix
|
|
1233
|
+
});
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
633
1236
|
if (command === "list" || command === "add" && !slug || !command) {
|
|
634
1237
|
listCommand(args.type);
|
|
635
1238
|
return;
|