@cosense-toolbox/parser 0.1.0-beta.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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +794 -0
  3. package/dist/ast-CIKXwSl5.mjs +41 -0
  4. package/dist/ast-CIKXwSl5.mjs.map +1 -0
  5. package/dist/ast-uYWtkHwT.cjs +52 -0
  6. package/dist/ast-uYWtkHwT.cjs.map +1 -0
  7. package/dist/compile.cjs +265 -0
  8. package/dist/compile.cjs.map +1 -0
  9. package/dist/compile.d.cts +135 -0
  10. package/dist/compile.d.cts.map +1 -0
  11. package/dist/compile.d.mts +135 -0
  12. package/dist/compile.d.mts.map +1 -0
  13. package/dist/compile.mjs +256 -0
  14. package/dist/compile.mjs.map +1 -0
  15. package/dist/create-compiler-CvlndKOA.d.cts +23 -0
  16. package/dist/create-compiler-CvlndKOA.d.cts.map +1 -0
  17. package/dist/create-compiler-jdA408ny.d.mts +23 -0
  18. package/dist/create-compiler-jdA408ny.d.mts.map +1 -0
  19. package/dist/image-url-YcTed8tg.cjs +68 -0
  20. package/dist/image-url-YcTed8tg.cjs.map +1 -0
  21. package/dist/image-url-eQ6X-oN0.mjs +51 -0
  22. package/dist/image-url-eQ6X-oN0.mjs.map +1 -0
  23. package/dist/index.cjs +716 -0
  24. package/dist/index.cjs.map +1 -0
  25. package/dist/index.d.cts +77 -0
  26. package/dist/index.d.cts.map +1 -0
  27. package/dist/index.d.mts +77 -0
  28. package/dist/index.d.mts.map +1 -0
  29. package/dist/index.mjs +709 -0
  30. package/dist/index.mjs.map +1 -0
  31. package/dist/plugin.cjs +0 -0
  32. package/dist/plugin.d.cts +4 -0
  33. package/dist/plugin.d.mts +4 -0
  34. package/dist/plugin.mjs +1 -0
  35. package/dist/schema.cjs +171 -0
  36. package/dist/schema.cjs.map +1 -0
  37. package/dist/schema.d.cts +34 -0
  38. package/dist/schema.d.cts.map +1 -0
  39. package/dist/schema.d.mts +34 -0
  40. package/dist/schema.d.mts.map +1 -0
  41. package/dist/schema.mjs +148 -0
  42. package/dist/schema.mjs.map +1 -0
  43. package/dist/types-BXlnUFr0.d.mts +63 -0
  44. package/dist/types-BXlnUFr0.d.mts.map +1 -0
  45. package/dist/types-Bo_BrmvK.d.cts +230 -0
  46. package/dist/types-Bo_BrmvK.d.cts.map +1 -0
  47. package/dist/types-Bo_BrmvK.d.mts +230 -0
  48. package/dist/types-Bo_BrmvK.d.mts.map +1 -0
  49. package/dist/types-DVwlUtla.d.cts +63 -0
  50. package/dist/types-DVwlUtla.d.cts.map +1 -0
  51. package/dist/utils.cjs +80 -0
  52. package/dist/utils.cjs.map +1 -0
  53. package/dist/utils.d.cts +46 -0
  54. package/dist/utils.d.cts.map +1 -0
  55. package/dist/utils.d.mts +46 -0
  56. package/dist/utils.d.mts.map +1 -0
  57. package/dist/utils.mjs +72 -0
  58. package/dist/utils.mjs.map +1 -0
  59. package/package.json +96 -0
