@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.
- package/ARCHITECTURE.md +304 -0
- package/CHANGELOG.md +335 -0
- package/LICENSE +21 -0
- package/README.md +479 -0
- package/agents/cesium.md +39 -0
- package/assets/styleguide.html +857 -0
- package/package.json +61 -0
- package/src/cli/commands/ls.ts +186 -0
- package/src/cli/commands/open.ts +208 -0
- package/src/cli/commands/prune.ts +348 -0
- package/src/cli/commands/restart.ts +38 -0
- package/src/cli/commands/serve.ts +214 -0
- package/src/cli/commands/stop.ts +130 -0
- package/src/cli/commands/theme.ts +333 -0
- package/src/cli/index.ts +78 -0
- package/src/config.ts +94 -0
- package/src/index.ts +35 -0
- package/src/prompt/system-fragment.md +97 -0
- package/src/render/client-js.ts +316 -0
- package/src/render/controls.ts +302 -0
- package/src/render/critique.ts +360 -0
- package/src/render/extract.ts +83 -0
- package/src/render/scrub.ts +141 -0
- package/src/render/theme.ts +712 -0
- package/src/render/validate.ts +524 -0
- package/src/render/wrap.ts +165 -0
- package/src/server/api.ts +166 -0
- package/src/server/http.ts +195 -0
- package/src/server/lifecycle.ts +331 -0
- package/src/server/stop.ts +124 -0
- package/src/storage/index-cache.ts +71 -0
- package/src/storage/index-gen.ts +447 -0
- package/src/storage/lock.ts +108 -0
- package/src/storage/mutate.ts +396 -0
- package/src/storage/paths.ts +159 -0
- package/src/storage/project-summaries.ts +19 -0
- package/src/storage/theme-write.ts +19 -0
- package/src/storage/write.ts +75 -0
- package/src/tools/ask.ts +353 -0
- package/src/tools/critique.ts +66 -0
- package/src/tools/publish.ts +404 -0
- package/src/tools/stop.ts +53 -0
- package/src/tools/styleguide.ts +23 -0
- 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, "<")
|
|
11
|
+
.replace(/>/g, ">")
|
|
12
|
+
.replace(/"/g, """);
|
|
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
|
+
}
|