@takagaki/cortex-decisions-viewer 0.12.17 → 0.12.19
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/dist/build.js +195 -5
- package/dist/render.js +1837 -77
- package/dist/repo.js +132 -3
- package/package.json +4 -2
package/dist/build.js
CHANGED
|
@@ -36,7 +36,10 @@ 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;
|
|
42
|
+
exports.readHarnessShelf = readHarnessShelf;
|
|
40
43
|
exports.readGoldProposals = readGoldProposals;
|
|
41
44
|
exports.buildHomeGrounding = buildHomeGrounding;
|
|
42
45
|
exports.collectMeetings = collectMeetings;
|
|
@@ -47,6 +50,7 @@ const node_fs_1 = require("node:fs");
|
|
|
47
50
|
const path = __importStar(require("node:path"));
|
|
48
51
|
const gray_matter_1 = __importDefault(require("gray-matter"));
|
|
49
52
|
const marked_1 = require("marked");
|
|
53
|
+
const sanitize_html_1 = __importDefault(require("sanitize-html"));
|
|
50
54
|
const repo_1 = require("./repo");
|
|
51
55
|
// 直近のGold昇格で追加されたレコードのパス集合。レコード生成の各所から参照するのでモジュールスコープに置く
|
|
52
56
|
// (引数で引き回すと呼び出し側の改修が広範囲になる)。build() の冒頭で1回だけ解決する。
|
|
@@ -102,6 +106,88 @@ function toRelations(value) {
|
|
|
102
106
|
function externalLinksToNewTab(html) {
|
|
103
107
|
return html.replace(/<a href="(https?:\/\/[^"]*)"/g, '<a target="_blank" rel="noopener" href="$1"');
|
|
104
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* 本文HTMLで許可する構造。**Markdown(marked)が生成しうるものだけ**を列挙したホワイトリスト。
|
|
111
|
+
* ここに無いタグ・属性は落ちる(`<script>`・`on*` ハンドラ・`javascript:` 等)。
|
|
112
|
+
*/
|
|
113
|
+
const SANITIZE_OPTIONS = {
|
|
114
|
+
allowedTags: [
|
|
115
|
+
"p", "br", "hr",
|
|
116
|
+
"h1", "h2", "h3", "h4", "h5", "h6",
|
|
117
|
+
"strong", "em", "del", "code", "pre",
|
|
118
|
+
"ul", "ol", "li",
|
|
119
|
+
"blockquote",
|
|
120
|
+
"a", "img",
|
|
121
|
+
"table", "thead", "tbody", "tr", "th", "td",
|
|
122
|
+
// タスクリスト `- [ ]` / `- [x]` のチェック状態を残すのに要る。
|
|
123
|
+
// 落とすと**装飾ではなく情報(未完了/完了の別)が消える**
|
|
124
|
+
"input",
|
|
125
|
+
],
|
|
126
|
+
allowedAttributes: {
|
|
127
|
+
a: ["href", "target", "rel"],
|
|
128
|
+
img: ["src", "alt", "title"], // src の許容スキームは allowedSchemesByTag で絞る
|
|
129
|
+
input: ["type", "checked", "disabled"],
|
|
130
|
+
},
|
|
131
|
+
allowedSchemes: ["http", "https", "mailto"],
|
|
132
|
+
allowedSchemesByTag: {
|
|
133
|
+
// 🔴 img は http(s) を**許可しない**。この画面は顧客が見るので、ブラウザが自動で
|
|
134
|
+
// 取りに行く外部参照を置くと「誰がいつ見たか」が参照先のログに残る
|
|
135
|
+
// (同じ理由で test/no-external-fetch.test.mjs が img を名指しで禁じている)。
|
|
136
|
+
// 埋め込みの data: 画像はネットワークに出ないので通す(実データに実在する)
|
|
137
|
+
img: ["data"],
|
|
138
|
+
},
|
|
139
|
+
transformTags: {
|
|
140
|
+
// data: のうち画像以外(data:text/html 等)を弾く。
|
|
141
|
+
// allowedSchemesByTag はスキーム名しか見ないので、その先を見る二重の防御
|
|
142
|
+
img: (tagName, attribs) => {
|
|
143
|
+
if (attribs.src && !/^data:image\//.test(attribs.src)) {
|
|
144
|
+
const { src, ...rest } = attribs;
|
|
145
|
+
return { tagName, attribs: rest };
|
|
146
|
+
}
|
|
147
|
+
return { tagName, attribs };
|
|
148
|
+
},
|
|
149
|
+
// checkbox 以外の input は本文で意味を持たないので無力化する
|
|
150
|
+
input: (tagName, attribs) => attribs.type === "checkbox" ? { tagName, attribs } : { tagName: "span", attribs: {} },
|
|
151
|
+
},
|
|
152
|
+
// 既定値だが明示する。タグを消して中身のテキストだけ残す("escape" だと未対応のタグが
|
|
153
|
+
// 文字として画面に見える)。落ち方は test/sanitize-body.test.mjs で固定してある
|
|
154
|
+
disallowedTagsMode: "discard",
|
|
155
|
+
// 既定値だが明示する。`//host/` 形式は通るが、img は上でスキームを data: のみに
|
|
156
|
+
// 絞っているため弾かれ、a は元々 http(s) 外部リンクを許可する設計なので実益がない
|
|
157
|
+
allowProtocolRelative: true,
|
|
158
|
+
};
|
|
159
|
+
/**
|
|
160
|
+
* Markdown由来のHTMLを、閲覧者のブラウザに渡す前に無害化する。
|
|
161
|
+
*
|
|
162
|
+
* 案件リポジトリの本文(Slack・Backlog・議事録からの転記)は書き込み時の承認フローが無く、
|
|
163
|
+
* **ここが唯一の関所**。本文HTMLは render 側で `innerHTML` に流し込まれるため、
|
|
164
|
+
* ビルド時に通していないHTMLが画面へ届く経路を作らないこと。
|
|
165
|
+
*/
|
|
166
|
+
function sanitizeBody(html) {
|
|
167
|
+
return (0, sanitize_html_1.default)(html, SANITIZE_OPTIONS);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* 詳細ページの本文用。先頭のH1=タイトルは画面が別途表示するので除去する。
|
|
171
|
+
*
|
|
172
|
+
* 🔴 **H1除去を落とさないこと。** 落とすと1,000件超のレコードでタイトルが二重に出る。
|
|
173
|
+
*
|
|
174
|
+
* サニタイズ → target=_blank 付与、の順で適用する。`externalLinksToNewTab()` は
|
|
175
|
+
* 生HTML文字列への正規表現書き換えなので、**サニタイズ後に走らせて初めて**
|
|
176
|
+
* 「`<a href="http…` という並びがタグ以外の場所に現れない」という前提が保証される
|
|
177
|
+
* (サニタイズ済みHTMLはテキスト・属性値中の `<` `"` をエスケープ済み)。
|
|
178
|
+
*/
|
|
179
|
+
async function renderRecordBody(markdown) {
|
|
180
|
+
const html = sanitizeBody(await marked_1.marked.parse(markdown, { async: true }))
|
|
181
|
+
.replace(/^\s*<h1[^>]*>[\s\S]*?<\/h1>\s*/, "");
|
|
182
|
+
return externalLinksToNewTab(html);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Home.mdの自己記述セクション用。見出しはbuild側が別途付与するのでH1除去は要らない。
|
|
186
|
+
* 順序の理由は renderRecordBody と同じ。
|
|
187
|
+
*/
|
|
188
|
+
async function renderGroundingSection(markdown) {
|
|
189
|
+
return externalLinksToNewTab(sanitizeBody(await marked_1.marked.parse(markdown, { async: true })));
|
|
190
|
+
}
|
|
105
191
|
/**
|
|
106
192
|
* 本文markdownを、検索用の軽量plaintextに変換する。
|
|
107
193
|
* リッチHTMLはbodyRefへ外出しするため、inlineデータには検索できる最小限のテキストだけを残す。
|
|
@@ -362,10 +448,26 @@ async function readFleetSources(repoRoot) {
|
|
|
362
448
|
const state = c.state != null ? String(c.state) : "";
|
|
363
449
|
if (!state)
|
|
364
450
|
continue;
|
|
451
|
+
// アダプタごとの状態(#153・前方互換キー)。単数形は broken > on > その他 の代表値なので、
|
|
452
|
+
// 複数アダプタの能力ではこちらが各タイルの正本になる。**ここも明示の許可リスト**——
|
|
453
|
+
// 列挙し忘れたフィールドは静かに消える(このファイル冒頭の罠と同じ)。
|
|
454
|
+
// 無い・空・要素が壊れている(adapter か state を欠く)ときは載せず、
|
|
455
|
+
// 単数形の代表値へ倒す(矛盾した載せ方をされても行が消えない側に倒す)
|
|
456
|
+
const ads = Array.isArray(c.adapters)
|
|
457
|
+
? c.adapters
|
|
458
|
+
.filter((a) => a != null && typeof a === "object")
|
|
459
|
+
.map((a) => ({
|
|
460
|
+
adapter: a.adapter != null ? String(a.adapter) : "",
|
|
461
|
+
state: a.state != null ? String(a.state) : "",
|
|
462
|
+
detail: a.detail != null ? String(a.detail) : undefined,
|
|
463
|
+
}))
|
|
464
|
+
.filter((a) => a.adapter !== "" && a.state !== "")
|
|
465
|
+
: [];
|
|
365
466
|
out[cap] = {
|
|
366
467
|
adapter: c.adapter != null ? String(c.adapter) : "",
|
|
367
468
|
state,
|
|
368
469
|
detail: c.detail != null ? String(c.detail) : undefined,
|
|
470
|
+
...(ads.length ? { adapters: ads } : {}),
|
|
369
471
|
};
|
|
370
472
|
}
|
|
371
473
|
if (Object.keys(out).length)
|
|
@@ -433,6 +535,64 @@ async function readFleetSources(repoRoot) {
|
|
|
433
535
|
}
|
|
434
536
|
return { externalSources, slackWorkspaces, internalSources, pipelines, capabilities, fleetGeneratedAt, secretSources, secretSpaces, checks, githubOwners, backlogTarget };
|
|
435
537
|
}
|
|
538
|
+
/**
|
|
539
|
+
* いまオンになっているハーネスの機能(リポジトリ直下 `ハーネス連携/schedules.json`)を読む。
|
|
540
|
+
*
|
|
541
|
+
* **読み方は readFleetSources と同じ流儀**——素通しはせず、明示の許可リストを通す。
|
|
542
|
+
* このファイルは engine 側(プラン生成)と画面の両方が読む正本で、engine が先に欄を
|
|
543
|
+
* 増やすことがある。**知らないものは画面へ載せない**が唯一の安全側。
|
|
544
|
+
* 篩の実体は render.ts の `sanitizeHarnessSchedules`(画面側の楽観更新と同じ関数を通す
|
|
545
|
+
* ——写経して2枚にすると、片方だけ語彙を増やしたときに静かに食い違う)。
|
|
546
|
+
*
|
|
547
|
+
* **無い・壊れているときは undefined**(=「何もオンでない」と同じ見え方)。
|
|
548
|
+
* 壊れの検知はプラン生成(赤で落ちる)と intake の責務で、画面がそれを兼ねると
|
|
549
|
+
* 「読めないから何も出さない」と「1件もオンでない」の区別が画面に出せない。
|
|
550
|
+
*/
|
|
551
|
+
async function readHarnessSchedules(repoRoot) {
|
|
552
|
+
let json;
|
|
553
|
+
try {
|
|
554
|
+
const raw = await node_fs_1.promises.readFile(path.join(repoRoot, "ハーネス連携", "schedules.json"), "utf8");
|
|
555
|
+
json = JSON.parse(raw);
|
|
556
|
+
}
|
|
557
|
+
catch {
|
|
558
|
+
return undefined; // 無い・壊れている → キーごと出さない
|
|
559
|
+
}
|
|
560
|
+
if (json == null || typeof json !== "object" || Array.isArray(json))
|
|
561
|
+
return undefined;
|
|
562
|
+
const rows = json.schedules;
|
|
563
|
+
if (!Array.isArray(rows))
|
|
564
|
+
return undefined;
|
|
565
|
+
return (0, render_1.sanitizeHarnessSchedules)(rows);
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* この案件で使う機能の棚(リポジトリ直下 `ハーネス連携/shelf.json`)を読む。
|
|
569
|
+
*
|
|
570
|
+
* **読み方は readHarnessSchedules と同じ流儀**(無い/壊れ→undefined・在れば配列)。
|
|
571
|
+
* ただし**意味が1つ違う**: このファイルは「無い」ことに意味がある(設計 B-1)。
|
|
572
|
+
* - 無い … まだ棚を作っていない → 画面はオン中の行を**注記なしで**並べる
|
|
573
|
+
* - 在って空 … 意図した空 → 画面は空状態の文言に倒す
|
|
574
|
+
* ここで空配列と undefined を同一視すると、**画面がこの2つを言い分けられなくなる**
|
|
575
|
+
*(導入直後の既存案件が、いきなり「棚に無いがオン」の注記だらけになる)。
|
|
576
|
+
*
|
|
577
|
+
* `items` が配列でなければ undefined。**黙って [] にしない**——手で書いた棚を
|
|
578
|
+
* 「0件の棚」として画面に出すと、空状態の文言が嘘になる(intake 側の2分岐と同じ判断)。
|
|
579
|
+
*/
|
|
580
|
+
async function readHarnessShelf(repoRoot) {
|
|
581
|
+
let json;
|
|
582
|
+
try {
|
|
583
|
+
const raw = await node_fs_1.promises.readFile(path.join(repoRoot, "ハーネス連携", "shelf.json"), "utf8");
|
|
584
|
+
json = JSON.parse(raw);
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
return undefined; // 無い・壊れている → キーごと出さない(=まだ棚を作っていない)
|
|
588
|
+
}
|
|
589
|
+
if (json == null || typeof json !== "object" || Array.isArray(json))
|
|
590
|
+
return undefined;
|
|
591
|
+
const rows = json.items;
|
|
592
|
+
if (!Array.isArray(rows))
|
|
593
|
+
return undefined;
|
|
594
|
+
return (0, render_1.sanitizeHarnessShelf)(rows);
|
|
595
|
+
}
|
|
436
596
|
/**
|
|
437
597
|
* AIからの更新提案(`.cortex/gold-proposals.json`)を読む。
|
|
438
598
|
*
|
|
@@ -596,7 +756,7 @@ async function buildHomeGrounding(rawMarkdown) {
|
|
|
596
756
|
continue;
|
|
597
757
|
sections.push({
|
|
598
758
|
heading: h,
|
|
599
|
-
html:
|
|
759
|
+
html: await renderGroundingSection(hit.body),
|
|
600
760
|
});
|
|
601
761
|
}
|
|
602
762
|
// 未記入の判定。新形式は「この案件について」と「ゴール」の2つ、旧形式は1つの節を見る。
|
|
@@ -618,7 +778,7 @@ async function buildHomeGrounding(rawMarkdown) {
|
|
|
618
778
|
.filter((p) => !(p.heading != null && dropped.has(p.heading)))
|
|
619
779
|
.map((p) => (p.heading == null ? p.body : `## ${p.heading}\n${p.body}`))
|
|
620
780
|
.join("\n");
|
|
621
|
-
const restHtml =
|
|
781
|
+
const restHtml = await renderRecordBody(rest);
|
|
622
782
|
return { sections, unfilled, restHtml };
|
|
623
783
|
}
|
|
624
784
|
/** パスがディレクトリとして存在するか */
|
|
@@ -672,7 +832,7 @@ async function parseGeneric(raw, fileName, repoRelDir, repoBaseUrl, branch) {
|
|
|
672
832
|
: data.summary != null
|
|
673
833
|
? String(data.summary)
|
|
674
834
|
: "";
|
|
675
|
-
const bodyHtml =
|
|
835
|
+
const bodyHtml = await renderRecordBody(content);
|
|
676
836
|
const filePath = repoRelDir ? `${repoRelDir}/${fileName}` : fileName;
|
|
677
837
|
const editUrl = repoBaseUrl
|
|
678
838
|
? `${repoBaseUrl}/edit/${branch}/${encodePath(filePath)}`
|
|
@@ -1569,6 +1729,7 @@ async function build(opts) {
|
|
|
1569
1729
|
}
|
|
1570
1730
|
let home = null;
|
|
1571
1731
|
let homeGrounding;
|
|
1732
|
+
let homeUpdatedAt;
|
|
1572
1733
|
const sections = [];
|
|
1573
1734
|
if (knowledgeRoot) {
|
|
1574
1735
|
try {
|
|
@@ -1584,6 +1745,16 @@ async function build(opts) {
|
|
|
1584
1745
|
catch {
|
|
1585
1746
|
// ホーム未作成の案件では着地画面なし(Decisionsタブが既定になる)
|
|
1586
1747
|
}
|
|
1748
|
+
// タイトル横の「最終更新」。**catch の外に置く**——上の catch は「ホーム未作成」
|
|
1749
|
+
// という単一の意味を持つので、ここの想定外の例外まで飲ませない。
|
|
1750
|
+
// 走る場所は repoRoot(絶対パス)。process.cwd() 起点にしない——Decisions
|
|
1751
|
+
// ディレクトリで実行しても機能が生きるし、テストが本物の経路を通せる。
|
|
1752
|
+
// Home.md 1ファイルへの git 呼び出しが1回増えるだけで、レコード数には依存しない
|
|
1753
|
+
//(下の「git は2回だけ呼ぶ」の不変条件は保っている)
|
|
1754
|
+
if (home) {
|
|
1755
|
+
homeUpdatedAt =
|
|
1756
|
+
(0, repo_1.lastSubstantiveUpdate)(repoRoot, path.basename(knowledgeRoot) + "/Home.md") ?? undefined;
|
|
1757
|
+
}
|
|
1587
1758
|
const rootEntries = await node_fs_1.promises.readdir(knowledgeRoot, { withFileTypes: true });
|
|
1588
1759
|
for (const e of rootEntries) {
|
|
1589
1760
|
if (!e.isDirectory() || e.name === "Decisions")
|
|
@@ -1619,6 +1790,10 @@ async function build(opts) {
|
|
|
1619
1790
|
computeLayout(graph); // 各ノードに x/y を書き込む(クライアントはこれを初期位置に使いウォームアップ省略)
|
|
1620
1791
|
// データソースと自動化(fleet-status.json 由来)。無ければ undefined でセクション非表示。
|
|
1621
1792
|
const fleet = await readFleetSources(repoRoot);
|
|
1793
|
+
// いまオンになっているハーネスの機能。無ければ undefined でタイルは「すべてオフ」
|
|
1794
|
+
const harnessSchedules = await readHarnessSchedules(repoRoot);
|
|
1795
|
+
// この案件で使う機能の棚。**無ければ undefined のまま**(「まだ棚を作っていない」)
|
|
1796
|
+
const harnessShelf = await readHarnessShelf(repoRoot);
|
|
1622
1797
|
// すでにある会議(投入モーダルの「会議名」の選択肢)。intake の有無に関わらず載せる——
|
|
1623
1798
|
// 出し分けは表示側の仕事で、ここで絞ると「投入の口を後から付けたら選択肢が空」になる
|
|
1624
1799
|
const meetings = await collectMeetings(repoRoot);
|
|
@@ -1637,6 +1812,7 @@ async function build(opts) {
|
|
|
1637
1812
|
decisions,
|
|
1638
1813
|
home,
|
|
1639
1814
|
homeGrounding: homeGrounding,
|
|
1815
|
+
homeUpdatedAt: homeUpdatedAt,
|
|
1640
1816
|
sections,
|
|
1641
1817
|
graph,
|
|
1642
1818
|
categories,
|
|
@@ -1669,6 +1845,13 @@ async function build(opts) {
|
|
|
1669
1845
|
checks: fleet.checks,
|
|
1670
1846
|
githubOwners: fleet.githubOwners,
|
|
1671
1847
|
backlogTarget: fleet.backlogTarget,
|
|
1848
|
+
// **短縮記法(`harnessSchedules,`)にしないこと。** fleet-passthrough のテストが
|
|
1849
|
+
// 「画面が読む欄を build.ts が受け渡しているか」を `名前:` の字面で走査しており、
|
|
1850
|
+
// 短縮記法にすると、その守りの外へ静かに落ちる
|
|
1851
|
+
harnessSchedules: harnessSchedules,
|
|
1852
|
+
// 同上(短縮記法にしない)。**undefined のまま渡すのが仕様**——「棚が無い」と
|
|
1853
|
+
// 「棚が空」を画面が言い分けるための材料で、ここで [] へ倒すと区別が消える
|
|
1854
|
+
harnessShelf: harnessShelf,
|
|
1672
1855
|
// null(無い・壊れ・version違い)は undefined へ落とす——SiteData の
|
|
1673
1856
|
// 「知らないことはキーが無いことで表す」に合わせる(空の器を通さない)
|
|
1674
1857
|
// 空の器は通さない(0件なら undefined = キーごと出さない)
|
|
@@ -1715,7 +1898,7 @@ async function parseDecision(raw, fileName, repoRelDir, repoBaseUrl, branch, ref
|
|
|
1715
1898
|
const references = referencesRaw.map((r) => refs.resolve(r));
|
|
1716
1899
|
const relations = toRelations(data.relations);
|
|
1717
1900
|
// 本文先頭の H1 はタイトルと重複するため除去(詳細画面ではタイトルを別途表示する)
|
|
1718
|
-
const bodyHtml =
|
|
1901
|
+
const bodyHtml = await renderRecordBody(content);
|
|
1719
1902
|
const filePath = repoRelDir ? `${repoRelDir}/${fileName}` : fileName;
|
|
1720
1903
|
const editUrl = repoBaseUrl
|
|
1721
1904
|
? `${repoBaseUrl}/edit/${branch}/${encodePath(filePath)}`
|
|
@@ -1772,8 +1955,15 @@ function createRefResolver(opts) {
|
|
|
1772
1955
|
if (!trimmed)
|
|
1773
1956
|
return { label: trimmed, url: null };
|
|
1774
1957
|
const mdLink = trimmed.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
|
|
1775
|
-
if (mdLink)
|
|
1958
|
+
if (mdLink) {
|
|
1959
|
+
// 🔴 http(s) 以外(`javascript:` `data:` 等)はリンクにしない。ラベルだけ残す。
|
|
1960
|
+
// **ここは references の表示だけの話ではない。** この url はグラフのノードにも
|
|
1961
|
+
// そのまま載り(addReferenceEdges)、ノードを押すと `window.open(n.url)` される。
|
|
1962
|
+
// 描画側にスキーム検査は無いので、**この1箇所を緩めるとグラフ経由の穴が静かに開く**
|
|
1963
|
+
if (!/^https?:\/\//.test(mdLink[2]))
|
|
1964
|
+
return { label: mdLink[1], url: null };
|
|
1776
1965
|
return { label: mdLink[1], url: mdLink[2] };
|
|
1966
|
+
}
|
|
1777
1967
|
if (/^https?:\/\//.test(trimmed))
|
|
1778
1968
|
return { label: trimmed, url: trimmed };
|
|
1779
1969
|
// 正本のURL(課題キー・material:・design:・minute:)。**ここが「ツールへ飛ぶ」の本体**
|