@cfbender/cesium 0.3.5

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 (44) hide show
  1. package/ARCHITECTURE.md +304 -0
  2. package/CHANGELOG.md +335 -0
  3. package/LICENSE +21 -0
  4. package/README.md +479 -0
  5. package/agents/cesium.md +39 -0
  6. package/assets/styleguide.html +857 -0
  7. package/package.json +61 -0
  8. package/src/cli/commands/ls.ts +186 -0
  9. package/src/cli/commands/open.ts +208 -0
  10. package/src/cli/commands/prune.ts +348 -0
  11. package/src/cli/commands/restart.ts +38 -0
  12. package/src/cli/commands/serve.ts +214 -0
  13. package/src/cli/commands/stop.ts +130 -0
  14. package/src/cli/commands/theme.ts +333 -0
  15. package/src/cli/index.ts +78 -0
  16. package/src/config.ts +94 -0
  17. package/src/index.ts +35 -0
  18. package/src/prompt/system-fragment.md +97 -0
  19. package/src/render/client-js.ts +316 -0
  20. package/src/render/controls.ts +302 -0
  21. package/src/render/critique.ts +360 -0
  22. package/src/render/extract.ts +83 -0
  23. package/src/render/scrub.ts +141 -0
  24. package/src/render/theme.ts +712 -0
  25. package/src/render/validate.ts +524 -0
  26. package/src/render/wrap.ts +165 -0
  27. package/src/server/api.ts +166 -0
  28. package/src/server/http.ts +195 -0
  29. package/src/server/lifecycle.ts +331 -0
  30. package/src/server/stop.ts +124 -0
  31. package/src/storage/index-cache.ts +71 -0
  32. package/src/storage/index-gen.ts +447 -0
  33. package/src/storage/lock.ts +108 -0
  34. package/src/storage/mutate.ts +396 -0
  35. package/src/storage/paths.ts +159 -0
  36. package/src/storage/project-summaries.ts +19 -0
  37. package/src/storage/theme-write.ts +19 -0
  38. package/src/storage/write.ts +75 -0
  39. package/src/tools/ask.ts +353 -0
  40. package/src/tools/critique.ts +66 -0
  41. package/src/tools/publish.ts +404 -0
  42. package/src/tools/stop.ts +53 -0
  43. package/src/tools/styleguide.ts +23 -0
  44. package/src/tools/wait.ts +192 -0
