@cfbender/cesium 0.3.6 → 0.5.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +28 -14
  3. package/assets/styleguide.html +149 -0
  4. package/package.json +1 -1
  5. package/src/cli/commands/serve.ts +3 -0
  6. package/src/index.ts +4 -1
  7. package/src/prompt/field-reference.ts +94 -0
  8. package/src/prompt/system-fragment.md +56 -65
  9. package/src/render/blocks/catalog.ts +39 -0
  10. package/src/render/blocks/escape.ts +27 -0
  11. package/src/render/blocks/index.ts +6 -0
  12. package/src/render/blocks/markdown.ts +217 -0
  13. package/src/render/blocks/render.ts +96 -0
  14. package/src/render/blocks/renderers/callout.ts +38 -0
  15. package/src/render/blocks/renderers/code.ts +44 -0
  16. package/src/render/blocks/renderers/compare-table.ts +56 -0
  17. package/src/render/blocks/renderers/diagram.ts +48 -0
  18. package/src/render/blocks/renderers/divider.ts +31 -0
  19. package/src/render/blocks/renderers/hero.ts +66 -0
  20. package/src/render/blocks/renderers/kv.ts +45 -0
  21. package/src/render/blocks/renderers/list.ts +51 -0
  22. package/src/render/blocks/renderers/pill-row.ts +45 -0
  23. package/src/render/blocks/renderers/prose.ts +29 -0
  24. package/src/render/blocks/renderers/raw-html.ts +32 -0
  25. package/src/render/blocks/renderers/risk-table.ts +76 -0
  26. package/src/render/blocks/renderers/section.ts +95 -0
  27. package/src/render/blocks/renderers/timeline.ts +58 -0
  28. package/src/render/blocks/renderers/tldr.ts +30 -0
  29. package/src/render/blocks/types.ts +127 -0
  30. package/src/render/blocks/validate-block.ts +202 -0
  31. package/src/render/critique.ts +410 -10
  32. package/src/render/fallback.ts +18 -0
  33. package/src/render/theme.ts +235 -0
  34. package/src/render/validate.ts +282 -17
  35. package/src/render/wrap.ts +7 -7
  36. package/src/server/lifecycle.ts +7 -1
  37. package/src/storage/assets.ts +66 -0
  38. package/src/storage/index-cache.ts +1 -0
  39. package/src/storage/index-gen.ts +13 -14
  40. package/src/tools/ask.ts +5 -3
  41. package/src/tools/critique.ts +41 -6
  42. package/src/tools/publish.ts +39 -12
  43. package/src/tools/styleguide.ts +109 -9
