@rse/specbook 1.2.9 → 1.2.11

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.
@@ -34,6 +34,7 @@ export declare class SpecBook {
34
34
  lint(options: {
35
35
  config?: string[];
36
36
  basedir?: string;
37
+ gitignore?: boolean;
37
38
  }): Promise<LintResult>;
38
39
  private renderFormats;
39
40
  export(options: {
@@ -41,6 +42,7 @@ export declare class SpecBook {
41
42
  basedir?: string;
42
43
  formats?: ExportFormat[];
43
44
  realtime?: boolean;
45
+ gitignore?: boolean;
44
46
  }): Promise<Buffer[]>;
45
47
  private observe;
46
48
  watch(options: {
@@ -48,6 +50,7 @@ export declare class SpecBook {
48
50
  basedir?: string;
49
51
  formats?: ExportFormat[];
50
52
  realtime?: boolean;
53
+ gitignore?: boolean;
51
54
  outputs?: string[];
52
55
  onExport: (buffers: Buffer[]) => void | Promise<void>;
53
56
  }): Promise<void>;
@@ -56,6 +59,7 @@ export declare class SpecBook {
56
59
  basedir?: string;
57
60
  addr?: string;
58
61
  port?: number;
62
+ gitignore?: boolean;
59
63
  }): Promise<void>;
60
64
  describe(options: {
61
65
  config?: string[];
@@ -81,10 +81,13 @@ export class SpecBook {
81
81
  return initSpecification({ config: this.requireConfig(await this.configFiles(options.config), verbose),
82
82
  basedir: options.basedir ?? ".", verbose });
83
83
  }
84
- /* lint the specification Markdown files below the base directory */
84
+ /* lint the specification Markdown files below the base directory,
85
+ where "gitignore" treats an artifact file Git excludes from its
86
+ project exactly like an absent one */
85
87
  async lint(options) {
86
88
  return lint({ config: await this.configFiles(options.config),
87
- basedir: options.basedir ?? ".", verbose: this.verboseOf("lint") });
89
+ basedir: options.basedir ?? ".", gitignore: options.gitignore === true,
90
+ verbose: this.verboseOf("lint") });
88
91
  }
89
92
  /* render an already parsed specification into the requested formats
90
93
  (strict: any error diagnostic prevents the export, as a partial or
@@ -116,7 +119,7 @@ export class SpecBook {
116
119
  if (requested.includes("pdf"))
117
120
  await requireBrowser(verbose);
118
121
  const result = lint({ config: await this.configFiles(options.config),
119
- basedir: options.basedir ?? ".", verbose });
122
+ basedir: options.basedir ?? ".", gitignore: options.gitignore === true, verbose });
120
123
  return this.renderFormats(result, requested, verbose, options.realtime === true);
121
124
  }
122
125
  /* keep an export in sync with its sources (the shared core of
@@ -135,7 +138,8 @@ export class SpecBook {
135
138
  invalid specification, and the configuration files are
136
139
  observed, too, so a failing export (even one due to an
137
140
  invalid configuration) still keeps the observe loop fed */
138
- const result = lint({ config, basedir: options.basedir ?? ".", verbose });
141
+ const result = lint({ config, basedir: options.basedir ?? ".",
142
+ gitignore: options.gitignore === true, verbose });
139
143
  const files = [...config.map((file) => path.resolve(file)), ...result.files];
140
144
  /* an output which is itself an observed source would re-trigger
141
145
  the observation with its own write and hence feed an endless
@@ -176,7 +180,7 @@ export class SpecBook {
176
180
  const server = await servePreview({ addr: options.addr ?? previewAddr,
177
181
  port: options.port ?? previewPort, verbose });
178
182
  return this.observe({ config: options.config, basedir: options.basedir,
179
- formats: ["html"], realtime: true,
183
+ formats: ["html"], realtime: true, gitignore: options.gitignore,
180
184
  onExport: ([html]) => server.update(html) }, verbose);
181
185
  }
182
186
  /* describe the generic SpecBook models and formats as Markdown (or
@@ -80,6 +80,13 @@ const configOf = (opts) => opts.config.length > 0 ? opts.config : envDefault("co
80
80
  for which the YAML schema configuration falls back onto the standard one */
81
81
  const withCommonOptions = (command) => withConfigOption(withVerboseOption(command), "the bundled standard schema configuration")
82
82
  .option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."));
83
+ /* provide the Git exclude option of the specification *reading*
84
+ sub-commands (not of "init", which creates artifact files instead of
85
+ reading them), which treats an artifact file Git excludes from its
86
+ project exactly like an absent one */
87
+ const withGitignoreOption = (command) => command
88
+ .option("-g, --gitignore", "skip the artifact files excluded by the Git exclude rules " +
89
+ "(the \".gitignore\" files, \"info/exclude\", and the global excludes file)", envDefaultFlag("gitignore", false));
83
90
  /* the help, version, and usage-error output Commander produces, which
84
91
  is collected instead of written directly, as Commander writes it
85
92
  synchronously and then terminates the process, truncating a piped
@@ -118,11 +125,12 @@ withCommonOptions(program.command("init"))
118
125
  "no artifact files were created\n");
119
126
  });
120
127
  /* the lint command reports all diagnostics and fails on any error */
121
- withCommonOptions(program.command("lint"))
128
+ withGitignoreOption(withCommonOptions(program.command("lint")))
122
129
  .description("lint the specification Markdown files below the base directory")
123
130
  .action(async (opts) => {
124
131
  const specbook = new SpecBook({ verbose: verboseOf(opts) });
125
- const result = await specbook.lint({ config: configOf(opts), basedir: opts.basedir });
132
+ const result = await specbook.lint({ config: configOf(opts), basedir: opts.basedir,
133
+ gitignore: opts.gitignore });
126
134
  for (const diagnostic of result.diagnostics)
127
135
  await writeStdout(parseVerbosity(opts.verbose) > 0 ?
128
136
  renderDiagnosticVerbose(diagnostic, process.stdout.isTTY === true) :
@@ -133,7 +141,7 @@ withCommonOptions(program.command("lint"))
133
141
  verboseOf(opts)("lint", "specification valid", "notice");
134
142
  });
135
143
  /* the export command parses the input once and writes every output */
136
- withCommonOptions(program.command("export"))
144
+ withGitignoreOption(withCommonOptions(program.command("export")))
137
145
  .description("export the specification Markdown files below the base directory " +
138
146
  "as JSON, JSON5, YAML, TOON, HTML, PDF, or normalized Markdown")
139
147
  .option("-w, --watch", "keep the outputs in sync by re-exporting on every source change", envDefaultFlag("watch", false))
@@ -156,13 +164,14 @@ withCommonOptions(program.command("export"))
156
164
  };
157
165
  if (opts.watch)
158
166
  await specbook.watch({ config: configOf(opts), basedir: opts.basedir,
159
- formats: distinct, outputs: outputs.map(({ output }) => output), onExport: write });
167
+ formats: distinct, gitignore: opts.gitignore,
168
+ outputs: outputs.map(({ output }) => output), onExport: write });
160
169
  else
