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.
Files changed (107) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +13 -11
  3. package/VERSION +1 -1
  4. package/dist/_raw/content/reference/gantt-widget.md +468 -0
  5. package/dist/_raw/content/reference/plugins.md +157 -2
  6. package/dist/content/deployment/preflight.html +5 -6
  7. package/dist/content/deployment/recipes/aws-s3.html +5 -6
  8. package/dist/content/deployment/recipes/cloudflare-pages.html +5 -6
  9. package/dist/content/deployment/recipes/cloudflare-r2.html +5 -6
  10. package/dist/content/deployment/recipes/fly-io.html +5 -6
  11. package/dist/content/deployment/recipes/github-pages.html +5 -6
  12. package/dist/content/deployment/recipes/netlify.html +5 -6
  13. package/dist/content/deployment/recipes/vercel.html +5 -6
  14. package/dist/content/deployment/secrets-and-env-vars.html +5 -6
  15. package/dist/content/deployment.html +5 -6
  16. package/dist/content/guide/approaches.html +5 -6
  17. package/dist/content/guide/branding.html +5 -6
  18. package/dist/content/guide/data-driven-content.html +5 -6
  19. package/dist/content/guide/features.html +5 -6
  20. package/dist/content/guide/getting-started.html +5 -6
  21. package/dist/content/guide/kitfly-overview.html +5 -6
  22. package/dist/content/reference/configuration.html +5 -6
  23. package/dist/content/reference/design-catalog.html +5 -6
  24. package/dist/content/reference/environment-variables.html +5 -6
  25. package/dist/content/reference/gantt-widget.html +899 -0
  26. package/dist/content/reference/glossary.html +5 -6
  27. package/dist/content/reference/key-concepts.html +5 -6
  28. package/dist/content/reference/plugins.html +245 -9
  29. package/dist/content/reference/slides-authoring-guidelines.html +5 -6
  30. package/dist/content/reference/structure.html +5 -6
  31. package/dist/content/reference.html +5 -6
  32. package/dist/content/templates/crucible.html +5 -6
  33. package/dist/content/templates/handbook.html +5 -6
  34. package/dist/content/templates/minimal.html +5 -6
  35. package/dist/content/templates/overview.html +5 -6
  36. package/dist/content/templates/pipeline.html +5 -6
  37. package/dist/content/templates/productbook.html +5 -6
  38. package/dist/content/templates/runbook.html +5 -6
  39. package/dist/content/templates/servicebook.html +5 -6
  40. package/dist/content-index.json +10 -2
  41. package/dist/docs/decisions/ADR-0001-minimalist-site-code.html +5 -6
  42. package/dist/docs/decisions/ADR-0002-ai-accessibility.html +5 -6
  43. package/dist/docs/decisions/ADR-0003-single-file-bundle.html +5 -6
  44. package/dist/docs/decisions/ADR-0004-bun-runtime.html +5 -6
  45. package/dist/docs/decisions/ADR-0005-plugin-contract-and-distribution.html +5 -6
  46. package/dist/docs/decisions/ADR-0006-data-driven-content.html +5 -6
  47. package/dist/docs/decisions/DDR-0001-viewport-locked-layout.html +5 -6
  48. package/dist/docs/decisions/DDR-0002-theme-system.html +5 -6
  49. package/dist/docs/decisions/DDR-0003-bounded-logo-slot.html +5 -6
  50. package/dist/docs/decisions/DDR-0004-slides-rendering-model.html +5 -6
  51. package/dist/docs/decisions/DDR-0005-deterministic-layout-boundary.html +5 -6
  52. package/dist/docs/userguide/cli/build.html +5 -6
  53. package/dist/docs/userguide/cli/bundle.html +5 -6
  54. package/dist/docs/userguide/cli/dev.html +5 -6
  55. package/dist/docs/userguide/cli/init.html +5 -6
  56. package/dist/docs/userguide/cli/servers.html +5 -6
  57. package/dist/docs/userguide/cli/stop.html +5 -6
  58. package/dist/docs/userguide/cli/update.html +5 -6
  59. package/dist/docs/userguide/cli/version.html +5 -6
  60. package/dist/docs/userguide/cli.html +5 -6
  61. package/dist/docs/userguide/sharing.html +5 -6
  62. package/dist/index.html +5 -6
  63. package/dist/llms.txt +3 -3
  64. package/dist/provenance.json +4 -5
  65. package/dist/reports/license-inventory.csv +199 -0
  66. package/dist/schemas/plugin-registry.schema.html +5 -6
  67. package/dist/schemas/plugin-schemas-notes.html +5 -6
  68. package/dist/schemas/plugin.schema.html +5 -6
  69. package/dist/schemas/plugins.schema.html +5 -6
  70. package/dist/schemas/v0/common.schema.html +5 -6
  71. package/dist/schemas/v0/plugin-registry.schema.html +5 -6
  72. package/dist/schemas/v0/plugin.schema.html +5 -6
  73. package/dist/schemas/v0/plugins.schema.html +5 -6
  74. package/dist/schemas/v0/site.schema.html +5 -6
  75. package/dist/schemas/v0/theme.schema.html +5 -6
  76. package/dist/schemas.html +5 -6
  77. package/package.json +1 -1
  78. package/plugins-dist/planning-visuals.css +261 -0
  79. package/plugins-dist/planning-visuals.js +669 -0
  80. package/registry/plugins.yaml +15 -1
  81. package/scripts/build-all.ts +5 -0
  82. package/scripts/build.ts +73 -11
  83. package/scripts/bundle.ts +73 -10
  84. package/scripts/dev.ts +49 -5
  85. package/scripts/embed-docs.ts +119 -0
  86. package/src/__tests__/build.test.ts +124 -0
  87. package/src/__tests__/bundle.test.ts +61 -0
  88. package/src/__tests__/docs.test.ts +117 -0
  89. package/src/__tests__/fixtures/fences/planning-visuals/invalid/bad-month-format.md +10 -0
  90. package/src/__tests__/fixtures/fences/planning-visuals/invalid/marker-format-mismatch.md +13 -0
  91. package/src/__tests__/fixtures/fences/planning-visuals/invalid/milestone-format-mismatch.md +13 -0
  92. package/src/__tests__/fixtures/fences/planning-visuals/invalid/missing-tracks.md +5 -0
  93. package/src/__tests__/fixtures/fences/planning-visuals/invalid/track-reversed.md +10 -0
  94. package/src/__tests__/fixtures/fences/planning-visuals/valid/markers-basic.md +15 -0
  95. package/src/__tests__/fixtures/fences/planning-visuals/valid/markers-no-milestones.md +13 -0
  96. package/src/__tests__/fixtures/fences/planning-visuals/valid/month-basic.md +16 -0
  97. package/src/__tests__/fixtures/fences/planning-visuals/valid/no-milestones.md +10 -0
  98. package/src/__tests__/fixtures/fences/planning-visuals/valid/week-basic.md +20 -0
  99. package/src/__tests__/planning-visuals-fence-contract.test.ts +28 -0
  100. package/src/__tests__/planning-visuals-runtime-regressions.bun.test.ts +68 -0
  101. package/src/__tests__/planning-visuals-runtime.bun.test.ts +192 -0
  102. package/src/__tests__/shared.test.ts +121 -0
  103. package/src/cli.ts +113 -18
  104. package/src/commands/docs.ts +71 -0
  105. package/src/generated/embedded-docs.ts +2384 -0
  106. package/src/server-registry.ts +50 -10
  107. package/src/shared.ts +449 -25
