@kanf/wema-kake 0.0.0-stage → 0.1.1

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 (84) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +123 -2
  3. package/bin/wema-kake.mjs +220 -0
  4. package/dist/lib/index.d.ts +13 -0
  5. package/dist/lib/index.js +14 -0
  6. package/dist/lib/shared/api.d.ts +97 -0
  7. package/dist/lib/shared/api.js +23 -0
  8. package/dist/lib/shared/delta.d.ts +24 -0
  9. package/dist/lib/shared/delta.js +19 -0
  10. package/dist/lib/shared/guide.d.ts +32 -0
  11. package/dist/lib/shared/guide.js +259 -0
  12. package/dist/lib/shared/hierarchy.d.ts +31 -0
  13. package/dist/lib/shared/hierarchy.js +40 -0
  14. package/dist/lib/shared/i18n.d.ts +23 -0
  15. package/dist/lib/shared/i18n.js +58 -0
  16. package/dist/lib/shared/note-html.d.ts +10 -0
  17. package/dist/lib/shared/note-html.js +28 -0
  18. package/dist/lib/shared/note-info.d.ts +20 -0
  19. package/dist/lib/shared/note-info.js +29 -0
  20. package/dist/lib/shared/note-text.d.ts +11 -0
  21. package/dist/lib/shared/note-text.js +118 -0
  22. package/dist/lib/shared/op-summary.d.ts +30 -0
  23. package/dist/lib/shared/op-summary.js +37 -0
  24. package/dist/lib/shared/protocol.d.ts +74 -0
  25. package/dist/lib/shared/protocol.js +12 -0
  26. package/dist/lib/shared/site.d.ts +16 -0
  27. package/dist/lib/shared/site.js +28 -0
  28. package/dist/lib/shared/slug.d.ts +4 -0
  29. package/dist/lib/shared/slug.js +6 -0
  30. package/dist/lib/shared/tools.d.ts +67 -0
  31. package/dist/lib/shared/tools.js +353 -0
  32. package/dist/lib/worker/access.d.ts +34 -0
  33. package/dist/lib/worker/access.js +114 -0
  34. package/dist/lib/worker/apply-ops.d.ts +75 -0
  35. package/dist/lib/worker/apply-ops.js +335 -0
  36. package/dist/lib/worker/assets.d.ts +4 -0
  37. package/dist/lib/worker/assets.js +35 -0
  38. package/dist/lib/worker/config.d.ts +52 -0
  39. package/dist/lib/worker/config.js +23 -0
  40. package/dist/lib/worker/first-page.d.ts +10 -0
  41. package/dist/lib/worker/first-page.js +30 -0
  42. package/dist/lib/worker/images.d.ts +3 -0
  43. package/dist/lib/worker/images.js +54 -0
  44. package/dist/lib/worker/index.d.ts +8 -0
  45. package/dist/lib/worker/index.js +72 -0
  46. package/dist/lib/worker/indexer.d.ts +42 -0
  47. package/dist/lib/worker/indexer.js +93 -0
  48. package/dist/lib/worker/mcp/board-access.d.ts +28 -0
  49. package/dist/lib/worker/mcp/board-access.js +67 -0
  50. package/dist/lib/worker/mcp/server.d.ts +9 -0
  51. package/dist/lib/worker/mcp/server.js +39 -0
  52. package/dist/lib/worker/page-do.d.ts +330 -0
  53. package/dist/lib/worker/page-do.js +1168 -0
  54. package/dist/lib/worker/page-queries.d.ts +28 -0
  55. package/dist/lib/worker/page-queries.js +88 -0
  56. package/dist/lib/worker/pages-api.d.ts +5 -0
  57. package/dist/lib/worker/pages-api.js +257 -0
  58. package/dist/lib/worker/plain-text.d.ts +22 -0
  59. package/dist/lib/worker/plain-text.js +113 -0
  60. package/dist/lib/worker/revert.d.ts +21 -0
  61. package/dist/lib/worker/revert.js +126 -0
  62. package/dist/lib/worker/sanitize.d.ts +4 -0
  63. package/dist/lib/worker/sanitize.js +135 -0
  64. package/dist/lib/worker/validate.d.ts +21 -0
  65. package/dist/lib/worker/validate.js +218 -0
  66. package/dist/web/assets/index-CVwMafiH.js +5 -0
  67. package/dist/web/assets/index-Cgi5orcJ.css +1 -0
  68. package/dist/web/favicon.svg +13 -0
  69. package/dist/web/index.html +14 -0
  70. package/migrations/0001_init.sql +18 -0
  71. package/migrations/0002_fts.sql +9 -0
  72. package/migrations/0003_hierarchy.sql +9 -0
  73. package/migrations/0004_settings.sql +12 -0
  74. package/migrations/0005_bookmarks.sql +8 -0
  75. package/migrations/0006_page_color.sql +5 -0
  76. package/package.json +74 -4
  77. package/template/.dev.vars.example +4 -0
  78. package/template/README.md +55 -0
  79. package/template/gitignore +8 -0
  80. package/template/package.json +25 -0
  81. package/template/src/config.ts +18 -0
  82. package/template/src/index.ts +9 -0
  83. package/template/tsconfig.json +16 -0
  84. package/template/wrangler.jsonc +57 -0