161
170
  await write(await specbook.export({ config: configOf(opts), basedir: opts.basedir,
162
- formats: distinct }));
171
+ formats: distinct, gitignore: opts.gitignore }));
163
172
  });
164
173
  /* the preview command serves the HTML export live in the browser */
165
- withCommonOptions(program.command("preview"))
174
+ withGitignoreOption(withCommonOptions(program.command("preview")))
166
175
  .description("serve the HTML export of the specification Markdown files below the base " +
167
176
  "directory as a live preview, re-exported and reloaded on every source change")
168
177
  .option("-a, --addr <ip-addr>", "IP address to listen on", envDefault("addr", previewAddr))
@@ -172,7 +181,8 @@ withCommonOptions(program.command("preview"))
172
181
  if (!Number.isInteger(port) || port < 1 || port > 65535)
173
182
  throw new Error(`invalid TCP port "${opts.port}"`);
174
183
  const specbook = new SpecBook({ verbose: verboseOf(opts) });
175
- await specbook.preview({ config: configOf(opts), basedir: opts.basedir, addr: opts.addr, port });
184
+ await specbook.preview({ config: configOf(opts), basedir: opts.basedir, addr: opts.addr, port,
185
+ gitignore: opts.gitignore });
176
186
  });
177
187
  /* the describe command also describes the generic SpecBook models and
178
188
  formats alone, so its YAML schema configuration stays optional */
