@takagaki/cortex-decisions-viewer 0.12.16 → 0.12.18

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 (3) hide show
  1. package/dist/build.js +148 -5
  2. package/dist/render.js +1305 -85
  3. package/package.json +4 -2
package/dist/build.js CHANGED
@@ -36,7 +36,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.sanitizeBody = sanitizeBody;
39
40
  exports.readFleetSources = readFleetSources;
41
+ exports.readHarnessSchedules = readHarnessSchedules;
40
42
  exports.readGoldProposals = readGoldProposals;
41
43
  exports.buildHomeGrounding = buildHomeGrounding;
42
44
  exports.collectMeetings = collectMeetings;
@@ -47,6 +49,7 @@ const node_fs_1 = require("node:fs");
47
49
  const path = __importStar(require("node:path"));
48
50
  const gray_matter_1 = __importDefault(require("gray-matter"));
49
51
  const marked_1 = require("marked");
52
+ const sanitize_html_1 = __importDefault(require("sanitize-html"));
50
53
  const repo_1 = require("./repo");
51
54
  // 直近のGold昇格で追加されたレコードのパス集合。レコード生成の各所から参照するのでモジュールスコープに置く
52
55
  // (引数で引き回すと呼び出し側の改修が広範囲になる)。build() の冒頭で1回だけ解決する。
@@ -102,6 +105,88 @@ function toRelations(value) {
102
105
  function externalLinksToNewTab(html) {
103
106
  return html.replace(/<a href="(https?:\/\/[^"]*)"/g, '<a target="_blank" rel="noopener" href="$1"');
104
107
  }
