rulebookguard 0.3.1 → 0.4.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.
package/README.md CHANGED
@@ -13,7 +13,7 @@ It is language-agnostic: rules are Markdown, and tests in any language link to t
13
13
 
14
14
  ## Install
15
15
 
16
- Requires Node.js 22.23.4 or later (the `site` command builds with Astro 7, which needs it).
16
+ Requires Node.js 22.23.4 or later (the `site:generate` and `site:run` commands build with Astro 7, which needs it).
17
17
 
18
18
  ```sh
19
19
  npx rulebookguard lint
@@ -260,15 +260,27 @@ An [MCP](https://modelcontextprotocol.io) server over stdio for AI agents, with
260
260
 
261
261
  The rulebook is found from `--project` (default: the current directory). A command that would fail answers a tool error carrying the same message the CLI prints, for example `Unknown rule id "R-NOPE-001". Run "rulebookguard list" to list them.`
262
262
 
263
- ### `site [--out <dir>] [--base <path>]`
263
+ ### `site:generate [--out <dir>] [--base <path>]`
264
264
 
265
265
  Builds a read-only static site of the rulebook with [Astro Starlight](https://starlight.astro.build) into `--out` (default `site`, relative to the current directory) and prints that path. Open it from disk or publish it as is, for example on GitHub Pages; pass `--base` when it is served from a sub-path (`--base /rules`).
266
266
 
267
267
  ```sh
268
- $ rulebookguard site --out public/rules --base /rules
268
+ $ rulebookguard site:generate --out public/rules --base /rules
269
269
  public/rules
270
270
  ```
271
271
 
272
+ `site` is the same command.
273
+
274
+ ### `site:run [--out <dir>] [--base <path>] [--port <n>]`
275
+
276
+ Builds the site like `site:generate`, then serves `--out` on `127.0.0.1` (port 4321, or `--port`) and prints the URL, until Ctrl+C. Unknown paths get the site's 404 page. A taken port fails, exit 1, before building.
277
+
278
+ ```sh
279
+ $ rulebookguard site:run
280
+ site
281
+ Serving site at http://127.0.0.1:4321/ (Ctrl+C to stop)
282
+ ```
283
+
272
284
  The site has:
273
285
 
274
286
  - **Home:** the contexts map, with how many rules of each status every context holds.
package/dist/cli.mjs CHANGED
@@ -80,7 +80,7 @@ function render(path, rules, text) {
80
80
  return parts.join("\n\n");
81
81
  }
82
82
  // package.json
83
- var version = "0.3.1";
83
+ var version = "0.4.0";
84
84
  // src/mcp/server.ts
85
85
  function createRulesServer(projectDir) {
86
86
  const server = new McpServer({
@@ -329,12 +329,19 @@ Commands:
329
329
  and prints the rules anchored to the edited file
330
330
  mcp [--project <dir>] MCP server over stdio with rules_for and rules_show;
331
331
  --project: directory to find the rulebook from (default: cwd)
332
- site [--out <dir>] [--base <path>] build the read-only static site of the rulebook;
332
+ site:generate [--out <dir>] [--base <path>] build the read-only static site of the rulebook (alias: site);
333
333
  --out: output directory (default: site), --base: URL path
334
+ site:run [--out <dir>] [--base <path>] [--port <n>]
335
+ build the site, then serve it on 127.0.0.1 until Ctrl+C;
336
+ --port: port to serve on (default: 4321)
334
337
 
335
338
  Exit codes: 0 success, 1 failure (lint: any error), 2 usage error.
336
339
  `;
337
340
  const JSON_OPTION = { json: { type: "boolean" } };
