kitfly 0.2.3 → 0.2.4
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/CHANGELOG.md +23 -0
- package/README.md +13 -11
- package/VERSION +1 -1
- package/dist/_raw/content/reference/gantt-widget.md +468 -0
- package/dist/_raw/content/reference/plugins.md +157 -2
- package/dist/content/deployment/preflight.html +5 -6
- package/dist/content/deployment/recipes/aws-s3.html +5 -6
- package/dist/content/deployment/recipes/cloudflare-pages.html +5 -6
- package/dist/content/deployment/recipes/cloudflare-r2.html +5 -6
- package/dist/content/deployment/recipes/fly-io.html +5 -6
- package/dist/content/deployment/recipes/github-pages.html +5 -6
- package/dist/content/deployment/recipes/netlify.html +5 -6
- package/dist/content/deployment/recipes/vercel.html +5 -6
- package/dist/content/deployment/secrets-and-env-vars.html +5 -6
- package/dist/content/deployment.html +5 -6
- package/dist/content/guide/approaches.html +5 -6
- package/dist/content/guide/branding.html +5 -6
- package/dist/content/guide/data-driven-content.html +5 -6
- package/dist/content/guide/features.html +5 -6
- package/dist/content/guide/getting-started.html +5 -6
- package/dist/content/guide/kitfly-overview.html +5 -6
- package/dist/content/reference/configuration.html +5 -6
- package/dist/content/reference/design-catalog.html +5 -6
- package/dist/content/reference/environment-variables.html +5 -6
- package/dist/content/reference/gantt-widget.html +899 -0
- package/dist/content/reference/glossary.html +5 -6
- package/dist/content/reference/key-concepts.html +5 -6
- package/dist/content/reference/plugins.html +245 -9
- package/dist/content/reference/slides-authoring-guidelines.html +5 -6
- package/dist/content/reference/structure.html +5 -6
- package/dist/content/reference.html +5 -6
- package/dist/content/templates/crucible.html +5 -6
- package/dist/content/templates/handbook.html +5 -6
- package/dist/content/templates/minimal.html +5 -6
- package/dist/content/templates/overview.html +5 -6
- package/dist/content/templates/pipeline.html +5 -6
- package/dist/content/templates/productbook.html +5 -6
- package/dist/content/templates/runbook.html +5 -6
- package/dist/content/templates/servicebook.html +5 -6
- package/dist/content-index.json +10 -2
- package/dist/docs/decisions/ADR-0001-minimalist-site-code.html +5 -6
- package/dist/docs/decisions/ADR-0002-ai-accessibility.html +5 -6
- package/dist/docs/decisions/ADR-0003-single-file-bundle.html +5 -6
- package/dist/docs/decisions/ADR-0004-bun-runtime.html +5 -6
- package/dist/docs/decisions/ADR-0005-plugin-contract-and-distribution.html +5 -6
- package/dist/docs/decisions/ADR-0006-data-driven-content.html +5 -6
- package/dist/docs/decisions/DDR-0001-viewport-locked-layout.html +5 -6
- package/dist/docs/decisions/DDR-0002-theme-system.html +5 -6
- package/dist/docs/decisions/DDR-0003-bounded-logo-slot.html +5 -6
- package/dist/docs/decisions/DDR-0004-slides-rendering-model.html +5 -6
- package/dist/docs/decisions/DDR-0005-deterministic-layout-boundary.html +5 -6
- package/dist/docs/userguide/cli/build.html +5 -6
- package/dist/docs/userguide/cli/bundle.html +5 -6
- package/dist/docs/userguide/cli/dev.html +5 -6
- package/dist/docs/userguide/cli/init.html +5 -6
- package/dist/docs/userguide/cli/servers.html +5 -6
- package/dist/docs/userguide/cli/stop.html +5 -6
- package/dist/docs/userguide/cli/update.html +5 -6
- package/dist/docs/userguide/cli/version.html +5 -6
- package/dist/docs/userguide/cli.html +5 -6
- package/dist/docs/userguide/sharing.html +5 -6
- package/dist/index.html +5 -6
- package/dist/llms.txt +3 -3
- package/dist/provenance.json +4 -5
- package/dist/reports/license-inventory.csv +199 -0
- package/dist/schemas/plugin-registry.schema.html +5 -6
- package/dist/schemas/plugin-schemas-notes.html +5 -6
- package/dist/schemas/plugin.schema.html +5 -6
- package/dist/schemas/plugins.schema.html +5 -6
- package/dist/schemas/v0/common.schema.html +5 -6
- package/dist/schemas/v0/plugin-registry.schema.html +5 -6
- package/dist/schemas/v0/plugin.schema.html +5 -6
- package/dist/schemas/v0/plugins.schema.html +5 -6
- package/dist/schemas/v0/site.schema.html +5 -6
- package/dist/schemas/v0/theme.schema.html +5 -6
- package/dist/schemas.html +5 -6
- package/package.json +1 -1
- package/plugins-dist/planning-visuals.css +261 -0
- package/plugins-dist/planning-visuals.js +669 -0
- package/registry/plugins.yaml +15 -1
- package/scripts/build-all.ts +5 -0
- package/scripts/build.ts +73 -11
- package/scripts/bundle.ts +73 -10
- package/scripts/dev.ts +49 -5
- package/scripts/embed-docs.ts +119 -0
- package/src/__tests__/build.test.ts +124 -0
- package/src/__tests__/bundle.test.ts +61 -0
- package/src/__tests__/docs.test.ts +117 -0
- package/src/__tests__/fixtures/fences/planning-visuals/invalid/bad-month-format.md +10 -0
- package/src/__tests__/fixtures/fences/planning-visuals/invalid/marker-format-mismatch.md +13 -0
- package/src/__tests__/fixtures/fences/planning-visuals/invalid/milestone-format-mismatch.md +13 -0
- package/src/__tests__/fixtures/fences/planning-visuals/invalid/missing-tracks.md +5 -0
- package/src/__tests__/fixtures/fences/planning-visuals/invalid/track-reversed.md +10 -0
- package/src/__tests__/fixtures/fences/planning-visuals/valid/markers-basic.md +15 -0
- package/src/__tests__/fixtures/fences/planning-visuals/valid/markers-no-milestones.md +13 -0
- package/src/__tests__/fixtures/fences/planning-visuals/valid/month-basic.md +16 -0
- package/src/__tests__/fixtures/fences/planning-visuals/valid/no-milestones.md +10 -0
- package/src/__tests__/fixtures/fences/planning-visuals/valid/week-basic.md +20 -0
- package/src/__tests__/planning-visuals-fence-contract.test.ts +28 -0
- package/src/__tests__/planning-visuals-runtime-regressions.bun.test.ts +68 -0
- package/src/__tests__/planning-visuals-runtime.bun.test.ts +192 -0
- package/src/__tests__/shared.test.ts +121 -0
- package/src/cli.ts +113 -18
- package/src/commands/docs.ts +71 -0
- package/src/generated/embedded-docs.ts +2384 -0
- package/src/server-registry.ts +50 -10
- package/src/shared.ts +449 -25
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
type PlanningVisualsHooks = {
|
|
4
|
+
parseFence: (text: string) => { type: string; data: Record<string, unknown> } | null;
|
|
5
|
+
buildAxisCellLabel: (
|
|
6
|
+
unit: string,
|
|
7
|
+
info: { year: number; week?: number; month?: number; label: string },
|
|
8
|
+
prev: { year: number; week?: number; month?: number; label: string } | null,
|
|
9
|
+
index: number,
|
|
10
|
+
totalUnits: number,
|
|
11
|
+
) => string;
|
|
12
|
+
weekLabelStepForUnits: (totalUnits: number) => number;
|
|
13
|
+
weekAxisContextLabel: (
|
|
14
|
+
startInfo: { year: number; week: number; label: string },
|
|
15
|
+
endInfo: { year: number; week: number; label: string },
|
|
16
|
+
) => string;
|
|
17
|
+
parseListItemText: (rawText: string) => {
|
|
18
|
+
item: Record<string, string>;
|
|
19
|
+
switchListKey: string | null;
|
|
20
|
+
};
|
|
21
|
+
assignMarkerLabelLanes: (markers: Array<Record<string, any>>) => {
|
|
22
|
+
markers: Array<Record<string, any>>;
|
|
23
|
+
laneCount: number;
|
|
24
|
+
};
|
|
25
|
+
parseMarkerPosition: (value: string, unit: string) => number | null;
|
|
26
|
+
parseUnitOrdinal: (value: string, unit: string) => number | null;
|
|
27
|
+
weekLabelFromOrdinal: (ordinal: number) => { year: number; week: number; label: string };
|
|
28
|
+
monthLabelFromOrdinal: (ordinal: number) => { year: number; month: number; label: string };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
async function loadHooks(): Promise<PlanningVisualsHooks> {
|
|
32
|
+
// @ts-expect-error JS plugin registers test hooks on globalThis in non-DOM environments.
|
|
33
|
+
await import("../../plugins-dist/planning-visuals.js");
|
|
34
|
+
const hooks = (globalThis as any).__kitflyPlanningVisualsTest as PlanningVisualsHooks | undefined;
|
|
35
|
+
if (!hooks) throw new Error("planning-visuals test hooks not found on globalThis");
|
|
36
|
+
return hooks;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test("planning-visuals: week roundtrip keeps same label", async () => {
|
|
40
|
+
const hooks = await loadHooks();
|
|
41
|
+
const ordinal = hooks.parseUnitOrdinal("2026-W14", "week");
|
|
42
|
+
expect(ordinal).not.toBeNull();
|
|
43
|
+
const label = hooks.weekLabelFromOrdinal(ordinal as number);
|
|
44
|
+
expect(label.label).toBe("W14");
|
|
45
|
+
expect(label.year).toBe(2026);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("planning-visuals: week boundary label resolves correct year", async () => {
|
|
49
|
+
const hooks = await loadHooks();
|
|
50
|
+
const ordinal = hooks.parseUnitOrdinal("2027-W01", "week");
|
|
51
|
+
expect(ordinal).not.toBeNull();
|
|
52
|
+
const label = hooks.weekLabelFromOrdinal(ordinal as number);
|
|
53
|
+
expect(label.label).toBe("W01");
|
|
54
|
+
expect(label.year).toBe(2027);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("planning-visuals: compact week axis uses sparse full week labels", async () => {
|
|
58
|
+
const hooks = await loadHooks();
|
|
59
|
+
expect(hooks.weekLabelStepForUnits(28)).toBe(4);
|
|
60
|
+
const first = hooks.weekLabelFromOrdinal(hooks.parseUnitOrdinal("2026-W10", "week") as number);
|
|
61
|
+
const interior = hooks.weekLabelFromOrdinal(
|
|
62
|
+
(hooks.parseUnitOrdinal("2026-W10", "week") as number) + 4,
|
|
63
|
+
);
|
|
64
|
+
const firstText = hooks.buildAxisCellLabel("week", first, null, 0, 28);
|
|
65
|
+
const interiorText = hooks.buildAxisCellLabel("week", interior, first, 4, 28);
|
|
66
|
+
expect(firstText.startsWith("W10")).toBe(true);
|
|
67
|
+
expect(interiorText).toBe("W14");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("planning-visuals: week axis context label is explicit for audience clarity", async () => {
|
|
71
|
+
const hooks = await loadHooks();
|
|
72
|
+
const start = hooks.weekLabelFromOrdinal(hooks.parseUnitOrdinal("2026-W10", "week") as number);
|
|
73
|
+
const end = hooks.weekLabelFromOrdinal(hooks.parseUnitOrdinal("2026-W36", "week") as number);
|
|
74
|
+
const context = hooks.weekAxisContextLabel(start, end);
|
|
75
|
+
expect(context).toBe("ISO Weeks W10-W36 (2026)");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("planning-visuals: parseFence preserves interleaved row order from repeated lists", async () => {
|
|
79
|
+
const hooks = await loadHooks();
|
|
80
|
+
const parsed = hooks.parseFence(
|
|
81
|
+
`:::gantt
|
|
82
|
+
+time-unit: week
|
|
83
|
+
+time-start: 2026-W14
|
|
84
|
+
+time-end: 2026-W20
|
|
85
|
+
+tracks:
|
|
86
|
+
+ - label: Wave 1
|
|
87
|
+
+ depth: 1
|
|
88
|
+
+ start: 2026-W14
|
|
89
|
+
+ end: 2026-W16
|
|
90
|
+
+milestones:
|
|
91
|
+
+ - label: Go/No-Go
|
|
92
|
+
+ date: 2026-W15
|
|
93
|
+
+tracks:
|
|
94
|
+
+ - label: Wave 2
|
|
95
|
+
+ depth: 1
|
|
96
|
+
+ start: 2026-W17
|
|
97
|
+
+ end: 2026-W20
|
|
98
|
+
+:::`.replace(/^\+/gm, ""),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
expect(parsed?.type).toBe("gantt");
|
|
102
|
+
const order = (parsed?.data.__rowOrder as Array<{ kind: string; index: number }>) || [];
|
|
103
|
+
expect(order).toEqual([
|
|
104
|
+
{ kind: "track", index: 0 },
|
|
105
|
+
{ kind: "milestone", index: 0 },
|
|
106
|
+
{ kind: "track", index: 1 },
|
|
107
|
+
]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("planning-visuals: parseFence parses markers list", async () => {
|
|
111
|
+
const hooks = await loadHooks();
|
|
112
|
+
const parsed = hooks.parseFence(
|
|
113
|
+
`:::gantt
|
|
114
|
+
+time-unit: week
|
|
115
|
+
+time-start: 2026-W14
|
|
116
|
+
+time-end: 2026-W30
|
|
117
|
+
+markers:
|
|
118
|
+
+ - label: Go/No-Go
|
|
119
|
+
+ date: 2026-W20
|
|
120
|
+
+ - label: Phase Gate
|
|
121
|
+
+ date: 2026-W28
|
|
122
|
+
+tracks:
|
|
123
|
+
+ - label: Phase 1
|
|
124
|
+
+ depth: 1
|
|
125
|
+
+ start: 2026-W14
|
|
126
|
+
+ end: 2026-W24
|
|
127
|
+
+:::`.replace(/^\+/gm, ""),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
expect(parsed?.type).toBe("gantt");
|
|
131
|
+
const markers = parsed?.data.markers as Array<Record<string, string>>;
|
|
132
|
+
expect(markers).toHaveLength(2);
|
|
133
|
+
expect(markers[0].label).toBe("Go/No-Go");
|
|
134
|
+
expect(markers[0].date).toBe("2026-W20");
|
|
135
|
+
expect(markers[1].label).toBe("Phase Gate");
|
|
136
|
+
expect(markers[1].date).toBe("2026-W28");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("planning-visuals: parseFence keeps optional marker color", async () => {
|
|
140
|
+
const hooks = await loadHooks();
|
|
141
|
+
const parsed = hooks.parseFence(
|
|
142
|
+
`:::gantt
|
|
143
|
+
time-unit: month
|
|
144
|
+
time-start: 2026-03
|
|
145
|
+
time-end: 2026-10
|
|
146
|
+
markers:
|
|
147
|
+
- label: Go/No-Go
|
|
148
|
+
date: 2026-05-26
|
|
149
|
+
color: #38bdf8
|
|
150
|
+
tracks:
|
|
151
|
+
- label: Phase 1
|
|
152
|
+
depth: 1
|
|
153
|
+
start: 2026-03
|
|
154
|
+
end: 2026-06
|
|
155
|
+
:::`.replace(/^\+/gm, ""),
|
|
156
|
+
);
|
|
157
|
+
const markers = parsed?.data.markers as Array<Record<string, string>>;
|
|
158
|
+
expect(markers).toHaveLength(1);
|
|
159
|
+
expect(markers[0].color).toBe("#38bdf8");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("planning-visuals: marker parser supports day precision in month mode", async () => {
|
|
163
|
+
const hooks = await loadHooks();
|
|
164
|
+
const monthCenter = hooks.parseMarkerPosition("2026-05", "month");
|
|
165
|
+
const monthDay = hooks.parseMarkerPosition("2026-05-26", "month");
|
|
166
|
+
expect(monthCenter).not.toBeNull();
|
|
167
|
+
expect(monthDay).not.toBeNull();
|
|
168
|
+
expect(monthDay as number).toBeGreaterThan(monthCenter as number);
|
|
169
|
+
expect(hooks.parseMarkerPosition("2026-02-30", "month")).toBeNull();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("planning-visuals: list item parser detects in-item list key switch", async () => {
|
|
173
|
+
const hooks = await loadHooks();
|
|
174
|
+
const parsed = hooks.parseListItemText(
|
|
175
|
+
`label: "P66 Conf (May 26)"
|
|
176
|
+
date: "2026-05"
|
|
177
|
+
tracks:`,
|
|
178
|
+
);
|
|
179
|
+
expect(parsed.item.label).toBe("P66 Conf (May 26)");
|
|
180
|
+
expect(parsed.item.date).toBe("2026-05");
|
|
181
|
+
expect(parsed.switchListKey).toBe("tracks");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("planning-visuals: marker layout assigns separate lanes for nearby labels", async () => {
|
|
185
|
+
const hooks = await loadHooks();
|
|
186
|
+
const layout = hooks.assignMarkerLabelLanes([
|
|
187
|
+
{ label: "Conference", left: 40, color: "" },
|
|
188
|
+
{ label: "Go-Live", left: 42, color: "" },
|
|
189
|
+
]);
|
|
190
|
+
expect(layout.laneCount).toBeGreaterThan(1);
|
|
191
|
+
expect(layout.markers[0].__lane).not.toBe(layout.markers[1].__lane);
|
|
192
|
+
});
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
buildToc,
|
|
20
20
|
type ContentFile,
|
|
21
21
|
collectFiles,
|
|
22
|
+
collectPlanningVisualsContainmentWarnings,
|
|
22
23
|
collectSlides,
|
|
23
24
|
envBool,
|
|
24
25
|
envInt,
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
escapeHtml,
|
|
27
28
|
exists,
|
|
28
29
|
filterByProfile,
|
|
30
|
+
filterUnknownPlanningVisualsTypeDiagnostics,
|
|
29
31
|
filterUnknownSlidesVisualsTypeDiagnostics,
|
|
30
32
|
formatDate,
|
|
31
33
|
generateProvenance,
|
|
@@ -50,6 +52,7 @@ import {
|
|
|
50
52
|
stripQuotes,
|
|
51
53
|
toUrlPath,
|
|
52
54
|
validatePath,
|
|
55
|
+
validatePlanningVisualsFences,
|
|
53
56
|
validateSlidesVisualsFences,
|
|
54
57
|
} from "../shared.ts";
|
|
55
58
|
|
|
@@ -450,6 +453,124 @@ label: Missing value
|
|
|
450
453
|
});
|
|
451
454
|
});
|
|
452
455
|
|
|
456
|
+
describe("planning-visuals diagnostics filtering", () => {
|
|
457
|
+
it("drops unknown-type diagnostics while preserving schema violations", () => {
|
|
458
|
+
const markdown = `:::future-planning
|
|
459
|
+
foo: bar
|
|
460
|
+
:::
|
|
461
|
+
|
|
462
|
+
:::gantt
|
|
463
|
+
time-unit: week
|
|
464
|
+
time-start: 2026-W10
|
|
465
|
+
time-end: 2026-W12
|
|
466
|
+
:::`;
|
|
467
|
+
const diagnostics = validatePlanningVisualsFences(markdown);
|
|
468
|
+
const filtered = filterUnknownPlanningVisualsTypeDiagnostics(diagnostics);
|
|
469
|
+
expect(
|
|
470
|
+
diagnostics.some((d) => d.message.startsWith("Unknown planning-visuals block type:")),
|
|
471
|
+
).toBe(true);
|
|
472
|
+
expect(filtered.some((d) => d.message.startsWith("Unknown planning-visuals block type:"))).toBe(
|
|
473
|
+
false,
|
|
474
|
+
);
|
|
475
|
+
expect(filtered.some((d) => d.message.includes("Missing required key: tracks"))).toBe(true);
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
describe("planning-visuals containment warnings", () => {
|
|
480
|
+
it("returns non-fatal containment warnings when rows exceed axis", () => {
|
|
481
|
+
const markdown = `:::gantt
|
|
482
|
+
time-unit: month
|
|
483
|
+
time-start: 2026-04
|
|
484
|
+
time-end: 2026-06
|
|
485
|
+
tracks:
|
|
486
|
+
- label: Wave 1
|
|
487
|
+
depth: 1
|
|
488
|
+
start: 2026-03
|
|
489
|
+
end: 2026-07
|
|
490
|
+
milestones:
|
|
491
|
+
- label: Late marker
|
|
492
|
+
date: 2026-08
|
|
493
|
+
:::`;
|
|
494
|
+
const warnings = collectPlanningVisualsContainmentWarnings(markdown);
|
|
495
|
+
expect(warnings.some((w) => w.message.includes("Track range is outside axis"))).toBe(true);
|
|
496
|
+
expect(warnings.some((w) => w.message.includes("Milestone date is outside axis"))).toBe(true);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("warns when marker date is outside axis range", () => {
|
|
500
|
+
const markdown = `:::gantt
|
|
501
|
+
time-unit: week
|
|
502
|
+
time-start: 2026-W14
|
|
503
|
+
time-end: 2026-W20
|
|
504
|
+
markers:
|
|
505
|
+
- label: Late gate
|
|
506
|
+
date: 2026-W22
|
|
507
|
+
tracks:
|
|
508
|
+
- label: Phase 1
|
|
509
|
+
depth: 1
|
|
510
|
+
start: 2026-W14
|
|
511
|
+
end: 2026-W18
|
|
512
|
+
:::`;
|
|
513
|
+
const warnings = collectPlanningVisualsContainmentWarnings(markdown);
|
|
514
|
+
expect(warnings.some((w) => w.message.includes("Marker date is outside axis"))).toBe(true);
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
describe("planning-visuals marker validation", () => {
|
|
519
|
+
it("accepts valid markers", () => {
|
|
520
|
+
const markdown = `:::gantt
|
|
521
|
+
time-unit: week
|
|
522
|
+
time-start: 2026-W14
|
|
523
|
+
time-end: 2026-W30
|
|
524
|
+
markers:
|
|
525
|
+
- label: Go/No-Go
|
|
526
|
+
date: 2026-W20
|
|
527
|
+
tracks:
|
|
528
|
+
- label: Phase 1
|
|
529
|
+
depth: 1
|
|
530
|
+
start: 2026-W14
|
|
531
|
+
end: 2026-W24
|
|
532
|
+
:::`;
|
|
533
|
+
const diags = validatePlanningVisualsFences(markdown);
|
|
534
|
+
expect(diags).toHaveLength(0);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it("rejects marker with mismatched date format", () => {
|
|
538
|
+
const markdown = `:::gantt
|
|
539
|
+
time-unit: month
|
|
540
|
+
time-start: 2026-04
|
|
541
|
+
time-end: 2026-10
|
|
542
|
+
markers:
|
|
543
|
+
- label: Gate
|
|
544
|
+
date: 2026-W20
|
|
545
|
+
tracks:
|
|
546
|
+
- label: Build
|
|
547
|
+
depth: 1
|
|
548
|
+
start: 2026-04
|
|
549
|
+
end: 2026-07
|
|
550
|
+
:::`;
|
|
551
|
+
const diags = validatePlanningVisualsFences(markdown);
|
|
552
|
+
expect(diags.some((d) => d.message.includes("Marker date must match month format"))).toBe(true);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it("accepts month markers with day precision", () => {
|
|
556
|
+
const markdown = `:::gantt
|
|
557
|
+
time-unit: month
|
|
558
|
+
time-start: 2026-04
|
|
559
|
+
time-end: 2026-10
|
|
560
|
+
markers:
|
|
561
|
+
- label: Conference
|
|
562
|
+
date: 2026-05-26
|
|
563
|
+
tracks:
|
|
564
|
+
- label: Build
|
|
565
|
+
depth: 1
|
|
566
|
+
start: 2026-04
|
|
567
|
+
end: 2026-07
|
|
568
|
+
:::`;
|
|
569
|
+
const diags = validatePlanningVisualsFences(markdown);
|
|
570
|
+
expect(diags).toHaveLength(0);
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
|
|
453
574
|
describe("segmentSlides", () => {
|
|
454
575
|
it("uses frontmatter title and class when present", () => {
|
|
455
576
|
const input = `---
|
package/src/cli.ts
CHANGED
|
@@ -12,6 +12,9 @@ import { dirname, join, resolve } from "node:path";
|
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
13
|
import { loadSiteConfig } from "./shared.ts";
|
|
14
14
|
|
|
15
|
+
// Exit cleanly when piped output is closed early (e.g., `kitfly docs show x | less` then quit)
|
|
16
|
+
process.on("SIGPIPE", () => process.exit(0));
|
|
17
|
+
|
|
15
18
|
// Resolve paths relative to CLI location (works in binary too)
|
|
16
19
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
20
|
const ROOT = join(__dirname, "..");
|
|
@@ -76,6 +79,7 @@ Usage:
|
|
|
76
79
|
kitfly servers List running dev servers
|
|
77
80
|
kitfly stop <port|all> Stop dev server(s)
|
|
78
81
|
kitfly logs <port> View daemon server logs
|
|
82
|
+
kitfly docs [list|show] Browse embedded documentation
|
|
79
83
|
kitfly version Show version (use 'version extended' for details)
|
|
80
84
|
kitfly help Show this help
|
|
81
85
|
|
|
@@ -275,7 +279,7 @@ async function main() {
|
|
|
275
279
|
|
|
276
280
|
if (daemon) {
|
|
277
281
|
// Daemon mode: spawn detached process using shell redirection
|
|
278
|
-
const { mkdir, writeFile } = await import("node:fs/promises");
|
|
282
|
+
const { mkdir, writeFile, open: fsOpen } = await import("node:fs/promises");
|
|
279
283
|
const logsDir = join(getKitflyHome(), "logs");
|
|
280
284
|
await mkdir(logsDir, { recursive: true });
|
|
281
285
|
|
|
@@ -287,19 +291,53 @@ async function main() {
|
|
|
287
291
|
|
|
288
292
|
// Build command with shell redirection for logging
|
|
289
293
|
// Pass --log-format structured so dev.ts enables structured request logging
|
|
290
|
-
// Use nohup to prevent SIGHUP on terminal close
|
|
291
294
|
const profileArg = profile ? ` --profile "${profile}"` : "";
|
|
292
|
-
const shellCmd = `nohup bun run "${devScript}" "${folder}" --port ${port} --host "${host}"${profileArg} --no-open --log-format structured > "${logPath}" 2>&1 &`;
|
|
293
|
-
|
|
294
|
-
const proc = Bun.spawn(["sh", "-c", shellCmd], {
|
|
295
|
-
cwd: process.cwd(),
|
|
296
|
-
stdout: "ignore",
|
|
297
|
-
stderr: "ignore",
|
|
298
|
-
stdin: "ignore",
|
|
299
|
-
});
|
|
300
295
|
|
|
301
|
-
//
|
|
302
|
-
await
|
|
296
|
+
// Open log file as a write handle to pass as stdout/stderr for the child
|
|
297
|
+
const logFd = await fsOpen(logPath, "a");
|
|
298
|
+
|
|
299
|
+
let proc: ReturnType<typeof Bun.spawn>;
|
|
300
|
+
if (process.platform === "win32") {
|
|
301
|
+
// On Windows, use Bun.spawn with detached:true and stdio redirected to log file.
|
|
302
|
+
// nohup and sh -c are not available; Bun's detached mode achieves the same.
|
|
303
|
+
const args = [
|
|
304
|
+
"bun",
|
|
305
|
+
"run",
|
|
306
|
+
devScript,
|
|
307
|
+
folder,
|
|
308
|
+
"--port",
|
|
309
|
+
String(port),
|
|
310
|
+
"--host",
|
|
311
|
+
host,
|
|
312
|
+
...(profile ? ["--profile", profile] : []),
|
|
313
|
+
"--no-open",
|
|
314
|
+
"--log-format",
|
|
315
|
+
"structured",
|
|
316
|
+
];
|
|
317
|
+
proc = Bun.spawn(args, {
|
|
318
|
+
cwd: process.cwd(),
|
|
319
|
+
stdout: logFd.fd,
|
|
320
|
+
stderr: logFd.fd,
|
|
321
|
+
stdin: "ignore",
|
|
322
|
+
detached: true,
|
|
323
|
+
});
|
|
324
|
+
proc.unref();
|
|
325
|
+
await logFd.close();
|
|
326
|
+
// Give Windows a moment to spawn the child before proc.exited resolves
|
|
327
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
328
|
+
} else {
|
|
329
|
+
// Unix: nohup via sh -c keeps the process alive after terminal close
|
|
330
|
+
await logFd.close();
|
|
331
|
+
const shellCmd = `nohup bun run "${devScript}" "${folder}" --port ${port} --host "${host}"${profileArg} --no-open --log-format structured > "${logPath}" 2>&1 &`;
|
|
332
|
+
proc = Bun.spawn(["sh", "-c", shellCmd], {
|
|
333
|
+
cwd: process.cwd(),
|
|
334
|
+
stdout: "ignore",
|
|
335
|
+
stderr: "ignore",
|
|
336
|
+
stdin: "ignore",
|
|
337
|
+
});
|
|
338
|
+
// Wait for shell to spawn the background process
|
|
339
|
+
await proc.exited;
|
|
340
|
+
}
|
|
303
341
|
|
|
304
342
|
// Give server a moment to start
|
|
305
343
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
@@ -538,12 +576,47 @@ async function main() {
|
|
|
538
576
|
const follow = flags.follow === true || flags.f === true;
|
|
539
577
|
|
|
540
578
|
if (follow) {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
579
|
+
if (process.platform !== "win32") {
|
|
580
|
+
// Unix: tail -f is available and efficient
|
|
581
|
+
const proc = Bun.spawn(["tail", "-f", logFile], {
|
|
582
|
+
stdout: "inherit",
|
|
583
|
+
stderr: "inherit",
|
|
584
|
+
});
|
|
585
|
+
await proc.exited;
|
|
586
|
+
} else {
|
|
587
|
+
// Windows: tail -f is not available; poll the file for new content
|
|
588
|
+
const { watch } = await import("node:fs");
|
|
589
|
+
const { open: fsOpen } = await import("node:fs/promises");
|
|
590
|
+
let fd: import("node:fs/promises").FileHandle;
|
|
591
|
+
try {
|
|
592
|
+
fd = await fsOpen(logFile, "r");
|
|
593
|
+
} catch {
|
|
594
|
+
console.error(`No log file found for port ${logPort}`);
|
|
595
|
+
console.error(` Expected: ${logFile}`);
|
|
596
|
+
process.exit(1);
|
|
597
|
+
}
|
|
598
|
+
// Print existing content first
|
|
599
|
+
const existing = await fd.readFile("utf-8");
|
|
600
|
+
if (existing.length > 0) process.stdout.write(existing);
|
|
601
|
+
let offset = Buffer.byteLength(existing, "utf-8");
|
|
602
|
+
// Watch for changes and stream new bytes
|
|
603
|
+
const watcher = watch(logFile, async () => {
|
|
604
|
+
const buf = Buffer.alloc(65536);
|
|
605
|
+
const { bytesRead } = await fd.read(buf, 0, buf.length, offset);
|
|
606
|
+
if (bytesRead > 0) {
|
|
607
|
+
offset += bytesRead;
|
|
608
|
+
process.stdout.write(buf.subarray(0, bytesRead));
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
// Keep running until Ctrl+C
|
|
612
|
+
await new Promise<void>((resolve) => {
|
|
613
|
+
process.on("SIGINT", () => {
|
|
614
|
+
watcher.close();
|
|
615
|
+
void fd.close();
|
|
616
|
+
resolve();
|
|
617
|
+
});
|
|
618
|
+
});
|
|
619
|
+
}
|
|
547
620
|
} else {
|
|
548
621
|
const { readFile } = await import("node:fs/promises");
|
|
549
622
|
try {
|
|
@@ -562,6 +635,28 @@ async function main() {
|
|
|
562
635
|
break;
|
|
563
636
|
}
|
|
564
637
|
|
|
638
|
+
case "docs": {
|
|
639
|
+
const sub = positional[0];
|
|
640
|
+
const { docsList, docsShow } = await import("./commands/docs.ts");
|
|
641
|
+
if (!sub || sub === "list") {
|
|
642
|
+
docsList();
|
|
643
|
+
} else if (sub === "show") {
|
|
644
|
+
const slug = positional[1];
|
|
645
|
+
if (!slug) {
|
|
646
|
+
console.error("Error: Slug required.\n");
|
|
647
|
+
console.error("Usage: kitfly docs show <slug>");
|
|
648
|
+
console.error(" kitfly docs list");
|
|
649
|
+
process.exit(1);
|
|
650
|
+
}
|
|
651
|
+
docsShow(slug);
|
|
652
|
+
} else {
|
|
653
|
+
console.error(`Unknown docs subcommand: "${sub}"\n`);
|
|
654
|
+
console.error("Usage: kitfly docs [list|show <slug>]");
|
|
655
|
+
process.exit(1);
|
|
656
|
+
}
|
|
657
|
+
break;
|
|
658
|
+
}
|
|
659
|
+
|
|
565
660
|
case "version":
|
|
566
661
|
case "-v":
|
|
567
662
|
case "--version": {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kitfly docs — browse embedded documentation from the CLI.
|
|
3
|
+
*
|
|
4
|
+
* Subcommands:
|
|
5
|
+
* kitfly docs [list] List available doc slugs with titles
|
|
6
|
+
* kitfly docs show <slug> Output raw markdown to stdout
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { EMBEDDED_DOCS } from "../generated/embedded-docs.ts";
|
|
10
|
+
|
|
11
|
+
// Build a Map from the generated tuple array for O(1) lookup
|
|
12
|
+
const docsMap = new Map<string, { title: string; content: string }>();
|
|
13
|
+
for (const [slug, title, content] of EMBEDDED_DOCS) {
|
|
14
|
+
docsMap.set(slug, { title, content });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function docsList(): void {
|
|
18
|
+
if (docsMap.size === 0) {
|
|
19
|
+
console.log("No embedded documentation available.");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const slugs = [...docsMap.keys()].sort();
|
|
24
|
+
const maxSlug = Math.max(...slugs.map((s) => s.length));
|
|
25
|
+
|
|
26
|
+
console.log(`Embedded documentation (${slugs.length} topics):\n`);
|
|
27
|
+
|
|
28
|
+
for (const slug of slugs) {
|
|
29
|
+
const entry = docsMap.get(slug);
|
|
30
|
+
if (entry) console.log(` ${slug.padEnd(maxSlug + 2)}${entry.title}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
console.log(`\nUsage: kitfly docs show <slug>`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function docsShow(slug: string): void {
|
|
37
|
+
const entry = docsMap.get(slug);
|
|
38
|
+
|
|
39
|
+
if (!entry) {
|
|
40
|
+
const suggestions = findSimilar(slug, [...docsMap.keys()], 3);
|
|
41
|
+
console.error(`Not found: "${slug}"`);
|
|
42
|
+
if (suggestions.length > 0) {
|
|
43
|
+
console.error(`Did you mean: ${suggestions.join(", ")}?`);
|
|
44
|
+
}
|
|
45
|
+
console.error(`\nUse 'kitfly docs list' to see available topics.`);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
console.log(entry.content);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Find similar slugs by prefix or substring match.
|
|
54
|
+
* Returns up to `max` results, prioritizing prefix matches.
|
|
55
|
+
*/
|
|
56
|
+
export function findSimilar(query: string, slugs: string[], max: number): string[] {
|
|
57
|
+
const q = query.toLowerCase();
|
|
58
|
+
const prefixMatches: string[] = [];
|
|
59
|
+
const substringMatches: string[] = [];
|
|
60
|
+
|
|
61
|
+
for (const slug of slugs) {
|
|
62
|
+
const s = slug.toLowerCase();
|
|
63
|
+
if (s.startsWith(q) || q.startsWith(s)) {
|
|
64
|
+
prefixMatches.push(slug);
|
|
65
|
+
} else if (s.includes(q) || q.includes(s)) {
|
|
66
|
+
substringMatches.push(slug);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return [...prefixMatches, ...substringMatches].slice(0, max);
|
|
71
|
+
}
|