108
+ /**
109
+ * 本文HTMLで許可する構造。**Markdown(marked)が生成しうるものだけ**を列挙したホワイトリスト。
110
+ * ここに無いタグ・属性は落ちる(`<script>`・`on*` ハンドラ・`javascript:` 等)。
111
+ */
112
+ const SANITIZE_OPTIONS = {
113
+ allowedTags: [
114
+ "p", "br", "hr",
115
+ "h1", "h2", "h3", "h4", "h5", "h6",
116
+ "strong", "em", "del", "code", "pre",
117
+ "ul", "ol", "li",
118
+ "blockquote",
119
+ "a", "img",
120
+ "table", "thead", "tbody", "tr", "th", "td",
121
+ // タスクリスト `- [ ]` / `- [x]` のチェック状態を残すのに要る。
122
+ // 落とすと**装飾ではなく情報(未完了/完了の別)が消える**
123
+ "input",
124
+ ],
125
+ allowedAttributes: {
126
+ a: ["href", "target", "rel"],
127
+ img: ["src", "alt", "title"], // src の許容スキームは allowedSchemesByTag で絞る
128
+ input: ["type", "checked", "disabled"],
129
+ },
130
+ allowedSchemes: ["http", "https", "mailto"],
131
+ allowedSchemesByTag: {
132
+ // 🔴 img は http(s) を**許可しない**。この画面は顧客が見るので、ブラウザが自動で
133
+ // 取りに行く外部参照を置くと「誰がいつ見たか」が参照先のログに残る
134
+ // (同じ理由で test/no-external-fetch.test.mjs が img を名指しで禁じている)。
135
+ // 埋め込みの data: 画像はネットワークに出ないので通す(実データに実在する)
136
+ img: ["data"],
137
+ },
138
+ transformTags: {
139
+ // data: のうち画像以外(data:text/html 等)を弾く。
140
+ // allowedSchemesByTag はスキーム名しか見ないので、その先を見る二重の防御
141
+ img: (tagName, attribs) => {
142
+ if (attribs.src && !/^data:image\//.test(attribs.src)) {
143
+ const { src, ...rest } = attribs;
144
+ return { tagName, attribs: rest };
145
+ }
146
+ return { tagName, attribs };
147
+ },
148
+ // checkbox 以外の input は本文で意味を持たないので無力化する
149
+ input: (tagName, attribs) => attribs.type === "checkbox" ? { tagName, attribs } : { tagName: "span", attribs: {} },
150
+ },
151
+ // 既定値だが明示する。タグを消して中身のテキストだけ残す("escape" だと未対応のタグが
152
+ // 文字として画面に見える)。落ち方は test/sanitize-body.test.mjs で固定してある
153
+ disallowedTagsMode: "discard",
154
+ // 既定値だが明示する。`//host/` 形式は通るが、img は上でスキームを data: のみに
155
+ // 絞っているため弾かれ、a は元々 http(s) 外部リンクを許可する設計なので実益がない
156
+ allowProtocolRelative: true,
157
+ };
158
+ /**
159
+ * Markdown由来のHTMLを、閲覧者のブラウザに渡す前に無害化する。
160
+ *
161
+ * 案件リポジトリの本文(Slack・Backlog・議事録からの転記)は書き込み時の承認フローが無く、
162
+ * **ここが唯一の関所**。本文HTMLは render 側で `innerHTML` に流し込まれるため、
163
+ * ビルド時に通していないHTMLが画面へ届く経路を作らないこと。
164
+ */
165
+ function sanitizeBody(html) {
166
+ return (0, sanitize_html_1.default)(html, SANITIZE_OPTIONS);
167
+ }
168
+ /**
169
+ * 詳細ページの本文用。先頭のH1=タイトルは画面が別途表示するので除去する。
170
+ *
171
+ * 🔴 **H1除去を落とさないこと。** 落とすと1,000件超のレコードでタイトルが二重に出る。
172
+ *
173
+ * サニタイズ → target=_blank 付与、の順で適用する。`externalLinksToNewTab()` は
174
+ * 生HTML文字列への正規表現書き換えなので、**サニタイズ後に走らせて初めて**
175
+ * 「`<a href="http…` という並びがタグ以外の場所に現れない」という前提が保証される
176
+ * (サニタイズ済みHTMLはテキスト・属性値中の `<` `"` をエスケープ済み)。
177
+ */
178
+ async function renderRecordBody(markdown) {
179
+ const html = sanitizeBody(await marked_1.marked.parse(markdown, { async: true }))
180
+ .replace(/^\s*<h1[^>]*>[\s\S]*?<\/h1>\s*/, "");
181
+ return externalLinksToNewTab(html);
182
+ }
183
+ /**
184
+ * Home.mdの自己記述セクション用。見出しはbuild側が別途付与するのでH1除去は要らない。
185
+ * 順序の理由は renderRecordBody と同じ。
186
+ */
187
+ async function renderGroundingSection(markdown) {
188
+ return externalLinksToNewTab(sanitizeBody(await marked_1.marked.parse(markdown, { async: true })));
189
+ }
105
190
  /**
106
191
  * 本文markdownを、検索用の軽量plaintextに変換する。
107
192
  * リッチHTMLはbodyRefへ外出しするため、inlineデータには検索できる最小限のテキストだけを残す。
@@ -362,10 +447,26 @@ async function readFleetSources(repoRoot) {
362
447
  const state = c.state != null ? String(c.state) : "";
363
448
  if (!state)
364
449
  continue;
450
+ // アダプタごとの状態(#153・前方互換キー)。単数形は broken > on > その他 の代表値なので、
451
+ // 複数アダプタの能力ではこちらが各タイルの正本になる。**ここも明示の許可リスト**——
452
+ // 列挙し忘れたフィールドは静かに消える(このファイル冒頭の罠と同じ)。
453
+ // 無い・空・要素が壊れている(adapter か state を欠く)ときは載せず、
454
+ // 単数形の代表値へ倒す(矛盾した載せ方をされても行が消えない側に倒す)
455
+ const ads = Array.isArray(c.adapters)
456
+ ? c.adapters
457
+ .filter((a) => a != null && typeof a === "object")
458
+ .map((a) => ({
459
+ adapter: a.adapter != null ? String(a.adapter) : "",
460
+ state: a.state != null ? String(a.state) : "",
461
+ detail: a.detail != null ? String(a.detail) : undefined,
462
+ }))
463
+ .filter((a) => a.adapter !== "" && a.state !== "")
464
+ : [];
365
465
  out[cap] = {
366
466
  adapter: c.adapter != null ? String(c.adapter) : "",
367
467
  state,
368
468
  detail: c.detail != null ? String(c.detail) : undefined,
469
+ ...(ads.length ? { adapters: ads } : {}),
369
470
  };
370
471
  }
371
472
  if (Object.keys(out).length)
@@ -433,6 +534,35 @@ async function readFleetSources(repoRoot) {
433
534
  }
434
535
  return { externalSources, slackWorkspaces, internalSources, pipelines, capabilities, fleetGeneratedAt, secretSources, secretSpaces, checks, githubOwners, backlogTarget };
435
536
  }
537
+ /**
538
+ * いまオンになっているハーネスの機能(リポジトリ直下 `ハーネス連携/schedules.json`)を読む。
539
+ *
540
+ * **読み方は readFleetSources と同じ流儀**——素通しはせず、明示の許可リストを通す。
541
+ * このファイルは engine 側(プラン生成)と画面の両方が読む正本で、engine が先に欄を
542
+ * 増やすことがある。**知らないものは画面へ載せない**が唯一の安全側。
543
+ * 篩の実体は render.ts の `sanitizeHarnessSchedules`(画面側の楽観更新と同じ関数を通す
544
+ * ——写経して2枚にすると、片方だけ語彙を増やしたときに静かに食い違う)。
545
+ *
546
+ * **無い・壊れているときは undefined**(=「何もオンでない」と同じ見え方)。
547
+ * 壊れの検知はプラン生成(赤で落ちる)と intake の責務で、画面がそれを兼ねると
548
+ * 「読めないから何も出さない」と「1件もオンでない」の区別が画面に出せない。
549
+ */
550
+ async function readHarnessSchedules(repoRoot) {
551
+ let json;
552
+ try {
553
+ const raw = await node_fs_1.promises.readFile(path.join(repoRoot, "ハーネス連携", "schedules.json"), "utf8");
554
+ json = JSON.parse(raw);
555
+ }
556
+ catch {
557
+ return undefined; // 無い・壊れている → キーごと出さない
558
+ }
559
+ if (json == null || typeof json !== "object" || Array.isArray(json))
560
+ return undefined;
561
+ const rows = json.schedules;
562
+ if (!Array.isArray(rows))
563
+ return undefined;
564
+ return (0, render_1.sanitizeHarnessSchedules)(rows);
565
+ }
436
566
  /**
437
567
  * AIからの更新提案(`.cortex/gold-proposals.json`)を読む。
438
568
  *
@@ -596,7 +726,7 @@ async function buildHomeGrounding(rawMarkdown) {
596
726
  continue;
597
727
  sections.push({
598
728
  heading: h,
599
- html: externalLinksToNewTab(await marked_1.marked.parse(hit.body, { async: true })),
729
+ html: await renderGroundingSection(hit.body),
600
730
  });
601
731
  }
602
732
  // 未記入の判定。新形式は「この案件について」と「ゴール」の2つ、旧形式は1つの節を見る。
@@ -618,7 +748,7 @@ async function buildHomeGrounding(rawMarkdown) {
618
748
  .filter((p) => !(p.heading != null && dropped.has(p.heading)))
619
749
  .map((p) => (p.heading == null ? p.body : `## ${p.heading}\n${p.body}`))
620
750
  .join("\n");
621
- const restHtml = externalLinksToNewTab((await marked_1.marked.parse(rest, { async: true })).replace(/^\s*<h1[^>]*>[\s\S]*?<\/h1>\s*/, ""));
751
+ const restHtml = await renderRecordBody(rest);
622
752
  return { sections, unfilled, restHtml };
