@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.
- package/CHANGELOG.md +53 -0
- package/README.md +28 -14
- package/assets/styleguide.html +149 -0
- package/package.json +1 -1
- package/src/cli/commands/serve.ts +3 -0
- package/src/index.ts +4 -1
- package/src/prompt/field-reference.ts +94 -0
- package/src/prompt/system-fragment.md +56 -65
- package/src/render/blocks/catalog.ts +39 -0
- package/src/render/blocks/escape.ts +27 -0
- package/src/render/blocks/index.ts +6 -0
- package/src/render/blocks/markdown.ts +217 -0
- package/src/render/blocks/render.ts +96 -0
- package/src/render/blocks/renderers/callout.ts +38 -0
- package/src/render/blocks/renderers/code.ts +44 -0
- package/src/render/blocks/renderers/compare-table.ts +56 -0
- package/src/render/blocks/renderers/diagram.ts +48 -0
- package/src/render/blocks/renderers/divider.ts +31 -0
- package/src/render/blocks/renderers/hero.ts +66 -0
- package/src/render/blocks/renderers/kv.ts +45 -0
- package/src/render/blocks/renderers/list.ts +51 -0
- package/src/render/blocks/renderers/pill-row.ts +45 -0
- package/src/render/blocks/renderers/prose.ts +29 -0
- package/src/render/blocks/renderers/raw-html.ts +32 -0
- package/src/render/blocks/renderers/risk-table.ts +76 -0
- package/src/render/blocks/renderers/section.ts +95 -0
- package/src/render/blocks/renderers/timeline.ts +58 -0
- package/src/render/blocks/renderers/tldr.ts +30 -0
- package/src/render/blocks/types.ts +127 -0
- package/src/render/blocks/validate-block.ts +202 -0
- package/src/render/critique.ts +410 -10
- package/src/render/fallback.ts +18 -0
- package/src/render/theme.ts +235 -0
- package/src/render/validate.ts +282 -17
- package/src/render/wrap.ts +7 -7
- package/src/server/lifecycle.ts +7 -1
- package/src/storage/assets.ts +66 -0
- package/src/storage/index-cache.ts +1 -0
- package/src/storage/index-gen.ts +13 -14
- package/src/tools/ask.ts +5 -3
- package/src/tools/critique.ts +41 -6
- package/src/tools/publish.ts +39 -12
- 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
|
+
}
|
package/src/render/critique.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
// Pure
|
|
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 =
|
|
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
|
-
//
|
|
140
|
+
// HTML mode — preserves all existing rules + adds prefer-blocks
|
|
136
141
|
// ---------------------------------------------------------------------------
|
|
137
142
|
|
|
138
|
-
export function
|
|
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"))
|
|
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
|
-
|
|
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
|
}
|