@@ -0,0 +1,202 @@
1
+ // Deep per-block field validator — walks catalog schemas and checks field shapes.
2
+ // src/render/blocks/validate-block.ts
3
+
4
+ import { blockCatalog } from "./catalog.ts";
5
+
6
+ export interface BlockFieldError {
7
+ path: string;
8
+ message: string;
9
+ }
10
+
11
+ // ─── Levenshtein distance (small, iterative) ─────────────────────────────────
12
+
13
+ function levenshtein(a: string, b: string): number {
14
+ if (a === b) return 0;
15
+ const aLen = a.length;
16
+ const bLen = b.length;
17
+ if (aLen === 0) return bLen;
18
+ if (bLen === 0) return aLen;
19
+ // Use two rows
20
+ let prev = Array.from({ length: bLen + 1 }, (_, i) => i);
21
+ let curr: number[] = Array.from({ length: bLen + 1 }, () => 0);
22
+ for (let i = 1; i <= aLen; i++) {
23
+ curr[0] = i;
24
+ for (let j = 1; j <= bLen; j++) {
25
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
26
+ curr[j] = Math.min(
27
+ (prev[j] ?? 0) + 1,
28
+ (curr[j - 1] ?? 0) + 1,
29
+ (prev[j - 1] ?? 0) + cost,
30
+ );
31
+ }
32
+ [prev, curr] = [curr, prev];
33
+ }
34
+ return prev[bLen] ?? 0;
35
+ }
36
+
37
+ /**
38
+ * Given an unknown field name and a list of known field names, returns the
39
+ * closest known name if the Levenshtein distance is ≤ 2, otherwise checks
40
+ * common alias patterns, otherwise returns null.
41
+ */
42
+ function didYouMean(unknown: string, known: string[]): string | null {
43
+ // Explicit common aliases that wouldn't be caught by Levenshtein alone
44
+ const ALIASES: Record<string, string> = {
45
+ label: "k",
46
+ value: "v",
47
+ description: "text",
48
+ title: "label",
49
+ med: "medium",
50
+ name: "k",
51
+ key: "k",
52
+ val: "v",
53
+ };
54
+
55
+ // Check alias map first
56
+ const aliased = ALIASES[unknown.toLowerCase()];
57
+ if (aliased !== undefined && known.includes(aliased)) {
58
+ return aliased;
59
+ }
60
+
61
+ // Fall back to Levenshtein ≤ 2
62
+ let best: string | null = null;
63
+ let bestDist = 3; // threshold: only suggest if distance ≤ 2
64
+ for (const name of known) {
65
+ const d = levenshtein(unknown, name);
66
+ if (d < bestDist) {
67
+ bestDist = d;
68
+ best = name;
69
+ }
70
+ }
71
+ return best;
72
+ }
73
+
74
+ // ─── JSON Schema fragment types ───────────────────────────────────────────────
75
+
76
+ type SchemaNode =
77
+ | { type: "string"; enum?: string[] }
78
+ | { type: "number" }
79
+ | { type: "boolean" }
80
+ | { type: "array"; items?: SchemaNode }
81
+ | { type: "object"; properties?: Record<string, SchemaNode>; required?: string[] }
82
+ | { const: unknown };
83
+
84
+ function isSchemaNode(v: unknown): v is SchemaNode {
85
+ return v !== null && typeof v === "object";
86
+ }
87
+
88
+ // ─── Deep validator ───────────────────────────────────────────────────────────
89
+
90
+ /**
91
+ * Validate a single value against a JSON Schema fragment node.
92
+ * Appends any findings to `errors`. `path` is the dotted path for error messages.
93
+ */
94
+ function validateNode(value: unknown, schema: SchemaNode, path: string, errors: BlockFieldError[]): void {
95
+ // const node
96
+ if ("const" in schema) {
97
+ if (value !== schema.const) {
98
+ errors.push({ path, message: `expected ${JSON.stringify(schema.const)}, got ${JSON.stringify(value)}` });
99
+ }
100
+ return;
101
+ }
102
+
103
+ const s = schema as Exclude<SchemaNode, { const: unknown }>;
104
+
105
+ switch (s.type) {
106
+ case "string": {
107
+ if (typeof value !== "string") {
108
+ errors.push({ path, message: `expected string, got ${typeof value}` });
109
+ return;
110
+ }
111
+ if (s.enum !== undefined && !s.enum.includes(value)) {
112
+ errors.push({
113
+ path,
114
+ message: `invalid value "${value}"; must be one of: ${s.enum.map((e) => `"${e}"`).join(", ")}`,
115
+ });
116
+ }
117
+ return;
118
+ }
119
+ case "number": {
120
+ if (typeof value !== "number") {
121
+ errors.push({ path, message: `expected number, got ${typeof value}` });
122
+ }
123
+ return;
124
+ }
125
+ case "boolean": {
126
+ if (typeof value !== "boolean") {
127
+ errors.push({ path, message: `expected boolean, got ${typeof value}` });
128
+ }
129
+ return;
130
+ }
131
+ case "array": {
132
+ if (!Array.isArray(value)) {
133
+ errors.push({ path, message: `expected array, got ${typeof value}` });
134
+ return;
135
+ }
136
+ if (s.items !== undefined) {
137
+ const arr = value as unknown[];
138
+ for (let i = 0; i < arr.length; i++) {
139
+ validateNode(arr[i], s.items, `${path}[${i}]`, errors);
140
+ }
141
+ }
142
+ return;
143
+ }
144
+ case "object": {
145
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
146
+ errors.push({ path, message: `expected object, got ${Array.isArray(value) ? "array" : typeof value}` });
147
+ return;
148
+ }
149
+ const obj = value as Record<string, unknown>;
150
+ const props = s.properties ?? {};
151
+ const required = s.required ?? [];
152
+ const knownKeys = Object.keys(props).filter((k) => k !== "type");
153
+
154
+ // Only validate required/unknown fields when the schema explicitly defines properties.
155
+ // An empty or missing properties block (e.g. `items: { type: "object" }` for generic
156
+ // child lists) means "accept any object" — don't report unknown fields.
157
+ if (Object.keys(props).length > 0) {
158
+ // Check required fields
159
+ for (const req of required) {
160
+ if (req === "type") continue; // type is already validated by dispatch
161
+ if (!(req in obj)) {
162
+ errors.push({ path: `${path}.${req}`, message: `required field "${req}" is missing` });
163
+ }
164
+ }
165
+
166
+ // Check each present field
167
+ for (const [key, val] of Object.entries(obj)) {
168
+ if (key === "type") continue; // skip discriminant
169
+ const propSchema = props[key];
170
+ if (propSchema === undefined) {
171
+ // Unknown field — suggest closest known
172
+ const suggestion = didYouMean(key, knownKeys);
173
+ const suggestionMsg = suggestion !== null ? `; did you mean "${suggestion}"?` : "";
174
+ errors.push({ path: `${path}.${key}`, message: `unknown field "${key}"${suggestionMsg}` });
175
+ } else if (isSchemaNode(propSchema)) {
176
+ validateNode(val, propSchema, `${path}.${key}`, errors);
177
+ }
178
+ }
179
+ }
180
+ return;
181
+ }
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Deeply validates a block's fields against its catalog schema.
187
+ * Returns an array of field errors (empty = valid).
188
+ */
189
+ export function deepValidateBlock(block: Record<string, unknown>, path: string): BlockFieldError[] {
190
+ const errors: BlockFieldError[] = [];
191
+ const type = block["type"] as string;
192
+ const catalogEntry = blockCatalog[type as keyof typeof blockCatalog];
193
+ if (catalogEntry === undefined) {
194
+ // Unknown type — already caught by outer validateBlock; skip
195
+ return errors;
196
+ }
197
+
198
+ const schema = catalogEntry.schema as SchemaNode;
199
+ validateNode(block, schema, path, errors);
200
+
201
+ return errors;
202
+ }
@@ -1,8 +1,9 @@
1
- // Pure HTML body analyzer — walks parse5 AST and returns structured findings + 0-100 score.
1
+ // Pure body analyzer — mode-aware: html or blocks.
2
2
  // Deterministic and pure: same input always yields the same output.
3
3
 
4
4
  import { parseFragment, defaultTreeAdapter as ta } from "parse5";
5
5
  import type { DefaultTreeAdapterMap, DefaultTreeAdapterTypes, ParserOptions } from "parse5";
6
+ import type { Block } from "./blocks/types.ts";
6
7
 
7
8
  type ChildNode = DefaultTreeAdapterTypes.ChildNode;
8
9
  type Element = DefaultTreeAdapterTypes.Element;
@@ -21,6 +22,8 @@ export interface CritiqueFinding {
21
22
  message: string;
22
23
  /** Populated when the rule represents an aggregate count. */
23
24
  count?: number;
25
+ /** Path in the block tree (blocks mode only), e.g. "blocks[2].children[1]". */
26
+ path?: string;
24
27
  }
25
28
 
26
29
  export interface CritiqueResult {
@@ -30,6 +33,8 @@ export interface CritiqueResult {
30
33
  findings: CritiqueFinding[];
31
34
  /** Sum of all text node values in the body (visible text content length). */
32
35
  textLength: number;
36
+ /** Which input mode was detected. */
37
+ mode: "html" | "blocks";
33
38
  }
34
39
 
35
40
  // ---------------------------------------------------------------------------
@@ -60,7 +65,7 @@ const SEVERITY_ORDER: Record<CritiqueSeverity, number> = {
60
65
  };
61
66
 
62
67
  // ---------------------------------------------------------------------------
63
- // Tree-walking helpers
68
+ // Tree-walking helpers (HTML mode)
64
69
  // ---------------------------------------------------------------------------
65
70
 
66
71
  function walkNodes(nodes: readonly ChildNode[], visitor: (node: ChildNode) => void): void {
@@ -115,12 +120,12 @@ function hasHighlightDescendant(el: Element): boolean {
115
120
  // Scoring + ordering
116
121
  // ---------------------------------------------------------------------------
117
122
 
118
- function computeScore(findings: readonly CritiqueFinding[]): number {
119
- let score = 100;
123
+ function computeScore(findings: readonly CritiqueFinding[], baseline = 100): number {
124
+ let score = baseline;
120
125
  for (const f of findings) {
121
126
  score -= SEVERITY_DEDUCTION[f.severity];
122
127
  }
123
- return Math.max(0, score);
128
+ return Math.min(100, Math.max(0, score));
124
129
  }
125
130
 
126
131
  function sortFindings(findings: CritiqueFinding[]): CritiqueFinding[] {
@@ -132,10 +137,10 @@ function sortFindings(findings: CritiqueFinding[]): CritiqueFinding[] {
132
137
  }
133
138
 
134
139
  // ---------------------------------------------------------------------------
135
- // Main analyzerpure, deterministic
140
+ // HTML modepreserves all existing rules + adds prefer-blocks
136
141
  // ---------------------------------------------------------------------------
137
142
 
138
- export function critique(htmlBody: string): CritiqueResult {
143
+ export function critiqueHtml(htmlBody: string): CritiqueResult {
139
144
  const fragment = parseFragment(htmlBody);
140
145
  const children = ta.getChildNodes(fragment) as ChildNode[];
141
146
  const findings: CritiqueFinding[] = [];
@@ -155,6 +160,9 @@ export function critique(htmlBody: string): CritiqueResult {
155
160
  const unknownCesiumClasses = new Set<string>();
156
161
  let codeBlocksWithoutHighlights = 0;
157
162
 
163
+ // prefer-blocks heuristic counters
164
+ let structuralElementCount = 0;
165
+
158
166
  walkNodes(children, (node) => {
159
167
  if (!ta.isElementNode(node)) return;
160
168
  const el = node as Element;
@@ -184,12 +192,16 @@ export function critique(htmlBody: string): CritiqueResult {
184
192
 
185
193
  // --- Class-based counts ---
186
194
  if (cls.has("h-display")) hDisplayCount++;
187
- if (cls.has("h-section")) hSectionCount++;
195
+ if (cls.has("h-section")) {
196
+ hSectionCount++;
197
+ structuralElementCount++;
198
+ }
188
199
  if (cls.has("tldr")) tldrCount++;
189
200
  if (cls.has("eyebrow")) eyebrowCount++;
190
201
 
191
202
  // Callout without a severity modifier
192
203
  if (cls.has("callout")) {
204
+ structuralElementCount++;
193
205
  let hasModifier = false;
194
206
  for (const mod of CALLOUT_MODIFIERS) {
195
207
  if (cls.has(mod)) {
@@ -200,6 +212,9 @@ export function critique(htmlBody: string): CritiqueResult {
200
212
  if (!hasModifier) calloutNoModifierCount++;
201
213
  }
202
214
 
215
+ // compare-table counts as structural
216
+ if (cls.has("compare-table")) structuralElementCount++;
217
+
203
218
  // Unknown cesium-* class names
204
219
  for (const c of cls) {
205
220
  if (c.startsWith("cesium-") && !KNOWN_CESIUM_CLASSES.has(c)) {
@@ -318,6 +333,16 @@ export function critique(htmlBody: string): CritiqueResult {
318
333
  });
319
334
  }
320
335
 
336
+ // prefer-blocks: suggest when ≥3 structural elements could be expressed as blocks
337
+ if (structuralElementCount >= 3) {
338
+ findings.push({
339
+ severity: "suggest",
340
+ code: "prefer-blocks",
341
+ message: `This document has ${structuralElementCount} structural elements that could be expressed as blocks. Consider using cesium_publish({ blocks: [...] }).`,
342
+ count: structuralElementCount,
343
+ });
344
+ }
345
+
321
346
  // ---------------------------------------------------------------------------
322
347
  // Emit info-level findings
323
348
  // ---------------------------------------------------------------------------
@@ -354,7 +379,382 @@ export function critique(htmlBody: string): CritiqueResult {
354
379
  // ---------------------------------------------------------------------------
355
380
 
356
381
  const sorted = sortFindings(findings);
357
- const score = computeScore(sorted);
382
+ const score = computeScore(sorted, 100);
383
+
384
+ return { score, findings: sorted, textLength, mode: "html" };
385
+ }
358
386
 
359
- return { score, findings: sorted, textLength };
387
+ // ---------------------------------------------------------------------------
388
+ // Blocks mode — quality-focused rules
389
+ // ---------------------------------------------------------------------------
390
+
391
+ /** Collect all raw_html blocks from a block tree, with their path. */
392
+ function collectRawHtmlBlocks(
393
+ blocks: readonly Block[],
394
+ basePath: string,
395
+ ): Array<{ block: Extract<Block, { type: "raw_html" }>; path: string }> {
396
+ const results: Array<{ block: Extract<Block, { type: "raw_html" }>; path: string }> = [];
397
+ for (let i = 0; i < blocks.length; i++) {
398
+ const b = blocks[i];
399
+ if (b === undefined) continue;
400
+ const path = `${basePath}[${i}]`;
401
+ if (b.type === "raw_html") {
402
+ results.push({ block: b, path });
403
+ }
404
+ if (b.type === "section") {
405
+ const nested = collectRawHtmlBlocks(b.children, `${path}.children`);
406
+ for (const n of nested) results.push(n);
407
+ }
408
+ }
409
+ return results;
410
+ }
411
+
412
+ /** Sum text content characters across all prose/tldr/callout/list/code/kv/timeline/table blocks. */
413
+ function collectTextChars(blocks: readonly Block[]): number {
414
+ let total = 0;
415
+ for (const b of blocks) {
416
+ if (b === undefined) continue;
417
+ switch (b.type) {
418
+ case "prose":
419
+ total += b.markdown.length;
420
+ break;
421
+ case "tldr":
422
+ total += b.markdown.length;
423
+ break;
424
+ case "callout":
425
+ total += b.markdown.length;
426
+ break;
427
+ case "list":
428
+ for (const item of b.items) total += item.length;
429
+ break;
430
+ case "code":
431
+ total += b.code.length;
432
+ break;
433
+ case "kv":
434
+ for (const row of b.rows) total += row.k.length + row.v.length;
435
+ break;
436
+ case "timeline":
437
+ for (const item of b.items) total += item.label.length + item.text.length;
438
+ break;
439
+ case "compare_table":
440
+ for (const row of b.rows) for (const cell of row) total += cell.length;
441
+ for (const h of b.headers) total += h.length;
442
+ break;
443
+ case "risk_table":
444
+ for (const row of b.rows) total += row.risk.length + row.mitigation.length;
445
+ break;
446
+ case "hero":
447
+ total += b.title.length;
448
+ if (b.subtitle !== undefined) total += b.subtitle.length;
449
+ break;
450
+ case "section":
451
+ total += b.title.length;
452
+ total += collectTextChars(b.children);
453
+ break;
454
+ case "raw_html":
455
+ total += b.html.length;
456
+ break;
457
+ case "diagram":
458
+ total += (b.svg ?? b.html ?? "").length;
459
+ break;
460
+ case "divider":
461
+ case "pill_row":
462
+ break;
463
+ }
464
+ }
465
+ return total;
466
+ }
467
+
468
+ /** Count top-level section blocks. */
469
+ function countTopLevelSections(blocks: readonly Block[]): number {
470
+ let count = 0;
471
+ for (const b of blocks) {
472
+ if (b !== undefined && b.type === "section") count++;
473
+ }
474
+ return count;
475
+ }
476
+
477
+ /** Count consecutive prose blocks at the same nesting level. Returns max run found + path. */
478
+ function findProseWalls(
479
+ blocks: readonly Block[],
480
+ basePath: string,
481
+ ): Array<{ start: number; end: number; path: string }> {
482
+ const walls: Array<{ start: number; end: number; path: string }> = [];
483
+ let runStart = -1;
484
+ let runLen = 0;
485
+
486
+ for (let i = 0; i < blocks.length; i++) {
487
+ const b = blocks[i];
488
+ if (b === undefined) continue;
489
+ if (b.type === "prose") {
490
+ if (runStart === -1) runStart = i;
491
+ runLen++;
492
+ } else {
493
+ if (runLen > 8) {
494
+ walls.push({ start: runStart, end: i - 1, path: basePath });
495
+ }
496
+ runStart = -1;
497
+ runLen = 0;
498
+ }
499
+ if (b.type === "section") {
500
+ const nested = findProseWalls(b.children, `${basePath}[${i}].children`);
501
+ for (const n of nested) walls.push(n);
502
+ }
503
+ }
504
+ // Check trailing run
505
+ if (runLen > 8 && runStart !== -1) {
506
+ walls.push({ start: runStart, end: blocks.length - 1, path: basePath });
507
+ }
508
+ return walls;
509
+ }
510
+
511
+ /** Collect all code blocks with their path. */
512
+ function collectCodeBlocks(
513
+ blocks: readonly Block[],
514
+ basePath: string,
515
+ ): Array<{ block: Extract<Block, { type: "code" }>; path: string }> {
516
+ const results: Array<{ block: Extract<Block, { type: "code" }>; path: string }> = [];
517
+ for (let i = 0; i < blocks.length; i++) {
518
+ const b = blocks[i];
519
+ if (b === undefined) continue;
520
+ const path = `${basePath}[${i}]`;
521
+ if (b.type === "code") {
522
+ results.push({ block: b, path });
523
+ }
524
+ if (b.type === "section") {
525
+ const nested = collectCodeBlocks(b.children, `${path}.children`);
526
+ for (const n of nested) results.push(n);
527
+ }
528
+ }
529
+ return results;
530
+ }
531
+
532
+ /** Check compare_table for row/header count mismatch. */
533
+ function checkTableShape(
534
+ blocks: readonly Block[],
535
+ basePath: string,
536
+ ): Array<{ path: string; message: string }> {
537
+ const issues: Array<{ path: string; message: string }> = [];
538
+ for (let i = 0; i < blocks.length; i++) {
539
+ const b = blocks[i];
540
+ if (b === undefined) continue;
541
+ const path = `${basePath}[${i}]`;
542
+ if (b.type === "compare_table") {
543
+ const hLen = b.headers.length;
544
+ for (let ri = 0; ri < b.rows.length; ri++) {
545
+ const row = b.rows[ri];
546
+ if (row !== undefined && row.length !== hLen) {
547
+ issues.push({
548
+ path: `${path}.rows[${ri}]`,
549
+ message: `compare_table row has ${row.length} cells but headers has ${hLen}`,
550
+ });
551
+ }
552
+ }
553
+ }
554
+ if (b.type === "section") {
555
+ const nested = checkTableShape(b.children, `${path}.children`);
556
+ for (const n of nested) issues.push(n);
557
+ }
558
+ }
559
+ return issues;
560
+ }
561
+
562
+ /** Check for sections nested deeper than 3. */
563
+ function checkNestingDepth(
564
+ blocks: readonly Block[],
565
+ basePath: string,
566
+ currentDepth: number,
567
+ ): Array<{ path: string }> {
568
+ const issues: Array<{ path: string }> = [];
569
+ for (let i = 0; i < blocks.length; i++) {
570
+ const b = blocks[i];
571
+ if (b === undefined) continue;
572
+ const path = `${basePath}[${i}]`;
573
+ if (b.type === "section") {
574
+ if (currentDepth > 3) {
575
+ issues.push({ path });
576
+ } else {
577
+ const nested = checkNestingDepth(b.children, `${path}.children`, currentDepth + 1);
578
+ for (const n of nested) issues.push(n);
579
+ }
580
+ }
581
+ }
582
+ return issues;
583
+ }
584
+
585
+ /** Heuristic: detect framework markup inside raw_html that has a known block equivalent. */
586
+ const REDUNDANT_PATTERNS: Array<{ pattern: RegExp; suggestion: string }> = [
587
+ { pattern: /<table\s[^>]*class="compare-table"/, suggestion: "compare_table" },
588
+ { pattern: /<div\s[^>]*class="card"/, suggestion: "section or callout" },
589
+ { pattern: /<aside\s[^>]*class="callout/, suggestion: "callout" },
590
+ { pattern: /<aside\s[^>]*class="tldr"/, suggestion: "tldr" },
591
+ { pattern: /<ul\s[^>]*class="timeline"/, suggestion: "timeline" },
592
+ { pattern: /<table\s[^>]*class="risk-table"/, suggestion: "risk_table" },
593
+ { pattern: /<dl\s[^>]*class="kv"/, suggestion: "kv" },
594
+ ];
595
+
596
+ function detectRedundantRawHtml(html: string): string | null {
597
+ for (const { pattern, suggestion } of REDUNDANT_PATTERNS) {
598
+ if (pattern.test(html)) {
599
+ return suggestion;
600
+ }
601
+ }
602
+ return null;
603
+ }
604
+
605
+ /** Find the first hero block index (if any). */
606
+ function findHeroIndex(blocks: readonly Block[]): number {
607
+ for (let i = 0; i < blocks.length; i++) {
608
+ if (blocks[i]?.type === "hero") return i;
609
+ }
610
+ return -1;
611
+ }
612
+
613
+ /** Check whether a tldr block exists anywhere in the tree. */
614
+ function hasTldr(blocks: readonly Block[]): boolean {
615
+ for (const b of blocks) {
616
+ if (b === undefined) continue;
617
+ if (b.type === "tldr") return true;
618
+ if (b.type === "section" && hasTldr(b.children)) return true;
619
+ }
620
+ return false;
621
+ }
622
+
623
+ export function critiqueBlocks(blocks: Block[]): CritiqueResult {
624
+ const findings: CritiqueFinding[] = [];
625
+
626
+ // Total text content for ratio checks
627
+ const totalChars = collectTextChars(blocks);
628
+
629
+ // --- raw-html-overuse (warn) ---
630
+ const rawHtmlBlocks = collectRawHtmlBlocks(blocks, "blocks");
631
+ const rawHtmlCharTotal = rawHtmlBlocks.reduce((sum, { block }) => sum + block.html.length, 0);
632
+ const rawHtmlRatio = totalChars > 0 ? rawHtmlCharTotal / totalChars : 0;
633
+
634
+ if (rawHtmlBlocks.length > 2 || rawHtmlRatio > 0.3) {
635
+ const reason =
636
+ rawHtmlBlocks.length > 2
637
+ ? `${rawHtmlBlocks.length} raw_html blocks (max 2 before critique warns)`
638
+ : `raw_html payload is ${Math.round(rawHtmlRatio * 100)}% of body characters (>30%)`;
639
+ findings.push({
640
+ severity: "warn",
641
+ code: "raw-html-overuse",
642
+ message: `raw_html overuse: ${reason}. Consider expressing more content as typed blocks.`,
643
+ count: rawHtmlBlocks.length,
644
+ });
645
+ }
646
+
647
+ // --- missing-tldr (suggest) ---
648
+ const topLevelSections = countTopLevelSections(blocks);
649
+ if (topLevelSections > 5 && !hasTldr(blocks)) {
650
+ findings.push({
651
+ severity: "suggest",
652
+ code: "missing-tldr",
653
+ message: `Document has ${topLevelSections} sections but no tldr block. Add one near the top to orient readers.`,
654
+ });
655
+ }
656
+
657
+ // --- prose-wall (suggest) ---
658
+ const proseWalls = findProseWalls(blocks, "blocks");
659
+ for (const wall of proseWalls) {
660
+ findings.push({
661
+ severity: "suggest",
662
+ code: "prose-wall",
663
+ message: `More than 8 consecutive prose blocks at ${wall.path}[${wall.start}..${wall.end}]. Break the run with a list, callout, or section.`,
664
+ path: `${wall.path}[${wall.start}..${wall.end}]`,
665
+ });
666
+ }
667
+
668
+ // --- code-without-meaningful-lang (info) ---
669
+ const codeBlocks = collectCodeBlocks(blocks, "blocks");
670
+ for (const { block, path } of codeBlocks) {
671
+ if (block.lang === "text") {
672
+ findings.push({
673
+ severity: "info",
674
+ code: "code-without-meaningful-lang",
675
+ message: `Code block at ${path} uses lang "text". Specify a real language (e.g. "typescript", "json", "bash") for better rendering.`,
676
+ path,
677
+ });
678
+ }
679
+ }
680
+
681
+ // --- table-shape (warn) --- defensive check
682
+ const tableIssues = checkTableShape(blocks, "blocks");
683
+ for (const issue of tableIssues) {
684
+ findings.push({
685
+ severity: "warn",
686
+ code: "table-shape",
687
+ message: issue.message,
688
+ path: issue.path,
689
+ });
690
+ }
691
+
692
+ // --- nesting-depth (warn) --- defensive check
693
+ const depthIssues = checkNestingDepth(blocks, "blocks", 1);
694
+ for (const issue of depthIssues) {
695
+ findings.push({
696
+ severity: "warn",
697
+ code: "nesting-depth",
698
+ message: `Section at ${issue.path} exceeds maximum nesting depth of 3.`,
699
+ path: issue.path,
700
+ });
701
+ }
702
+
703
+ // --- redundant-raw-html (suggest) ---
704
+ for (const { block, path } of rawHtmlBlocks) {
705
+ const suggestion = detectRedundantRawHtml(block.html);
706
+ if (suggestion !== null) {
707
+ findings.push({
708
+ severity: "suggest",
709
+ code: "redundant-raw-html",
710
+ message: `raw_html block at ${path} contains framework markup that could be expressed as a \`${suggestion}\` block.`,
711
+ path,
712
+ });
713
+ }
714
+ }
715
+
716
+ // --- tldr-too-long (suggest) ---
717
+ for (let i = 0; i < blocks.length; i++) {
718
+ const b = blocks[i];
719
+ if (b === undefined) continue;
720
+ if (b.type === "tldr" && b.markdown.length > 400) {
721
+ const path = `blocks[${i}]`;
722
+ findings.push({
723
+ severity: "suggest",
724
+ code: "tldr-too-long",
725
+ message: `tldr at ${path} is ${b.markdown.length} characters (>400). Keep tldrs punchy — aim for 1-3 sentences.`,
726
+ path,
727
+ });
728
+ }
729
+ }
730
+
731
+ // --- hero-not-first (warn) --- defensive: validate already enforces this
732
+ const heroIdx = findHeroIndex(blocks);
733
+ if (heroIdx > 0) {
734
+ findings.push({
735
+ severity: "warn",
736
+ code: "hero-not-first",
737
+ message: `Hero block found at blocks[${heroIdx}] but must be the first block.`,
738
+ path: `blocks[${heroIdx}]`,
739
+ });
740
+ }
741
+
742
+ // Sort, score with +5 baseline bonus, return
743
+ const sorted = sortFindings(findings);
744
+ // blocks mode starts at 105, capped at 100 — gives a +5 bonus for well-formed docs
745
+ const score = computeScore(sorted, 105);
746
+
747
+ return { score, findings: sorted, textLength: totalChars, mode: "blocks" };
748
+ }
749
+
750
+ // ---------------------------------------------------------------------------
751
+ // Backwards-compat wrapper — accepts an HTML body string (html mode)
752
+ // ---------------------------------------------------------------------------
753
+
754
+ /**
755
+ * @deprecated Use critiqueHtml() or critiqueBlocks() directly.
756
+ * Kept for backwards compatibility with existing call sites.
757
+ */
758
+ export function critique(htmlBody: string): CritiqueResult {
759
+ return critiqueHtml(htmlBody);
360
760
  }