@@ -81,24 +81,64 @@ async function writeRegistry(registry: ServerRegistry): Promise<void> {
81
81
 
82
82
  import { listeningPorts, processList, procGet } from "@3leaps/sysprims";
83
83
 
84
+ /**
85
+ * Windows fallback: parse `netstat -ano` to find PID listening on a port.
86
+ * Returns null if not found or on error.
87
+ */
88
+ function findPidOnPortWindows(port: number): number | null {
89
+ try {
90
+ const result = Bun.spawnSync(["netstat", "-ano", "-p", "TCP"], {
91
+ stdout: "pipe",
92
+ stderr: "pipe",
93
+ });
94
+ if (result.exitCode !== 0) return null;
95
+ const output = new TextDecoder().decode(result.stdout);
96
+ // Each LISTENING line looks like:
97
+ // TCP 0.0.0.0:3333 0.0.0.0:0 LISTENING 1234
98
+ // TCP [::]:3333 [::]:0 LISTENING 1234
99
+ const portStr = String(port);
100
+ for (const line of output.split(/\r?\n/)) {
101
+ if (!line.includes("LISTENING")) continue;
102
+ // Match the local address column containing :<port>
103
+ const m = line.match(/^\s*TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)/i);
104
+ if (m && m[1] === portStr) {
105
+ const pid = parseInt(m[2], 10);
106
+ return Number.isNaN(pid) ? null : pid;
107
+ }
108
+ }
109
+ return null;
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
84
115
  /**
85
116
  * Find PID listening on a port.
86
117
  * When multiple processes bind the same port (e.g., nohup shell + bun child),
87
118
  * prefer the bun process over shell wrappers.
119
+ * Falls back to netstat on Windows when sysprims is unsupported.
88
120
  */
89
121
  export function findPidOnPort(port: number): number | null {
90
- const result = listeningPorts({ local_port: port });
91
- if (result.bindings.length === 0) return null;
92
- if (result.bindings.length === 1) return result.bindings[0].pid ?? null;
93
-
94
- // Multiple bindings — prefer the bun process over shell wrapper
95
- for (const binding of result.bindings) {
96
- if (binding.process?.name.includes("bun")) {
97
- return binding.pid ?? null;
122
+ try {
123
+ const result = listeningPorts({ local_port: port });
124
+ if (result.bindings.length === 0) return null;
125
+ if (result.bindings.length === 1) return result.bindings[0].pid ?? null;
126
+
127
+ // Multiple bindings prefer the bun process over shell wrapper
128
+ for (const binding of result.bindings) {
129
+ if (binding.process?.name.includes("bun")) {
130
+ return binding.pid ?? null;
131
+ }
132
+ }
133
+ // Fallback: last binding (child is usually listed after parent)
134
+ return result.bindings[result.bindings.length - 1].pid ?? null;
135
+ } catch {
136
+ // sysprims doesn't support port bindings on this platform (e.g. Windows)
137
+ if (process.platform === "win32") {
138
+ return findPidOnPortWindows(port);
98
139
  }
140
+ return null;
99
141
  }
100
- // Fallback: last binding (child is usually listed after parent)
101
- return result.bindings[result.bindings.length - 1].pid ?? null;
102
142
  }
103
143
 
104
144
  /**
package/src/shared.ts CHANGED
@@ -1008,17 +1008,16 @@ const SLIDES_VISUALS_TYPES = new Set([
1008
1008
  "staircase",
1009
1009
  ]);
1010
1010
 
1011
- const SLIDES_VISUALS_RULES: Record<
1012
- string,
1013
- {
1014
- required: string[];
1015
- scalars: string[];
1016
- lists: Record<
1017
- string,
1018
- { kind: "strings" } | { kind: "objects"; fields: string[]; optional?: string[] }
1019
- >;
1020
- }
1021
- > = {
1011
+ type VisualListRule =
1012
+ | { kind: "strings" }
1013
+ | { kind: "objects"; fields: string[]; optional?: string[] };
1014
+ type VisualRules = {
1015
+ required: string[];
1016
+ scalars: string[];
1017
+ lists: Record<string, VisualListRule>;
1018
+ };
1019
+
1020
+ const SLIDES_VISUALS_RULES: Record<string, VisualRules> = {
1022
1021
  kpi: {
1023
1022
  required: ["label", "value"],
1024
1023
  scalars: ["label", "value", "trend"],
@@ -1091,11 +1090,44 @@ const SLIDES_VISUALS_RULES: Record<
1091
1090
  },
1092
1091
  };
1093
1092
 
1094
- /**
1095
- * Validate slides-visuals `:::` blocks in a single markdown slide body.
1096
- * This contract is intentionally strict so writers/devs don’t guess at edge cases.
1097
- */
1098
- export function validateSlidesVisualsFences(markdown: string): SlidesVisualsFenceDiagnostic[] {
1093
+ const PLANNING_VISUALS_TYPES = new Set(["gantt"]);
1094
+
1095
+ const PLANNING_VISUALS_RULES: Record<string, VisualRules> = {
1096
+ gantt: {
1097
+ required: ["time-unit", "time-start", "time-end", "tracks"],
1098
+ scalars: ["label", "time-unit", "time-start", "time-end", "max-depth", "max-tracks", "today"],
1099
+ lists: {
1100
+ tracks: {
1101
+ kind: "objects",
1102
+ fields: ["label", "depth", "start", "end"],
1103
+ optional: ["status"],
1104
+ },
1105
+ milestones: {
1106
+ kind: "objects",
1107
+ fields: ["label", "date"],
1108
+ optional: ["depth"],
1109
+ },
1110
+ markers: {
1111
+ kind: "objects",
1112
+ fields: ["label", "date"],
1113
+ optional: ["color"],
1114
+ },
1115
+ },
1116
+ },
1117
+ };
1118
+
1119
+ function parseQuotedScalar(raw: string): string {
1120
+ const trimmed = raw.trim();
1121
+ const match = trimmed.match(/^"(.*)"$/) || trimmed.match(/^'(.*)'$/);
1122
+ return match ? match[1] : trimmed;
1123
+ }
1124
+
1125
+ function validateVisualFences(
1126
+ markdown: string,
1127
+ visualTypes: Set<string>,
1128
+ visualRules: Record<string, VisualRules>,
1129
+ unknownTypePrefix: string,
1130
+ ): SlidesVisualsFenceDiagnostic[] {
1099
1131
  const diagnostics: SlidesVisualsFenceDiagnostic[] = [];
1100
1132
  const lines = markdown.replaceAll("\r\n", "\n").split("\n");
1101
1133
 
@@ -1113,7 +1145,7 @@ export function validateSlidesVisualsFences(markdown: string): SlidesVisualsFenc
1113
1145
  }
1114
1146
 
1115
1147
  function finishFence(closeLine: number) {
1116
- const rules = SLIDES_VISUALS_RULES[visualType];
1148
+ const rules = visualRules[visualType];
1117
1149
  if (!rules) return;
1118
1150
 
1119
1151
  for (const key of rules.required) {
@@ -1159,10 +1191,10 @@ export function validateSlidesVisualsFences(markdown: string): SlidesVisualsFenc
1159
1191
  const m = raw.match(/^:::\s*([a-z0-9-]+)\s*$/i);
1160
1192
  if (!m) continue;
1161
1193
  const type = m[1].toLowerCase();
1162
- if (!SLIDES_VISUALS_TYPES.has(type)) {
1194
+ if (!visualTypes.has(type)) {
1163
1195
  diagnostics.push({
1164
1196
  line: i + 1,
1165
- message: `Unknown slides-visuals block type: ${type}`,
1197
+ message: `${unknownTypePrefix}${type}`,
1166
1198
  type,
1167
1199
  });
1168
1200
  continue;
@@ -1177,7 +1209,6 @@ export function validateSlidesVisualsFences(markdown: string): SlidesVisualsFenc
1177
1209
  continue;
1178
1210
  }
1179
1211
 
1180
- // inside visual fence
1181
1212
  if (trimmed === ":::" && !raw.startsWith(":::")) {
1182
1213
  err(i + 1, "Closing ::: fence must start at column 0");
1183
1214
  continue;
@@ -1197,13 +1228,12 @@ export function validateSlidesVisualsFences(markdown: string): SlidesVisualsFenc
1197
1228
  }
1198
1229
 
1199
1230
  if (/^\s/.test(raw)) {
1200
- // list item or list continuation
1201
1231
  if (!currentListKey) {
1202
1232
  err(i + 1, "Indented content is only allowed inside a list");
1203
1233
  continue;
1204
1234
  }
1205
1235
 
1206
- const listRule = SLIDES_VISUALS_RULES[visualType]?.lists[currentListKey];
1236
+ const listRule = visualRules[visualType]?.lists[currentListKey];
1207
1237
  const item = raw.match(/^ {2}-\s+(.+)$/);
1208
1238
  if (item) {
1209
1239
  listItems += 1;
@@ -1234,7 +1264,7 @@ export function validateSlidesVisualsFences(markdown: string): SlidesVisualsFenc
1234
1264
  continue;
1235
1265
  }
1236
1266
 
1237
- const rules = SLIDES_VISUALS_RULES[visualType];
1267
+ const rules = visualRules[visualType];
1238
1268
  if (!rules) continue;
1239
1269
 
1240
1270
  const kv = raw.match(/^([a-z][a-z0-9-]*)\s*:\s*(.*)$/i);
@@ -1247,7 +1277,6 @@ export function validateSlidesVisualsFences(markdown: string): SlidesVisualsFenc
1247
1277
  const value = kv[2];
1248
1278
 
1249
1279
  if (value === "") {
1250
- // list key
1251
1280
  const listRule = rules.lists[key];
1252
1281
  if (!listRule) {
1253
1282
  err(i + 1, `Key '${key}' is not a supported list for ${visualType}`);
@@ -1259,7 +1288,6 @@ export function validateSlidesVisualsFences(markdown: string): SlidesVisualsFenc
1259
1288
  continue;
1260
1289
  }
1261
1290
 
1262
- // scalar key
1263
1291
  if (!rules.scalars.includes(key)) {
1264
1292
  err(i + 1, `Key '${key}' is not a supported scalar for ${visualType}`);
1265
1293
  continue;
@@ -1279,12 +1307,408 @@ export function validateSlidesVisualsFences(markdown: string): SlidesVisualsFenc
1279
1307
  return diagnostics;
1280
1308
  }
1281
1309
 
1310
+ /**
1311
+ * Validate slides-visuals `:::` blocks in a single markdown slide body.
1312
+ * This contract is intentionally strict so writers/devs don’t guess at edge cases.
1313
+ */
1314
+ export function validateSlidesVisualsFences(markdown: string): SlidesVisualsFenceDiagnostic[] {
1315
+ return validateVisualFences(
1316
+ markdown,
1317
+ SLIDES_VISUALS_TYPES,
1318
+ SLIDES_VISUALS_RULES,
1319
+ "Unknown slides-visuals block type: ",
1320
+ );
1321
+ }
1322
+
1323
+ type ParsedPlanningBlock = {
1324
+ startLine: number;
1325
+ data: Record<string, unknown>;
1326
+ scalarLines: Record<string, number>;
1327
+ listLines: Record<string, number>;
1328
+ };
1329
+
1330
+ function parsePlanningGanttBlocks(markdown: string): ParsedPlanningBlock[] {
1331
+ const blocks: ParsedPlanningBlock[] = [];
1332
+ const lines = markdown.replaceAll("\r\n", "\n").split("\n");
1333
+ let mdFence: FenceState | null = null;
1334
+ let current: ParsedPlanningBlock | null = null;
1335
+ let currentList: string | null = null;
1336
+ let currentObject: Record<string, unknown> | null = null;
1337
+
1338
+ for (let i = 0; i < lines.length; i++) {
1339
+ const raw = lines[i];
1340
+ const trimmed = raw.trim();
1341
+ const lineNo = i + 1;
1342
+
1343
+ mdFence = updateFenceState(trimmed, mdFence);
1344
+ if (mdFence) continue;
1345
+
1346
+ if (!current) {
1347
+ const open = raw.match(/^:::\s*([a-z0-9-]+)\s*$/i);
1348
+ if (!open || open[1].toLowerCase() !== "gantt") continue;
1349
+ current = {
1350
+ startLine: lineNo,
1351
+ data: {},
1352
+ scalarLines: {},
1353
+ listLines: {},
1354
+ };
1355
+ currentList = null;
1356
+ currentObject = null;
1357
+ continue;
1358
+ }
1359
+
1360
+ if (raw.match(/^:::\s*$/)) {
1361
+ blocks.push(current);
1362
+ current = null;
1363
+ currentList = null;
1364
+ currentObject = null;
1365
+ continue;
1366
+ }
1367
+
1368
+ const kv = raw.match(/^([a-z][a-z0-9-]*)\s*:\s*(.*)$/i);
1369
+ if (kv) {
1370
+ const key = kv[1].toLowerCase();
1371
+ const value = kv[2];
1372
+ if (value === "") {
1373
+ currentList = key;
1374
+ currentObject = null;
1375
+ current.listLines[key] = lineNo;
1376
+ if (!Array.isArray(current.data[key])) current.data[key] = [];
1377
+ } else {
1378
+ current.data[key] = parseQuotedScalar(value);
1379
+ current.scalarLines[key] = lineNo;
1380
+ currentList = null;
1381
+ currentObject = null;
1382
+ }
1383
+ continue;
1384
+ }
1385
+
1386
+ const item = raw.match(/^ {2}-\s+(.+)$/);
1387
+ if (item && currentList) {
1388
+ const list: unknown[] = Array.isArray(current.data[currentList])
1389
+ ? (current.data[currentList] as unknown[])
1390
+ : [];
1391
+ current.data[currentList] = list;
1392
+ const objKV = item[1].match(/^([a-z][a-z0-9-]*)\s*:\s*(.+)$/i);
1393
+ if (objKV) {
1394
+ currentObject = { [objKV[1].toLowerCase()]: parseQuotedScalar(objKV[2]) };
1395
+ list.push(currentObject);
1396
+ } else {
1397
+ currentObject = null;
1398
+ list.push(parseQuotedScalar(item[1]));
1399
+ }
1400
+ continue;
1401
+ }
1402
+
1403
+ const cont = raw.match(/^ {4}([a-z][a-z0-9-]*)\s*:\s*(.+)$/i);
1404
+ if (cont && currentObject) {
1405
+ currentObject[cont[1].toLowerCase()] = parseQuotedScalar(cont[2]);
1406
+ }
1407
+ }
1408
+
1409
+ return blocks;
1410
+ }
1411
+
1412
+ function isoWeeksInYear(year: number): number {
1413
+ const dec28 = new Date(Date.UTC(year, 11, 28));
1414
+ return getIsoWeekInfo(Math.floor(dec28.getTime() / (24 * 60 * 60 * 1000))).week;
1415
+ }
1416
+
1417
+ function isoWeekMondayUtcMs(year: number, week: number): number {
1418
+ const jan4 = new Date(Date.UTC(year, 0, 4));
1419
+ const jan4Weekday = (jan4.getUTCDay() + 6) % 7; // Monday=0
1420
+ const weekOneMondayMs = jan4.getTime() - jan4Weekday * 24 * 60 * 60 * 1000;
1421
+ return weekOneMondayMs + (week - 1) * 7 * 24 * 60 * 60 * 1000;
1422
+ }
1423
+
1424
+ function parseWeekOrdinal(value: string): number | null {
1425
+ const isoWeekAnchorDay = -3; // 1969-12-29 (Monday of 1970-W01)
1426
+ const match = value.match(/^(\d{4})-W(\d{2})$/i);
1427
+ if (!match) return null;
1428
+ const year = Number.parseInt(match[1], 10);
1429
+ const week = Number.parseInt(match[2], 10);
1430
+ if (week < 1 || week > isoWeeksInYear(year)) return null;
1431
+ const dayMs = 24 * 60 * 60 * 1000;
1432
+ const mondayDayOrdinal = Math.floor(isoWeekMondayUtcMs(year, week) / dayMs);
1433
+ return Math.floor((mondayDayOrdinal - isoWeekAnchorDay) / 7);
1434
+ }
1435
+
1436
+ function parseMonthOrdinal(value: string): number | null {
1437
+ const match = value.match(/^(\d{4})-(\d{2})$/);
1438
+ if (!match) return null;
1439
+ const year = Number.parseInt(match[1], 10);
1440
+ const month = Number.parseInt(match[2], 10);
1441
+ if (month < 1 || month > 12) return null;
1442
+ return year * 12 + (month - 1);
1443
+ }
1444
+
1445
+ function daysInMonthUtc(year: number, month: number): number {
1446
+ return new Date(Date.UTC(year, month, 0)).getUTCDate();
1447
+ }
1448
+
1449
+ function parsePlanningMonthMarkerPosition(value: string): number | null {
1450
+ const monthOrdinal = parseMonthOrdinal(value);
1451
+ if (monthOrdinal != null) return monthOrdinal + 0.5;
1452
+
1453
+ const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
1454
+ if (!match) return null;
1455
+ const year = Number.parseInt(match[1], 10);
1456
+ const month = Number.parseInt(match[2], 10);
1457
+ const day = Number.parseInt(match[3], 10);
1458
+ if (month < 1 || month > 12) return null;
1459
+ const dim = daysInMonthUtc(year, month);
1460
+ if (day < 1 || day > dim) return null;
1461
+ const ordinal = year * 12 + (month - 1);
1462
+ return ordinal + (day - 0.5) / dim;
1463
+ }
1464
+
1465
+ function parsePlanningUnitOrdinal(value: unknown, unit: string): number | null {
1466
+ if (typeof value !== "string" || !value.trim()) return null;
1467
+ if (unit === "week") return parseWeekOrdinal(value.trim());
1468
+ if (unit === "month") return parseMonthOrdinal(value.trim());
1469
+ return null;
1470
+ }
1471
+
1472
+ function parsePlanningMarkerPosition(value: unknown, unit: string): number | null {
1473
+ if (typeof value !== "string" || !value.trim()) return null;
1474
+ const raw = value.trim();
1475
+ if (unit === "week") {
1476
+ const ordinal = parseWeekOrdinal(raw);
1477
+ return ordinal == null ? null : ordinal + 0.5;
1478
+ }
1479
+ if (unit === "month") {
1480
+ return parsePlanningMonthMarkerPosition(raw);
1481
+ }
1482
+ return null;
1483
+ }
1484
+
1485
+ function getIsoWeekInfo(dayOrdinal: number): { year: number; week: number } {
1486
+ const dayMs = 24 * 60 * 60 * 1000;
1487
+ const date = new Date(dayOrdinal * dayMs);
1488
+ const day = (date.getUTCDay() + 6) % 7; // Monday=0
1489
+ const thursday = new Date(date.getTime() + (3 - day) * dayMs);
1490
+ const year = thursday.getUTCFullYear();
1491
+ const firstThursday = new Date(Date.UTC(year, 0, 4));
1492
+ const firstThursdayDay = (firstThursday.getUTCDay() + 6) % 7;
1493
+ const firstThursdayOrdinal = Math.floor(firstThursday.getTime() / dayMs) + (3 - firstThursdayDay);
1494
+ const week = Math.floor((Math.floor(thursday.getTime() / dayMs) - firstThursdayOrdinal) / 7) + 1;
1495
+ return { year, week };
1496
+ }
1497
+
1498
+ export function validatePlanningVisualsFences(markdown: string): SlidesVisualsFenceDiagnostic[] {
1499
+ const diagnostics = validateVisualFences(
1500
+ markdown,
1501
+ PLANNING_VISUALS_TYPES,
1502
+ PLANNING_VISUALS_RULES,
1503
+ "Unknown planning-visuals block type: ",
1504
+ );
1505
+ const ganttBlocks = parsePlanningGanttBlocks(markdown);
1506
+
1507
+ for (const block of ganttBlocks) {
1508
+ const unitRaw = block.data["time-unit"];
1509
+ const unit = typeof unitRaw === "string" ? unitRaw.trim().toLowerCase() : "";
1510
+ const unitLine = block.scalarLines["time-unit"] ?? block.startLine;
1511
+ const startLine = block.scalarLines["time-start"] ?? block.startLine;
1512
+ const endLine = block.scalarLines["time-end"] ?? block.startLine;
1513
+ const tracksLine = block.listLines.tracks ?? block.startLine;
1514
+ const milestonesLine = block.listLines.milestones ?? block.startLine;
1515
+
1516
+ if (unit !== "week" && unit !== "month") {
1517
+ diagnostics.push({
1518
+ line: unitLine,
1519
+ message: "Invalid time-unit (expected 'week' or 'month')",
1520
+ type: "gantt",
1521
+ });
1522
+ continue;
1523
+ }
1524
+
1525
+ const axisStart = parsePlanningUnitOrdinal(block.data["time-start"], unit);
1526
+ const axisEnd = parsePlanningUnitOrdinal(block.data["time-end"], unit);
1527
+ if (axisStart == null) {
1528
+ diagnostics.push({
1529
+ line: startLine,
1530
+ message: `Invalid time-start format for ${unit}`,
1531
+ type: "gantt",
1532
+ });
1533
+ }
1534
+ if (axisEnd == null) {
1535
+ diagnostics.push({
1536
+ line: endLine,
1537
+ message: `Invalid time-end format for ${unit}`,
1538
+ type: "gantt",
1539
+ });
1540
+ }
1541
+ if (axisStart != null && axisEnd != null && axisStart >= axisEnd) {
1542
+ diagnostics.push({
1543
+ line: endLine,
1544
+ message: "time-start must be before time-end",
1545
+ type: "gantt",
1546
+ });
1547
+ }
1548
+
1549
+ const todayRaw = block.data.today;
1550
+ if (todayRaw != null && parsePlanningUnitOrdinal(todayRaw, unit) == null) {
1551
+ diagnostics.push({
1552
+ line: block.scalarLines.today ?? block.startLine,
1553
+ message: `Invalid today format for ${unit}`,
1554
+ type: "gantt",
1555
+ });
1556
+ }
1557
+
1558
+ const tracks = Array.isArray(block.data.tracks) ? block.data.tracks : [];
1559
+ for (const track of tracks) {
1560
+ if (!track || typeof track !== "object") {
1561
+ diagnostics.push({
1562
+ line: tracksLine,
1563
+ message: "Track items must be objects",
1564
+ type: "gantt",
1565
+ });
1566
+ continue;
1567
+ }
1568
+ const start = parsePlanningUnitOrdinal((track as Record<string, unknown>).start, unit);
1569
+ const end = parsePlanningUnitOrdinal((track as Record<string, unknown>).end, unit);
1570
+ if (start == null || end == null) {
1571
+ diagnostics.push({
1572
+ line: tracksLine,
1573
+ message: `Track start/end must match ${unit} format`,
1574
+ type: "gantt",
1575
+ });
1576
+ continue;
1577
+ }
1578
+ if (start > end) {
1579
+ diagnostics.push({
1580
+ line: tracksLine,
1581
+ message: "Track start must be before or equal to end",
1582
+ type: "gantt",
1583
+ });
1584
+ }
1585
+ }
1586
+
1587
+ const milestones = Array.isArray(block.data.milestones) ? block.data.milestones : [];
1588
+ for (const milestone of milestones) {
1589
+ if (!milestone || typeof milestone !== "object") {
1590
+ diagnostics.push({
1591
+ line: milestonesLine,
1592
+ message: "Milestone items must be objects",
1593
+ type: "gantt",
1594
+ });
1595
+ continue;
1596
+ }
1597
+ const date = parsePlanningUnitOrdinal((milestone as Record<string, unknown>).date, unit);
1598
+ if (date == null) {
1599
+ diagnostics.push({
1600
+ line: milestonesLine,
1601
+ message: `Milestone date must match ${unit} format`,
1602
+ type: "gantt",
1603
+ });
1604
+ }
1605
+ }
1606
+
1607
+ const markersLine = block.listLines.markers ?? block.startLine;
1608
+ const markers = Array.isArray(block.data.markers) ? block.data.markers : [];
1609
+ for (const marker of markers) {
1610
+ if (!marker || typeof marker !== "object") {
1611
+ diagnostics.push({
1612
+ line: markersLine,
1613
+ message: "Marker items must be objects",
1614
+ type: "gantt",
1615
+ });
1616
+ continue;
1617
+ }
1618
+ const date = parsePlanningMarkerPosition((marker as Record<string, unknown>).date, unit);
1619
+ if (date == null) {
1620
+ const expected =
1621
+ unit === "month" ? "month format (YYYY-MM or YYYY-MM-DD)" : "week format (YYYY-Www)";
1622
+ diagnostics.push({
1623
+ line: markersLine,
1624
+ message: `Marker date must match ${expected}`,
1625
+ type: "gantt",
1626
+ });
1627
+ }
1628
+ }
1629
+ }
1630
+
1631
+ return diagnostics;
1632
+ }
1633
+
1634
+ export function collectPlanningVisualsContainmentWarnings(
1635
+ markdown: string,
1636
+ ): SlidesVisualsFenceDiagnostic[] {
1637
+ const warnings: SlidesVisualsFenceDiagnostic[] = [];
1638
+ const ganttBlocks = parsePlanningGanttBlocks(markdown);
1639
+
1640
+ for (const block of ganttBlocks) {
1641
+ const unitRaw = block.data["time-unit"];
1642
+ const unit = typeof unitRaw === "string" ? unitRaw.trim().toLowerCase() : "";
1643
+ if (unit !== "week" && unit !== "month") continue;
1644
+
1645
+ const axisStart = parsePlanningUnitOrdinal(block.data["time-start"], unit);
1646
+ const axisEnd = parsePlanningUnitOrdinal(block.data["time-end"], unit);
1647
+ if (axisStart == null || axisEnd == null || axisStart >= axisEnd) continue;
1648
+
1649
+ const tracksLine = block.listLines.tracks ?? block.startLine;
1650
+ const milestonesLine = block.listLines.milestones ?? block.startLine;
1651
+
1652
+ const tracks = Array.isArray(block.data.tracks) ? block.data.tracks : [];
1653
+ for (const track of tracks) {
1654
+ if (!track || typeof track !== "object") continue;
1655
+ const start = parsePlanningUnitOrdinal((track as Record<string, unknown>).start, unit);
1656
+ const end = parsePlanningUnitOrdinal((track as Record<string, unknown>).end, unit);
1657
+ if (start == null || end == null) continue;
1658
+ if (start < axisStart || end > axisEnd) {
1659
+ warnings.push({
1660
+ line: tracksLine,
1661
+ message: "Track range is outside axis and will be clipped",
1662
+ type: "gantt",
1663
+ });
1664
+ }
1665
+ }
1666
+
1667
+ const milestones = Array.isArray(block.data.milestones) ? block.data.milestones : [];
1668
+ for (const milestone of milestones) {
1669
+ if (!milestone || typeof milestone !== "object") continue;
1670
+ const date = parsePlanningUnitOrdinal((milestone as Record<string, unknown>).date, unit);
1671
+ if (date == null) continue;
1672
+ if (date < axisStart || date > axisEnd) {
1673
+ warnings.push({
1674
+ line: milestonesLine,
1675
+ message: "Milestone date is outside axis and will not be rendered",
1676
+ type: "gantt",
1677
+ });
1678
+ }
1679
+ }
1680
+
1681
+ const markersLine = block.listLines.markers ?? block.startLine;
1682
+ const markers = Array.isArray(block.data.markers) ? block.data.markers : [];
1683
+ for (const marker of markers) {
1684
+ if (!marker || typeof marker !== "object") continue;
1685
+ const position = parsePlanningMarkerPosition((marker as Record<string, unknown>).date, unit);
1686
+ if (position == null) continue;
1687
+ if (position < axisStart || position > axisEnd + 1) {
1688
+ warnings.push({
1689
+ line: markersLine,
1690
+ message: "Marker date is outside axis and will not be rendered",
1691
+ type: "gantt",
1692
+ });
1693
+ }
1694
+ }
1695
+ }
1696
+
1697
+ return warnings;
1698
+ }
1699
+
1282
1700
  export function filterUnknownSlidesVisualsTypeDiagnostics(
1283
1701
  diagnostics: SlidesVisualsFenceDiagnostic[],
1284
1702
  ): SlidesVisualsFenceDiagnostic[] {
1285
1703
  return diagnostics.filter((d) => !d.message.startsWith("Unknown slides-visuals block type:"));
1286
1704
  }
1287
1705
 
1706
+ export function filterUnknownPlanningVisualsTypeDiagnostics(
1707
+ diagnostics: SlidesVisualsFenceDiagnostic[],
1708
+ ): SlidesVisualsFenceDiagnostic[] {
1709
+ return diagnostics.filter((d) => !d.message.startsWith("Unknown planning-visuals block type:"));
1710
+ }
1711
+
1288
1712
  /**
1289
1713
  * Split markdown content into slide chunks using explicit delimiter.
1290
1714
  * Delimiter lines inside fenced code blocks are ignored.