@@ -0,0 +1,302 @@
1
+ // Pure HTML render functions for each interactive question type.
2
+ // renderControl — pre-submission interactive control
3
+ // renderAnswered — post-submission read-only answered section
4
+
5
+ import type { Question, AnswerValue } from "./validate.ts";
6
+
7
+ function escapeHtml(str: string): string {
8
+ return str
9
+ .replace(/&/g, "&")
10
+ .replace(/</g, "&lt;")
11
+ .replace(/>/g, "&gt;")
12
+ .replace(/"/g, "&quot;");
13
+ }
14
+
15
+ function escapeAttr(str: string): string {
16
+ return escapeHtml(str);
17
+ }
18
+
19
+ // ─── Header shared by both control and answered sections ──────────────────────
20
+
21
+ function renderSectionHeader(q: Question): string {
22
+ const qTextEsc = escapeHtml(q.question);
23
+ const contextLine =
24
+ q.context !== undefined ? `\n <p class="cs-context">${escapeHtml(q.context)}</p>` : "";
25
+ const eyebrow = q.context !== undefined ? `QUESTION | ${escapeHtml(q.context)}` : "QUESTION";
26
+ return ` <p class="eyebrow">${eyebrow}</p>
27
+ <h3 class="h-section">${qTextEsc}</h3>${contextLine}`;
28
+ }
29
+
30
+ // ─── renderControl — interactive (pre-submission) ─────────────────────────────
31
+
32
+ function renderPickOneControl(q: Extract<Question, { type: "pick_one" }>): string {
33
+ const buttons = q.options
34
+ .map((opt) => {
35
+ const isRecommended = q.recommended !== undefined && q.recommended === opt.id;
36
+ const cls = isRecommended ? "cs-pick cs-recommended" : "cs-pick";
37
+ const recommendedChip = isRecommended
38
+ ? `\n <span class="eyebrow" style="color: var(--accent);">RECOMMENDED</span>`
39
+ : "";
40
+ const descLine =
41
+ opt.description !== undefined
42
+ ? `\n <p class="cs-pick-desc">${escapeHtml(opt.description)}</p>`
43
+ : "";
44
+ return ` <button class="${cls}" data-value="${escapeAttr(opt.id)}">
45
+ <strong>${escapeHtml(opt.label)}</strong>${recommendedChip}${descLine}
46
+ </button>`;
47
+ })
48
+ .join("\n");
49
+ return buttons;
50
+ }
51
+
52
+ function renderPickManyControl(q: Extract<Question, { type: "pick_many" }>): string {
53
+ const hint =
54
+ q.min !== undefined || q.max !== undefined
55
+ ? (() => {
56
+ const parts: string[] = [];
57
+ if (q.min !== undefined) parts.push(`at least ${q.min}`);
58
+ if (q.max !== undefined) parts.push(`at most ${q.max}`);
59
+ return `\n <p class="eyebrow">PICK ${parts.join(" / ")}</p>`;
60
+ })()
61
+ : "";
62
+ const checkboxes = q.options
63
+ .map((opt) => {
64
+ const descLine =
65
+ opt.description !== undefined
66
+ ? `\n <p class="cs-pick-desc">${escapeHtml(opt.description)}</p>`
67
+ : "";
68
+ return ` <label class="cs-pick">
69
+ <input type="checkbox" data-value="${escapeAttr(opt.id)}">
70
+ <strong>${escapeHtml(opt.label)}</strong>${descLine}
71
+ </label>`;
72
+ })
73
+ .join("\n");
74
+ return `${hint}
75
+ ${checkboxes}
76
+ <button class="cs-submit" disabled>Submit</button>`;
77
+ }
78
+
79
+ function renderConfirmControl(q: Extract<Question, { type: "confirm" }>): string {
80
+ const yesLabel = escapeHtml(q.yesLabel ?? "Yes");
81
+ const noLabel = escapeHtml(q.noLabel ?? "No");
82
+ return ` <div class="cs-confirm-row">
83
+ <button class="cs-confirm cs-yes" data-value="yes">${yesLabel}</button>
84
+ <button class="cs-confirm cs-no" data-value="no">${noLabel}</button>
85
+ </div>`;
86
+ }
87
+
88
+ function renderAskTextControl(q: Extract<Question, { type: "ask_text" }>): string {
89
+ const placeholder = q.placeholder !== undefined ? escapeAttr(q.placeholder) : "";
90
+ const idAttr = escapeAttr(q.id);
91
+ const skipButton =
92
+ q.optional === true
93
+ ? `\n <div class="cs-button-row">\n <button class="cs-submit" data-question-id="${idAttr}" disabled>Submit</button>\n <button class="cs-skip" data-question-id="${idAttr}">Skip</button>\n </div>`
94
+ : `\n <button class="cs-submit" disabled>Submit</button>`;
95
+ if (q.multiline === true) {
96
+ return ` <textarea class="cs-text" rows="4" placeholder="${placeholder}"></textarea>${skipButton}`;
97
+ }
98
+ return ` <input type="text" class="cs-text" placeholder="${placeholder}">${skipButton}`;
99
+ }
100
+
101
+ function renderSliderControl(q: Extract<Question, { type: "slider" }>): string {
102
+ const min = q.min;
103
+ const max = q.max;
104
+ const step = q.step ?? 1;
105
+ const defaultVal = q.defaultValue ?? min;
106
+ return ` <input type="range" class="cs-slider" min="${min}" max="${max}" step="${step}" value="${defaultVal}">
107
+ <output class="cs-slider-out">${defaultVal}</output>
108
+ <button class="cs-submit">Submit</button>`;
109
+ }
110
+
111
+ function renderReactControl(q: Extract<Question, { type: "react" }>): string {
112
+ const mode = q.mode ?? "approve";
113
+ const commentTextarea =
114
+ q.allowComment === true
115
+ ? `\n <textarea class="cs-react-comment" placeholder="Optional comment..."></textarea>`
116
+ : "";
117
+
118
+ let buttons: string;
119
+ if (mode === "thumbs") {
120
+ buttons = ` <button class="cs-react" data-value="up">Thumbs up</button>
121
+ <button class="cs-react" data-value="down">Thumbs down</button>`;
122
+ } else {
123
+ buttons = ` <button class="cs-react" data-value="approve">Approve</button>
124
+ <button class="cs-react" data-value="reject">Reject</button>
125
+ <button class="cs-react" data-value="comment">Just a comment</button>`;
126
+ }
127
+
128
+ return `${commentTextarea}
129
+ <div class="cs-react-row">
130
+ ${buttons}
131
+ </div>`;
132
+ }
133
+
134
+ /** Renders the interactive (pre-submission) control HTML for a question. */
135
+ export function renderControl(question: Question): string {
136
+ const idAttr = escapeAttr(question.id);
137
+ const header = renderSectionHeader(question);
138
+ let controlHtml: string;
139
+
140
+ switch (question.type) {
141
+ case "pick_one":
142
+ controlHtml = renderPickOneControl(question);
143
+ break;
144
+ case "pick_many":
145
+ controlHtml = renderPickManyControl(question);
146
+ break;
147
+ case "confirm":
148
+ controlHtml = renderConfirmControl(question);
149
+ break;
150
+ case "ask_text":
151
+ controlHtml = renderAskTextControl(question);
152
+ break;
153
+ case "slider":
154
+ controlHtml = renderSliderControl(question);
155
+ break;
156
+ case "react":
157
+ controlHtml = renderReactControl(question);
158
+ break;
159
+ }
160
+
161
+ return `<section class="cs-control-${question.type}" data-question-id="${idAttr}">
162
+ ${header}
163
+ ${controlHtml}
164
+ </section>`;
165
+ }
166
+
167
+ // ─── renderAnswered — read-only post-submission ───────────────────────────────
168
+
169
+ function renderAnsweredPickOne(
170
+ q: Extract<Question, { type: "pick_one" }>,
171
+ answer: Extract<AnswerValue, { type: "pick_one" }>,
172
+ ): string {
173
+ const opt = q.options.find((o) => o.id === answer.selected);
174
+ const label = opt !== undefined ? escapeHtml(opt.label) : escapeHtml(answer.selected);
175
+ const descLine =
176
+ opt?.description !== undefined
177
+ ? `\n <p class="cs-pick-desc">${escapeHtml(opt.description)}</p>`
178
+ : "";
179
+ return ` <div class="cs-pick cs-pick-final">
180
+ <strong>${label}</strong>${descLine}
181
+ </div>`;
182
+ }
183
+
184
+ function renderAnsweredPickMany(
185
+ q: Extract<Question, { type: "pick_many" }>,
186
+ answer: Extract<AnswerValue, { type: "pick_many" }>,
187
+ ): string {
188
+ return answer.selected
189
+ .map((sel) => {
190
+ const opt = q.options.find((o) => o.id === sel);
191
+ const label = opt !== undefined ? escapeHtml(opt.label) : escapeHtml(sel);
192
+ const descLine =
193
+ opt?.description !== undefined
194
+ ? `\n <p class="cs-pick-desc">${escapeHtml(opt.description)}</p>`
195
+ : "";
196
+ return ` <div class="cs-pick cs-pick-final">
197
+ <strong>${label}</strong>${descLine}
198
+ </div>`;
199
+ })
200
+ .join("\n");
201
+ }
202
+
203
+ function renderAnsweredConfirm(
204
+ q: Extract<Question, { type: "confirm" }>,
205
+ answer: Extract<AnswerValue, { type: "confirm" }>,
206
+ ): string {
207
+ const yesClass =
208
+ answer.choice === "yes" ? "cs-confirm cs-confirm-final cs-yes" : "cs-confirm cs-no";
209
+ const noClass =
210
+ answer.choice === "no" ? "cs-confirm cs-confirm-final cs-no" : "cs-confirm cs-yes";
211
+ const yesLabel = escapeHtml(q.yesLabel ?? "Yes");
212
+ const noLabel = escapeHtml(q.noLabel ?? "No");
213
+ if (answer.choice === "yes") {
214
+ return ` <div class="cs-confirm-row">
215
+ <button class="${yesClass}" disabled>${yesLabel}</button>
216
+ <button class="${noClass}" disabled aria-disabled="true" style="opacity: 0.4;">${noLabel}</button>
217
+ </div>`;
218
+ }
219
+ return ` <div class="cs-confirm-row">
220
+ <button class="${yesClass}" disabled aria-disabled="true" style="opacity: 0.4;">${yesLabel}</button>
221
+ <button class="${noClass}" disabled>${noLabel}</button>
222
+ </div>`;
223
+ }
224
+
225
+ function renderAnsweredAskText(
226
+ q: Extract<Question, { type: "ask_text" }>,
227
+ answer: Extract<AnswerValue, { type: "ask_text" }>,
228
+ ): string {
229
+ if (answer.text === "" && q.optional === true) {
230
+ return ` <p class="cs-answered-skipped"><em>(skipped)</em></p>`;
231
+ }
232
+ const escaped = escapeHtml(answer.text);
233
+ const withBreaks = escaped.replace(/\n/g, "<br>");
234
+ return ` <blockquote class="cs-answered-text">${withBreaks}</blockquote>`;
235
+ }
236
+
237
+ function renderAnsweredSlider(answer: Extract<AnswerValue, { type: "slider" }>): string {
238
+ return ` <p class="cs-slider-final">Value: <strong>${answer.value}</strong></p>`;
239
+ }
240
+
241
+ function renderAnsweredReact(answer: Extract<AnswerValue, { type: "react" }>): string {
242
+ const decisionClass = `cs-react cs-confirm-final`;
243
+ const commentHtml =
244
+ answer.comment !== undefined && answer.comment !== ""
245
+ ? `\n <p class="cs-comment">${escapeHtml(answer.comment)}</p>`
246
+ : "";
247
+ return ` <div class="cs-react-row">
248
+ <button class="${decisionClass}" disabled>${escapeHtml(answer.decision)}</button>
249
+ </div>${commentHtml}`;
250
+ }
251
+
252
+ /** Renders the read-only post-submission answered section for a question. */
253
+ export function renderAnswered(question: Question, answer: AnswerValue): string {
254
+ const idAttr = escapeAttr(question.id);
255
+ const qTextEsc = escapeHtml(question.question);
256
+ let valueHtml: string;
257
+
258
+ switch (answer.type) {
259
+ case "pick_one": {
260
+ if (question.type !== "pick_one") {
261
+ valueHtml = "";
262
+ break;
263
+ }
264
+ valueHtml = renderAnsweredPickOne(question, answer);
265
+ break;
266
+ }
267
+ case "pick_many": {
268
+ if (question.type !== "pick_many") {
269
+ valueHtml = "";
270
+ break;
271
+ }
272
+ valueHtml = renderAnsweredPickMany(question, answer);
273
+ break;
274
+ }
275
+ case "confirm": {
276
+ if (question.type !== "confirm") {
277
+ valueHtml = "";
278
+ break;
279
+ }
280
+ valueHtml = renderAnsweredConfirm(question, answer);
281
+ break;
282
+ }
283
+ case "ask_text":
284
+ valueHtml = renderAnsweredAskText(
285
+ question as Extract<Question, { type: "ask_text" }>,
286
+ answer,
287
+ );
288
+ break;
289
+ case "slider":
290
+ valueHtml = renderAnsweredSlider(answer);
291
+ break;
292
+ case "react":
293
+ valueHtml = renderAnsweredReact(answer);
294
+ break;
295
+ }
296
+
297
+ return `<section class="cs-answered" data-question-id="${idAttr}">
298
+ <p class="eyebrow">YOU ANSWERED</p>
299
+ <h3 class="h-section">${qTextEsc}</h3>
300
+ ${valueHtml}
301
+ </section>`;
302
+ }
@@ -0,0 +1,360 @@
1
+ // Pure HTML body analyzer — walks parse5 AST and returns structured findings + 0-100 score.
2
+ // Deterministic and pure: same input always yields the same output.
3
+
4
+ import { parseFragment, defaultTreeAdapter as ta } from "parse5";
5
+ import type { DefaultTreeAdapterMap, DefaultTreeAdapterTypes, ParserOptions } from "parse5";
6
+
7
+ type ChildNode = DefaultTreeAdapterTypes.ChildNode;
8
+ type Element = DefaultTreeAdapterTypes.Element;
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Public types
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export type CritiqueSeverity = "info" | "suggest" | "warn";
15
+
16
+ export interface CritiqueFinding {
17
+ severity: CritiqueSeverity;
18
+ /** Stable kebab-case identifier, e.g. "no-tldr". */
19
+ code: string;
20
+ /** Single sentence, agent-readable. */
21
+ message: string;
22
+ /** Populated when the rule represents an aggregate count. */
23
+ count?: number;
24
+ }
25
+
26
+ export interface CritiqueResult {
27
+ /** 0-100 score computed from findings. */
28
+ score: number;
29
+ /** Ordered: warn → suggest → info, then alphabetically by code. */
30
+ findings: CritiqueFinding[];
31
+ /** Sum of all text node values in the body (visible text content length). */
32
+ textLength: number;
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Internal constants
37
+ // ---------------------------------------------------------------------------
38
+
39
+ const HTTP_RE = /^https?:\/\//i;
40
+
41
+ /** The only cesium-* class the framework ships with. All others are unknown. */
42
+ const KNOWN_CESIUM_CLASSES = new Set(["cesium-back"]);
43
+
44
+ /** Callout severity modifiers — a callout needs at least one of these. */
45
+ const CALLOUT_MODIFIERS = new Set(["note", "warn", "risk"]);
46
+
47
+ /** Inline highlight span classes for .code blocks. */
48
+ const CODE_HIGHLIGHT_CLASSES = new Set(["kw", "str", "cm", "fn"]);
49
+
50
+ const SEVERITY_DEDUCTION: Record<CritiqueSeverity, number> = {
51
+ warn: 10,
52
+ suggest: 3,
53
+ info: 1,
54
+ };
55
+
56
+ const SEVERITY_ORDER: Record<CritiqueSeverity, number> = {
57
+ warn: 0,
58
+ suggest: 1,
59
+ info: 2,
60
+ };
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Tree-walking helpers
64
+ // ---------------------------------------------------------------------------
65
+
66
+ function walkNodes(nodes: readonly ChildNode[], visitor: (node: ChildNode) => void): void {
67
+ for (const node of nodes) {
68
+ visitor(node);
69
+ if (ta.isElementNode(node)) {
70
+ walkNodes(ta.getChildNodes(node as Element) as ChildNode[], visitor);
71
+ }
72
+ }
73
+ }
74
+
75
+ function getClasses(el: Element): Set<string> {
76
+ const attr = ta.getAttrList(el).find((a) => a.name === "class");
77
+ if (!attr || !attr.value.trim()) return new Set();
78
+ return new Set(attr.value.trim().split(/\s+/));
79
+ }
80
+
81
+ function attrVal(el: Element, name: string): string | undefined {
82
+ return ta.getAttrList(el).find((a) => a.name === name)?.value;
83
+ }
84
+
85
+ /** Recursively sum all text-node values — gives the visible text content length. */
86
+ function collectTextLength(nodes: readonly ChildNode[]): number {
87
+ let total = 0;
88
+ for (const node of nodes) {
89
+ if (ta.isTextNode(node)) {
90
+ total += node.value.length;
91
+ } else if (ta.isElementNode(node)) {
92
+ total += collectTextLength(ta.getChildNodes(node as Element) as ChildNode[]);
93
+ }
94
+ }
95
+ return total;
96
+ }
97
+
98
+ /** Returns true if any descendant of `el` carries a code-highlight class. */
99
+ function hasHighlightDescendant(el: Element): boolean {
100
+ let found = false;
101
+ walkNodes(ta.getChildNodes(el) as ChildNode[], (node) => {
102
+ if (found || !ta.isElementNode(node)) return;
103
+ const cls = getClasses(node as Element);
104
+ for (const c of CODE_HIGHLIGHT_CLASSES) {
105
+ if (cls.has(c)) {
106
+ found = true;
107
+ return;
108
+ }
109
+ }
110
+ });
111
+ return found;
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Scoring + ordering
116
+ // ---------------------------------------------------------------------------
117
+
118
+ function computeScore(findings: readonly CritiqueFinding[]): number {
119
+ let score = 100;
120
+ for (const f of findings) {
121
+ score -= SEVERITY_DEDUCTION[f.severity];
122
+ }
123
+ return Math.max(0, score);
124
+ }
125
+
126
+ function sortFindings(findings: CritiqueFinding[]): CritiqueFinding[] {
127
+ return [...findings].toSorted((a, b) => {
128
+ const sevDiff = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
129
+ if (sevDiff !== 0) return sevDiff;
130
+ return a.code.localeCompare(b.code);
131
+ });
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Main analyzer — pure, deterministic
136
+ // ---------------------------------------------------------------------------
137
+
138
+ export function critique(htmlBody: string): CritiqueResult {
139
+ const fragment = parseFragment(htmlBody);
140
+ const children = ta.getChildNodes(fragment) as ChildNode[];
141
+ const findings: CritiqueFinding[] = [];
142
+
143
+ // Visible text content length
144
+ const textLength = collectTextLength(children);
145
+
146
+ // Counters / flags accumulated in the single tree walk
147
+ let hDisplayCount = 0;
148
+ let hSectionCount = 0;
149
+ let tldrCount = 0;
150
+ let eyebrowCount = 0;
151
+ let calloutNoModifierCount = 0;
152
+ let externalResourceFound = false;
153
+ let inlineStyleCount = 0;
154
+ let hasSection = false;
155
+ const unknownCesiumClasses = new Set<string>();
156
+ let codeBlocksWithoutHighlights = 0;
157
+
158
+ walkNodes(children, (node) => {
159
+ if (!ta.isElementNode(node)) return;
160
+ const el = node as Element;
161
+ const tag = ta.getTagName(el);
162
+ const cls = getClasses(el);
163
+
164
+ // Section tag present?
165
+ if (tag === "section") hasSection = true;
166
+
167
+ // --- External resource detection (warn) ---
168
+ if (!externalResourceFound) {
169
+ if (tag === "script" && attrVal(el, "src") !== undefined) {
170
+ externalResourceFound = true;
171
+ } else if (tag === "link") {
172
+ const rel = (attrVal(el, "rel") ?? "").toLowerCase();
173
+ const href = attrVal(el, "href") ?? "";
174
+ if (rel === "stylesheet" && HTTP_RE.test(href)) {
175
+ externalResourceFound = true;
176
+ }
177
+ } else if (tag === "img") {
178
+ const src = attrVal(el, "src") ?? "";
179
+ if (HTTP_RE.test(src)) {
180
+ externalResourceFound = true;
181
+ }
182
+ }
183
+ }
184
+
185
+ // --- Class-based counts ---
186
+ if (cls.has("h-display")) hDisplayCount++;
187
+ if (cls.has("h-section")) hSectionCount++;
188
+ if (cls.has("tldr")) tldrCount++;
189
+ if (cls.has("eyebrow")) eyebrowCount++;
190
+
191
+ // Callout without a severity modifier
192
+ if (cls.has("callout")) {
193
+ let hasModifier = false;
194
+ for (const mod of CALLOUT_MODIFIERS) {
195
+ if (cls.has(mod)) {
196
+ hasModifier = true;
197
+ break;
198
+ }
199
+ }
200
+ if (!hasModifier) calloutNoModifierCount++;
201
+ }
202
+
203
+ // Unknown cesium-* class names
204
+ for (const c of cls) {
205
+ if (c.startsWith("cesium-") && !KNOWN_CESIUM_CLASSES.has(c)) {
206
+ unknownCesiumClasses.add(c);
207
+ }
208
+ }
209
+
210
+ // .code block without inline highlights
211
+ if (cls.has("code") && !hasHighlightDescendant(el)) {
212
+ codeBlocksWithoutHighlights++;
213
+ }
214
+
215
+ // Inline style attribute
216
+ if (attrVal(el, "style") !== undefined) inlineStyleCount++;
217
+ });
218
+
219
+ // Detect parse errors via a second parseFragment pass with onParseError
220
+ let parseErrors = 0;
221
+ const parseOpts: ParserOptions<DefaultTreeAdapterMap> = {
222
+ onParseError: () => {
223
+ parseErrors++;
224
+ },
225
+ };
226
+ parseFragment(htmlBody, parseOpts);
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // Emit warn-level findings
230
+ // ---------------------------------------------------------------------------
231
+
232
+ if (externalResourceFound) {
233
+ findings.push({
234
+ severity: "warn",
235
+ code: "external-resource",
236
+ message:
237
+ "External resource will be stripped at publish time. Use inline styles/scripts/data URIs.",
238
+ });
239
+ }
240
+
241
+ if (hDisplayCount > 1) {
242
+ findings.push({
243
+ severity: "warn",
244
+ code: "multiple-h-display",
245
+ message: "Only one .h-display per artifact (it's the page title).",
246
+ });
247
+ }
248
+
249
+ if (parseErrors > 0) {
250
+ findings.push({
251
+ severity: "warn",
252
+ code: "unbalanced-html",
253
+ message:
254
+ "Body has structural HTML issues; the browser will attempt recovery but layout may break.",
255
+ });
256
+ }
257
+
258
+ if (unknownCesiumClasses.size > 0) {
259
+ findings.push({
260
+ severity: "warn",
261
+ code: "unknown-cesium-class",
262
+ message: `Found ${unknownCesiumClasses.size} unknown cesium-* class names; the framework only ships with the documented vocabulary.`,
263
+ count: unknownCesiumClasses.size,
264
+ });
265
+ }
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // Emit suggest-level findings
269
+ // ---------------------------------------------------------------------------
270
+
271
+ if (hDisplayCount === 0) {
272
+ findings.push({
273
+ severity: "suggest",
274
+ code: "no-h-display",
275
+ message: "No .h-display heading; agent should give the artifact a clear page title.",
276
+ });
277
+ }
278
+
279
+ if (tldrCount === 0 && textLength > 1500) {
280
+ findings.push({
281
+ severity: "suggest",
282
+ code: "no-tldr",
283
+ message: "Long artifact with no .tldr summary; consider adding one near the top.",
284
+ });
285
+ }
286
+
287
+ if (eyebrowCount === 0) {
288
+ findings.push({
289
+ severity: "suggest",
290
+ code: "no-eyebrow",
291
+ message: "No .eyebrow micro-labels — they help anchor sections and document type.",
292
+ });
293
+ }
294
+
295
+ if (textLength > 1200 && hSectionCount === 0 && !hasSection) {
296
+ findings.push({
297
+ severity: "suggest",
298
+ code: "unsectioned-long-body",
299
+ message: "Long body has no section markers; readability suffers.",
300
+ });
301
+ }
302
+
303
+ if (calloutNoModifierCount > 0) {
304
+ findings.push({
305
+ severity: "suggest",
306
+ code: "callout-without-modifier",
307
+ message: `${calloutNoModifierCount} callout${calloutNoModifierCount === 1 ? "" : "s"} have no severity modifier (.note/.warn/.risk).`,
308
+ count: calloutNoModifierCount,
309
+ });
310
+ }
311
+
312
+ if (textLength < 250) {
313
+ findings.push({
314
+ severity: "suggest",
315
+ code: "body-too-short",
316
+ message:
317
+ "Very short body; a terminal reply may be more appropriate than a published artifact.",
318
+ });
319
+ }
320
+
321
+ // ---------------------------------------------------------------------------
322
+ // Emit info-level findings
323
+ // ---------------------------------------------------------------------------
324
+
325
+ if (textLength > 25000) {
326
+ findings.push({
327
+ severity: "info",
328
+ code: "body-very-long",
329
+ message: "Very long artifact; consider splitting into linked smaller pieces.",
330
+ });
331
+ }
332
+
333
+ if (codeBlocksWithoutHighlights > 0) {
334
+ findings.push({
335
+ severity: "info",
336
+ code: "code-without-highlights",
337
+ message:
338
+ "Code blocks render without inline highlights; readers benefit from .kw/.str/.cm/.fn spans.",
339
+ count: codeBlocksWithoutHighlights,
340
+ });
341
+ }
342
+
343
+ if (inlineStyleCount > 8) {
344
+ findings.push({
345
+ severity: "info",
346
+ code: "inline-style-heavy",
347
+ message: "Heavy reliance on inline styles; named classes are usually clearer.",
348
+ count: inlineStyleCount,
349
+ });
350
+ }
351
+
352
+ // ---------------------------------------------------------------------------
353
+ // Sort, score, return
354
+ // ---------------------------------------------------------------------------
355
+
356
+ const sorted = sortFindings(findings);
357
+ const score = computeScore(sorted);
358
+
359
+ return { score, findings: sorted, textLength };
360
+ }