@@ -0,0 +1,259 @@
1
+ /** 最初のページのスラッグ */
2
+ export const FIRST_PAGE_SLUG = 'first-page';
3
+ const paragraphs = (heading, lines) => `<b>${heading}</b>${lines.map((line) => `<div>${line}</div>`).join('')}`;
4
+ /** 装飾の見本。HTML は、wema が画面の操作で作るものと同じ形にする */
5
+ const sample = (text) => `<b>${text.heading}</b>` +
6
+ `<div><b>${text.bold}</b>${text.separator}<s>${text.strike}</s>${text.separator}` +
7
+ `<span style="color: #d32f2f">${text.color}</span>${text.separator}` +
8
+ `<a href="https://github.com/kan/wema-kake" target="_blank" rel="noopener">${text.link}</a></div>` +
9
+ `<ul><li>${text.bullet}<ul><li>${text.nested}</li></ul></li></ul>` +
10
+ `<ol><li>${text.numbered[0]}</li><li>${text.numbered[1]}</li></ol>` +
11
+ '<ul class="wema-checklist">' +
12
+ `<li><input type="checkbox">${text.todo}</li>` +
13
+ `<li class="wema-checked"><input type="checkbox" checked>${text.done}</li>` +
14
+ '</ul>';
15
+ const TEXTS = {
16
+ ja: {
17
+ firstPageTitle: '最初のページ',
18
+ intro: {
19
+ first: paragraphs('wema-kake へようこそ', [
20
+ '付箋を並べて書く Wiki です。',
21
+ '1 ページが、1 枚のボードになります。',
22
+ 'このページの付箋は、消してかまいません。',
23
+ ]),
24
+ help: paragraphs('使い方', [
25
+ '試すためのボードです。',
26
+ '付箋を動かしたり、書き換えたりできます。',
27
+ 'ここでの変更は、保存されません。',
28
+ ]),
29
+ },
30
+ sample: sample({
31
+ heading: '装飾の見本',
32
+ bold: '太字',
33
+ strike: '取り消し線',
34
+ color: '文字の色',
35
+ link: 'リンク',
36
+ separator: '、',
37
+ bullet: '箇条書き',
38
+ nested: '段を下げた項目',
39
+ numbered: ['番号付きのリスト', '2 つ目'],
40
+ todo: 'チェックリスト',
41
+ done: '済んだ項目',
42
+ }),
43
+ sections: {
44
+ notes: [
45
+ '付箋',
46
+ [
47
+ '空いている場所をダブルクリック → 作る',
48
+ '上端をドラッグ → 動かす',
49
+ '右下をドラッグ → 大きさを変える',
50
+ '右下をダブルクリック → 内容に合わせる',
51
+ '選択して Delete → 削除する',
52
+ 'Shift + クリック → 複数を選ぶ',
53
+ 'Ctrl + ドラッグ → 囲んで選ぶ',
54
+ ],
55
+ ],
56
+ text: [
57
+ '本文',
58
+ [
59
+ '文字を選択 → 太字、取り消し線、色、リンク',
60
+ '付箋を選択 → 色、リスト、画像',
61
+ 'リストの中で Tab → 段を下げる',
62
+ '長い本文は、畳んで表示できる',
63
+ ],
64
+ ],
65
+ edges: [
66
+ '接続線',
67
+ [
68
+ '縁の ● をドラッグ → 他の付箋とつなぐ',
69
+ '空いている場所で離す → 先に付箋を作る',
70
+ '線をクリック → 種類やラベルを変える',
71
+ ],
72
+ ],
73
+ view: [
74
+ '表示',
75
+ [
76
+ 'ホイール、空いた場所をドラッグ → 動かす',
77
+ 'Ctrl + ホイール、ピンチ → 拡大と縮小',
78
+ 'Ctrl + Z → 元に戻す(自分の操作だけ)',
79
+ 'メニューの「参照モード」 → 読むだけにする',
80
+ '狭い画面では、縦 1 列に並べて表示する',
81
+ '並んでいる間は動かせず、線は印になる',
82
+ ],
83
+ ],
84
+ pages: [
85
+ 'ページ',
86
+ [
87
+ '「子ページを置く」 → 中にページを作る',
88
+ '子ページの表示名をクリック → 中へ入る',
89
+ '付箋を子ページへドラッグ → 中へ移動(Ctrl でコピー)',
90
+ '/p/ページ名 へのリンク → 他のページへ移る',
91
+ '左上の表示名をクリック → 名前を変える',
92
+ ],
93
+ ],
94
+ llm: [
95
+ 'LLM',
96
+ [
97
+ 'MCP でつなぐと、Claude などが読み書きできる',
98
+ 'LLM の操作は、メニューの「操作の履歴」に残る',
99
+ '履歴から、操作ごとに取り消せる',
100
+ ],
101
+ ],
102
+ },
103
+ column: 460,
104
+ },
105
+ en: {
106
+ firstPageTitle: 'First page',
107
+ intro: {
108
+ first: paragraphs('Welcome to wema-kake', [
109
+ 'A wiki you write with sticky notes.',
110
+ 'Each page is one board.',
111
+ 'Feel free to delete the notes on this page.',
112
+ ]),
113
+ help: paragraphs('Help', [
114
+ 'A board for trying things out.',
115
+ 'Move the notes around or rewrite them.',
116
+ 'Nothing you change here is saved.',
117
+ ]),
118
+ },
119
+ sample: sample({
120
+ heading: 'Formatting sample',
121
+ bold: 'Bold',
122
+ strike: 'strikethrough',
123
+ color: 'text color',
124
+ link: 'link',
125
+ separator: ', ',
126
+ bullet: 'Bulleted list',
127
+ nested: 'Indented item',
128
+ numbered: ['Numbered list', 'Second item'],
129
+ todo: 'Checklist',
130
+ done: 'Finished item',
131
+ }),
132
+ sections: {
133
+ notes: [
134
+ 'Notes',
135
+ [
136
+ 'Double-click an empty spot → create',
137
+ 'Drag the top edge → move',
138
+ 'Drag the bottom-right corner → resize',
139
+ 'Double-click that corner → fit the content',
140
+ 'Select and press Delete → delete',
141
+ 'Shift + click → select several',
142
+ 'Ctrl + drag → select an area',
143
+ ],
144
+ ],
145
+ text: [
146
+ 'Text',
147
+ [
148
+ 'Select text → bold, strikethrough, color, link',
149
+ 'Select a note → color, lists, image',
150
+ 'Tab inside a list → indent',
151
+ 'A long text can be shown folded',
152
+ ],
153
+ ],
154
+ edges: [
155
+ 'Connections',
156
+ [
157
+ 'Drag a ● on the edge → connect to a note',
158
+ 'Drop on an empty spot → create a note there',
159
+ 'Click a line → change its style or label',
160
+ ],
161
+ ],
162
+ view: [
163
+ 'View',
164
+ [
165
+ 'Wheel, or drag an empty spot → pan',
166
+ 'Ctrl + wheel, or pinch → zoom',
167
+ 'Ctrl + Z → undo (your own changes only)',
168
+ '“View-only mode” in the menu → read only',
169
+ 'A narrow screen shows the notes in one column',
170
+ 'There, notes do not move; lines become chips',
171
+ ],
172
+ ],
173
+ pages: [
174
+ 'Pages',
175
+ [
176
+ '“Place a child page” → a page inside this one',
177
+ 'Click a child page’s title → go inside',
178
+ 'Drag a note onto a child page → move it inside (Ctrl: copy)',
179
+ 'A link to /p/page-name → go to that page',
180
+ 'Click the title at the top left → rename',
181
+ ],
182
+ ],
183
+ llm: [
184
+ 'LLM',
185
+ [
186
+ 'Over MCP, Claude and others can read and write',
187
+ 'Their operations are kept in “Operation history”',
188
+ 'Revert them there, one operation at a time',
189
+ ],
190
+ ],
191
+ },
192
+ column: 480,
193
+ },
194
+ };
195
+ /** 最初のページの表示名 */
196
+ export const firstPageTitle = (lang) => TEXTS[lang].firstPageTitle;
197
+ /** 段の位置 */
198
+ const ROWS = [60, 370, 680];
199
+ /**
200
+ * 付箋の色、位置(列、段)、高さの目安。autoSize にするので、実際の大きさは、ボードが読み込んだ
201
+ * ときに wema が計測して決める(wema 0.10.0 以降)。高さは、計測されるまでの値
202
+ */
203
+ const LAYOUT = {
204
+ sample: { color: '#F5F5F5', column: 0, row: 0, height: 250 },
205
+ intro: { color: '#FFF9C4', column: 1, row: 0, height: 120 },
206
+ notes: { color: '#BBDEFB', column: 0, row: 1, height: 220 },
207
+ text: { color: '#FFE0B2', column: 1, row: 1, height: 170 },
208
+ edges: { color: '#C8E6C9', column: 2, row: 1, height: 150 },
209
+ view: { color: '#FFCDD2', column: 0, row: 2, height: 210 },
210
+ pages: { color: '#E1BEE7', column: 1, row: 2, height: 170 },
211
+ llm: { color: '#B2DFDB', column: 2, row: 2, height: 150 },
212
+ };
213
+ /** 接続線。案内の付箋から 2 段目へ、2 段目から 3 段目へ。装飾の見本は、どれともつながない */
214
+ const EDGES = [
215
+ ['intro', 'notes'],
216
+ ['intro', 'text'],
217
+ ['intro', 'edges'],
218
+ ['notes', 'view'],
219
+ ['text', 'pages'],
220
+ ['edges', 'llm'],
221
+ ];
222
+ const noteId = (key) => `guide-${key}`;
223
+ /** 案内の付箋の id。付箋を縦に積む画面で、先頭に並べるのに使う(src/web/stacked-view.ts) */
224
+ export const GUIDE_INTRO_ID = noteId('intro');
225
+ /** 使い方の付箋と接続線。`intro` で、案内の付箋の文面を選ぶ */
226
+ export function guideBoard(intro, lang) {
227
+ const text = TEXTS[lang];
228
+ const bodyOf = (key) => {
229
+ if (key === 'sample')
230
+ return text.sample;
231
+ if (key === 'intro')
232
+ return text.intro[intro];
233
+ const [heading, items] = text.sections[key];
234
+ return `<b>${heading}</b><ul>${items.map((item) => `<li>${item}</li>`).join('')}</ul>`;
235
+ };
236
+ const notes = Object.keys(LAYOUT).map((key, index) => {
237
+ const { color, column, row, height } = LAYOUT[key];
238
+ return {
239
+ id: noteId(key),
240
+ x: 60 + column * text.column,
241
+ y: ROWS[row],
242
+ width: 380,
243
+ height,
244
+ text: bodyOf(key),
245
+ color,
246
+ zIndex: index + 1,
247
+ autoSize: true,
248
+ };
249
+ });
250
+ const edges = EDGES.map(([from, to]) => ({
251
+ id: `guide-${from}-${to}`,
252
+ from: noteId(from),
253
+ to: noteId(to),
254
+ fromAnchor: 'auto',
255
+ toAnchor: 'auto',
256
+ style: 'arrow',
257
+ }));
258
+ return { notes, edges };
259
+ }
@@ -0,0 +1,31 @@
1
+ import type { HistoryDelta, WemaNote } from './delta';
2
+ /** 子ページの付箋の meta のキー。値は、子ページのスラッグ */
3
+ export declare const CHILD_PAGE_KEY = "page";
4
+ /** 階層の深さの上限。ルートを 1 段目と数える */
5
+ export declare const MAX_DEPTH = 5;
6
+ /**
7
+ * 子ページとして置けなかった理由。
8
+ * - not-found: ページがない
9
+ * - self: 自分自身を置こうとした
10
+ * - other-parent: すでに別のページの子になっている
11
+ * - duplicate: このページに、すでに置いてある
12
+ * - ancestor: このページの先祖なので、輪になる
13
+ * - too-deep: 深さの上限を超える
14
+ */
15
+ export type ChildRejectCode = 'not-found' | 'self' | 'other-parent' | 'duplicate' | 'ancestor' | 'too-deep';
16
+ /** 置けなかったことを表す `reason`。画面は `parseChildRejection` で読み、agent はそのまま読む */
17
+ export declare function childRejection(code: ChildRejectCode, page: string): string;
18
+ /** `reason` が、子ページとして置けなかったことを表していれば、理由とページを返す */
19
+ export declare function parseChildRejection(reason: string): {
20
+ code: ChildRejectCode;
21
+ page: string;
22
+ } | undefined;
23
+ /** 子ページの付箋なら、子ページのスラッグを返す */
24
+ export declare function childPageOf(note: Pick<WemaNote, 'meta'>): string | undefined;
25
+ /**
26
+ * デルタが、子ページの付箋の集合を変え得るか。変え得るのは、付箋の削除(消す付箋が子ページの
27
+ * 付箋かどうかは、保存してある値で決まる)、子ページの付箋の作成、meta の更新
28
+ */
29
+ export declare function touchesChildren(deltas: HistoryDelta[]): boolean;
30
+ /** デルタを適用すると、新しく子として置かれることになるページ(重複は除く) */
31
+ export declare function placedPages(deltas: HistoryDelta[]): string[];
@@ -0,0 +1,40 @@
1
+ /** 子ページの付箋の meta のキー。値は、子ページのスラッグ */
2
+ export const CHILD_PAGE_KEY = 'page';
3
+ /** 階層の深さの上限。ルートを 1 段目と数える */
4
+ export const MAX_DEPTH = 5;
5
+ /** 置けなかったときの `reject` の `reason` の先頭。続けて、理由と、置こうとしたページが入る */
6
+ const REJECT_PREFIX = 'child page: ';
7
+ /** 置けなかったことを表す `reason`。画面は `parseChildRejection` で読み、agent はそのまま読む */
8
+ export function childRejection(code, page) {
9
+ return `${REJECT_PREFIX}${code}: ${page}`;
10
+ }
11
+ /** `reason` が、子ページとして置けなかったことを表していれば、理由とページを返す */
12
+ export function parseChildRejection(reason) {
13
+ if (!reason.startsWith(REJECT_PREFIX))
14
+ return undefined;
15
+ const [code, page] = reason.slice(REJECT_PREFIX.length).split(': ');
16
+ return { code: code, page: page ?? '' };
17
+ }
18
+ /** 子ページの付箋なら、子ページのスラッグを返す */
19
+ export function childPageOf(note) {
20
+ return note.meta?.[CHILD_PAGE_KEY];
21
+ }
22
+ /**
23
+ * デルタが、子ページの付箋の集合を変え得るか。変え得るのは、付箋の削除(消す付箋が子ページの
24
+ * 付箋かどうかは、保存してある値で決まる)、子ページの付箋の作成、meta の更新
25
+ */
26
+ export function touchesChildren(deltas) {
27
+ return deltas.some((d) => d.type === 'note:delete' ||
28
+ (d.type === 'note:create' && childPageOf(d.note) !== undefined) ||
29
+ (d.type === 'note:update' && ('meta' in d.before || 'meta' in d.after)));
30
+ }
31
+ /** デルタを適用すると、新しく子として置かれることになるページ(重複は除く) */
32
+ export function placedPages(deltas) {
33
+ const pages = new Set();
34
+ for (const d of deltas) {
35
+ const page = d.type === 'note:create' ? childPageOf(d.note) : d.type === 'note:update' ? childPageOf(d.after) : undefined;
36
+ if (page !== undefined)
37
+ pages.add(page);
38
+ }
39
+ return [...pages];
40
+ }
@@ -0,0 +1,23 @@
1
+ export type Lang = 'ja' | 'en';
2
+ export declare const LANGS: readonly Lang[];
3
+ /** サイトの設定で既定の言語を省いたときの言語 */
4
+ export declare const DEFAULT_LANG: Lang;
5
+ /**
6
+ * 選んだ言語を持つ Cookie の名前。値は Lang。選んでいない端末には置かない。
7
+ * 画面のスクリプトが読み書きする(秘密の値ではない)。サーバーは読むだけ
8
+ */
9
+ export declare const LANG_COOKIE = "wk_lang";
10
+ export declare const isLang: (value: string | undefined) => value is Lang;
11
+ /**
12
+ * 言語を決める。`chosen` は、選んだ言語(Cookie の値。なければ undefined)。`preferred` は、
13
+ * ブラウザの言語の並び(優先の高い順。`ja-JP` のような地域つきの値は、先頭の部分で比べる)。
14
+ * `fallback` は、どちらにも当たらないときの言語(サイトの既定の言語)。省けないようにしてあるのは、
15
+ * 渡し忘れて設定の `defaultLang` が黙って無視されるのを、型で防ぐため
16
+ */
17
+ export declare function resolveLang(chosen: string | undefined, preferred: readonly string[], fallback: Lang): Lang;
18
+ /** Cookie の文字列(`document.cookie` か、Cookie ヘッダー)から、`name` の値を取り出す */
19
+ export declare function cookieValue(cookies: string | null | undefined, name: string): string | undefined;
20
+ /** Accept-Language の値を、優先の高い順の言語の並びにする。q が 0 のものは外す */
21
+ export declare function parseAcceptLanguage(header: string | null | undefined): string[];
22
+ /** リクエストを送ってきたブラウザの、画面の言語。`fallback` はサイトの既定の言語 */
23
+ export declare function requestLang(request: Request, fallback: Lang): Lang;
@@ -0,0 +1,58 @@
1
+ // 画面の言語を決める処理。画面とサーバーの両方が使う(docs/plan.md のフェーズ 9)。
2
+ //
3
+ // 言語は、次の順で決める。
4
+ // 1. その端末で選んだ言語(Cookie)
5
+ // 2. ブラウザの言語の設定(サーバーでは Accept-Language、画面では navigator.languages)
6
+ // 3. どちらにも対応する言語がなければ、サイトの既定の言語(設定の `defaultLang`。省けば DEFAULT_LANG)
7
+ export const LANGS = ['ja', 'en'];
8
+ /** サイトの設定で既定の言語を省いたときの言語 */
9
+ export const DEFAULT_LANG = 'en';
10
+ /**
11
+ * 選んだ言語を持つ Cookie の名前。値は Lang。選んでいない端末には置かない。
12
+ * 画面のスクリプトが読み書きする(秘密の値ではない)。サーバーは読むだけ
13
+ */
14
+ export const LANG_COOKIE = 'wk_lang';
15
+ export const isLang = (value) => LANGS.includes(value);
16
+ /**
17
+ * 言語を決める。`chosen` は、選んだ言語(Cookie の値。なければ undefined)。`preferred` は、
18
+ * ブラウザの言語の並び(優先の高い順。`ja-JP` のような地域つきの値は、先頭の部分で比べる)。
19
+ * `fallback` は、どちらにも当たらないときの言語(サイトの既定の言語)。省けないようにしてあるのは、
20
+ * 渡し忘れて設定の `defaultLang` が黙って無視されるのを、型で防ぐため
21
+ */
22
+ export function resolveLang(chosen, preferred, fallback) {
23
+ if (isLang(chosen))
24
+ return chosen;
25
+ for (const tag of preferred) {
26
+ const primary = tag.trim().toLowerCase().split('-')[0];
27
+ if (isLang(primary))
28
+ return primary;
29
+ }
30
+ return fallback;
31
+ }
32
+ /** Cookie の文字列(`document.cookie` か、Cookie ヘッダー)から、`name` の値を取り出す */
33
+ export function cookieValue(cookies, name) {
34
+ for (const part of (cookies ?? '').split(';')) {
35
+ const eq = part.indexOf('=');
36
+ if (eq !== -1 && part.slice(0, eq).trim() === name)
37
+ return part.slice(eq + 1).trim();
38
+ }
39
+ return undefined;
40
+ }
41
+ /** Accept-Language の値を、優先の高い順の言語の並びにする。q が 0 のものは外す */
42
+ export function parseAcceptLanguage(header) {
43
+ return (header ?? '')
44
+ .split(',')
45
+ .map((part, index) => {
46
+ const [tag, ...params] = part.split(';');
47
+ const q = params.map((param) => /^\s*q\s*=\s*([\d.]+)\s*$/.exec(param)?.[1]).find((value) => value !== undefined);
48
+ return { tag: tag.trim(), q: q === undefined ? 1 : Number(q), index };
49
+ })
50
+ .filter(({ tag, q }) => tag !== '' && tag !== '*' && q > 0)
51
+ // q が同じなら、書かれた順
52
+ .sort((a, b) => b.q - a.q || a.index - b.index)
53
+ .map(({ tag }) => tag);
54
+ }
55
+ /** リクエストを送ってきたブラウザの、画面の言語。`fallback` はサイトの既定の言語 */
56
+ export function requestLang(request, fallback) {
57
+ return resolveLang(cookieValue(request.headers.get('Cookie'), LANG_COOKIE), parseAcceptLanguage(request.headers.get('Accept-Language')), fallback);
58
+ }
@@ -0,0 +1,10 @@
1
+ /** テキストにするときに、改行として扱うタグ */
2
+ export declare const BLOCK_TAGS: readonly ['br', 'div', 'p', 'li', 'ul', 'ol', 'blockquote'];
3
+ /**
4
+ * 文字参照を、文字に戻す。タグを外した後のテキストに使う(HTMLRewriter も、正規表現でタグを外した
5
+ * 結果も、文字参照は復号されないまま残る)。
6
+ *
7
+ * 名前付きの文字参照は、上の 6 つだけを戻す(ブラウザが付箋の HTML に書き出すもの)。`&nbsp;` は、
8
+ * ふつうの空白にする。それ以外は、書かれたまま残す
9
+ */
10
+ export declare function decodeEntities(s: string): string;
@@ -0,0 +1,28 @@
1
+ // 付箋の text(HTML)からテキストを取り出すときに使うもの(検索の索引、LLM へ渡すテキスト。
2
+ // src/worker/plain-text.ts)。HTMLRewriter に頼らないので、ブラウザの側からも使える。
3
+ /** テキストにするときに、改行として扱うタグ */
4
+ export const BLOCK_TAGS = ['br', 'div', 'p', 'li', 'ul', 'ol', 'blockquote'];
5
+ const NAMED_ENTITIES = {
6
+ amp: '&',
7
+ lt: '<',
8
+ gt: '>',
9
+ quot: '"',
10
+ apos: "'",
11
+ nbsp: ' ',
12
+ };
13
+ /**
14
+ * 文字参照を、文字に戻す。タグを外した後のテキストに使う(HTMLRewriter も、正規表現でタグを外した
15
+ * 結果も、文字参照は復号されないまま残る)。
16
+ *
17
+ * 名前付きの文字参照は、上の 6 つだけを戻す(ブラウザが付箋の HTML に書き出すもの)。`&nbsp;` は、
18
+ * ふつうの空白にする。それ以外は、書かれたまま残す
19
+ */
20
+ export function decodeEntities(s) {
21
+ return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, entity) => {
22
+ if (entity[0] !== '#')
23
+ return NAMED_ENTITIES[entity.toLowerCase()] ?? whole;
24
+ const hex = entity[1] === 'x' || entity[1] === 'X';
25
+ const code = Number.parseInt(entity.slice(hex ? 2 : 1), hex ? 16 : 10);
26
+ return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole;
27
+ });
28
+ }
@@ -0,0 +1,20 @@
1
+ import type { HistoryDelta } from './delta';
2
+ export interface NoteInfo {
3
+ /** 作成者('user:<email>' / 'agent:<client>')。分からなければ null */
4
+ createdBy: string | null;
5
+ /** 最後に変更した時刻(UNIX ミリ秒)。移動や大きさの変更でも変わる */
6
+ updatedAt: number;
7
+ }
8
+ /** 付箋の id ごとの付加情報(スナップショットに載せる形) */
9
+ export type NoteInfos = Record<string, NoteInfo>;
10
+ /** 作成者が agent(MCP のクライアント)か */
11
+ export declare const isAgentActor: (actor: string | null) => boolean;
12
+ /**
13
+ * 確定した操作を、付加情報に反映する。サーバーが付箋の行に書く値(`created_by`、`updated_at`)と
14
+ * 同じ結果になる。変わった付箋の id を返す。
15
+ *
16
+ * @param actor 操作の主体
17
+ * @param at 操作を記録した時刻
18
+ * @param deltas サーバーが実際に適用したデルタ
19
+ */
20
+ export declare function applyOpToInfos(infos: Map<string, NoteInfo>, actor: string, at: number, deltas: HistoryDelta[]): Set<string>;
@@ -0,0 +1,29 @@
1
+ /** 作成者が agent(MCP のクライアント)か */
2
+ export const isAgentActor = (actor) => actor?.startsWith('agent:') ?? false;
3
+ /**
4
+ * 確定した操作を、付加情報に反映する。サーバーが付箋の行に書く値(`created_by`、`updated_at`)と
5
+ * 同じ結果になる。変わった付箋の id を返す。
6
+ *
7
+ * @param actor 操作の主体
8
+ * @param at 操作を記録した時刻
9
+ * @param deltas サーバーが実際に適用したデルタ
10
+ */
11
+ export function applyOpToInfos(infos, actor, at, deltas) {
12
+ const changed = new Set();
13
+ for (const d of deltas) {
14
+ if (d.type === 'note:create') {
15
+ infos.set(d.note.id, { createdBy: actor, updatedAt: at });
16
+ changed.add(d.note.id);
17
+ }
18
+ else if (d.type === 'note:delete') {
19
+ if (infos.delete(d.note.id))
20
+ changed.add(d.note.id);
21
+ }
22
+ else if (d.type === 'note:update') {
23
+ // 手元にない付箋の更新は、作成者が分からないまま、時刻だけを持つ
24
+ infos.set(d.noteId, { createdBy: infos.get(d.noteId)?.createdBy ?? null, updatedAt: at });
25
+ changed.add(d.noteId);
26
+ }
27
+ }
28
+ return changed;
29
+ }
@@ -0,0 +1,11 @@
1
+ /** ページへのリンクの書式 */
2
+ export declare const pageLink: (slug: string) => string;
3
+ /**
4
+ * URL に使える文字(正規表現の文字クラスの中身)。ASCII の、空白でない文字のうち、引用符、山かっこ、
5
+ * 角かっこ(`[[slug]]` と紛れる)、バックスラッシュ、波かっこなどを除いたもの。
6
+ * **引用符と山かっこを足さないこと**(リンクの `href` に、エスケープなしで入れている)
7
+ */
8
+ export declare const URL_CHARS = "!#-&(-;=?-Z_a-z~";
9
+ /** 文の側のものとして、URL の末尾から外す句読点 */
10
+ export declare const TRAILING_PUNCTUATION = ".,;:!?";
11
+ export declare function textToHtml(text: string): string;
@@ -0,0 +1,118 @@
1
+ // LLM(MCP / WebMCP)が渡すプレーンテキストを、付箋の text(HTML)にする。
2
+ // 受け付ける書式は、箇条書き(`- `)、チェックリスト(`- [ ]` / `- [x]`)、ページへのリンク
3
+ // (`[[slug]]`)だけ。それ以外はそのままの文字として扱う(HTML のタグを書いても、タグとしては
4
+ // 解釈しない)。本文にそのまま書いた URL(http / https)は、URL を文字にしたリンクになる。
5
+ //
6
+ // 読むとき(read_board)は、src/worker/plain-text.ts が、ページへのリンクを同じ書式に戻す。
7
+ import { SLUG_PATTERN } from './slug';
8
+ /** 本文の中の、ページへのリンク。`[[` と `]]` の間は、スラッグそのもの */
9
+ const PAGE_LINK_RE = new RegExp(`\\[\\[(${SLUG_PATTERN})\\]\\]`, 'g');
10
+ /** ページへのリンクの書式 */
11
+ export const pageLink = (slug) => `[[${slug}]]`;
12
+ const escapeHtml = (text) => text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
13
+ /**
14
+ * URL に使える文字(正規表現の文字クラスの中身)。ASCII の、空白でない文字のうち、引用符、山かっこ、
15
+ * 角かっこ(`[[slug]]` と紛れる)、バックスラッシュ、波かっこなどを除いたもの。
16
+ * **引用符と山かっこを足さないこと**(リンクの `href` に、エスケープなしで入れている)
17
+ */
18
+ export const URL_CHARS = '!#-&(-;=?-Z_a-z~';
19
+ /**
20
+ * 本文の中の URL の候補。URL に使える文字の並び(日本語の文に続けて書かれても、URL の終わりが分かる)
21
+ */
22
+ const URL_RE = new RegExp(`https?://[${URL_CHARS}]+`, 'g');
23
+ /** 全体が、URL に使える文字だけでできているか */
24
+ const SAFE_URL_RE = new RegExp(`^[${URL_CHARS}]+$`);
25
+ /** 文の側のものとして、URL の末尾から外す句読点 */
26
+ export const TRAILING_PUNCTUATION = '.,;:!?';
27
+ /**
28
+ * URL の候補から、後ろに続いていた文字(文の句読点や、閉じかっこ)を外して、URL にする。
29
+ * `length` は、候補のうち URL として使った長さ。リンクにできないものは null。
30
+ *
31
+ * **リンクの文字は、リンク先の URL そのものにする**(`href`)。文字とリンク先を別々に書ける
32
+ * 書式は作らない。LLM が、文字でリンク先を偽れないようにするため。
33
+ * - ユーザー名やパスワードを含む URL(`https://信頼できる名前@別のホスト/`)は、リンクにしない
34
+ * - 国際化ドメイン名は、解釈した後の形(Punycode)で出る。似た文字のホスト名を見分けられる
35
+ */
36
+ function externalLink(candidate) {
37
+ let end = candidate.length;
38
+ // 余っている閉じかっこの数。最初に 1 回だけ数える(外すたびに数え直すと、かっこの並んだ本文で遅くなる)
39
+ let unmatched = candidate.split(')').length - candidate.split('(').length;
40
+ for (;; end--) {
41
+ const last = candidate[end - 1];
42
+ // 閉じかっこは、対になる開きかっこが URL の中にないときだけ、文の側のものとして外す
43
+ if (last === ')' && unmatched > 0)
44
+ unmatched--;
45
+ else if (!TRAILING_PUNCTUATION.includes(last))
46
+ break;
47
+ }
48
+ let url;
49
+ try {
50
+ url = new URL(candidate.slice(0, end));
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ if (url.username !== '' || url.password !== '')
56
+ return null;
57
+ // 解釈した後の URL に、使えない文字が現れたら、リンクにしない。ホスト名のパーセントエンコードは
58
+ // 復号されるので(`https://x%22y/` → `https://x"y/`)、引用符で `href` の外へ出られてしまう
59
+ if (!SAFE_URL_RE.test(url.href))
60
+ return null;
61
+ return { href: url.href, length: end };
62
+ }
63
+ /** URL を含まない文字を HTML にする。エスケープしてから、ページへのリンクをリンクに変える */
64
+ const plainHtml = (text) => escapeHtml(text).replace(PAGE_LINK_RE, (_, slug) => `<a href="/p/${slug}">${slug}</a>`);
65
+ /** 1 行ぶんの文字を HTML にする。URL と、ページへのリンクを、リンクに変える */
66
+ function inlineHtml(text) {
67
+ let html = '';
68
+ let from = 0;
69
+ for (const match of text.matchAll(URL_RE)) {
70
+ const link = externalLink(match[0]);
71
+ if (!link)
72
+ continue;
73
+ const href = escapeHtml(link.href);
74
+ html += plainHtml(text.slice(from, match.index));
75
+ html += `<a href="${href}" target="_blank" rel="noopener noreferrer">${href}</a>`;
76
+ from = match.index + link.length;
77
+ }
78
+ return html + plainHtml(text.slice(from));
79
+ }
80
+ const CHECK_RE = /^- \[([ xX])\] ?(.*)$/;
81
+ const BULLET_RE = /^- (.*)$/;
82
+ function kindOf(line) {
83
+ if (CHECK_RE.test(line))
84
+ return 'check';
85
+ if (BULLET_RE.test(line))
86
+ return 'bullet';
87
+ return 'plain';
88
+ }
89
+ /** wema のチェックリストと同じ形の HTML にする */
90
+ function checkItem(line) {
91
+ const [, mark, text] = CHECK_RE.exec(line);
92
+ const checked = mark !== ' ';
93
+ return (`<li${checked ? ' class="wema-checked"' : ''}>` +
94
+ `<input type="checkbox"${checked ? ' checked' : ''}>${inlineHtml(text)}</li>`);
95
+ }
96
+ export function textToHtml(text) {
97
+ const lines = text.replace(/\r\n?/g, '\n').split('\n');
98
+ const blocks = [];
99
+ for (let i = 0; i < lines.length;) {
100
+ const kind = kindOf(lines[i]);
101
+ let end = i;
102
+ while (end < lines.length && kindOf(lines[end]) === kind)
103
+ end++;
104
+ const group = lines.slice(i, end);
105
+ if (kind === 'check') {
106
+ blocks.push(`<ul class="wema-checklist">${group.map(checkItem).join('')}</ul>`);
107
+ }
108
+ else if (kind === 'bullet') {
109
+ const items = group.map((line) => `<li>${inlineHtml(BULLET_RE.exec(line)[1])}</li>`);
110
+ blocks.push(`<ul>${items.join('')}</ul>`);
111
+ }
112
+ else {
113
+ blocks.push(group.map(inlineHtml).join('<br>'));
114
+ }
115
+ i = end;
116
+ }
117
+ return blocks.join('');
118
+ }