@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.
- package/_schemas/graph.test.ts +15 -9
- package/_schemas/graph.ts +15 -6
- package/_schemas/human-manifest.test.ts +67 -0
- package/_schemas/human-manifest.ts +31 -0
- package/_schemas/port-spec.test.ts +33 -92
- package/_schemas/port-spec.ts +36 -59
- package/dist/_cli/previewCommand.js +3 -3
- package/dist/_cli/previewFiles.js +2 -3
- package/dist/_cli/previewSlack.d.ts +1 -1
- package/dist/_cli/previewSlack.js +1 -1
- package/dist/_schemas/graph.d.ts +6 -3
- package/dist/_schemas/graph.js +12 -6
- package/dist/_schemas/human-manifest.d.ts +9 -0
- package/dist/_schemas/human-manifest.js +29 -0
- package/dist/_schemas/port-spec.d.ts +5 -11
- package/dist/_schemas/port-spec.js +28 -37
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/init.js +22 -3
- package/dist/typegen.d.ts +3 -1
- package/dist/typegen.js +96 -14
- package/dist/validate.js +57 -0
- package/dist/views/_bridge.d.ts +12 -0
- package/dist/views/_bridge.js +10 -0
- package/dist/views/_humanProps.d.ts +10 -0
- package/dist/views/_humanProps.js +11 -0
- package/dist/views/_parts/DocumentCard.d.ts +2 -0
- package/dist/views/_parts/DocumentCard.js +16 -0
- package/dist/views/_parts/FilePreview.d.ts +5 -0
- package/dist/views/_parts/FilePreview.js +101 -0
- package/dist/views/_parts/Linkify.d.ts +7 -0
- package/dist/views/_parts/Linkify.js +28 -0
- package/dist/views/_parts/Markdown.d.ts +44 -0
- package/dist/views/_parts/Markdown.js +313 -0
- package/dist/views/_parts/SchemaForm.d.ts +19 -0
- package/dist/views/_parts/SchemaForm.js +310 -0
- package/dist/views/_parts/fileRef.d.ts +7 -0
- package/dist/views/_parts/fileRef.js +11 -0
- package/dist/views/_parts/useNarrow.d.ts +5 -0
- package/dist/views/_parts/useNarrow.js +23 -0
- package/dist/views/components.d.ts +7 -0
- package/dist/views/components.js +12 -0
- package/dist/views/host.d.ts +23 -0
- package/dist/views/host.js +20 -0
- package/dist/views/human.d.ts +54 -0
- package/dist/views/human.js +5 -0
- package/dist/views/protocol.d.ts +44 -0
- package/dist/views/protocol.js +5 -0
- package/dist/views/runtime.d.ts +2 -0
- package/dist/views/runtime.js +86 -0
- package/dist/views/sample.d.ts +22 -0
- package/dist/views/sample.js +38 -0
- package/dist/{slack-view.d.ts → views/slack.d.ts} +1 -1
- package/docs/development.md +39 -2
- package/index.ts +1 -0
- package/package.json +40 -4
- package/skills/create-loop/SKILL.md +7 -3
- package/templates/CLAUDE.md +2 -0
- package/views/_bridge.tsx +36 -0
- package/views/_humanProps.ts +27 -0
- package/views/_parts/DocumentCard.ts +18 -0
- package/views/_parts/FilePreview.tsx +180 -0
- package/views/_parts/Linkify.test.ts +40 -0
- package/views/_parts/Linkify.tsx +40 -0
- package/views/_parts/Markdown.test.ts +131 -0
- package/views/_parts/Markdown.tsx +472 -0
- package/views/_parts/SchemaForm.tsx +742 -0
- package/views/_parts/fileRef.ts +21 -0
- package/views/_parts/useNarrow.ts +24 -0
- package/views/components.ts +16 -0
- package/views/host.ts +27 -0
- package/views/human.ts +83 -0
- package/views/protocol.ts +63 -0
- package/views/runtime.tsx +123 -0
- package/views/sample.tsx +94 -0
- package/views/slack.test.ts +40 -0
- package/{slack-view.ts → views/slack.ts} +3 -3
- /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
|
+
}
|