@@ -5,6 +5,7 @@ import { type Schema } from "./specbook-format-schema.js";
5
5
  export interface LintOptions {
6
6
  config: string[];
7
7
  basedir: string;
8
+ gitignore: boolean;
8
9
  verbose: Verbose;
9
10
  }
10
11
  export interface LintResult {
@@ -10,6 +10,7 @@ import { literal } from "./specbook-verbose.js";
10
10
  import { parseSpecification, specStatistics, resolveArtifact, collectSchemas } from "./specbook-parse.js";
11
11
  import { buildLinkIndex, chainOf, plainText } from "./specbook-link.js";
12
12
  import { referencedCoverage, specCoverage, coverageRatio } from "./specbook-coverage.js";
13
+ import { excluder } from "./specbook-ignore.js";
13
14
  /* collect the distinct artifact files the schema configuration
14
15
  references, in their declaration order, each mapped onto whether
15
16
  all of its artifacts are optional and hence the file may be absent */
@@ -44,12 +45,24 @@ export const lint = (options) => {
44
45
  if (config !== undefined && files.size === 0)
45
46
  diagnostics.push({ file: options.config[0], line: 1, column: 1, severity: "error",
46
47
  message: "no artifact files configured" });
48
+ /* an artifact file Git excludes from its project is treated exactly
49
+ like an absent one -- it is no part of the project and hence no
50
+ part of its specification */
51
+ const excluded = options.gitignore ? excluder(options.basedir) : () => false;
47
52
  const sources = new Array();
48
53
  const present = new Set();
49
54
  const watched = new Array();
50
55
  for (const [name, optional] of files) {
51
56
  const file = path.join(options.basedir, name);
52
57
  watched.push(path.resolve(file));
58
+ if (excluded(file)) {
59
+ if (optional)
60
+ options.verbose(`skipping artifact file "${literal(file)}" excluded by Git`);
61
+ else
62
+ diagnostics.push({ file, line: 1, column: 1, severity: "error",
63
+ message: "artifact file excluded by Git" });
64
+ continue;
65
+ }
53
66
  if (!fs.existsSync(file)) {
54
67
  if (!optional)
55
68
  diagnostics.push({ file, line: 1, column: 1, severity: "error",
@@ -11,7 +11,9 @@
11
11
  the table header and the headings of all enclosing objects, and
12
12
  every matched word is highlighted with a <mark> element (an SVG
13
13
  <tspan> element underlaid with a <rect> inside a diagram), while
14
- the table of contents (page and side panel) is filtered along */
14
+ the table of contents (page and side panel) is filtered along; the
15
+ explicit anchor id of an object (invisible in the document) is
16
+ searched like its text, a hit marking the anchor symbol instead */
15
17
  (function () {
16
18
  const tab = document.getElementById("search")
17
19
  const toggle = document.getElementById("search-toggle")
@@ -52,7 +54,9 @@
52
54
  one not nested inside another unit (like a paragraph or diagram
53
55
  in a table cell, which is covered by its table row), where a
54
56
  diagram is searched by its SVG text labels, joined by spaces,
55
- as its raw text content would run them together */
57
+ as its raw text content would run them together, and where the
58
+ explicit anchor id of an object heading or table row joins the
59
+ searched text */
56
60
  const index = () => {
57
61
  if (indexed)
58
62
  return
@@ -66,12 +70,13 @@
66
70
  return
67
71
  parent = parent.parentElement
68
72
  }
69
- const text = (el.classList.contains("diagram") ?
73
+ const id = (el.dataset.id ?? "").toLowerCase()
74
+ const text = ((el.classList.contains("diagram") ?
70
75
  Array.from(el.querySelectorAll("text")).map((t) => t.textContent ?? "").join(" ") :
71
- (el.textContent ?? "")).toLowerCase()
76
+ (el.textContent ?? "")) + " " + id).toLowerCase()
72
77
  const words = new Set(text.split(/[^\p{L}\p{N}]+/u).filter((w) => w !== ""))
73
78
  words.forEach((w) => { vocab.add(w) })
74
- units.push({ el, text, words, keep: false })
79
+ units.push({ el, id, text, words, keep: false })
75
80
  })
76
81
  })
77
82
 
@@ -319,12 +324,24 @@
319
324
  el.classList.add("search-hide")
320
325
  })
321
326
 
322
- /* highlight every matched word within the kept units */
327
+ /* highlight every matched word within the kept units, plus the
328
+ anchor symbol of a unit whose explicit anchor id matched, as
329
+ the id itself is nowhere visible */
323
330
  const words = Array.from(new Set(parts.flat()))
324
331
  const regex = buildRegex(words, Array.from(variants))
325
332
  units.forEach((unit) => {
326
- if (unit.el.classList.contains("search-keep"))
327
- highlight(unit.el, regex)
333
+ if (!unit.el.classList.contains("search-keep"))
334
+ return
335
+ highlight(unit.el, regex)
336
+ regex.lastIndex = 0
337
+ const symbol = unit.el.querySelector("span.anchor-symbol")
338
+ if (unit.id === "" || symbol === null || !regex.test(unit.id))
339
+ return
340
+ const mark = document.createElement("mark")
341
+ mark.classList.add("search-hit")
342
+ while (symbol.firstChild !== null)
343
+ mark.appendChild(symbol.firstChild)
344
+ symbol.appendChild(mark)
328
345
  })
329
346
 
330
347
  /* filter the table of contents (its page rows and its side
@@ -152,7 +152,7 @@ const templates = {
152
152
  /* <Object/> */
153
153
  "Object": textframe `
154
154
  <section>
155
- <h{{ Object.level }} id="{{ Object.id }}"><span class="object-kind"{% if Object.info %} data-info="{{ Object.info }}" data-info-path="{{ Object.infopath }}"{% endif %}>{{ Object.kind }}:</span> <span class="object-name"{% if Object.spec %} data-info-spec="{{ Object.spec }}" data-info-path="{{ Object.specpath }}"{% endif %}>{{ Object.name }}</span>{% if Object.primary %} <span class="primary-marker">&#x2318;</span>{% endif %}{% if Object.paren %} <span class="anchor-paren">({{ Object.paren }})</span>{% endif %} <a href="#{{ Object.id }}"><span class="anchor-symbol">&#x2693;&#xFE0E;</span></a></h{{ Object.level }}>
155
+ <h{{ Object.level }} id="{{ Object.id }}"{% if Object.anchor %} data-id="{{ Object.anchor }}"{% endif %}><span class="object-kind"{% if Object.info %} data-info="{{ Object.info }}" data-info-path="{{ Object.infopath }}"{% endif %}>{{ Object.kind }}:</span> <span class="object-name"{% if Object.spec %} data-info-spec="{{ Object.spec }}" data-info-path="{{ Object.specpath }}"{% endif %}>{{ Object.name }}</span>{% if Object.primary %} <span class="primary-marker">&#x2318;</span>{% endif %}{% if Object.paren %} <span class="anchor-paren">({{ Object.paren }})</span>{% endif %} <a href="#{{ Object.id }}"><span class="anchor-symbol">&#x2693;&#xFE0E;</span></a></h{{ Object.level }}>
156
156
  {{ Object.diagram }}
157
157
  {{ Object.properties }}
158
158
  {{ Object.description }}
@@ -206,7 +206,7 @@ const templates = {
206
206
  </thead>
207
207
  <tbody>
208
208
  {% for row in Table.rows %}
209
- <tr id="{{ row.id }}"{% if row.even %} class="even"{% endif %}>
209
+ <tr id="{{ row.id }}"{% if row.anchor %} data-id="{{ row.anchor }}"{% endif %}{% if row.even %} class="even"{% endif %}>
210
210
  <td><span{% if row.spec %} data-info-spec="{{ row.spec }}" data-info-path="{{ row.specpath }}"{% endif %}>{{ row.name }}</span>{% if row.primary %} <span class="primary-marker">&#x2318;</span>{% endif %}{% if row.paren %} <span class="anchor-paren">({{ row.paren }})</span>{% endif %} <a href="#{{ row.id }}"><span class="anchor-symbol">&#x2693;&#xFE0E;</span></a></td>
211
211
  {%- for value in row.values %}<td>{{ value }}</td>{% endfor %}
212
212
  {%- if Table.desc %}<td>{{ row.description }}</td>{% endif %}
@@ -226,7 +226,7 @@ const templates = {
226
226
  </thead>
227
227
  <tbody>
228
228
  {% for row in Table.rows %}
229
- <tr id="{{ row.id }}"{% if row.even %} class="even"{% endif %}>
229
+ <tr id="{{ row.id }}"{% if row.anchor %} data-id="{{ row.anchor }}"{% endif %}{% if row.even %} class="even"{% endif %}>
230
230
  <td><span{% if row.spec %} data-info-spec="{{ row.spec }}" data-info-path="{{ row.specpath }}"{% endif %}>{{ row.name }}</span>{% if row.primary %} <span class="primary-marker">&#x2318;</span>{% endif %}{% if row.paren %} <span class="anchor-paren">({{ row.paren }})</span>{% endif %} <a href="#{{ row.id }}"><span class="anchor-symbol">&#x2693;&#xFE0E;</span></a></td>
231
231
  <td class="chunks">
232
232
  <table class="chunks">
@@ -1460,6 +1460,7 @@ const renderTable = (children, maxColumns) => {
1460
1460
  width: Math.round(200 / (keys.length + 3)),
1461
1461
  rows: children.map((child, i) => scoped(child, () => ({
1462
1462
  id: anchorOf(child),
1463
+ anchor: child.anchor,
1463
1464
  paren: child.paren,
1464
1465
  primary: child.primary,
1465
1466
  spec: infoParents !== null ? anchorOf(child) : undefined,
@@ -1500,6 +1501,7 @@ const renderTable = (children, maxColumns) => {
1500
1501
  chunks.unshift([{ key: "Diagram", desc: true, span: size, value: diagram }]);
1501
1502
  return {
1502
1503
  id: anchorOf(child),
1504
+ anchor: child.anchor,
1503
1505
  paren: child.paren,
1504
1506
  primary: child.primary,
1505
1507
  spec: infoParents !== null ? anchorOf(child) : undefined,
@@ -1522,6 +1524,7 @@ const renderObject = (object, level, concise) => {
1522
1524
  spec: infoParents !== null ? anchorOf(object) : undefined,
1523
1525
  specpath: infoPathOf(object),
1524
1526
  id: anchorOf(object),
1527
+ anchor: object.anchor,
1525
1528
  paren: object.paren,
1526
1529
  primary: object.primary,
1527
1530
  name: inline(object.name),
@@ -0,0 +1,2 @@
1
+ export type Excluder = (file: string) => boolean;
2
+ export declare const excluder: (dir: string) => Excluder;
@@ -0,0 +1,173 @@
1
+ /*
2
+ ** Specification Book (SpecBook)
3
+ ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
+ */
6
+ import * as fs from "node:fs";
7
+ import * as os from "node:os";
8
+ import * as path from "node:path";
9
+ import { execFileSync } from "node:child_process";
10
+ import picomatch from "picomatch";
11
+ /* translate a single Git exclude line into a picomatch-backed rule,
12
+ honoring the anchored-vs-floating, directory-only, and negation
13
+ semantics of the pattern; an anchored pattern resolves relative to
14
+ the "base" directory its rule file governs, given as a POSIX path
15
+ relative to the working tree root */
16
+ const compileRule = (line, base) => {
17
+ let pattern = line.trim();
18
+ if (pattern === "" || pattern.startsWith("#"))
19
+ return null;
20
+ let negated = false;
21
+ if (pattern.startsWith("!")) {
22
+ negated = true;
23
+ pattern = pattern.slice(1);
24
+ }
25
+ let dirOnly = false;
26
+ if (pattern.endsWith("/")) {
27
+ dirOnly = true;
28
+ pattern = pattern.slice(0, -1);
29
+ }
30
+ /* a pattern carrying a slash anywhere but at its end is anchored at
31
+ the directory of its rule file, while every other one floats and
32
+ hence matches at any depth below it */
33
+ const anchored = pattern.includes("/");
34
+ if (pattern.startsWith("/"))
35
+ pattern = pattern.slice(1);
36
+ const glob = anchored ? (base === "" ? pattern : `${base}/${pattern}`) : `**/${pattern}`;
37
+ const isMatch = picomatch(glob, { dot: true });
38
+ return { matcher: (p) => isMatch(p), negated, dirOnly };
39
+ };
40
+ /* load the rules of a single exclude file, given as an absolute "file"
41
+ and the working-tree-relative "base" its anchored patterns resolve
42
+ against; an absent or unreadable file contributes no rules */
43
+ const loadRules = (file, base) => {
44
+ let text;
45
+ try {
46
+ text = fs.readFileSync(file, "utf8");
47
+ }
48
+ catch {
49
+ return [];
50
+ }
51
+ const rules = new Array();
52
+ for (const line of text.split(/\r?\n/)) {
53
+ const rule = compileRule(line, base);
54
+ if (rule !== null)
55
+ rules.push(rule);
56
+ }
57
+ return rules;
58
+ };
59
+ /* locate the Git working tree containing a directory, i.e. the nearest
60
+ ancestor-or-self carrying a ".git" directory (a regular working
61
+ tree) or a ".git" file (a linked worktree or a submodule) */
62
+ const workingTree = (dir) => {
63
+ let current = path.resolve(dir);
64
+ for (;;) {
65
+ if (fs.existsSync(path.join(current, ".git")))
66
+ return current;
67
+ const parent = path.dirname(current);
68
+ if (parent === current)
69
+ return null;
70
+ current = parent;
71
+ }
72
+ };
73
+ /* resolve the global Git excludes file: the configured
74
+ "core.excludesFile", or else the XDG location Git falls back onto
75
+ when that configuration value is unset */
76
+ const globalFile = () => {
77
+ let file = "";
78
+ try {
79
+ file = execFileSync("git", ["config", "--get", "core.excludesFile"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
80
+ }
81
+ catch {
82
+ /* no such configuration value, or no Git at all */
83
+ }
84
+ if (file === "") {
85
+ const xdg = process.env.XDG_CONFIG_HOME;
86
+ return xdg !== undefined && xdg !== "" ?
87
+ path.join(xdg, "git", "ignore") :
88
+ path.join(os.homedir(), ".config", "git", "ignore");
89
+ }
90
+ if (file.startsWith("~/"))
91
+ return path.join(os.homedir(), file.slice(2));
92
+ return file;
93
+ };
94
+ /* resolve the repository-local exclude file of a working tree, held by
95
+ the Git *common* directory: a regular working tree carries ".git" as
96
+ that very directory, while a linked worktree carries it as a file
97
+ pointing at its own administrative directory, whose "commondir" in
98
+ turn points at the shared one */
99
+ const infoFile = (root) => {
100
+ const dot = path.join(root, ".git");
101
+ let dir = dot;
102
+ if (fs.statSync(dot, { throwIfNoEntry: false })?.isFile() === true) {
103
+ const pointer = (/^gitdir:\s*(.+?)\s*$/m).exec(fs.readFileSync(dot, "utf8"));
104
+ if (pointer === null)
105
+ return "";
106
+ dir = path.resolve(root, pointer[1]);
107
+ const common = path.join(dir, "commondir");
108
+ if (fs.existsSync(common))
109
+ dir = path.resolve(dir, fs.readFileSync(common, "utf8").trim());
110
+ }
111
+ return path.join(dir, "info", "exclude");
112
+ };
113
+ /* create the predicate deciding whether an absolute file path is
114
+ excluded from the Git working tree containing "dir", honoring the
115
+ same three rule sources and the same precedence order as Git itself
116
+ -- the global excludes file, the repository-local "info/exclude",
117
+ and the ".gitignore" files from the working tree root down to the
118
+ file. A path outside any Git working tree is never excluded, as
119
+ there is no project to be excluded from */
120
+ export const excluder = (dir) => {
121
+ const root = workingTree(dir);
122
+ if (root === null)
123
+ return () => false;
124
+ /* the rules which no directory of the working tree owns and which
125
+ hence govern it as a whole, in ascending Git precedence */
126
+ const base = [...loadRules(globalFile(), ""), ...loadRules(infoFile(root), "")];
127
+ /* the per-directory ".gitignore" rules, loaded on first use, as one
128
+ run queries many files sharing the very same directories */
129
+ const cache = new Map();
130
+ const rulesOf = (relDir) => {
131
+ let rules = cache.get(relDir);
132
+ if (rules === undefined) {
133
+ rules = loadRules(path.join(root, relDir, ".gitignore"), relDir);
134
+ cache.set(relDir, rules);
135
+ }
136
+ return rules;
137
+ };
138
+ return (file) => {
139
+ const rel = path.relative(root, path.resolve(file)).replace(/\\/g, "/");
140
+ if (rel === "" || rel.startsWith("../"))
141
+ return false;
142
+ const segments = rel.split("/");
143
+ /* walk the directory chain from the working tree root down to
144
+ the file, picking up the ".gitignore" rules on the way and
145
+ deciding every segment against the rules known at its level,
146
+ where the last matching rule wins; an excluded ancestor
147
+ directory excludes the file itself, as Git never descends
148
+ into it -- and, for the same reason, a rule below such an
149
+ ancestor can never re-include the file */
150
+ let rules = base;
151
+ let relDir = "";
152
+ for (let i = 0; i < segments.length; i++) {
153
+ rules = rules.concat(rulesOf(relDir));
154
+ const current = relDir === "" ? segments[i] : `${relDir}/${segments[i]}`;
155
+ /* every segment but the last one is a directory by
156
+ construction, while the last one is whatever the file
157
+ system says it is */
158
+ const isDir = i < segments.length - 1
159
+ || fs.statSync(path.join(root, current), { throwIfNoEntry: false })?.isDirectory() === true;
160
+ let excluded = false;
161
+ for (const rule of rules) {
162
+ if (rule.dirOnly && !isDir)
163
+ continue;
164
+ if (rule.matcher(current))
165
+ excluded = !rule.negated;
166
+ }
167
+ if (excluded)
168
+ return true;
169
+ relDir = current;
170
+ }
171
+ return false;
172
+ };
173
+ };
@@ -51,7 +51,10 @@ export const serveMcp = async (verbose) => {
51
51
  config: z.array(z.string()).optional().describe("YAML schema configuration files or glob " +
52
52
  "patterns, merged in order (\"std\" for the bundled standard schema configuration; " +
53
53
  "default: the bundled standard schema configuration)"),
54
- basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")")
54
+ basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")"),
55
+ gitignore: z.boolean().optional().describe("skip the artifact files excluded by the Git " +
56
+ "exclude rules (the \".gitignore\" files, \"info/exclude\", and the global excludes " +
57
+ "file), treating such a file exactly like an absent one (default: false)")
55
58
  }
56
59
  }, async (args) => {
57
60
  try {
@@ -78,7 +81,10 @@ export const serveMcp = async (verbose) => {
78
81
  basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")"),
79
82
  format: z.enum(formats).optional().describe("output format (default: inferred from the " +
80
83
  "output file extension, else json)"),
81
- output: z.string().optional().describe("output file path (\"-\" or omitted returns the result directly)")
84
+ output: z.string().optional().describe("output file path (\"-\" or omitted returns the result directly)"),
85
+ gitignore: z.boolean().optional().describe("skip the artifact files excluded by the Git " +
86
+ "exclude rules (the \".gitignore\" files, \"info/exclude\", and the global excludes " +
87
+ "file), treating such a file exactly like an absent one (default: false)")
82
88
  }
83
89
  }, async (args) => {
84
90
  try {
@@ -89,7 +95,7 @@ export const serveMcp = async (verbose) => {
89
95
  { format: args.format ?? "json", output: args.output } :
90
96
  parseOutputSpec(args.output);
91
97
  const [data] = await specbook.export({ config: args.config, basedir: args.basedir,
92
- formats: [spec.format] });
98
+ formats: [spec.format], gitignore: args.gitignore });
93
99
  if (spec.output !== undefined && spec.output !== "-") {
94
100
  await fs.promises.writeFile(spec.output, data);
95
101
  return { content: [{ type: "text", text: `exported specification into "${spec.output}" (${data.length} bytes)` }] };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rse/specbook",
3
- "version": "1.2.9",
3
+ "version": "1.2.11",
4
4
  "publishConfig": { "access": "public" },
5
5
  "description": "Markdown-based Specification Format (API, CLI and MCP service)",
6
6
  "license": "Apache-2.0",
@@ -17,9 +17,9 @@
17
17
  },
18
18
  "files": [ "dst" ],
19
19
  "dependencies": {
20
- "marked": "18.0.12",
20
+ "marked": "18.0.13",
21
21
  "marked-smartypants": "1.1.12",
22
- "yaml": "2.9.0",
22
+ "yaml": "2.9.1",
23
23
  "json5": "2.2.3",
24
24
  "pdf-lib": "1.17.1",
25
25
  "subset-font": "2.7.0",
@@ -38,11 +38,12 @@
38
38
  "chalk": "6.0.0",
39
39
  "chokidar5": "npm:chokidar@5.0.0",
40
40
  "glob": "13.0.6",
41
+ "picomatch": "4.0.7",
41
42
  "es-toolkit": "1.52.0",
42
- "fastify": "5.12.3",
43
+ "fastify": "5.12.4",
43
44
  "@fastify/websocket": "11.3.0",
44
45
  "@modelcontextprotocol/sdk": "1.30.0",
45
- "zod": "4.6.1"
46
+ "zod": "4.6.5"
46
47
  },
47
48
  "devDependencies": {
48
49
  "chokidar-cli": "3.0.0",
@@ -54,7 +55,8 @@
54
55
  "globals": "17.12.0",
55
56
  "markdownlint": "0.41.1",
56
57
  "markdownlint-cli2": "0.23.2",
57
- "@types/node": "26.5.0",
58
+ "@types/node": "26.5.1",
59
+ "@types/picomatch": "4.0.3",
58
60
  "@types/subset-font": "2.3.0",
59
61
  "@types/nunjucks": "3.2.6",
60
62
  "@types/ws": "8.18.1",
@@ -67,7 +69,7 @@
67
69
  "postcss-cli": "12.0.0",
68
70
  "postcss-url": "10.1.4",
69
71
  "stylus": "0.64.0",
70
- "@rse/stx": "1.1.6",
72
+ "@rse/stx": "1.1.7",
71
73
  "shx": "0.4.0",
72
74
  "nodemon": "3.1.14"
73
75
  },