package/README.md ADDED
@@ -0,0 +1,794 @@
1
+ # @cosense-toolbox/parser
2
+
3
+ Cosense (旧 Scrapbox) 記法のパーサー。
4
+ 位置情報つきの unist 風 AST を返す。
5
+
6
+ > **beta**:公開 API はまだ変わりうる。
7
+ > 安定するまでは `^` ではなくバージョンを固定して使うほうが安全。
8
+
9
+ ```sh
10
+ npm i @cosense-toolbox/parser@beta
11
+ ```
12
+
13
+ ```ts
14
+ import { parse } from '@cosense-toolbox/parser'
15
+
16
+ const page = parse('タイトル\nこれは [リンク] です')
17
+
18
+ page.children[1].children
19
+ // [ { type: 'text', value: 'これは ', position: {…} },
20
+ // { type: 'internalLink', label: 'リンク', target: 'リンク', position: {…} },
21
+ // { type: 'text', value: ' です', position: {…} } ]
22
+ ```
23
+
24
+ - 依存は `effect` だけで、DOM も Node の API も使わない。
25
+ - すべてのノードが位置情報を持つので、エディタのハイライトやキャレット連携に使える。
26
+ - AST は plain object なので、`JSON.stringify` と `JSON.parse` で往復でき、worklet や postMessage を跨げる。
27
+ - 記法と出力の両方をプラグインで拡張できる。
28
+ - サブパスごとに export が分かれていて tree-shaking が効く (`parse` だけなら gzip 約 8 KB)。
29
+
30
+ > このパッケージは Cosense (Scrapbox) の記法を解釈する非公式の実装である。
31
+ > 開発元である Helpfeel社 とは関係がなく、公認も受けていない。
32
+
33
+ ## パース
34
+
35
+ ### parse
36
+
37
+ ページ全文を受け取り、ブロックの並びを返す。
38
+ どんな入力でも例外を投げず、必ず `Page` を返す。
39
+
40
+ ```ts
41
+ import { parse } from '@cosense-toolbox/parser'
42
+
43
+ const page = parse('タイトル\nこれは [リンク] です')
44
+
45
+ for (const block of page.children) {
46
+ switch (block.type) {
47
+ case 'title':
48
+ console.log('title:', block.value)
49
+ break
50
+ case 'line':
51
+ console.log('line:', block.children.map((node) => node.type))
52
+ break
53
+ case 'codeBlock':
54
+ console.log(block.filename, block.lines.map((line) => line.value))
55
+ break
56
+ case 'table':
57
+ console.log(block.name, block.rows.length)
58
+ break
59
+ default:
60
+ // ノード型は minor で増えうるので default を置く
61
+ break
62
+ }
63
+ }
64
+ ```
65
+
66
+ `code:` と `table:` は、ページの文脈があって初めてブロックにまとまる。
67
+ 1 行目は無条件でタイトルとして扱う。
68
+
69
+ ### parseLine
70
+
71
+ 1 行だけを通常行としてパースする。
72
+ エディタのように行単位で扱うときに使う。
73
+
74
+ ```ts
75
+ import { parseLine } from '@cosense-toolbox/parser'
76
+
77
+ parseLine(' > [リンク]')
78
+ // LineBlock { indent: 2, quote: true, monospace: false, children: [...] }
79
+ ```
80
+
81
+ ページの文脈がないので、`code:` と `table:` もブロックにはならず通常行になる。
82
+ その行がページの何行目かは `{ line, offset }` で渡せる。位置情報がページ全体と揃う。
83
+
84
+ ### tokenizeInline
85
+
86
+ 行の中だけを解析して、インラインノードの並びを返す。
87
+
88
+ ```ts
89
+ import { tokenizeInline } from '@cosense-toolbox/parser'
90
+
91
+ tokenizeInline('[* 太字] と [リンク]') // → readonly InlineNode[]
92
+ ```
93
+
94
+ 改行を含まない 1 行分の文字列を渡す。
95
+
96
+ ### createParser
97
+
98
+ 拡張を固定したパーサーを作る。
99
+ 同じ拡張で何度もパースするときに、毎回 `extensions` を渡さずに済む。
100
+
101
+ ```ts
102
+ import { createParser } from '@cosense-toolbox/parser'
103
+
104
+ const parser = createParser({ extensions: [mentions] })
105
+ parser.parse(source)
106
+ parser.parseLine(line)
107
+ ```
108
+
109
+ ### asImageSrc
110
+
111
+ 画像 URL を `<img src>` に入れられる形にする。
112
+ 画像でなければ `null` を返す。
113
+
114
+ ```ts
115
+ import { asImageSrc } from '@cosense-toolbox/parser'
116
+
117
+ asImageSrc('https://gyazo.com/503a911fea542532aa5aba0a88eb7b60')
118
+ // → 'https://i.gyazo.com/503a911fea542532aa5aba0a88eb7b60.png'
119
+ asImageSrc('https://example.test/page')
120
+ // → null
121
+ ```
122
+
123
+ Gyazo のページ URL は画像そのものではないので、ここでだけ画像 URL に差し替わる。
124
+ `parse` はこの変換をしない。AST はソースに書かれた文字列を保つ。
125
+
126
+ ### normalizeLineEndings
127
+
128
+ CRLF と CR を LF に揃える。
129
+ `parse` は必ずこれを通してから解析するので、位置情報を自分で計算するときに使う。
130
+
131
+ ## AST
132
+
133
+ ```
134
+ Page
135
+ ├─ TitleBlock 1 行目。value に生テキスト、children にインラインノード
136
+ ├─ CodeBlock code:filename とその配下 (CodeLine[])
137
+ ├─ TableBlock table:name とその配下 (TableRow[] → TableCell[])
138
+ └─ LineBlock 通常の行 (indent / quote / monospace + InlineNode[])
139
+ ```
140
+
141
+ インラインノードは次の 10 種類で、すべて `type` で判別できる。
142
+
143
+ | type | 記法 |
144
+ | :--- | :--- |
145
+ | `text` | 記法にならなかった素のテキスト |
146
+ | `internalLink` | `[title]` |
147
+ | `externalLink` | 裸の URL、`[url]`、`[url label]`、`[label url]` |
148
+ | `projectLink` | `[/project/title]` (`project` と `title` に分解済み) |
149
+ | `hashtag` | `#tag` |
150
+ | `inlineCode` | `` `code` `` |
151
+ | `image` | `[url.png]`、`[[url.png]]` (large)、`[linkUrl imageUrl]` (link つき) |
152
+ | `icon` | `[user.icon]`、`[user.icon*5]` |
153
+ | `formula` | `[$ x^2]` |
154
+ | `decoration` | `[* 太字]` `[/ 斜体]` `[- 打消し]` `[_ 下線]` とその複合、`[[太字]]` |
155
+
156
+ 装飾は Markdown のような入れ子の強調にならない。
157
+ Cosense では 1 つの記法が複数の装飾を同時に持つ (`[-/ x]` は打消しかつ斜体) ので、1 ノードが複数のフラグを持つ形にしている。
158
+
159
+ `image` の `src` はソースに書かれた URL のままで、パーサーは書き換えない。
160
+ Gyazo のページ URL (`https://gyazo.com/{hash}`) のように、そのままでは `<img>` に入れられない URL を表示用に直すのは描画側の仕事になる。
161
+ その変換は [`asImageSrc`](#asimagesrc) が担当する。
162
+
163
+ ## 位置情報
164
+
165
+ すべてのノードが `position` を持つ。
166
+
167
+ ```ts
168
+ interface Point { line: number; column: number; offset: number } // すべて 0-based
169
+ interface Position { start: Point; end: Point } // end は exclusive
170
+ ```
171
+
172
+ `line` はページ内の行番号で、0 がタイトル行になる。
173
+ `column` は行内の文字オフセット、`offset` はページ全文の中の文字オフセットを指す。
174
+
175
+ `source.slice(start.offset, end.offset)` は、その記法の生テキスト全体と一致する。
176
+ `[` や `#` などのマーカーも含む。
177
+
178
+ unist は 1-based だが、JS の `slice` とそのまま噛み合うよう 0-based を採用している。
179
+ CRLF と CR は LF に正規化されてから解析されるので、位置は正規化後の文字列が基準になる。
180
+
181
+ ノードの生テキストを切り出すには [`rawTextOf`](#rawtextof) を使う。
182
+
183
+ ## utils
184
+
185
+ `@cosense-toolbox/parser/utils` は AST を走査して中身を取り出す。
186
+ パースはしないので、AST を作る側と使う側を分けて import できる。
187
+
188
+ ### visit
189
+
190
+ 木を深さ優先で辿る。
191
+ 型を渡すとその型のノードだけが visitor に届く。
192
+
193
+ ```ts
194
+ import { visit } from '@cosense-toolbox/parser/utils'
195
+
196
+ visit(page, 'internalLink', (node) => {
197
+ console.log(node.target)
198
+ })
199
+ ```
200
+
201
+ visitor は `'skip'` を返すとそのノードの子を辿らず、`'exit'` を返すと走査全体を打ち切る。
202
+ 第 2 引数を省くとすべてのノードが届く。
203
+ visitor の第 2 引数には、ルートからそのノードまでの祖先が配列で渡る。
204
+
205
+ ### find
206
+
207
+ その型の最初のノードを返す。
208
+ 無ければ `null`。
209
+
210
+ ```ts
211
+ import { find } from '@cosense-toolbox/parser/utils'
212
+
213
+ find(page, 'codeBlock') // → CodeBlock | null
214
+ ```
215
+
216
+ ### collect
217
+
218
+ その型のノードを出現順にすべて返す。
219
+
220
+ ```ts
221
+ import { collect } from '@cosense-toolbox/parser/utils'
222
+
223
+ collect(page, 'icon') // → IconNode[]
224
+ ```
225
+
226
+ 戻り値の型はノード型から導出されるので、`collect(page, 'icon')` は `IconNode[]` になる。
227
+
228
+ ### collectLinks
229
+
230
+ 内部リンクとハッシュタグの指す先を、出現順、重複なしで返す。
231
+
232
+ ```ts
233
+ import { collectLinks } from '@cosense-toolbox/parser/utils'
234
+
235
+ collectLinks(parse('t\n[A] と #B と [A]')) // → ['A', 'B']
236
+ ```
237
+
238
+ ### firstImage
239
+
240
+ 最初の画像ノードを返す。
241
+ 無ければ `null`。ページのサムネイルを選ぶときに使う。
242
+
243
+ ```ts
244
+ import { firstImage } from '@cosense-toolbox/parser/utils'
245
+
246
+ firstImage(page)?.src
247
+ ```
248
+
249
+ ### rawTextOf
250
+
251
+ そのノードがソースで占めていた生テキストを返す。
252
+ 位置情報から切り出すので、記法のマーカーも含む。
253
+
254
+ ```ts
255
+ import { rawTextOf } from '@cosense-toolbox/parser/utils'
256
+
257
+ const source = 'title\nこれは [リンク] です'
258
+ const link = parse(source).children[1].children[1]
259
+ rawTextOf(source, link) // → '[リンク]'
260
+ ```
261
+
262
+ ## compile
263
+
264
+ `@cosense-toolbox/parser/compile` は AST を別の形式に変える。
265
+ この層はパーサー本体を import しないので、変換だけを使う側のバンドルにパーサーは入らない。
266
+
267
+ ### toHtml
268
+
269
+ AST を HTML 文字列にする。
270
+ 引数はページ全体でなくてもよく、`parseLine` が返した 1 行でも AST の任意のノードでも受け取る。
271
+
272
+ ```ts
273
+ import { parse } from '@cosense-toolbox/parser'
274
+ import { toHtml } from '@cosense-toolbox/parser/compile'
275
+
276
+ toHtml(parse('タイトル\nこれは [リンク] です'))
277
+ ```
278
+
279
+ ```html
280
+ <div class="page">
281
+ <h1 class="title">タイトル</h1>
282
+ <div class="line">
283
+ これは <a class="link" href="/%E3%83%AA%E3%83%B3%E3%82%AF">リンク</a> です
284
+ </div>
285
+ </div>
286
+ ```
287
+
288
+ 以降の HTML は読みやすさのために字下げして示す。
289
+ 実際の出力に要素間の空白は入らない。
290
+
291
+ 既定の見た目は [`@cosense-toolbox/style`](https://github.com/qaynam/cosense-toolbox/blob/main/packages/style/README.md) が別パッケージとして持っている。
292
+
293
+ #### 出力の形
294
+
295
+ class 名は接頭辞を持たず、すべて [`classNames`](#classnames) で差し替えられる。
296
+
297
+ ブロックの対応は次のとおり。
298
+
299
+ | 記法 | HTML |
300
+ | :--- | :--- |
301
+ | 1 行目 | `<h1 class="title">タイトル</h1>` |
302
+ | 通常の行 | `<div class="line">本文</div>` |
303
+ | 字下げした行 | `<div class="line" data-indent="2">本文</div>` |
304
+ | 空行 | `<div class="line"><br></div>` |
305
+ | `> 引用` | `<blockquote class="quote">引用</blockquote>` |
306
+ | `$ ls` | `<code class="monospace">$ ls</code>` |
307
+
308
+ ページ全体は `<div class="page">` で包まれる。
309
+ インデントの深さは class ではなく `data-indent` 属性で表す。
310
+ 中点は要素を持たず、CSS の擬似要素が描く。
311
+ 本家と同じ `.indent-mark` と `.pad` と `.dot` の要素が要る場合は [`showPads`](#showpads) を渡す。
312
+
313
+ テーブルは `<table>` になる。
314
+
315
+ ```html
316
+ <table class="table">
317
+ <caption>テーブル名</caption>
318
+ <tbody>
319
+ <tr>
320
+ <td>abc</td>
321
+ <td>def</td>
322
+ </tr>
323
+ </tbody>
324
+ </table>
325
+ ```
326
+
327
+ コードブロックは本家と同じく 1 行を 1 要素に切る。
328
+
329
+ ```html
330
+ <div class="line code-block">
331
+ <code class="code-start">
332
+ <span class="code-block-start">a.js</span>
333
+ </code>
334
+ </div>
335
+ <div class="line code-block" data-indent="1">
336
+ <code class="code-body">const a = 1</code>
337
+ </div>
338
+ ```
339
+
340
+ 本体行はヘッダより 1 段深い `data-indent` を持つ。
341
+ それより深い字下げは中身の文字列に残る。
342
+
343
+ インラインの対応は次のとおり。
344
+
345
+ | 記法 | HTML |
346
+ | :--- | :--- |
347
+ | `[title]` | `<a class="link" href="/title">title</a>` |
348
+ | `https://a.test/x` | `<a class="link link-external" href="https://a.test/x">https://a.test/x</a>` |
349
+ | `[https://a.test/x label]` | `<a class="link link-external" href="https://a.test/x">label</a>` |
350
+ | `[/proj/page]` | `<a class="link link-project" href="/proj/page">/proj/page</a>` |
351
+ | `#tag` | `<a class="hashtag" href="/tag">#tag</a>` |
352
+ | `` `code` `` | `<code class="code">code</code>` |
353
+ | `[a.png]` | `<img class="image" src="a.png" alt="">` |
354
+ | `[[a.png]]` | `<img class="image" src="a.png" alt="" data-large="true">` |
355
+ | `[user.icon]` | `<a class="link icon" href="/user">user</a>` |
356
+ | `[$ x^2]` | `<span class="formula">x^2</span>` |
357
+
358
+ 装飾はフラグの集合を入れ子の要素に開く。
359
+
360
+ ```html
361
+ <!-- [* 太字] -->
362
+ <span class="decoration">
363
+ <strong>太字</strong>
364
+ </span>
365
+
366
+ <!-- [-/ x] は打消しかつ斜体 -->
367
+ <span class="decoration">
368
+ <em>
369
+ <s>x</s>
370
+ </em>
371
+ </span>
372
+
373
+ <!-- [*** 見出し] -->
374
+ <span class="decoration" data-size-level="2">
375
+ <strong>見出し</strong>
376
+ </span>
377
+ ```
378
+
379
+ 見出しの段階は `data-size-level` 属性で表す。
380
+
381
+ 数式は組版せず、記法を外した中身をそのまま置く。
382
+ KaTeX に渡したい場合は [`handlers`](#handlers) で差し替える。
383
+
384
+ アイコンは既定では画像を出さない。
385
+ [`iconImageUrl`](#iconimageurl) を渡すと `<img>` が入る。
386
+
387
+ #### オプション
388
+
389
+ | オプション | 型 | 既定 |
390
+ | :--- | :--- | :--- |
391
+ | [`pageUrl`](#pageurl) | `(title, node) => string` | `/{title}` |
392
+ | [`iconImageUrl`](#iconimageurl) | `(node) => string \| null` | 常に `null` |
393
+ | [`highlight`](#highlight) | `(code, language) => string` | 色付けしない |
394
+ | [`classNames`](#classnames) | `HtmlClassNames` | `defaultClassNames` |
395
+ | [`showPads`](#showpads) | `boolean` | `false` |
396
+ | [`handlers`](#handlers) | `NodeHandlers<string>` | 既定のハンドラ |
397
+ | [`style`](#style) | `string` | `<style>` を出さない |
398
+
399
+ 上の 3 つは、記法に書かれていないので AST から導けない情報を外から渡す。
400
+ ページをどの URL で配信しているか、アイコン画像がどこにあるか、コードの構文がどう色分けされるかは、どれもソースには存在しない。
401
+ 残りは出力の見た目と構造を調整する。
402
+
403
+ #### pageUrl
404
+
405
+ ```ts
406
+ pageUrl?: (title: string, node: PageRefNode) => string
407
+ ```
408
+
409
+ ページを指す記法の遷移先を決める。
410
+ 対象は `[title]` と `[/proj/page]` と `#tag` と `[user.icon]` の 4 つ。
411
+ アイコンについてはリンク先だけを決め、画像は `iconImageUrl` が決める。
412
+
413
+ `title` は記法に書かれたタイトルそのままで、`[title]` なら `title`、`[/proj/page]` なら `/proj/page`、`#tag` なら `tag`、`[user.icon]` なら `user` が渡る。
414
+ ノード型で分岐する必要はない。
415
+
416
+ ```ts
417
+ toHtml(page, { pageUrl: (title) => `/wiki/${encodeURIComponent(title)}` })
418
+ ```
419
+
420
+ 既定は `/{title}` で、区切りを含むタイトルは区切りを残して各段を encode する。
421
+ `defaultPageUrl` として export しているので、独自のハンドラからも呼べる。
422
+
423
+ 外部リンクには効かない。
424
+ 外部リンクは記法そのものが URL なので、解決するものがない。
425
+
426
+ #### iconImageUrl
427
+
428
+ ```ts
429
+ iconImageUrl?: (node: IconNode) => string | null
430
+ ```
431
+
432
+ `[user.icon]` の画像 URL を決める。
433
+ `null` を返すと `<img>` を出さず、ユーザー名のテキストリンクになる。
434
+
435
+ ```ts
436
+ toHtml(page, {
437
+ iconImageUrl: (node) => `/api/pages/help-jp/${encodeURIComponent(node.user)}/icon`,
438
+ })
439
+ ```
440
+
441
+ ```html
442
+ <a class="link icon" href="/rakusai">
443
+ <img class="icon" src="/api/pages/help-jp/rakusai/icon" alt="rakusai" title="rakusai">
444
+ </a>
445
+ ```
446
+
447
+ 既定が `null` なのは、本家の画像 URL がプロジェクト名を含む (`/api/pages/{project}/{user}/icon`) 一方で、記法にプロジェクト名が書かれていないためである。
448
+
449
+ `[/icons/name.icon]` のように別プロジェクトを指す場合、`node.user` には `/icons/name` が入る。
450
+
451
+ > **注意**:
452
+ > `https://scrapbox.io/api/pages/{project}/{user}/icon` は `Cross-Origin-Resource-Policy: same-origin` を返す。
453
+ > scrapbox.io 以外のページの `<img>` からは読めず、`Access-Control-Allow-Origin` も無いので `crossorigin` 属性でも回避できない。
454
+ > 別オリジンで表示するなら、自前のサーバーやワーカーで中継してそちらに向ける。
455
+
456
+ #### highlight
457
+
458
+ ```ts
459
+ highlight?: (code: string, language: string) => string
460
+ ```
461
+
462
+ コードブロックの中身を色付けする。
463
+ シグネチャは markdown-it の同名オプションと同じなので、たいていのハイライタがそのまま嵌る。
464
+
465
+ ```ts
466
+ import hljs from 'highlight.js'
467
+
468
+ toHtml(page, {
469
+ highlight: (code, language) =>
470
+ hljs.highlight(code, { language: hljs.getLanguage(language) ? language : 'plaintext' }).value,
471
+ })
472
+ ```
473
+
474
+ `language` はファイル名から推測した名前で、拡張子があればそれ、無ければファイル名全体が渡る。
475
+ `code:hello.js` なら `js`、`code:python` なら `python` になる。
476
+ 言語名の綴りはライブラリごとに違うので、必要なら受け取った側で読み替える。
477
+
478
+ 戻り値は HTML としてそのまま埋め込まれるので、エスケープはハイライタの責任になる。
479
+
480
+ これを渡すと、コードブロックの本体は 1 行 1 要素ではなく 1 つの要素にまとまる。
481
+ ハイライタの出力が複数行にまたがるタグを含みうるためで、行で切るとタグが壊れる。
482
+ ヘッダ行とファイル名は変わらない。
483
+
484
+ ```html
485
+ <div class="line code-block">
486
+ <code class="code-start">
487
+ <span class="code-block-start">a.js</span>
488
+ </code>
489
+ </div>
490
+ <div class="line code-block" data-indent="1">
491
+ <code class="code-body highlight">…ハイライタの出力…</code>
492
+ </div>
493
+ ```
494
+
495
+ ライブラリごとの書きかたは次のとおり。
496
+
497
+ ```ts
498
+ // Prism
499
+ highlight: (code, lang) => Prism.highlight(code, Prism.languages[lang] ?? Prism.languages.plain, lang)
500
+
501
+ // sugar-high
502
+ highlight: (code) => sugarHigh(code)
503
+
504
+ // Shiki は既定で <pre><code> ごと返すので、structure: 'inline' で中身だけにする
505
+ const shiki = await createHighlighter({ themes: ['github-light'], langs: ['js'] })
506
+ highlight: (code, lang) => shiki.codeToHtml(code, { lang, theme: 'github-light', structure: 'inline' })
507
+ ```
508
+
509
+ ハイライタのテーマ CSS が特定の class を要求する場合は `classNames` で足せる。
510
+
511
+ ```ts
512
+ toHtml(page, { highlight, classNames: { codeHighlight: 'highlight hljs' } })
513
+ ```
514
+
515
+ #### classNames
516
+
517
+ ```ts
518
+ classNames?: HtmlClassNames
519
+ ```
520
+
521
+ 出力する要素に付ける class 名を差し替える。
522
+ 指定したキーだけが既定を上書きする。
523
+
524
+ ```ts
525
+ toHtml(page, { classNames: { line: 'my-2 leading-7', internalLink: 'text-sky-600 underline' } })
526
+ ```
527
+
528
+ 値は置き換えであって追加ではない。
529
+ 既定の名前を残したまま足すなら `'line my-2'` のように自分で並べる。
530
+ 空文字を渡すと class 属性そのものを出さない。
531
+
532
+ 既定の一覧は `defaultClassNames` として export している。
533
+ キーはノード型のほか、要素を持つ細部にも用意してある。
534
+
535
+ | キー | 対象 |
536
+ | :--- | :--- |
537
+ | `page` `title` `line` | ページ全体、1 行目、通常の行 |
538
+ | `quote` `monospace` | 引用行の `<blockquote>`、等幅行の `<code>` |
539
+ | `codeBlock` | コードブロックに属する行 (ヘッダと本体の両方) |
540
+ | `codeStart` `codeFilename` `codeBody` `codeHighlight` | ヘッダの `<code>`、ファイル名、本体の `<code>`、色付けした本体に足す class |
541
+ | `indentMark` `pad` `dot` | `showPads` のときだけ出る要素 |
542
+ | `internalLink` `externalLink` `projectLink` `hashtag` | 各リンク |
543
+ | `inlineCode` `image` `icon` `formula` `decoration` `table` | 各インライン記法とテーブル |
544
+
545
+ #### showPads
546
+
547
+ ```ts
548
+ showPads?: boolean
549
+ ```
550
+
551
+ インデントを本家と同じ要素として書き出す。
552
+ 深さ 1 段につき `pad` が 1 つ並び、その右端に中点が付く。
553
+
554
+ ```html
555
+ <div class="line" data-indent="2">
556
+ <span class="indent-mark">
557
+ <span class="pad"> </span>
558
+ <span class="pad"> </span>
559
+ <span class="dot"></span>
560
+ </span>
561
+ 字下げ
562
+ </div>
563
+ ```
564
+
565
+ 既定では要素を出さず、深さは `data-indent` 属性だけで表す。
566
+ 中点は CSS の擬似要素で描けるので、見た目はどちらでも変わらない。
567
+
568
+ #### handlers
569
+
570
+ ```ts
571
+ handlers?: NodeHandlers<string>
572
+ ```
573
+
574
+ ノード型ごとの出力そのものを差し替える。
575
+ 既定のハンドラに自動で重ねられるので、変えたい型だけ書けばよい。
576
+
577
+ ```ts
578
+ toHtml(page, {
579
+ handlers: { formula: (node) => katex.renderToString(node.value) },
580
+ })
581
+ ```
582
+
583
+ ハンドラは `(node, ctx)` を受け取る。
584
+ `ctx.children(node)` で子ノードの変換結果が配列で得られる。
585
+
586
+ ```ts
587
+ handlers: {
588
+ line: (node, ctx) => `<p>${ctx.children(node).join('')}</p>`,
589
+ }
590
+ ```
591
+
592
+ 既定のハンドラ一式は `createHtmlHandlers(options)` で取れる。
593
+ 既定の出力を包みたいときに使う。
594
+
595
+ 拡張が足した独自のノード型も、`InlineNodeMap` を declaration merging で拡張してあればここのキーになる。
596
+ ハンドラを書かなかった独自ノードは、子があればその中身が出力される。
597
+
598
+ #### style
599
+
600
+ ```ts
601
+ style?: string
602
+ ```
603
+
604
+ 渡した CSS を `<style>` 要素として出力の先頭に差し込む。
605
+
606
+ ```ts
607
+ import css from '@cosense-toolbox/style/style.css?raw'
608
+
609
+ toHtml(page, { style: css })
610
+ // <style>…</style><div class="page">…</div>
611
+ ```
612
+
613
+ iframe の `srcdoc` のように、1 つの文字列で完結させたいときに使う。
614
+ CSS そのものはこのパッケージに含まれていない。
615
+
616
+ #### 独自のハンドラを書く
617
+
618
+ `handlers` で要素ごと差し替えると、既定のハンドラがやっているエスケープと URL の検査が無くなる。
619
+ 同じことをするための部品を export している。
620
+
621
+ | export | 役割 |
622
+ | :--- | :--- |
623
+ | `escapeHtml(value)` | `& < > " '` を実体参照にする |
624
+ | `safeHref(url)` | href に入れて安全な URL だけを返す。script が動くスキームなら `null` |
625
+ | `safeSrc(url)` | src 版。`data:` 画像は許す |
626
+ | `defaultPageUrl(title)` | `pageUrl` の既定の実装 |
627
+ | `defaultClassNames` | 既定の class 名 |
628
+
629
+ 外部リンクを別タブで開く例を示す。
630
+
631
+ ```ts
632
+ import { escapeHtml, safeHref, toHtml } from '@cosense-toolbox/parser/compile'
633
+
634
+ toHtml(page, {
635
+ handlers: {
636
+ externalLink: (node) => {
637
+ const href = safeHref(node.target)
638
+ return `<a class="link link-external" href="${escapeHtml(href ?? '')}" target="_blank" rel="noreferrer">${escapeHtml(node.label)}</a>`
639
+ },
640
+ },
641
+ })
642
+ ```
643
+
644
+ ハンドラは `classNames` の設定を受け取らない。
645
+ 両方を使う場合、class 名は書いた側で決めることになる。
646
+
647
+ #### エスケープと URL の検査
648
+
649
+ 既定のハンドラは、テキストと属性値をすべてエスケープする。
650
+ `javascript:` と `vbscript:` のスキームは href と src の両方から落とし、`data:` は href からだけ落とす。
651
+ `data:` 画像には正当な使い道があるためである。
652
+
653
+ スキームの判定では、先に空白と制御文字を落とす。
654
+ ブラウザは途中にタブや改行が挟まった `javascript:` もスキームとして解釈するので、それを潰す。
655
+
656
+ `handlers` と `highlight` が返した文字列はそのまま埋め込む。
657
+ そこでのエスケープは書いた人の責任になる。
658
+
659
+ ### toPlainText
660
+
661
+ 記法を外したテキストを返す。
662
+ オプションは無い。
663
+
664
+ ```ts
665
+ import { toPlainText } from '@cosense-toolbox/parser/compile'
666
+
667
+ toPlainText(parse('タイトル\n[* 太字] と [リンク]'))
668
+ // 'タイトル\n太字 と リンク'
669
+ ```
670
+
671
+ インデントは半角 2 文字、引用は `> ` として残る。
672
+ コードブロックとテーブルは中身がそのまま出る。
673
+
674
+ ### createCompiler
675
+
676
+ HTML 以外を出すときに使う。
677
+ `toHtml` も `toPlainText` もこれで書かれている。
678
+
679
+ ```ts
680
+ import { createCompiler } from '@cosense-toolbox/parser/compile'
681
+
682
+ const toMarkdown = createCompiler<string>({
683
+ handlers: {
684
+ internalLink: (node) => `[[${node.target}]]`,
685
+ decoration: (node, ctx) => `**${ctx.children(node).join('')}**`,
686
+ text: (node) => node.value,
687
+ },
688
+ fallback: (node, ctx) => ctx.children(node).join(''),
689
+ })
690
+ ```
691
+
692
+ `handlers` の型はノード型のマップから導出されるので、ノード型が増えても型が追随する。
693
+ `fallback` はハンドラの無いノード型に使われる。
694
+
695
+ ## schema
696
+
697
+ `@cosense-toolbox/parser/schema` は、worklet や postMessage を跨いで受け取った、本当に `Page` か分からない値を検証する。
698
+
699
+ ```ts
700
+ import { decodePage } from '@cosense-toolbox/parser/schema'
701
+ import { Either } from 'effect'
702
+
703
+ const decoded = decodePage(JSON.parse(input))
704
+ if (Either.isRight(decoded)) {
705
+ // decoded.right は Page
706
+ }
707
+ ```
708
+
709
+ パース自体は失敗しないので、このサブパスが要るのは外から来た値を扱うときだけになる。
710
+
711
+ ## plugin
712
+
713
+ `@cosense-toolbox/parser/plugin` は、記法を足す `InlineConstruct` と `BracketRule` と `Extension`、出力を足す `NodeHandlers` の型を公開する。
714
+ このサブパスは型だけを持ち、実行時のコードを含まない。
715
+
716
+ ### 記法を拡張する
717
+
718
+ `Extension` を作って `parse` か `tokenizeInline` に渡す。
719
+ 既定のルールより先に試されるので、既存の記法を上書きすることもできる。
720
+
721
+ ```ts
722
+ import { Option } from 'effect'
723
+ import { parse } from '@cosense-toolbox/parser'
724
+ import type { Extension, InlineConstruct } from '@cosense-toolbox/parser/plugin'
725
+
726
+ const mention: InlineConstruct = (source, index) => {
727
+ if (source[index] !== '@') return Option.none()
728
+ const match = source.slice(index + 1).match(/^[A-Za-z0-9_-]+/)
729
+ if (!match) return Option.none()
730
+ return Option.some({
731
+ node: { type: 'internalLink', label: `@${match[0]}`, target: match[0] },
732
+ length: match[0].length + 1,
733
+ })
734
+ }
735
+
736
+ const mentions: Extension = { constructs: [mention] }
737
+ parse(source, { extensions: [mentions] })
738
+ ```
739
+
740
+ `[...]` の中身の解釈を足すなら `bracketRules` を使う。
741
+ 同じ拡張で何度もパースするなら、`createParser({ extensions })` でパーサーを固定できる。
742
+
743
+ ### 独自のノード型を足す
744
+
745
+ 既存のノード型に寄せず新しい `type` を作る場合は、`InlineNodeMap` を declaration merging で拡張する。
746
+ mdast と同じ手法で、`NodeHandlers` のキーにも `visit` の型引数にも自動で現れるので、描画まで型が通る。
747
+
748
+ ```ts
749
+ declare module '@cosense-toolbox/parser' {
750
+ interface InlineNodeMap {
751
+ mention: { type: 'mention'; user: string; position: Position }
752
+ }
753
+ }
754
+
755
+ // 記法を足す
756
+ const mention: InlineConstruct = (source, index) => { /* → { type: 'mention', user } */ }
757
+
758
+ // 描画を足す
759
+ toHtml(page, {
760
+ handlers: { mention: (node) => `<a href="/u/${node.user}">@${escapeHtml(node.user)}</a>` },
761
+ })
762
+ ```
763
+
764
+ ハンドラを書かなかった独自ノードは、子があればその中身が出力される。
765
+
766
+ ## 設計
767
+
768
+ パースは総関数で、どんな入力でも例外を投げず必ず `Page` を返す。
769
+ 記法として成立しない部分は素のテキストになるだけで、エラーにはならない。
770
+
771
+ I/O をしない。
772
+ oEmbed の取得や動画判定のような URL の意味解決は、このパッケージの外の仕事になる。
773
+ 画像かどうかの判定だけは、記法の構造そのものを決めるので含んでいる。
774
+
775
+ 構造は micromark の「位置つきノードと記法ハンドラの登録制」と、markdown-it の「優先順位付きルールを先頭から試す」形を参考にしている。
776
+
777
+ 開発時の規約は [CLAUDE.md](./CLAUDE.md) にある。
778
+
779
+ ## 互換性の方針
780
+
781
+ | 変更 | バージョン |
782
+ | :--- | :--- |
783
+ | 新しいノード `type` の追加 | minor |
784
+ | 既存ノードへの optional フィールド追加 | minor |
785
+ | オプションへの optional フィールド追加 | minor |
786
+ | 既存ノードのフィールドの削除、型変更、必須化 | major |
787
+ | `position` の意味論の変更 | major |
788
+ | ノード `type` 文字列のリネーム | major |
789
+
790
+ ノード型は minor で増えうるので、`switch (node.type)` には `default` を置いておくとよい。
791
+
792
+ ## ライセンス
793
+
794
+ MIT