optbrew 0.1.2 → 0.2.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 (119) hide show
  1. package/.claude-plugin/marketplace.json +4 -4
  2. package/.claude-plugin/plugin.json +2 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/AGENT_INSTALL.md +94 -0
  5. package/CHANGELOG.md +74 -1
  6. package/README.ko.md +129 -105
  7. package/README.md +129 -105
  8. package/THIRD_PARTY_NOTICES.md +88 -0
  9. package/docs/platforms.md +45 -8
  10. package/docs/release.md +37 -4
  11. package/lib/cli.mjs +5 -2
  12. package/lib/install.mjs +2 -1
  13. package/lib/records.mjs +2 -1
  14. package/lib/transaction.mjs +12 -5
  15. package/lib/update.mjs +3 -2
  16. package/package.json +29 -9
  17. package/plugin.json +2 -2
  18. package/skills/.claude-plugin/icon.png +0 -0
  19. package/skills/.claude-plugin/plugin.json +23 -0
  20. package/skills/README.md +27 -0
  21. package/skills/optbrew/SKILL.md +223 -8
  22. package/skills/optbrew/THIRD_PARTY_NOTICES.md +88 -0
  23. package/skills/optbrew/bundle-manifest.json +409 -49
  24. package/skills/optbrew/modules/naver-searchadvisor/scripts/audit.mjs +4 -4
  25. package/skills/optbrew/references/ai-crawlers.json +1 -1
  26. package/skills/optbrew/references/ai-crawlers.md +1 -1
  27. package/skills/optbrew/references/api.md +6 -4
  28. package/skills/optbrew/references/check-registry.json +16 -16
  29. package/skills/optbrew/references/chrome-paths.json +31 -0
  30. package/skills/optbrew/references/endpoints.json +10 -0
  31. package/skills/optbrew/references/harness-signals.json +5 -0
  32. package/skills/optbrew/references/intake.md +76 -5
  33. package/skills/optbrew/references/judging.md +233 -0
  34. package/skills/optbrew/references/limits.json +26 -0
  35. package/skills/optbrew/references/measurement.md +1 -1
  36. package/skills/optbrew/references/messages.ko.json +170 -0
  37. package/skills/optbrew/references/report.md +18 -6
  38. package/skills/optbrew/references/routing.md +2 -2
  39. package/skills/optbrew/references/runbooks/anthropic-api-key.md +34 -0
  40. package/skills/optbrew/references/runbooks/chrome-install.md +56 -0
  41. package/skills/optbrew/references/runbooks/cwv-site-collection.md +38 -0
  42. package/skills/optbrew/references/runbooks/google-api-key.md +51 -0
  43. package/skills/optbrew/references/runbooks/gsc-export.md +32 -0
  44. package/skills/optbrew/references/runbooks/index.json +18 -0
  45. package/skills/optbrew/references/runbooks/key-set-claude-cloud.md +32 -0
  46. package/skills/optbrew/references/runbooks/key-set-codex.md +33 -0
  47. package/skills/optbrew/references/runbooks/key-set-local.md +40 -0
  48. package/skills/optbrew/references/runbooks/naver-searchadvisor-export.md +32 -0
  49. package/skills/optbrew/references/runbooks/network-claude-cloud-custom.md +33 -0
  50. package/skills/optbrew/references/runbooks/network-codex-approval.md +26 -0
  51. package/skills/optbrew/references/runbooks/network-codex-config.md +36 -0
  52. package/skills/optbrew/references/runbooks/seranking.md +38 -0
  53. package/skills/optbrew/references/schemas/contract.schema.json +42 -15
  54. package/skills/optbrew/references/scoring.md +40 -5
  55. package/skills/optbrew/references/source-analysis.md +40 -0
  56. package/skills/optbrew/references/versions.json +9 -0
  57. package/skills/optbrew/scripts/adapters/anthropic.mjs +5 -5
  58. package/skills/optbrew/scripts/adapters/imports.mjs +138 -1
  59. package/skills/optbrew/scripts/audit.mjs +10 -96
  60. package/skills/optbrew/scripts/cli.mjs +5 -2
  61. package/skills/optbrew/scripts/core/bundle.mjs +30 -13
  62. package/skills/optbrew/scripts/core/capabilities.mjs +8 -4
  63. package/skills/optbrew/scripts/core/cdp.mjs +105 -0
  64. package/skills/optbrew/scripts/core/checks/canonical-consistency.mjs +56 -0
  65. package/skills/optbrew/scripts/core/checks/cwv-field.mjs +77 -0
  66. package/skills/optbrew/scripts/core/checks/discovery.mjs +43 -0
  67. package/skills/optbrew/scripts/core/checks/document-title.mjs +62 -0
  68. package/skills/optbrew/scripts/core/checks/entity-identity.mjs +31 -0
  69. package/skills/optbrew/scripts/core/checks/fact-consistency.mjs +31 -0
  70. package/skills/optbrew/scripts/core/checks/fetch-response.mjs +18 -0
  71. package/skills/optbrew/scripts/core/checks/index-policy.mjs +16 -0
  72. package/skills/optbrew/scripts/core/checks/index.mjs +18 -0
  73. package/skills/optbrew/scripts/core/checks/internal-navigation.mjs +67 -0
  74. package/skills/optbrew/scripts/core/checks/locale-links.mjs +55 -0
  75. package/skills/optbrew/scripts/core/checks/naver-markup.mjs +21 -0
  76. package/skills/optbrew/scripts/core/checks/observation.mjs +37 -0
  77. package/skills/optbrew/scripts/core/checks/prepare.mjs +103 -0
  78. package/skills/optbrew/scripts/core/checks/render-content.mjs +104 -0
  79. package/skills/optbrew/scripts/core/checks/search-crawl-policy.mjs +29 -0
  80. package/skills/optbrew/scripts/core/checks/structured-data-truth.mjs +46 -0
  81. package/skills/optbrew/scripts/core/chrome-temp.mjs +68 -0
  82. package/skills/optbrew/scripts/core/chrome.mjs +546 -0
  83. package/skills/optbrew/scripts/core/collector.mjs +65 -59
  84. package/skills/optbrew/scripts/core/compare.mjs +18 -1
  85. package/skills/optbrew/scripts/core/config.mjs +31 -0
  86. package/skills/optbrew/scripts/core/contracts.mjs +31 -2
  87. package/skills/optbrew/scripts/core/cwv.mjs +100 -0
  88. package/skills/optbrew/scripts/core/derive.mjs +10 -161
  89. package/skills/optbrew/scripts/core/evidence-store.mjs +112 -0
  90. package/skills/optbrew/scripts/core/facts.mjs +203 -19
  91. package/skills/optbrew/scripts/core/findings.mjs +19 -1
  92. package/skills/optbrew/scripts/core/html.mjs +41 -3
  93. package/skills/optbrew/scripts/core/index-targets.mjs +118 -0
  94. package/skills/optbrew/scripts/core/judgments.mjs +675 -0
  95. package/skills/optbrew/scripts/core/keys.mjs +86 -0
  96. package/skills/optbrew/scripts/core/link-graph.mjs +93 -0
  97. package/skills/optbrew/scripts/core/messages.mjs +23 -0
  98. package/skills/optbrew/scripts/core/metrics.mjs +4 -2
  99. package/skills/optbrew/scripts/core/migration.mjs +37 -0
  100. package/skills/optbrew/scripts/core/page-select.mjs +136 -0
  101. package/skills/optbrew/scripts/core/probe.mjs +83 -0
  102. package/skills/optbrew/scripts/core/protocol.mjs +38 -14
  103. package/skills/optbrew/scripts/core/render.mjs +160 -0
  104. package/skills/optbrew/scripts/core/report-compose.mjs +377 -0
  105. package/skills/optbrew/scripts/core/report-html.mjs +198 -0
  106. package/skills/optbrew/scripts/core/report-page.mjs +14 -0
  107. package/skills/optbrew/scripts/core/report.mjs +357 -131
  108. package/skills/optbrew/scripts/core/runbooks.mjs +63 -0
  109. package/skills/optbrew/scripts/core/scope.mjs +23 -5
  110. package/skills/optbrew/scripts/core/score.mjs +12 -4
  111. package/skills/optbrew/scripts/core/secrets.mjs +2 -0
  112. package/skills/optbrew/scripts/core/stages.mjs +694 -0
  113. package/skills/optbrew/scripts/core/transport.mjs +157 -0
  114. package/skills/optbrew/scripts/core/weights.mjs +2 -1
  115. package/skills/optbrew/scripts/key.mjs +90 -0
  116. package/skills/optbrew/scripts/preflight.mjs +176 -0
  117. package/skills/optbrew/scripts/run.mjs +81 -0
  118. package/skills/optbrew/source-lock.json +1 -1
  119. package/source-lock.json +1 -1
