@mawaru/sdk 0.39.0 → 0.41.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 (79) hide show
  1. package/_schemas/graph.test.ts +15 -9
  2. package/_schemas/graph.ts +15 -6
  3. package/_schemas/human-manifest.test.ts +67 -0
  4. package/_schemas/human-manifest.ts +31 -0
  5. package/_schemas/port-spec.test.ts +33 -92
  6. package/_schemas/port-spec.ts +36 -59
  7. package/dist/_cli/previewCommand.js +3 -3
  8. package/dist/_cli/previewFiles.js +2 -3
  9. package/dist/_cli/previewSlack.d.ts +1 -1
  10. package/dist/_cli/previewSlack.js +1 -1
  11. package/dist/_schemas/graph.d.ts +6 -3
  12. package/dist/_schemas/graph.js +12 -6
  13. package/dist/_schemas/human-manifest.d.ts +9 -0
  14. package/dist/_schemas/human-manifest.js +29 -0
  15. package/dist/_schemas/port-spec.d.ts +5 -11
  16. package/dist/_schemas/port-spec.js +28 -37
  17. package/dist/cli.js +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.js +1 -0
  20. package/dist/init.js +22 -3
  21. package/dist/typegen.d.ts +3 -1
  22. package/dist/typegen.js +96 -14
  23. package/dist/validate.js +57 -0
  24. package/dist/views/_bridge.d.ts +12 -0
  25. package/dist/views/_bridge.js +10 -0
  26. package/dist/views/_humanProps.d.ts +10 -0
  27. package/dist/views/_humanProps.js +11 -0
  28. package/dist/views/_parts/DocumentCard.d.ts +2 -0
  29. package/dist/views/_parts/DocumentCard.js +16 -0
  30. package/dist/views/_parts/FilePreview.d.ts +5 -0
  31. package/dist/views/_parts/FilePreview.js +101 -0
  32. package/dist/views/_parts/Linkify.d.ts +7 -0
  33. package/dist/views/_parts/Linkify.js +28 -0
  34. package/dist/views/_parts/Markdown.d.ts +44 -0
  35. package/dist/views/_parts/Markdown.js +313 -0
  36. package/dist/views/_parts/SchemaForm.d.ts +19 -0
  37. package/dist/views/_parts/SchemaForm.js +310 -0
  38. package/dist/views/_parts/fileRef.d.ts +7 -0
  39. package/dist/views/_parts/fileRef.js +11 -0
  40. package/dist/views/_parts/useNarrow.d.ts +5 -0
  41. package/dist/views/_parts/useNarrow.js +23 -0
  42. package/dist/views/components.d.ts +7 -0
  43. package/dist/views/components.js +12 -0
  44. package/dist/views/host.d.ts +23 -0
  45. package/dist/views/host.js +20 -0
  46. package/dist/views/human.d.ts +54 -0
  47. package/dist/views/human.js +5 -0
  48. package/dist/views/protocol.d.ts +44 -0
  49. package/dist/views/protocol.js +5 -0
  50. package/dist/views/runtime.d.ts +2 -0
  51. package/dist/views/runtime.js +86 -0
  52. package/dist/views/sample.d.ts +22 -0
  53. package/dist/views/sample.js +38 -0
  54. package/dist/{slack-view.d.ts → views/slack.d.ts} +1 -1
  55. package/docs/development.md +39 -2
  56. package/index.ts +1 -0
  57. package/package.json +40 -4
  58. package/skills/create-loop/SKILL.md +7 -3
  59. package/templates/CLAUDE.md +2 -0
  60. package/views/_bridge.tsx +36 -0
  61. package/views/_humanProps.ts +27 -0
  62. package/views/_parts/DocumentCard.ts +18 -0
  63. package/views/_parts/FilePreview.tsx +180 -0
  64. package/views/_parts/Linkify.test.ts +40 -0
  65. package/views/_parts/Linkify.tsx +40 -0
  66. package/views/_parts/Markdown.test.ts +131 -0
  67. package/views/_parts/Markdown.tsx +472 -0
  68. package/views/_parts/SchemaForm.tsx +742 -0
  69. package/views/_parts/fileRef.ts +21 -0
  70. package/views/_parts/useNarrow.ts +24 -0
  71. package/views/components.ts +16 -0
  72. package/views/host.ts +27 -0
  73. package/views/human.ts +83 -0
  74. package/views/protocol.ts +63 -0
  75. package/views/runtime.tsx +123 -0
  76. package/views/sample.tsx +94 -0
  77. package/views/slack.test.ts +40 -0
  78. package/{slack-view.ts → views/slack.ts} +3 -3
  79. /package/dist/{slack-view.js → views/slack.js} +0 -0
