nimbus-docs 0.1.4 → 0.1.6

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 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 { dirname, join, relative } from "node:path";
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 add 404-page --print | codex # …or any other agent
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: ["type"],
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",
@@ -626,10 +1219,20 @@ async function main() {
626
1219
  return;
627
1220
  }
628
1221
  if (args.version) {
629
- process.stdout.write(`0.1.4\n`);
1222
+ process.stdout.write(`0.1.6\n`);
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;