341
+ const SITE_OPTIONS = {
342
+ out: { type: "string" },
343
+ base: { type: "string" }
344
+ };
338
345
  const COMMANDS = {
339
346
  contexts: {
340
347
  usage: "rulebookguard contexts [--json]",
@@ -378,9 +385,17 @@ const COMMANDS = {
378
385
  },
379
386
  site: {
380
387
  usage: "rulebookguard site [--out <dir>] [--base <path>]",
388
+ options: SITE_OPTIONS
389
+ },
390
+ "site:generate": {
391
+ usage: "rulebookguard site:generate [--out <dir>] [--base <path>]",
392
+ options: SITE_OPTIONS
393
+ },
394
+ "site:run": {
395
+ usage: "rulebookguard site:run [--out <dir>] [--base <path>] [--port <n>]",
381
396
  options: {
382
- out: { type: "string" },
383
- base: { type: "string" }
397
+ ...SITE_OPTIONS,
398
+ port: { type: "string" }
384
399
  }
385
400
  },
386
401
  lint: {
@@ -424,11 +439,13 @@ async function dispatch(argv, io) {
424
439
  await mcpCommand(io, values.project);
425
440
  return 0;
426
441
  }
427
- if (name === "site") {
428
- const { siteCommand } = await import("./site-DDM1Hs0N.mjs");
442
+ if (name === "site" || name === "site:generate" || name === "site:run") {
443
+ const { siteCommand } = await import("./site-BgH5724T.mjs");
429
444
  await siteCommand(io, {
430
445
  out: values.out,
431
- base: values.base
446
+ base: values.base,
447
+ serve: name === "site:run",
448
+ port: sitePort(values.port, spec.usage)
432
449
  });
433
450
  return 0;
434
451
  }
@@ -461,6 +478,12 @@ async function dispatch(argv, io) {
461
478
  }
462
479
  return 0;
463
480
  }
481
+ function sitePort(value, usage) {
482
+ if (value === void 0) return;
483
+ const port = Number(value);
484
+ if (!/^\d+$/.test(value) || port > 65535) throw new CliError(`--port must be a number from 0 to 65535.\nUsage: ${usage}`, 2);
485
+ return port;
486
+ }
464
487
  function parse(spec, args) {
465
488
  let parsed;
466
489
  try {
@@ -1,10 +1,11 @@
1
1
  import { a as CliError, c as openCatalog, d as scanTestTags, g as expandGlobs, n as lintCatalog, s as gitFallbackWarning, u as writeLines, y as runGit } from "./lint-C1yUeLOq.mjs";
2
- import { cp, mkdir, readdir, rm, writeFile } from "node:fs/promises";
3
- import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
2
+ import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
  import { execFile } from "node:child_process";
5
5
  import { promisify } from "node:util";
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { existsSync, readFileSync } from "node:fs";
8
+ import { createServer } from "node:http";
8
9
  import { fileURLToPath } from "node:url";
9
10
  // src/site/data.ts
10
11
  const FIELD = "";
@@ -65,6 +66,12 @@ const STATUSES = [
65
66
  "proposed",
66
67
  "deprecated"
67
68
  ];
69
+ const FAVICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
70
+ <rect width="32" height="32" rx="7" fill="#3c51e8"/>
71
+ <path d="M9 8h10a4 4 0 0 1 4 4v12H13a4 4 0 0 1-4-4z" fill="#fff"/>
72
+ <path d="m13 16 2.5 2.5L20 14" fill="none" stroke="#3c51e8" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
73
+ </svg>
74
+ `;
68
75
  const TEXT = {
69
76
  en: {
70
77
  title: "Business rules",
@@ -162,6 +169,7 @@ function sitePages(data, options) {
162
169
  return path === void 0 ? id : `[${id}](${path})`;
163
170
  };
164
171
  const files = /* @__PURE__ */ new Map();
172
+ files.set("public/favicon.svg", FAVICON);
165
173
  files.set("src/content.config.ts", `import { defineCollection } from 'astro:content';
166
174
  import { docsLoader } from '@astrojs/starlight/loaders';
167
175
  import { docsSchema } from '@astrojs/starlight/schema';
@@ -267,7 +275,7 @@ function rulesPage(data, text, rulePath) {
267
275
  rule.summary
268
276
  ].join(" ").toLowerCase();
269
277
  return ` <tr data-context="${html(rule.context)}" data-status="${rule.status}" data-search="${html(search)}">
270
- <td><a href="${rulePath.get(rule.id) ?? ""}">${rule.id}</a></td>
278
+ <td class="rbg-id"><a href="${rulePath.get(rule.id) ?? ""}">${rule.id}</a></td>
271
279
  <td>${html(rule.context)}</td>
272
280
  <td>${rule.status}</td>
273
281
  <td>${html(rule.summary)}</td>
@@ -289,6 +297,7 @@ ${contexts.map((context) => ` <option value="${html(context)}">${html(conte
289
297
  ${STATUSES.map((status) => ` <option value="${status}">${status}</option>`).join("\n")}
290
298
  </select>
291
299
  </div>
300
+ <div class="rbg-table">
292
301
  <table class="rbg-rules">
293
302
  <thead>
294
303
  <tr><th>${html(text.rule)}</th><th>${html(text.context)}</th><th>${html(text.status)}</th><th>${html(text.summary)}</th></tr>
@@ -297,12 +306,32 @@ ${STATUSES.map((status) => ` <option value="${status}">${status}</option>`)
297
306
  ${rows.join("\n")}
298
307
  </tbody>
299
308
  </table>
309
+ </div>
300
310
  <p class="rbg-empty" hidden>${html(text.noMatch)}</p>
301
311
  </StarlightPage>
302
312
 
303
313
  <style>
304
- .rbg-filters { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; }
314
+ .rbg-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin-bottom: 1rem; }
315
+ /* Starlight spaces sibling content blocks with margin-top; inside the filter row it misaligns the controls. */
316
+ .rbg-filters > * { margin: 0; }
317
+ .rbg-filters :is(input, select) {
318
+ border: 1px solid var(--sl-color-gray-5);
319
+ border-radius: 0.5rem;
320
+ background-color: var(--sl-color-bg);
321
+ color: var(--sl-color-white);
322
+ font: inherit;
323
+ font-size: var(--sl-text-sm);
324
+ padding: 0.5rem 0.75rem;
325
+ height: 2.5rem;
326
+ box-sizing: border-box;
327
+ }
305
328
  .rbg-filters input { flex: 1 1 16rem; }
329
+ .rbg-filters select { flex: 1 1 12rem; cursor: pointer; }
330
+ .rbg-filters :is(input, select):focus-visible { outline: 2px solid var(--sl-color-accent); outline-offset: 1px; }
331
+ .rbg-id { white-space: nowrap; }
332
+ .rbg-table { overflow-x: auto; }
333
+ .rbg-table table { margin: 0; }
334
+ .rbg-rules td:last-child { min-width: 16rem; }
306
335
  </style>
307
336
 
308
337
  <script>
@@ -370,7 +399,10 @@ function demote(markdown) {
370
399
  const execFileAsync = promisify(execFile);
371
400
  /** Marks an output directory as written by `site`, so a rebuild may replace it. */
372
401
  const MARKER = ".rulebookguard-site";
373
- async function siteCommand(io, options) {
402
+ /**
403
+ * Builds the site into `--out`; with `serve`, also serves it on 127.0.0.1 until `until` settles (Ctrl+C by default).
404
+ */
405
+ async function siteCommand(io, options, until = untilInterrupted()) {
374
406
  const catalog = await openCatalog(io);
375
407
  const out = resolve(io.cwd, options.out ?? "site");
376
408
  const fromCwd = relative(io.cwd, out);
@@ -378,6 +410,24 @@ async function siteCommand(io, options) {
378
410
  await assertReplaceable(out, shown);
379
411
  const language = catalog.config.language ?? "en";
380
412
  const base = normalizeBase(options.base ?? "/");
413
+ const server = options.serve ? await listen(out, base, options.port ?? 4321) : void 0;
414
+ try {
415
+ await buildSite(catalog, out, language, base, io);
416
+ } catch (error) {
417
+ server?.close();
418
+ throw error;
419
+ }
420
+ writeLines(io, [shown]);
421
+ if (server === void 0) return;
422
+ const { port } = server.address();
423
+ writeLines(io, [`Serving ${shown} at http://127.0.0.1:${port}${base} (Ctrl+C to stop)`]);
424
+ await until;
425
+ await new Promise((closed) => {
426
+ server.close(() => closed());
427
+ server.closeAllConnections();
428
+ });
429
+ }
430
+ async function buildSite(catalog, out, language, base, io) {
381
431
  const files = sitePages(await siteData(catalog, gitFallbackWarning(io)), {
382
432
  language,
383
433
  base
@@ -417,7 +467,79 @@ async function siteCommand(io, options) {
417
467
  force: true
418
468
  });
419
469
  }
420
- writeLines(io, [shown]);
470
+ }
471
+ function untilInterrupted() {
472
+ return new Promise((stop) => {
473
+ process.once("SIGINT", () => stop());
474
+ process.once("SIGTERM", () => stop());
475
+ });
476
+ }
477
+ const CONTENT_TYPES = {
478
+ ".html": "text/html; charset=utf-8",
479
+ ".css": "text/css; charset=utf-8",
480
+ ".js": "text/javascript; charset=utf-8",
481
+ ".mjs": "text/javascript; charset=utf-8",
482
+ ".json": "application/json; charset=utf-8",
483
+ ".txt": "text/plain; charset=utf-8",
484
+ ".xml": "application/xml; charset=utf-8",
485
+ ".svg": "image/svg+xml",
486
+ ".png": "image/png",
487
+ ".jpg": "image/jpeg",
488
+ ".ico": "image/x-icon",
489
+ ".webp": "image/webp",
490
+ ".woff2": "font/woff2",
491
+ ".wasm": "application/wasm"
492
+ };
493
+ function listen(out, base, port) {
494
+ const server = createServer((request, response) => {
495
+ serveFile(out, base, request, response).catch(() => {
496
+ response.writeHead(500).end();
497
+ });
498
+ });
499
+ return new Promise((ready, fail) => {
500
+ server.once("error", (error) => {
501
+ fail(error.code === "EADDRINUSE" ? new CliError(`Port ${port} is already in use; pass another --port.`) : error);
502
+ });
503
+ server.listen(port, "127.0.0.1", () => ready(server));
504
+ });
505
+ }
506
+ async function serveFile(out, base, request, response) {
507
+ const path = decodeURIComponent(new URL(request.url ?? "/", "http://localhost").pathname);
508
+ if (`${path}/` === base) {
509
+ response.writeHead(301, { location: base }).end();
510
+ return;
511
+ }
512
+ if (!path.startsWith(base)) {
513
+ if (path === "/") {
514
+ response.writeHead(301, { location: base }).end();
515
+ return;
516
+ }
517
+ await sendNotFound(out, response);
518
+ return;
519
+ }
520
+ let file = resolve(out, `.${sep}${path.slice(base.length)}`);
521
+ if (file !== out && !file.startsWith(`${out}${sep}`)) {
522
+ await sendNotFound(out, response);
523
+ return;
524
+ }
525
+ if ((await stat(file).catch(() => void 0))?.isDirectory()) {
526
+ if (!path.endsWith("/")) {
527
+ response.writeHead(301, { location: `${path}/` }).end();
528
+ return;
529
+ }
530
+ file = join(file, "index.html");
531
+ }
532
+ const body = await readFile(file).catch(() => void 0);
533
+ if (body === void 0) {
534
+ await sendNotFound(out, response);
535
+ return;
536
+ }
537
+ response.writeHead(200, { "content-type": CONTENT_TYPES[extname(file)] ?? "application/octet-stream" });
538
+ response.end(body);
539
+ }
540
+ async function sendNotFound(out, response) {
541
+ const page = await readFile(join(out, "404.html")).catch(() => "Not found");
542
+ response.writeHead(404, { "content-type": CONTENT_TYPES[".html"] }).end(page);
421
543
  }
422
544
  /**
423
545
  * Starlight injects its routes as package specifiers that Astro resolves from the project root, so the generated
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rulebookguard",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Keep business rules as the single source of truth, bound to code and tests.",
5
5
  "homepage": "https://github.com/JuanCJR/rulebookguard#readme",
6
6
  "bugs": {