@@ -0,0 +1,198 @@
1
+ import { SECTION_TITLES, SYNTHETIC_BANNER, UNKNOWN, coverageOf, fixEffort, fixMethod, headlineLine, pagesLabel, percent, resolved } from './report-compose.mjs';
2
+ import { runbookEntry } from './runbooks.mjs';
3
+
4
+ /** Fixes shown open in the "fix first" section; the rest wait inside one fold so the top stays short. */
5
+ const FIXES_SHOWN = 10;
6
+ const LEVEL_NAMES = { beginner: '자세한 단계', brief: '요약' };
7
+ const VERDICTS = { sufficient: '충분', insufficient_coverage: '확인 부족', insufficient_core: '핵심 검사 빠짐', unaudited: '감사 안 됨' };
8
+
9
+ /** Every value that came from the audited site or from the user passes through here: the site is untrusted input. */
10
+ const esc = value => String(value ?? UNKNOWN).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
11
+ /** Runbook inline marks, applied after escaping: `code` spans first, then **bold** outside them, so the tags never cross. */
12
+ const inline = text => esc(text).split(/(`[^`]+`)/).map(part => part.startsWith('`') && part.endsWith('`') && part.length > 1 ?
13
+ `<code>${part.slice(1, -1)}</code>` : part.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')).join('');
14
+
15
+ // A runbook step shows a drawing only when its author names the screen zone with a trailing tag, `{화면: 주소창}`.
16
+ // A zone is never guessed from the words of a step: a wrong mark sends a beginner to the wrong place.
17
+ const ZONES = { '주소창': 'address', '화면 위쪽': 'top', '왼쪽 메뉴': 'side', '화면 가운데': 'main', '새 창': 'dialog', '터미널': 'terminal' };
18
+ const ZONE_TAG = /\s*\{화면:\s*([^}]+?)\s*\}\s*$/;
19
+ // The highlighted box of each zone in the 160 x 100 drawing: x, y, width, height.
20
+ const AREAS = { address: [34, 3, 100, 10], top: [1, 1, 158, 14], side: [1, 16, 36, 83], main: [44, 24, 108, 68], dialog: [38, 28, 84, 46], terminal: [8, 52, 144, 16] };
21
+ const BROWSER_FRAME = '<rect class="win" x=".5" y=".5" width="159" height="99" rx="6"/><rect class="bar" x="1" y="1" width="158" height="14" rx="5"/>' +
22
+ '<circle class="dot" cx="8" cy="8" r="2"/><circle class="dot" cx="15" cy="8" r="2"/><circle class="dot" cx="22" cy="8" r="2"/><rect class="pill" x="34" y="3" width="100" height="10" rx="5"/>' +
23
+ '<rect class="pane" x="1" y="16" width="36" height="83"/><rect class="ln" x="48" y="30" width="96" height="5" rx="2"/><rect class="ln" x="48" y="44" width="80" height="5" rx="2"/><rect class="ln" x="48" y="58" width="88" height="5" rx="2"/>';
24
+ const TERMINAL_FRAME = '<rect class="term" x=".5" y=".5" width="159" height="99" rx="6"/><rect class="tl" x="8" y="12" width="60" height="4" rx="2"/><rect class="tl" x="8" y="26" width="90" height="4" rx="2"/>' +
25
+ '<rect class="tl" x="8" y="40" width="40" height="4" rx="2"/><rect class="tl" x="8" y="58" width="70" height="4" rx="2"/>';
26
+
27
+ /** One small drawing: a schematic window with the named zone marked and the step number on it. */
28
+ function diagram(name, number) {
29
+ const zone = ZONES[name];
30
+ const [x, y, width, height] = AREAS[zone];
31
+ const [cx, cy] = [x + width / 2, y + height / 2];
32
+ const label = `${number}단계 대략의 화면 위치: ${name}`;
33
+ return `<svg class="shot" viewBox="0 0 160 100" role="img" aria-label="${esc(label)}"><title>${esc(label)}</title>${zone === 'terminal' ? TERMINAL_FRAME : BROWSER_FRAME}` +
34
+ `<rect class="hit" x="${x}" y="${y}" width="${width}" height="${height}" rx="4"/><circle class="badge" cx="${cx}" cy="${cy}" r="8"/>` +
35
+ `<text class="num" x="${cx}" y="${cy + 3.5}" text-anchor="middle">${number}</text></svg>`;
36
+ }
37
+
38
+ /** A runbook body as blocks: numbered steps, bullets, code fences and paragraphs. */
39
+ function blocksOf(text) {
40
+ const blocks = [];
41
+ let fence = null;
42
+ for (const raw of text.replace(/\r\n/g, '\n').split('\n')) {
43
+ if (fence) {
44
+ if (raw.startsWith('```')) { blocks.push({ kind: 'pre', items: fence }); fence = null; } else fence.push(raw);
45
+ continue;
46
+ }
47
+ if (raw.startsWith('```')) { fence = []; continue; }
48
+ const step = /^(\d+)\.\s+(.*)$/.exec(raw), bullet = /^[-*]\s+(.*)$/.exec(raw);
49
+ const kind = step ? 'ol' : bullet ? 'ul' : raw.trim() ? 'p' : null;
50
+ if (!kind) continue;
51
+ const item = step ? step[2] : bullet ? bullet[1] : raw.trim();
52
+ const last = blocks.at(-1);
53
+ if (kind !== 'p' && last?.kind === kind) last.items.push(item);
54
+ else blocks.push({ kind, start: step ? Number(step[1]) : 1, items: [item] });
55
+ }
56
+ if (fence) blocks.push({ kind: 'pre', items: fence });
57
+ return blocks;
58
+ }
59
+
60
+ /** A numbered list keeps the author's numbers; a step carries a drawing only where it names a zone. Returns the markup and the drawings made. */
61
+ function listHtml({ kind, start, items }) {
62
+ let drawings = 0;
63
+ const rows = items.map((item, index) => {
64
+ const tag = kind === 'ol' ? ZONE_TAG.exec(item) : null;
65
+ const text = item.replace(ZONE_TAG, '');
66
+ const picture = tag && Object.hasOwn(ZONES, tag[1]) ? (drawings += 1, diagram(tag[1], start + index)) : '';
67
+ return picture ? `<li><div class="step"><span class="say">${inline(text)}</span>${picture}</div></li>` : `<li>${inline(text)}</li>`;
68
+ });
69
+ return { html: `<${kind}${kind === 'ol' ? ` start="${start}"` : ''}>${rows.join('')}</${kind}>`, drawings };
70
+ }
71
+
72
+ const runbookHtml = (runbook, level) => {
73
+ const name = runbookEntry(runbook.id).item.split(' — ')[0];
74
+ let drawings = 0;
75
+ const body = blocksOf(runbook.text).map(block => {
76
+ if (block.kind === 'pre') return `<pre><code>${esc(block.items.join('\n'))}</code></pre>`;
77
+ if (block.kind === 'p') return `<p>${inline(block.items[0])}</p>`;
78
+ const list = listHtml(block);
79
+ drawings += list.drawings;
80
+ return list.html;
81
+ }).join('');
82
+ const note = drawings ? ' 그림은 화면 위치를 보여 주는 도식입니다. 실제 화면과 모양이 다를 수 있습니다.' : '';
83
+ return `<details class="runbook" data-runbook="${esc(runbook.id)}" data-level="${esc(level)}"><summary>${esc(name)} · ${esc(LEVEL_NAMES[level])}</summary>` +
84
+ `<p class="note">확인일 ${esc(runbook.checked)}.${note}</p>${body}</details>`;
85
+ };
86
+
87
+ const fixHtml = fix => {
88
+ const chip = resolved(fix) ? '<span class="chip ok">이미 고침</span>' : fix.blocker ? '<span class="chip hot">가장 급함</span>' : `<span class="chip">${esc(fix.priority_label)}</span>`;
89
+ const rows = [['근거', fix.sources.map(x => `${x.source_ref} ${x.evidence_ref}`).join('; ') || UNKNOWN],
90
+ ['해결 방법', fixMethod(fix)], ['비용 / 노력', fixEffort(fix)],
91
+ ['확인 방법', fix.text_ko.remediation?.validation ?? UNKNOWN], ['상태', fix.status_label]];
92
+ return `<li class="fix"><div class="head">${chip}<span class="msg">${esc(fix.text_ko.message)}</span></div><p class="pages">${esc(pagesLabel(fix))}${fix.text_ko.cited ? ` ${esc(fix.text_ko.cited)}` : ''}</p>` +
93
+ `<p class="where"><strong>고칠 곳</strong> ${esc(fix.where)}</p><details><summary>자세히 보기</summary><dl>${rows.map(([term, value]) => `<dt>${term}</dt><dd>${esc(value)}</dd>`).join('')}</dl></details></li>`;
94
+ };
95
+
96
+ const fixesSection = ({ fixes }) => {
97
+ const shown = fixes.slice(0, FIXES_SHOWN), rest = fixes.slice(FIXES_SHOWN);
98
+ const list = items => `<ol class="fixes">${items.map(fixHtml).join('')}</ol>`;
99
+ return `<section id="fixes"><h2>${SECTION_TITLES.fixes}</h2>${fixes.length ? list(shown) : '<p>고칠 것이 없습니다. 확인한 범위에서는 실패한 검사가 없었습니다.</p>'}` +
100
+ `${rest.length ? `<details><summary>나머지 ${rest.length}건 보기</summary>${list(rest)}</details>` : ''}</section>`;
101
+ };
102
+
103
+ /** One short plain sentence for the verdict of a phase; the full line waits in the fold. */
104
+ const sentenceOf = h => h.status === 'sufficient' ? `확인한 범위의 통과율은 ${h.headline_score}점입니다.` :
105
+ h.status === 'insufficient_coverage' ? `${h.coverage_threshold}% 이상 확인해야 점수를 냅니다. 지금 ${percent(coverageOf(h))}입니다.` :
106
+ h.status === 'insufficient_core' ? `접근·색인 검사 ${esc(h.unresolved_core)}건에 결과가 없어 점수를 내지 않습니다.` : '감사 기록이 없어 점수를 내지 않습니다.';
107
+
108
+ const profileHtml = (row, excluded) => {
109
+ const phase = row.after ?? row.before, h = phase.headline, shown = coverageOf(h);
110
+ const difference = row.after ? `<p>확인 범위 이전 ${percent(coverageOf(row.before.headline))} → 이후 ${percent(coverageOf(row.after.headline))}; ${typeof row.difference === 'number' ? `이전 → 이후 차이 ${row.difference}점${row.between_measured_subsets ? ' (확인한 범위끼리의 비교)' : ''}` : '두 단계를 비교할 수 없어 차이는 알 수 없습니다'}</p>` : '';
111
+ return `<li class="profile"><div class="head"><strong>${esc(row.profile)}</strong><span class="chip">${esc(VERDICTS[h.status])}</span></div>` +
112
+ `${typeof shown === 'number' ? `<meter min="0" max="100" value="${shown}" aria-label="확인 범위 ${shown}%">${shown}%</meter>` : ''}` +
113
+ `<p class="big">확인 범위 ${percent(shown)}</p><p>${sentenceOf(h)}</p>` +
114
+ `${excluded ? `<p class="note">이번 실행에서 모델이 해당 없음으로 뺀 단위는 모두 ${excluded}건이고, 점수를 내는 영역에 따라 일부만 이 확인 범위에서 빠집니다.</p>` : ''}` +
115
+ `<details><summary>자세히</summary><p>${esc(headlineLine(h))}</p>${difference}<p class="note">방문자 실측(CWV)을 뺀 확인 범위 ${percent(row.coverage_without_cwv)}</p></details></li>`;
116
+ };
117
+
118
+ const completenessSection = ({ completeness, conditions }) =>
119
+ `<section id="completeness"><h2>${SECTION_TITLES.completeness}</h2><p class="note">점수는 확인한 범위 안의 통과율입니다. 실제 검색 성과가 아닙니다.</p>` +
120
+ `${completeness.length ? `<ul class="profiles">${completeness.map(row => profileHtml(row, Object.values(conditions.model_not_applicable).reduce((sum, n) => sum + n, 0))).join('')}</ul>` : '<p>점수를 낼 영역이 없어 확인 범위를 알 수 없습니다.</p>'}</section>`;
121
+
122
+ const blankSection = (composed, runbooks, level) => {
123
+ const byId = new Map(runbooks.map(runbook => [runbook.id, runbook]));
124
+ const items = composed.remaining.map(row => `<li class="blank"><p class="msg">${esc(row.item)}</p>` +
125
+ `${row.fills.length ? `<p class="note">채워지는 검사: ${esc(row.fills.join(', '))}</p>` : ''}` +
126
+ `${row.runbook_ids.map(id => {
127
+ if (!byId.has(id)) throw new Error(`runbook missing for the report: ${id}`);
128
+ return runbookHtml(byId.get(id), level);
129
+ }).join('')}</li>`);
130
+ return `<section id="blank"><h2>${SECTION_TITLES.blank}</h2>${items.length ? `<ul class="blanks">${items.join('')}</ul>` : '<p>비어 있는 곳이 없습니다.</p>'}</section>`;
131
+ };
132
+
133
+ const followUpsSection = ({ follow_ups: followUps }) => {
134
+ const rows = followUps.map(({ text_ko: item }) => `<li class="blank"><p class="msg">${esc(item.problem)}</p><dl><dt>필요한 입력 / 권한</dt><dd>${esc(item.required_input_or_authority)}</dd><dt>다음 행동</dt><dd>${esc(item.next_action)}</dd><dt>다시 볼 때</dt><dd>${esc(item.reobserve_when)}</dd></dl></li>`);
135
+ return `<section id="remaining"><h2>${SECTION_TITLES.remaining}</h2><details><summary>${followUps.length}건 보기</summary>` +
136
+ `${rows.length ? `<ul class="blanks">${rows.join('')}</ul>` : '<p>남은 일이 없습니다.</p>'}</details></section>`;
137
+ };
138
+
139
+ const conditionsSection = ({ conditions }) =>
140
+ `<section id="conditions"><details><summary>${SECTION_TITLES.conditions}</summary><dl>${conditions.lines.map(({ label, value }) => `<dt>${esc(label)}</dt><dd>${esc(value)}</dd>`).join('')}</dl>` +
141
+ `${conditions.notes.map(note => `<p class="note">${esc(note)}</p>`).join('')}</details></section>`;
142
+
143
+ const STYLE = `
144
+ :root{color-scheme:light dark;--bg:#fff;--fg:#1d2430;--mut:#4d5868;--card:#f4f6f9;--line:#cfd6e0;--acc:#0b57d0;--on-acc:#fff;--hot:#a8201a;--ok:#146c2e;--chip:#e3ebfb;--hit:rgba(11,87,208,.18)}
145
+ @media (prefers-color-scheme:dark){:root{--bg:#10151c;--fg:#e8ecf2;--mut:#a9b4c4;--card:#1a212b;--line:#35414f;--acc:#8ab4f8;--on-acc:#10151c;--hot:#f2b8b5;--ok:#8ddba3;--chip:#243452;--hit:rgba(138,180,248,.22)}}
146
+ *{box-sizing:border-box}
147
+ body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.65 system-ui,"Malgun Gothic","Apple SD Gothic Neo",sans-serif;word-break:keep-all;overflow-wrap:anywhere}
148
+ main{max-width:46rem;margin:0 auto;padding:1.5rem 1rem 4rem}
149
+ h1{font-size:1.5rem;margin:0 0 .25rem}h2{font-size:1.25rem;margin:2.25rem 0 .75rem}
150
+ section{margin-top:1rem}p{margin:.4rem 0}ol,ul{padding-left:1.25rem;margin:.5rem 0}
151
+ .target{color:var(--mut);margin:0 0 1rem}
152
+ .banner{border:2px solid var(--hot);border-radius:10px;padding:.5rem .8rem;font-weight:700;margin:0 0 1rem}
153
+ .hero{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:1.1rem 1.1rem 1.25rem}
154
+ .hero .lead{font-size:1.25rem;font-weight:700;margin:0 0 .9rem}
155
+ .tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(8.5rem,1fr));gap:.6rem;margin:0;padding:0;list-style:none}
156
+ .tiles li{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:.6rem .8rem}
157
+ .tiles b{display:block;font-size:1.6rem;line-height:1.2}
158
+ .skipped{margin:.9rem 0 0;border-top:1px solid var(--line);padding-top:.7rem}
159
+ .fixes,.blanks,.profiles{list-style:none;padding:0}
160
+ .fix,.blank,.profile{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:.8rem .9rem;margin:.6rem 0}
161
+ .head{display:flex;gap:.5rem;align-items:baseline;flex-wrap:wrap}.msg{font-weight:700}
162
+ .chip{display:inline-block;background:var(--chip);border-radius:999px;padding:0 .6rem;font-size:.85rem;font-weight:700}
163
+ .chip.hot{color:var(--hot)}.chip.ok{color:var(--ok)}
164
+ .pages,.note{color:var(--mut);font-size:.9rem}
165
+ .big{font-size:1.35rem;font-weight:700}
166
+ meter{width:100%;height:1rem}
167
+ summary{cursor:pointer;font-weight:700;padding:.35rem 0}
168
+ details details{margin-left:.25rem}
169
+ dl{display:grid;grid-template-columns:minmax(6rem,max-content) 1fr;gap:.25rem .9rem;margin:.5rem 0}dt{color:var(--mut)}dd{margin:0}
170
+ .runbook{border-top:1px solid var(--line);margin-top:.5rem}
171
+ .runbook li{margin:.5rem 0}
172
+ .step{display:flex;gap:.8rem;align-items:flex-start;flex-wrap:wrap}.say{flex:1 1 14rem}
173
+ .shot{flex:0 0 9rem;width:9rem;max-width:100%;height:auto}
174
+ .win{fill:var(--bg);stroke:var(--line)}.bar{fill:var(--card)}.dot{fill:var(--line)}.pill{fill:var(--line)}.pane{fill:var(--chip)}.ln{fill:var(--line)}
175
+ .term{fill:#1b1f27;stroke:var(--line)}.tl{fill:#8f9bb0}
176
+ .hit{fill:var(--hit);stroke:var(--acc);stroke-width:2}.badge{fill:var(--acc)}.num{fill:var(--on-acc);font:700 10px system-ui,sans-serif}
177
+ pre{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:.6rem .8rem;overflow-x:auto}code{font-family:ui-monospace,Consolas,monospace;font-size:.92em}
178
+ footer{margin-top:3rem;color:var(--mut);font-size:.9rem}
179
+ `;
180
+
181
+ /**
182
+ * The picture report: one self-contained page from the composed report. Nothing here composes or decides; it only formats.
183
+ * `runbooks` are the loaded runbooks the "still blank" items point to, at the guidance `level`; `target` is the audited address;
184
+ * `synthetic` marks an example report. No script, no external file: the page opens offline, and a policy line refuses any
185
+ * outside request even if site text slipped in.
186
+ */
187
+ export function renderHtmlReport(composed, { runbooks, level, target, synthetic = false }) {
188
+ const { headline } = composed;
189
+ return `<!doctype html>\n<html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">` +
190
+ `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'"><meta name="color-scheme" content="light dark">` +
191
+ `<title>optbrew 보고서</title><style>${STYLE}</style></head><body><main><h1>사이트 점검 결과</h1><p class="target">${esc(target)}</p>` +
192
+ `${synthetic ? `<p class="banner">${SYNTHETIC_BANNER}</p>` : ''}` +
193
+ `<section class="hero"><p class="lead">${esc(headline.text)}</p><ul class="tiles"><li><b>${esc(headline.open)}건</b>먼저 고칠 것</li>` +
194
+ `<li><b>${esc(headline.blocking)}건</b>가장 급한 것</li><li><b>${esc(headline.already_fixed)}건</b>이미 고친 것</li></ul>` +
195
+ `${headline.not_indexed ? `<p class="skipped">${esc(headline.not_indexed.text)}</p>` : ''}</section>` +
196
+ `${fixesSection(composed)}${completenessSection(composed)}${blankSection(composed, runbooks, level)}${followUpsSection(composed)}${conditionsSection(composed)}` +
197
+ `<footer>실제 결과와 모델 판단의 표는 report.md와 report.json에 있습니다. 점수 계산에 쓴 원본 값은 report.json에 있습니다.</footer></main></body></html>\n`;
198
+ }
@@ -0,0 +1,14 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { renderHtmlReport } from './report-html.mjs';
4
+ import { loadRunbook } from './runbooks.mjs';
5
+
6
+ /** The skill folder; the runbooks name it where they run a script of the skill. */
7
+ const SKILL_ROOT = path.resolve(fileURLToPath(new URL('../../', import.meta.url)));
8
+
9
+ /**
10
+ * The page writer a report run hands to `writeReport`: the composed report in, the HTML out, with each runbook the blank items point to
11
+ * loaded once at the guidance `level`. A run (`run.mjs`) and a report replay (`cli.mjs report`) both write `report.html` through it.
12
+ */
13
+ export const htmlReportWriter = ({ level, target, synthetic = false }) => composed =>
14
+ renderHtmlReport(composed, { runbooks: [...new Set(composed.remaining.flatMap(row => row.runbook_ids))].map(id => loadRunbook(id, level, { skillRoot: SKILL_ROOT })), level, target, synthetic });