623
753
  }
624
754
  /** パスがディレクトリとして存在するか */
@@ -672,7 +802,7 @@ async function parseGeneric(raw, fileName, repoRelDir, repoBaseUrl, branch) {
672
802
  : data.summary != null
673
803
  ? String(data.summary)
674
804
  : "";
675
- const bodyHtml = externalLinksToNewTab((await marked_1.marked.parse(content, { async: true })).replace(/^\s*<h1[^>]*>[\s\S]*?<\/h1>\s*/, ""));
805
+ const bodyHtml = await renderRecordBody(content);
676
806
  const filePath = repoRelDir ? `${repoRelDir}/${fileName}` : fileName;
677
807
  const editUrl = repoBaseUrl
678
808
  ? `${repoBaseUrl}/edit/${branch}/${encodePath(filePath)}`
@@ -1619,6 +1749,8 @@ async function build(opts) {
1619
1749
  computeLayout(graph); // 各ノードに x/y を書き込む(クライアントはこれを初期位置に使いウォームアップ省略)
1620
1750
  // データソースと自動化(fleet-status.json 由来)。無ければ undefined でセクション非表示。
1621
1751
  const fleet = await readFleetSources(repoRoot);
1752
+ // いまオンになっているハーネスの機能。無ければ undefined でタイルは「すべてオフ」
1753
+ const harnessSchedules = await readHarnessSchedules(repoRoot);
1622
1754
  // すでにある会議(投入モーダルの「会議名」の選択肢)。intake の有無に関わらず載せる——
1623
1755
  // 出し分けは表示側の仕事で、ここで絞ると「投入の口を後から付けたら選択肢が空」になる
1624
1756
  const meetings = await collectMeetings(repoRoot);
@@ -1669,6 +1801,10 @@ async function build(opts) {
1669
1801
  checks: fleet.checks,
1670
1802
  githubOwners: fleet.githubOwners,
1671
1803
  backlogTarget: fleet.backlogTarget,
1804
+ // **短縮記法(`harnessSchedules,`)にしないこと。** fleet-passthrough のテストが
1805
+ // 「画面が読む欄を build.ts が受け渡しているか」を `名前:` の字面で走査しており、
1806
+ // 短縮記法にすると、その守りの外へ静かに落ちる
1807
+ harnessSchedules: harnessSchedules,
1672
1808
  // null(無い・壊れ・version違い)は undefined へ落とす——SiteData の
1673
1809
  // 「知らないことはキーが無いことで表す」に合わせる(空の器を通さない)
1674
1810
  // 空の器は通さない(0件なら undefined = キーごと出さない)
@@ -1715,7 +1851,7 @@ async function parseDecision(raw, fileName, repoRelDir, repoBaseUrl, branch, ref
1715
1851
  const references = referencesRaw.map((r) => refs.resolve(r));
1716
1852
  const relations = toRelations(data.relations);
1717
1853
  // 本文先頭の H1 はタイトルと重複するため除去(詳細画面ではタイトルを別途表示する)
1718
- const bodyHtml = externalLinksToNewTab((await marked_1.marked.parse(content, { async: true })).replace(/^\s*<h1[^>]*>[\s\S]*?<\/h1>\s*/, ""));
1854
+ const bodyHtml = await renderRecordBody(content);
1719
1855
  const filePath = repoRelDir ? `${repoRelDir}/${fileName}` : fileName;
1720
1856
  const editUrl = repoBaseUrl
1721
1857
  ? `${repoBaseUrl}/edit/${branch}/${encodePath(filePath)}`
@@ -1772,8 +1908,15 @@ function createRefResolver(opts) {
1772
1908
  if (!trimmed)
1773
1909
  return { label: trimmed, url: null };
1774
1910
  const mdLink = trimmed.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
1775
- if (mdLink)
1911
+ if (mdLink) {
1912
+ // 🔴 http(s) 以外(`javascript:` `data:` 等)はリンクにしない。ラベルだけ残す。
1913
+ // **ここは references の表示だけの話ではない。** この url はグラフのノードにも
1914
+ // そのまま載り(addReferenceEdges)、ノードを押すと `window.open(n.url)` される。
1915
+ // 描画側にスキーム検査は無いので、**この1箇所を緩めるとグラフ経由の穴が静かに開く**
1916
+ if (!/^https?:\/\//.test(mdLink[2]))
1917
+ return { label: mdLink[1], url: null };
1776
1918
  return { label: mdLink[1], url: mdLink[2] };
1919
+ }
1777
1920
  if (/^https?:\/\//.test(trimmed))
1778
1921
  return { label: trimmed, url: trimmed };
1779
1922
  // 正本のURL(課題キー・material:・design:・minute:)。**ここが「ツールへ飛ぶ」の本体**