@@ -0,0 +1,131 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { isLongText, parseBlocks } from "./Markdown.js"
3
+
4
+ // Markdown レンダラのブロック分割(描画は含まない)。
5
+ // AI の下書き・調査レポートに実際に出る記法だけを見る前提
6
+
7
+ describe("parseBlocks", () => {
8
+ it("見出し・箇条書き・段落を分ける", () => {
9
+ expect(
10
+ parseBlocks(
11
+ [
12
+ "## 調査レポート(社内向け)",
13
+ "",
14
+ "### 問い合わせ概要",
15
+ "- ユーザー: **不明**",
16
+ "- 発生時刻: 2026-08-10",
17
+ "",
18
+ "現時点で判明している原因は次の2点です。",
19
+ ].join("\n"),
20
+ ),
21
+ ).toEqual([
22
+ { kind: "heading", level: 2, text: "調査レポート(社内向け)" },
23
+ { kind: "heading", level: 3, text: "問い合わせ概要" },
24
+ {
25
+ kind: "list",
26
+ ordered: false,
27
+ items: [
28
+ { text: "ユーザー: **不明**", nested: false },
29
+ { text: "発生時刻: 2026-08-10", nested: false },
30
+ ],
31
+ },
32
+ { kind: "para", lines: ["現時点で判明している原因は次の2点です。"] },
33
+ ])
34
+ })
35
+
36
+ it("段落は空行で切れ、段落内の改行は行として残る", () => {
37
+ expect(parseBlocks("一行目\n二行目\n\n次の段落")).toEqual([
38
+ { kind: "para", lines: ["一行目", "二行目"] },
39
+ { kind: "para", lines: ["次の段落"] },
40
+ ])
41
+ })
42
+
43
+ it("番号付きリストとネスト(1段)を拾う", () => {
44
+ expect(parseBlocks("1. 一つ目\n2. 二つ目\n 1. ぶら下がり")).toEqual([
45
+ {
46
+ kind: "list",
47
+ ordered: true,
48
+ items: [
49
+ { text: "一つ目", nested: false },
50
+ { text: "二つ目", nested: false },
51
+ { text: "ぶら下がり", nested: true },
52
+ ],
53
+ },
54
+ ])
55
+ })
56
+
57
+ it("コードブロックは中身を素のまま持つ", () => {
58
+ expect(parseBlocks("```ts\nconst a = 1\n\nconst b = 2\n```\nあと")).toEqual(
59
+ [
60
+ { kind: "code", lines: ["const a = 1", "", "const b = 2"] },
61
+ { kind: "para", lines: ["あと"] },
62
+ ],
63
+ )
64
+ })
65
+
66
+ it("閉じない ``` でも終端まで読んで止まる(無限ループにしない)", () => {
67
+ expect(parseBlocks("```\nconst a = 1")).toEqual([
68
+ { kind: "code", lines: ["const a = 1"] },
69
+ ])
70
+ })
71
+
72
+ it("--- は水平線、引用はまとめて1ブロック", () => {
73
+ expect(parseBlocks("---\n> 引用の1行目\n> 2行目")).toEqual([
74
+ { kind: "rule" },
75
+ { kind: "quote", lines: ["引用の1行目", "2行目"] },
76
+ ])
77
+ })
78
+
79
+ it("表:見出し行+区切り行+本体行を1ブロックにし、寄せは区切りのコロンで決める", () => {
80
+ expect(
81
+ parseBlocks(
82
+ [
83
+ "前置き",
84
+ "| 項目 | 値 | 件数 |",
85
+ "|:-- | :-: | --:|",
86
+ "| ログ | **402** | 3 |",
87
+ "| DB | `plan=expired` | 1 |",
88
+ "",
89
+ "あと",
90
+ ].join("\n"),
91
+ ),
92
+ ).toEqual([
93
+ { kind: "para", lines: ["前置き"] },
94
+ {
95
+ kind: "table",
96
+ align: ["left", "center", "right"],
97
+ header: ["項目", "値", "件数"],
98
+ rows: [
99
+ ["ログ", "**402**", "3"],
100
+ ["DB", "`plan=expired`", "1"],
101
+ ],
102
+ },
103
+ { kind: "para", lines: ["あと"] },
104
+ ])
105
+ })
106
+
107
+ it("表:外側の | が無い行・セル数が足りない行も受ける(欠けは空セル)", () => {
108
+ expect(parseBlocks("a | b\n--- | ---\n1")).toEqual([
109
+ {
110
+ kind: "table",
111
+ align: [null, null],
112
+ header: ["a", "b"],
113
+ rows: [["1", ""]],
114
+ },
115
+ ])
116
+ })
117
+
118
+ it("表:区切り行が続かない | 行は段落のまま(壊さない)", () => {
119
+ expect(parseBlocks("| a | b |\n本文")).toEqual([
120
+ { kind: "para", lines: ["| a | b |", "本文"] },
121
+ ])
122
+ })
123
+ })
124
+
125
+ describe("isLongText", () => {
126
+ it("改行を含むか80文字超なら長文", () => {
127
+ expect(isLongText("短い一行")).toBe(false)
128
+ expect(isLongText("一行目\n二行目")).toBe(true)
129
+ expect(isLongText("あ".repeat(81))).toBe(true)
130
+ })
131
+ })
@@ -0,0 +1,472 @@
1
+ import {
2
+ type CSSProperties,
3
+ type ReactNode,
4
+ useCallback,
5
+ useLayoutEffect,
6
+ useRef,
7
+ } from "react"
8
+ import { linkify } from "./Linkify.js"
9
+
10
+ // AI の下書き・調査レポートは Markdown で届く。生ソースのまま読ませると読み物として
11
+ // 読めないので整形して出す。frontend は依存最小主義なのでレンダラは自前で持ち、
12
+ // 実際に出る記法だけを見る:見出し / 箇条書き・番号付き / 引用 / コードブロック /
13
+ // 水平線 / 表 / 段落と、インラインの強調・コード・リンク(素の URL を含む)。
14
+ // 対応しない記法は素のテキストとしてそのまま出す=壊さない
15
+
16
+ /** 長文(=Markdown として描く)か。入力欄の昇格判定と共通の基準 */
17
+ export const isLongText = (value: string): boolean =>
18
+ value.includes("\n") || value.length > 80
19
+
20
+ const bodyText: CSSProperties = {
21
+ fontSize: "var(--text-base)",
22
+ lineHeight: 1.9,
23
+ margin: 0,
24
+ overflowWrap: "anywhere",
25
+ }
26
+
27
+ const codeChip: CSSProperties = {
28
+ fontFamily: "var(--font-mono)",
29
+ fontSize: "0.9em",
30
+ background: "var(--surface-2)",
31
+ border: "var(--border-w) solid var(--border)",
32
+ borderRadius: "var(--radius-s)",
33
+ padding: "1px 5px",
34
+ }
35
+
36
+ // インライン:**太字** / `コード` / [リンク](url)。分割は捕捉グループ付きなので
37
+ // 区切り自身も配列に残る(split の仕様)
38
+ const inlineToken = /(`[^`\n]+`|\*\*[^*\n]+\*\*|\[[^\]\n]+\]\([^)\s]+\))/g
39
+
40
+ const renderInline = (text: string, prefix = ""): ReactNode[] =>
41
+ text.split(inlineToken).map((token, i) => {
42
+ if (!token) return null
43
+ const key = `${prefix}${i}`
44
+ if (token.startsWith("`") && token.endsWith("`") && token.length > 2) {
45
+ return (
46
+ <code key={key} style={codeChip}>
47
+ {token.slice(1, -1)}
48
+ </code>
49
+ )
50
+ }
51
+ if (token.startsWith("**") && token.endsWith("**") && token.length > 4) {
52
+ return (
53
+ <strong
54
+ key={key}
55
+ style={{
56
+ fontWeight: "var(--weight-bold)" as CSSProperties["fontWeight"],
57
+ }}
58
+ >
59
+ {token.slice(2, -2)}
60
+ </strong>
61
+ )
62
+ }
63
+ const link = /^\[([^\]]+)\]\(([^)\s]+)\)$/.exec(token)
64
+ if (link) {
65
+ return (
66
+ <a key={key} href={link[2]} target="_blank" rel="noreferrer">
67
+ {link[1]}
68
+ </a>
69
+ )
70
+ }
71
+ // 素の URL もリンクにする
72
+ return <span key={key}>{linkify(token, `${key}-`)}</span>
73
+ })
74
+
75
+ // 段落内の改行は改行として残す(日本語の下書きは1行1文で書かれることが多い)
76
+ const renderLines = (lines: string[]): ReactNode[] =>
77
+ lines.flatMap((line, i) =>
78
+ i === 0
79
+ ? renderInline(line, "0-")
80
+ : [
81
+ // biome-ignore lint/suspicious/noArrayIndexKey: 表示専用(テキスト全体が置き換わる)
82
+ <br key={`br${i}`} />,
83
+ ...renderInline(line, `${i}-`),
84
+ ],
85
+ )
86
+
87
+ type ListItem = { text: string; nested: boolean }
88
+
89
+ export type TableAlign = "left" | "center" | "right" | null
90
+
91
+ export type Block =
92
+ | { kind: "heading"; level: number; text: string }
93
+ | { kind: "list"; ordered: boolean; items: ListItem[] }
94
+ | { kind: "quote"; lines: string[] }
95
+ | { kind: "code"; lines: string[] }
96
+ | { kind: "rule" }
97
+ | { kind: "table"; align: TableAlign[]; header: string[]; rows: string[][] }
98
+ | { kind: "para"; lines: string[] }
99
+
100
+ const headingOf = /^(#{1,6})\s+(.*)$/
101
+ const bulletOf = /^(\s*)[-*+]\s+(.*)$/
102
+ const orderedOf = /^(\s*)\d+[.)]\s+(.*)$/
103
+ const quoteOf = /^>\s?(.*)$/
104
+ const ruleOf = /^(-{3,}|\*{3,}|_{3,})\s*$/
105
+
106
+ // 表(GFM):`| a | b |` の行の直後に区切り行(`|---|:-:|--:|`)があれば表。
107
+ // 外側の | は無くてもよい。セルの分割は `\|` でエスケープした | を除く
108
+ const splitCells = (line: string): string[] => {
109
+ let body = line.trim()
110
+ if (body.startsWith("|")) body = body.slice(1)
111
+ if (body.endsWith("|") && !body.endsWith("\\|")) body = body.slice(0, -1)
112
+ return body
113
+ .split(/(?<!\\)\|/)
114
+ .map((cell) => cell.trim().replace(/\\\|/g, "|"))
115
+ }
116
+
117
+ const delimiterCellOf = /^:?-+:?$/
118
+
119
+ const alignOf = (cell: string): TableAlign =>
120
+ cell.startsWith(":") && cell.endsWith(":")
121
+ ? "center"
122
+ : cell.endsWith(":")
123
+ ? "right"
124
+ : cell.startsWith(":")
125
+ ? "left"
126
+ : null
127
+
128
+ const isTableStart = (lines: string[], i: number): boolean => {
129
+ const head = lines[i] ?? ""
130
+ const delimiter = lines[i + 1] ?? ""
131
+ if (!head.includes("|") || !delimiter.includes("-")) return false
132
+ const cells = splitCells(delimiter)
133
+ return (
134
+ cells.length > 0 &&
135
+ cells.every((cell) => delimiterCellOf.test(cell)) &&
136
+ cells.length === splitCells(head).length
137
+ )
138
+ }
139
+
140
+ // 表の本体行は空行か別ブロックの始まりまで(| を含まない行も1セルの行として受ける)
141
+ const isBlockStart = (line: string): boolean =>
142
+ headingOf.test(line) ||
143
+ bulletOf.test(line) ||
144
+ orderedOf.test(line) ||
145
+ quoteOf.test(line) ||
146
+ ruleOf.test(line.trim()) ||
147
+ line.trimStart().startsWith("```")
148
+
149
+ /** 行を読んでブロックに割る(描画から切り離してテストできるよう公開している) */
150
+ export const parseBlocks = (source: string): Block[] => {
151
+ const lines = source.replace(/\r\n?/g, "\n").split("\n")
152
+ const blocks: Block[] = []
153
+ let i = 0
154
+
155
+ while (i < lines.length) {
156
+ const line = lines[i] ?? ""
157
+
158
+ if (line.trim() === "") {
159
+ i += 1
160
+ continue
161
+ }
162
+
163
+ // コードブロック(閉じないまま終わっても、そこまでをコードとして出す)
164
+ if (line.trimStart().startsWith("```")) {
165
+ const body: string[] = []
166
+ i += 1
167
+ while (
168
+ i < lines.length &&
169
+ !(lines[i] ?? "").trimStart().startsWith("```")
170
+ ) {
171
+ body.push(lines[i] ?? "")
172
+ i += 1
173
+ }
174
+ // 閉じの ``` を飛ばす(閉じないまま終端に着いたときは何も飛ばさない)
175
+ i += 1
176
+ blocks.push({ kind: "code", lines: body })
177
+ continue
178
+ }
179
+
180
+ if (isTableStart(lines, i)) {
181
+ const header = splitCells(line)
182
+ const align = splitCells(lines[i + 1] ?? "").map(alignOf)
183
+ const rows: string[][] = []
184
+ i += 2
185
+ while (i < lines.length) {
186
+ const current = lines[i] ?? ""
187
+ if (current.trim() === "" || isBlockStart(current)) break
188
+ const cells = splitCells(current)
189
+ // 列数は見出しに揃える(足りなければ空セル、余れば捨てる)
190
+ rows.push(header.map((_, col) => cells[col] ?? ""))
191
+ i += 1
192
+ }
193
+ blocks.push({ kind: "table", align, header, rows })
194
+ continue
195
+ }
196
+
197
+ if (ruleOf.test(line.trim())) {
198
+ blocks.push({ kind: "rule" })
199
+ i += 1
200
+ continue
201
+ }
202
+
203
+ const heading = headingOf.exec(line)
204
+ if (heading) {
205
+ blocks.push({
206
+ kind: "heading",
207
+ level: (heading[1] ?? "#").length,
208
+ text: heading[2] ?? "",
209
+ })
210
+ i += 1
211
+ continue
212
+ }
213
+
214
+ const quote = quoteOf.exec(line)
215
+ if (quote) {
216
+ const body: string[] = []
217
+ while (i < lines.length) {
218
+ const next = quoteOf.exec(lines[i] ?? "")
219
+ if (!next) break
220
+ body.push(next[1] ?? "")
221
+ i += 1
222
+ }
223
+ blocks.push({ kind: "quote", lines: body })
224
+ continue
225
+ }
226
+
227
+ const bullet = bulletOf.exec(line)
228
+ const ordered = orderedOf.exec(line)
229
+ if (bullet || ordered) {
230
+ const isOrdered = ordered !== null && bullet === null
231
+ const items: ListItem[] = []
232
+ while (i < lines.length) {
233
+ const current = lines[i] ?? ""
234
+ const next = isOrdered
235
+ ? orderedOf.exec(current)
236
+ : bulletOf.exec(current)
237
+ if (!next) break
238
+ items.push({
239
+ text: next[2] ?? "",
240
+ // ネストは1段だけ表現する(2段以上は同じ深さに畳む)
241
+ nested: (next[1] ?? "").length >= 2,
242
+ })
243
+ i += 1
244
+ }
245
+ blocks.push({ kind: "list", ordered: isOrdered, items })
246
+ continue
247
+ }
248
+
249
+ // 段落:空行 or 他のブロックの始まりまで
250
+ const body: string[] = []
251
+ while (i < lines.length) {
252
+ const current = lines[i] ?? ""
253
+ if (
254
+ current.trim() === "" ||
255
+ isBlockStart(current) ||
256
+ isTableStart(lines, i)
257
+ ) {
258
+ break
259
+ }
260
+ body.push(current)
261
+ i += 1
262
+ }
263
+ blocks.push({ kind: "para", lines: body })
264
+ }
265
+
266
+ return blocks
267
+ }
268
+
269
+ const headingStyle = (level: number): CSSProperties => ({
270
+ margin: 0,
271
+ fontSize:
272
+ level <= 1
273
+ ? "var(--text-lg)"
274
+ : level === 2
275
+ ? "var(--text-md)"
276
+ : "var(--text-base)",
277
+ fontWeight: "var(--weight-bold)" as CSSProperties["fontWeight"],
278
+ lineHeight: "var(--leading-tight)",
279
+ ...(level <= 2
280
+ ? {
281
+ borderBottom: "var(--border-w) solid var(--border)",
282
+ paddingBottom: "var(--space-2)",
283
+ }
284
+ : { color: "var(--ink)" }),
285
+ })
286
+
287
+ const tableCell: CSSProperties = {
288
+ padding: "var(--space-1) var(--space-3)",
289
+ borderBottom: "var(--border-w) solid var(--border)",
290
+ verticalAlign: "top",
291
+ overflowWrap: "anywhere",
292
+ }
293
+
294
+ const Blocks = ({ blocks }: { blocks: Block[] }) => (
295
+ <>
296
+ {blocks.map((block, i) => {
297
+ // ブロックは位置で安定(テキスト全体が置き換わる表示専用リスト)
298
+ const key = `${block.kind}${i}`
299
+ switch (block.kind) {
300
+ case "heading": {
301
+ // 見出しレベルは Markdown の意味をそのまま持たせる(h1..h6)
302
+ const Tag = `h${Math.min(6, block.level)}` as "h1"
303
+ return (
304
+ <Tag key={key} style={headingStyle(block.level)}>
305
+ {renderInline(block.text)}
306
+ </Tag>
307
+ )
308
+ }
309
+ case "list": {
310
+ const Tag = block.ordered ? "ol" : "ul"
311
+ return (
312
+ <Tag
313
+ key={key}
314
+ style={{
315
+ ...bodyText,
316
+ paddingLeft: "var(--space-5)",
317
+ display: "grid",
318
+ gap: "var(--space-1)",
319
+ }}
320
+ >
321
+ {block.items.map((item, j) => (
322
+ <li
323
+ // biome-ignore lint/suspicious/noArrayIndexKey: 表示専用
324
+ key={j}
325
+ style={item.nested ? { marginLeft: "var(--space-5)" } : {}}
326
+ >
327
+ {renderInline(item.text)}
328
+ </li>
329
+ ))}
330
+ </Tag>
331
+ )
332
+ }
333
+ case "quote":
334
+ return (
335
+ <blockquote
336
+ key={key}
337
+ style={{
338
+ ...bodyText,
339
+ margin: 0,
340
+ paddingLeft: "var(--space-4)",
341
+ borderLeft: "var(--border-w-strong) solid var(--border-strong)",
342
+ color: "var(--muted)",
343
+ }}
344
+ >
345
+ {renderLines(block.lines)}
346
+ </blockquote>
347
+ )
348
+ case "code":
349
+ return (
350
+ <pre
351
+ key={key}
352
+ style={{
353
+ margin: 0,
354
+ padding: "var(--space-3) var(--space-4)",
355
+ background: "var(--surface-2)",
356
+ border: "var(--border-w) solid var(--border)",
357
+ borderRadius: "var(--radius-s)",
358
+ fontSize: "var(--text-sm)",
359
+ lineHeight: 1.7,
360
+ overflowX: "auto",
361
+ }}
362
+ >
363
+ {block.lines.join("\n")}
364
+ </pre>
365
+ )
366
+ case "rule":
367
+ return (
368
+ <hr
369
+ key={key}
370
+ style={{
371
+ margin: 0,
372
+ border: "none",
373
+ borderTop: "var(--border-w) solid var(--border)",
374
+ }}
375
+ />
376
+ )
377
+ case "table":
378
+ return (
379
+ <div key={key} style={{ overflowX: "auto" }}>
380
+ <table
381
+ style={{
382
+ borderCollapse: "collapse",
383
+ fontSize: "var(--text-sm)",
384
+ lineHeight: 1.6,
385
+ minWidth: "100%",
386
+ }}
387
+ >
388
+ <thead>
389
+ <tr>
390
+ {block.header.map((cell, col) => (
391
+ <th
392
+ // biome-ignore lint/suspicious/noArrayIndexKey: 表示専用
393
+ key={col}
394
+ style={{
395
+ ...tableCell,
396
+ textAlign: block.align[col] ?? "left",
397
+ fontWeight:
398
+ "var(--weight-bold)" as CSSProperties["fontWeight"],
399
+ color: "var(--muted)",
400
+ background: "var(--surface-2)",
401
+ whiteSpace: "nowrap",
402
+ }}
403
+ >
404
+ {renderInline(cell)}
405
+ </th>
406
+ ))}
407
+ </tr>
408
+ </thead>
409
+ <tbody>
410
+ {block.rows.map((row, r) => (
411
+ // biome-ignore lint/suspicious/noArrayIndexKey: 表示専用
412
+ <tr key={r}>
413
+ {row.map((cell, col) => (
414
+ <td
415
+ // biome-ignore lint/suspicious/noArrayIndexKey: 表示専用
416
+ key={col}
417
+ style={{
418
+ ...tableCell,
419
+ textAlign: block.align[col] ?? "left",
420
+ }}
421
+ >
422
+ {renderInline(cell)}
423
+ </td>
424
+ ))}
425
+ </tr>
426
+ ))}
427
+ </tbody>
428
+ </table>
429
+ </div>
430
+ )
431
+ default:
432
+ return (
433
+ <p key={key} style={bodyText}>
434
+ {renderLines(block.lines)}
435
+ </p>
436
+ )
437
+ }
438
+ })}
439
+ </>
440
+ )
441
+
442
+ export const Markdown = ({ text }: { text: string }) => (
443
+ <div style={{ display: "grid", gap: "var(--space-4)" }}>
444
+ <Blocks blocks={parseBlocks(text)} />
445
+ </div>
446
+ )
447
+
448
+ // 一度縮めてから測らないと、削ったときに高さが戻らない
449
+ const fitToContent = (el: HTMLTextAreaElement | null) => {
450
+ if (!el) return
451
+ el.style.height = "auto"
452
+ el.style.height = `${el.scrollHeight}px`
453
+ }
454
+
455
+ /**
456
+ * 内容の高さまで伸びる textarea(上限を超えたらその中でスクロール)。
457
+ * 返り値は ref にそのまま渡す。「編集」を押して後から現れる欄でも、
458
+ * 付いた瞬間に測るので初回から正しい高さで出る
459
+ */
460
+ export const useAutoGrow = (
461
+ value: string,
462
+ ): ((el: HTMLTextAreaElement | null) => void) => {
463
+ const ref = useRef<HTMLTextAreaElement | null>(null)
464
+ // biome-ignore lint/correctness/useExhaustiveDependencies: value は測り直しの引き金(本文では読まない)
465
+ useLayoutEffect(() => {
466
+ fitToContent(ref.current)
467
+ }, [value])
468
+ return useCallback((el: HTMLTextAreaElement | null) => {
469
+ ref.current = el
470
+ fitToContent(el)
471
+ }, [])
472
+ }