@tenjuu99/blog 0.3.6 → 0.4.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 (49) hide show
  1. package/bin/dev-server +3 -1
  2. package/bin/server +8 -1
  3. package/docs/develop.md +30 -0
  4. package/docs/dictionary.json +1806 -0
  5. package/lib/dir.js +2 -2
  6. package/lib/distribute.js +5 -5
  7. package/lib/files.js +1 -1
  8. package/lib/filter.js +5 -5
  9. package/lib/generate.js +3 -3
  10. package/lib/helper.js +2 -2
  11. package/lib/imageDistributor.js +3 -3
  12. package/lib/indexer.js +2 -2
  13. package/lib/pageData.js +5 -5
  14. package/lib/render.js +4 -4
  15. package/lib/replaceVariablesFilter.js +4 -4
  16. package/lib/server/helper/parseRequestBody.js +46 -0
  17. package/lib/server.js +7 -3
  18. package/package.json +3 -2
  19. package/packages/category/helper/category.js +10 -10
  20. package/packages/category/helper/categoryIndexer.js +8 -8
  21. package/packages/category/helper/pagination.js +3 -3
  22. package/packages/editor/css/editor.css +87 -18
  23. package/packages/editor/helper/sidebarTree.js +7 -2
  24. package/packages/editor/js/autoPreviewInitializer.js +11 -0
  25. package/packages/editor/js/autoSaveInitializer.js +15 -0
  26. package/packages/editor/js/debouncer.js +21 -0
  27. package/packages/editor/js/editor.js +261 -91
  28. package/packages/editor/js/frontmatter_template.js +3 -3
  29. package/packages/editor/js/imageReferenceExtractor.js +19 -0
  30. package/packages/editor/js/image_upload.js +1 -1
  31. package/packages/editor/js/tree.js +27 -12
  32. package/packages/editor/server/changeReflector.js +47 -0
  33. package/packages/editor/server/createConverter.js +2 -2
  34. package/packages/editor/server/get_editor_target.js +2 -2
  35. package/packages/editor/server/get_frontmatter_templates.js +1 -1
  36. package/packages/editor/server/get_publication_status.js +29 -0
  37. package/packages/editor/server/get_sidebar.js +51 -0
  38. package/packages/editor/server/image_upload.js +32 -40
  39. package/packages/editor/server/preview.js +49 -19
  40. package/packages/editor/server/publicationStatus.js +27 -0
  41. package/packages/editor/server/publish.js +135 -0
  42. package/packages/editor/server/publishTargetCollector.js +19 -0
  43. package/packages/editor/server/save.js +97 -0
  44. package/packages/editor/server/sidebarStatusCollector.js +17 -0
  45. package/packages/editor/template/editor.html +28 -18
  46. package/src-sample/helper/add.js +1 -1
  47. package/src-sample/helper/index.js +6 -6
  48. package/docs/dictionary.md +0 -553
  49. package/packages/editor/server/editor.js +0 -43
@@ -1,39 +1,8 @@
1
- const sleep = waitTime => new Promise( resolve => setTimeout(resolve, waitTime) );
1
+ import { initAutoPreview } from './autoPreviewInitializer.js'
2
+ import { initAutoSave } from './autoSaveInitializer.js'
3
+ import { matchTemplate, buildFrontmatterString, loadFrontmatterTemplate } from './frontmatter_template.js'
2
4
 
3
- // @vocab: テンプレートマッチャー (docs/dictionary.md#テンプレートマッチャー)
4
- // @test: tests/editor/editor-frontmatter-template.test.js
5
- const matchTemplate = (filePath, templates) => {
6
- if (!filePath || !templates || templates.length === 0) return null
7
- let best = null
8
- for (const tmpl of templates) {
9
- if (filePath.startsWith(tmpl.path_prefix)) {
10
- if (!best || tmpl.path_prefix.length > best.path_prefix.length) {
11
- best = tmpl
12
- }
13
- }
14
- }
15
- return best
16
- }
17
-
18
- // @vocab: テンプレートインジェクター (docs/dictionary.md#テンプレートインジェクター)
19
- // @test: tests/editor/editor-frontmatter-template.test.js
20
- const buildFrontmatterString = (template, baseName) => {
21
- const fields = { ...template.fields }
22
- fields.title = baseName
23
- const lines = Object.entries(fields).map(([key, value]) => `${key}: ${value}`)
24
- return `---\n${lines.join('\n')}\n---\n`
25
- }
26
-
27
- // @vocab: フロントマターテンプレートローダー (docs/dictionary.md#フロントマターテンプレートローダー)
28
- // @test: tests/editor/editor-frontmatter-template.test.js
29
- const loadFrontmatterTemplate = (filePath, templates) => {
30
- const matched = matchTemplate(filePath, templates)
31
- if (!matched) return null
32
- const baseName = filePath.split('/').pop().replace(/\.[^.]+$/, '')
33
- return buildFrontmatterString(matched, baseName)
34
- }
35
-
36
- // @vocab: テンプレートレゾルバー (docs/dictionary.md#テンプレートレゾルバー)
5
+ // @vocab: テンプレートレゾルバー
37
6
  // @test: tests/editor/editor-frontmatter-template.test.js
38
7
  let _frontmatterTemplates = []
39
8
  const initFrontmatterTemplate = async () => {
@@ -52,12 +21,10 @@ const fetchData = (target) => {
52
21
  return fetch(`/get_editor_target?md=${encodeURIComponent(target)}`)
53
22
  .then(async res => {
54
23
  if (!res.ok) {
55
- document.querySelector('#inputFileName').value = target
56
24
  const baseName = target.split('/').pop().replace(/\.[^.]+$/, '')
57
25
  const initialContent = loadFrontmatterTemplate(target, _frontmatterTemplates)
58
26
  ?? `---\ntitle: ${baseName}\n---\n${baseName} についての記事を作成しましょう`
59
27
  document.querySelector('#editorTextArea').value = initialContent
60
- // submit('/preview', form)
61
28
  throw new Error(`${target} does not exist.`)
62
29
  } else {
63
30
  const json = await res.json()
@@ -65,52 +32,33 @@ const fetchData = (target) => {
65
32
  }
66
33
  })
67
34
  }
35
+
68
36
  const onloadFunction = async (e) => {
69
37
  const form = document.querySelector('#editor')
70
38
  const textarea = form.querySelector('#editorTextArea')
71
- const select = form.querySelector('#selectDataFile')
72
39
  const inputFileName = form.querySelector('#inputFileName')
40
+ const currentFileName = document.querySelector('#currentFileName')
73
41
  const preview = document.querySelector('#previewContent')
74
42
  const url = new URL(location)
75
43
  const target = url.searchParams.get('md')
44
+
45
+ const setCurrentFile = (filename) => {
46
+ inputFileName.value = filename
47
+ currentFileName.textContent = filename
48
+ }
49
+
76
50
  if (target) {
77
51
  fetchData(target).then(json => {
78
52
  textarea.value = json.content
79
- select.value = json.filename
80
- inputFileName.value = json.filename
81
- inputFileName.setAttribute('disabled', true)
53
+ setCurrentFile(json.filename)
82
54
  submit('/preview', form)
55
+ fetchPublicationStatus(target)
83
56
  }).catch(e => {
84
57
  console.log('error!!!')
85
58
  console.log(e)
59
+ setCurrentFile(target)
86
60
  })
87
61
  }
88
- select.addEventListener('change', async (event) => {
89
- if (select.value) {
90
- const json = await fetchData(select.value)
91
- textarea.value = json.content
92
- inputFileName.value = json.filename
93
- inputFileName.setAttribute('disabled', true)
94
- url.searchParams.set('md', select.value)
95
- submit('/preview', form)
96
- } else {
97
- inputFileName.value = ""
98
- inputFileName.removeAttribute('disabled')
99
- textarea.value = ''
100
- url.searchParams.set('md', "")
101
- const iframe = preview.querySelector('iframe')
102
- if (iframe) {
103
- iframe.srcdoc = ''
104
- }
105
- }
106
- history.pushState({}, "", url)
107
- })
108
-
109
- inputFileName.addEventListener('blur', async () => {
110
- if (inputFileName.value && !select.value) {
111
- fetchData(inputFileName.value).catch(() => {})
112
- }
113
- })
114
62
 
115
63
  const submit = (fetchUrl, form) => {
116
64
  const formData = new FormData(form)
@@ -127,35 +75,238 @@ const onloadFunction = async (e) => {
127
75
  alert(json.message)
128
76
  return
129
77
  }
130
- if (json.href) {
131
- await sleep(300)
132
- location.href = json.href
133
- }
134
78
  if (json.preview) {
135
- const iframe = document.createElement('iframe')
136
- iframe.setAttribute('srcdoc', json.preview)
137
- iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts')
138
79
  const old = preview.querySelector('iframe')
139
80
  if (!old) {
81
+ const iframe = document.createElement('iframe')
82
+ iframe.setAttribute('srcdoc', json.preview)
83
+ iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts')
140
84
  preview.appendChild(iframe)
85
+ } else {
86
+ old.setAttribute('srcdoc', json.preview)
141
87
  }
142
- old.setAttribute('srcdoc', json.preview)
143
88
  }
144
89
  }).catch(e => {
145
90
  console.log(e.message)
146
91
  })
147
92
  }
93
+
148
94
  form.addEventListener('submit', (event) => {
149
95
  event.preventDefault()
150
- const fetchUrl = event.submitter.dataset.url
151
- submit(fetchUrl, event.target)
96
+ publishWithFeedback(form)
97
+ })
98
+
99
+ const statusLabels = { new: '未公開', modified: '更新あり', published: '公開済み' }
100
+ const fetchPublicationStatus = async (filePath) => {
101
+ if (!filePath) return
102
+ const statusEl = document.querySelector('#publicationStatus')
103
+ if (!statusEl) return
104
+ statusEl.textContent = ''
105
+ statusEl.dataset.status = ''
106
+ try {
107
+ const res = await fetch(`/publication-status?md=${encodeURIComponent(filePath)}`)
108
+ if (!res.ok) { statusEl.textContent = ''; return }
109
+ const { status } = await res.json()
110
+ const label = statusLabels[status]
111
+ statusEl.textContent = label ? `(${label})` : ''
112
+ statusEl.dataset.status = status
113
+ // サイドバーリンクの data-status も同期する
114
+ const sidebarLink = document.querySelector(`.sidebar a[href="/editor?md=${encodeURIComponent(filePath)}"]`)
115
+ if (sidebarLink) sidebarLink.dataset.status = status || ''
116
+ } catch {
117
+ statusEl.textContent = ''
118
+ }
119
+ }
120
+
121
+ const publishWithFeedback = async (form) => {
122
+ const feedback = document.querySelector('#publishFeedback')
123
+ const btn = document.querySelector('#publishBtn')
124
+ const filePath = form.querySelector('#inputFileName').value
125
+ const fileContent = form.querySelector('textarea').value
126
+ btn.disabled = true
127
+ try {
128
+ feedback.textContent = '公開中...'
129
+ const publishRes = await fetch('/publish', {
130
+ method: 'POST',
131
+ headers: { 'Content-Type': 'application/json' },
132
+ body: JSON.stringify({ filePath, fileContent })
133
+ })
134
+ if (!publishRes.ok) {
135
+ const json = await publishRes.json().catch(() => ({}))
136
+ feedback.textContent = `公開失敗: ${json.error ?? 'サーバーに接続できませんでした'}`
137
+ return
138
+ }
139
+ const json = await publishRes.json()
140
+ feedback.textContent = json.success ? '公開しました' : `公開失敗: ${json.error ?? '不明なエラー'}`
141
+ if (json.success) fetchPublicationStatus(filePath)
142
+ } catch (e) {
143
+ feedback.textContent = 'サーバーに接続できませんでした。しばらくしてからお試しください。'
144
+ } finally {
145
+ btn.disabled = false
146
+ }
147
+ }
148
+
149
+ // @vocab: 自動保存
150
+ // @vocab: 自動保存初期化器
151
+ const autoSave = async () => {
152
+ const filename = inputFileName.value
153
+ if (!filename) return
154
+ try {
155
+ const res = await fetch('/save', {
156
+ method: 'POST',
157
+ headers: { 'Content-Type': 'application/json' },
158
+ body: JSON.stringify({ filename, content: textarea.value })
159
+ })
160
+ if (!res.ok) {
161
+ console.log('[auto-save] 保存に失敗しました', res.status)
162
+ return
163
+ }
164
+ fetchPublicationStatus(filename)
165
+ } catch (e) {
166
+ console.log('[auto-save] ネットワークエラー', e)
167
+ }
168
+ }
169
+
170
+ // @vocab: プレビュー自動更新器
171
+ const debouncedUpdate = initAutoPreview(textarea, () => submit('/preview', form), 500)
172
+ initAutoSave(textarea, autoSave, 500)
173
+ initDropReceiver(textarea, () => inputFileName.value, () => submit('/preview', form), () => debouncedUpdate.cancel())
174
+
175
+ // @vocab: 新規作成UI
176
+ const newFileBtn = document.querySelector('#newFileBtn')
177
+ const newFileDialog = document.querySelector('#newFileDialog')
178
+ const newFileNameInput = document.querySelector('#newFileName')
179
+ const newFileTemplateSelect = document.querySelector('#newFileTemplate')
180
+ const newFileError = document.querySelector('#newFileError')
181
+ const confirmNewFile = document.querySelector('#confirmNewFile')
182
+ const cancelNewFile = document.querySelector('#cancelNewFile')
183
+
184
+ const refreshSidebar = () => initSidebarContent(inputFileName.value)
185
+
186
+ newFileBtn.addEventListener('click', () => {
187
+ newFileNameInput.value = ''
188
+ newFileError.textContent = ''
189
+ // テンプレート選択肢を再構築して auto-select をリセット
190
+ newFileTemplateSelect.innerHTML = '<option value="">テンプレートなし</option>'
191
+ for (const tmpl of _frontmatterTemplates) {
192
+ const option = document.createElement('option')
193
+ option.value = tmpl.path_prefix
194
+ option.textContent = `テンプレート: ${tmpl.path_prefix}`
195
+ newFileTemplateSelect.appendChild(option)
196
+ }
197
+ newFileTemplateSelect.value = ''
198
+ newFileDialog.showModal()
199
+ })
200
+
201
+ // ファイル名入力に応じてテンプレートを auto-select
202
+ newFileNameInput.addEventListener('input', () => {
203
+ const filename = newFileNameInput.value
204
+ const template = matchTemplate(filename, _frontmatterTemplates)
205
+ newFileTemplateSelect.value = template ? template.path_prefix : ''
206
+ })
207
+
208
+ // F-01: Enter キーで確定
209
+ newFileNameInput.addEventListener('keydown', (e) => {
210
+ if (e.key === 'Enter') {
211
+ e.preventDefault()
212
+ confirmNewFile.click()
213
+ }
214
+ })
215
+
216
+ // F-02: 重複チェック付き作成。エラー時はダイアログを閉じない
217
+ confirmNewFile.addEventListener('click', async () => {
218
+ const filename = newFileNameInput.value.trim()
219
+ if (!filename) return
220
+
221
+ const selectedPrefix = newFileTemplateSelect.value
222
+ const selectedTemplate = selectedPrefix
223
+ ? _frontmatterTemplates.find(t => t.path_prefix === selectedPrefix)
224
+ : null
225
+ const baseName = filename.split('/').pop().replace(/\.[^.]+$/, '')
226
+ const content = selectedTemplate
227
+ ? buildFrontmatterString(selectedTemplate, baseName)
228
+ : `---\ntitle: ${baseName}\n---\n`
229
+
230
+ const res = await fetch('/save', {
231
+ method: 'POST',
232
+ headers: { 'Content-Type': 'application/json' },
233
+ body: JSON.stringify({ filename, content, createOnly: true })
234
+ })
235
+
236
+ if (!res.ok) {
237
+ const json = await res.json().catch(() => ({}))
238
+ newFileError.textContent = json.error ?? 'ファイルの作成に失敗しました'
239
+ return
240
+ }
241
+
242
+ newFileDialog.close()
243
+ textarea.value = content
244
+ setCurrentFile(filename)
245
+ url.searchParams.set('md', filename)
246
+ history.pushState({}, '', url)
247
+ submit('/preview', form)
248
+ fetchPublicationStatus(filename)
249
+ refreshSidebar()
250
+ })
251
+
252
+ cancelNewFile.addEventListener('click', () => newFileDialog.close())
253
+
254
+ // インプレースファイル読み込み
255
+ // サイドバーリンクのクリックやブラウザ履歴移動で呼ばれる。
256
+ // turbolink によるページ全体の差し替えではなく、エディタ状態をその場で更新することでちらつきを防ぐ。
257
+ const loadFileInPlace = async (newTarget) => {
258
+ // サイドバーのアクティブ状態を更新
259
+ document.querySelectorAll('.sidebar a').forEach(a => a.classList.remove('active'))
260
+ const activeLink = document.querySelector(`.sidebar a[href="/editor?md=${encodeURIComponent(newTarget)}"]`)
261
+ if (activeLink) activeLink.classList.add('active')
262
+
263
+ try {
264
+ const json = await fetchData(newTarget)
265
+ textarea.value = json.content
266
+ setCurrentFile(json.filename)
267
+ submit('/preview', form)
268
+ fetchPublicationStatus(newTarget)
269
+ } catch (e) {
270
+ // ファイルが存在しない場合、fetchData 内でテキストエリアに初期内容を設定済み
271
+ setCurrentFile(newTarget)
272
+ }
273
+ }
274
+
275
+ // サイドバーのリンクをインターセプトしてインプレース読み込みに切り替える。
276
+ // サイドバーは動的に生成されるため、個別リンクへのバインドではなく .sidebar へのデリゲーションで処理する。
277
+ document.querySelector('.sidebar').addEventListener('click', async (e) => {
278
+ const link = e.target.closest('a')
279
+ if (!link) return
280
+ let linkUrl
281
+ try { linkUrl = new URL(link.href) } catch { return }
282
+ if (linkUrl.pathname !== '/editor') return
283
+ const newTarget = linkUrl.searchParams.get('md')
284
+ if (!newTarget) return
285
+ e.preventDefault()
286
+
287
+ const currentTarget = inputFileName.value || url.searchParams.get('md')
288
+ if (newTarget === currentTarget) return
289
+
290
+ const newUrl = new URL(location)
291
+ newUrl.searchParams.set('md', newTarget)
292
+ history.pushState({}, '', newUrl)
293
+
294
+ await loadFileInPlace(newTarget)
295
+ })
296
+
297
+ // ブラウザの戻る/進むに対応
298
+ window.addEventListener('popstate', async () => {
299
+ const newTarget = new URL(location).searchParams.get('md') || ''
300
+ if (newTarget) {
301
+ await loadFileInPlace(newTarget)
302
+ }
152
303
  })
153
304
  }
154
305
 
155
306
  const SIDEBAR_OPEN_KEY = 'sidebar-is-open'
156
307
  const DIR_OPEN_KEY = 'sidebar-dir-open'
157
308
 
158
- // @vocab: 展開状態 (docs/dictionary.md#展開状態)
309
+ // @vocab: 展開状態
159
310
  // @test: tests/editor/editor-sidebar.test.js
160
311
  const loadDirOpenState = () => {
161
312
  try {
@@ -165,15 +316,15 @@ const loadDirOpenState = () => {
165
316
  }
166
317
  }
167
318
 
168
- // @vocab: 展開状態 (docs/dictionary.md#展開状態)
319
+ // @vocab: 展開状態
169
320
  // @test: tests/editor/editor-sidebar.test.js
170
321
  const saveDirOpenState = (state) => {
171
322
  localStorage.setItem(DIR_OPEN_KEY, JSON.stringify(state))
172
323
  }
173
324
 
174
- // @vocab: サイドバー (docs/dictionary.md#サイドバー)
175
- // @vocab: アクティブファイル (docs/dictionary.md#アクティブファイル)
176
- // @vocab: 展開状態 (docs/dictionary.md#展開状態)
325
+ // @vocab: サイドバー
326
+ // @vocab: アクティブファイル
327
+ // @vocab: 展開状態
177
328
  // @test: tests/editor/editor-sidebar.test.js
178
329
  const initSidebarTree = (activeFile) => {
179
330
  const state = loadDirOpenState()
@@ -203,14 +354,30 @@ const initSidebarTree = (activeFile) => {
203
354
 
204
355
  // アクティブファイルに class="active" を付与(SSG では静的に付与できないため JS で補完)
205
356
  if (activeFile) {
206
- const link = document.querySelector(`.sidebar a[href="/editor?md=${CSS.escape(activeFile)}"]`)
357
+ const link = document.querySelector(`.sidebar a[href="/editor?md=${encodeURIComponent(activeFile)}"]`)
207
358
  if (link) {
208
359
  link.classList.add('active')
209
360
  }
210
361
  }
211
362
  }
212
363
 
213
- // @vocab: サイドバー (docs/dictionary.md#サイドバー)
364
+ // @vocab: サイドバー取得エンドポイント
365
+ const initSidebarContent = async (activeFile) => {
366
+ try {
367
+ const res = await fetch('/get_sidebar')
368
+ if (!res.ok) return
369
+ const { html } = await res.json()
370
+ const sidebar = document.querySelector('.sidebar')
371
+ const toggle = sidebar.querySelector('.sidebar-toggle')
372
+ sidebar.innerHTML = html
373
+ sidebar.appendChild(toggle)
374
+ initSidebarTree(activeFile)
375
+ } catch (e) {
376
+ console.log('[sidebar] init failed', e)
377
+ }
378
+ }
379
+
380
+ // @vocab: サイドバー
214
381
  // @test: tests/editor/editor-sidebar.test.js
215
382
  const sidebarToggle = (e) => {
216
383
  const sidebar = document.querySelector('.sidebar')
@@ -231,7 +398,8 @@ const sidebarToggle = (e) => {
231
398
  main.classList.toggle('sidebar-close')
232
399
  })
233
400
  }
234
- // @vocab: 画像アップローダー (docs/dictionary.md#画像アップローダー)
401
+
402
+ // @vocab: 画像アップローダー
235
403
  const uploadImage = async (file, mdFile) => {
236
404
  const buffer = await file.arrayBuffer()
237
405
  const base64 = btoa(new Uint8Array(buffer).reduce((s, b) => s + String.fromCharCode(b), ''))
@@ -245,14 +413,17 @@ const uploadImage = async (file, mdFile) => {
245
413
  return json.markdownUrl
246
414
  }
247
415
 
248
- // @vocab: ドロップレシーバー (docs/dictionary.md#ドロップレシーバー)
249
- const initDropReceiver = (textarea, getMdFile) => {
416
+ // @vocab: ドロップレシーバー
417
+ // @vocab: ドロップ後更新
418
+ const initDropReceiver = (textarea, getMdFile, onUpdate, cancelPendingDebounce) => {
250
419
  textarea.addEventListener('dragover', (e) => {
251
420
  e.preventDefault()
252
421
  })
253
422
  textarea.addEventListener('drop', async (e) => {
254
423
  e.preventDefault()
424
+ if (cancelPendingDebounce) cancelPendingDebounce()
255
425
  const files = [...e.dataTransfer.files].filter(f => f.type.startsWith('image/'))
426
+ let inserted = false
256
427
  for (const file of files) {
257
428
  const markdownUrl = await uploadImage(file, getMdFile())
258
429
  if (markdownUrl) {
@@ -260,19 +431,18 @@ const initDropReceiver = (textarea, getMdFile) => {
260
431
  const content = textarea.value
261
432
  textarea.value = content.slice(0, start) + `![](${markdownUrl})` + content.slice(start)
262
433
  textarea.selectionStart = textarea.selectionEnd = start + `![](${markdownUrl})`.length
434
+ inserted = true
263
435
  }
264
436
  }
437
+ if (inserted && onUpdate) onUpdate()
265
438
  })
266
439
  }
267
440
 
268
441
  document.addEventListener('DOMContentLoaded', async (event) => {
269
442
  const url = new URL(location)
270
443
  const activeFile = url.searchParams.get('md') || ''
444
+ await initSidebarContent(activeFile)
271
445
  await initFrontmatterTemplate()
272
446
  onloadFunction(event)
273
447
  sidebarToggle(event)
274
- initSidebarTree(activeFile)
275
- const textarea = document.querySelector('#editorTextArea')
276
- const inputFileName = document.querySelector('#inputFileName')
277
- initDropReceiver(textarea, () => inputFileName.value)
278
448
  })
@@ -6,7 +6,7 @@
6
6
  * ファイルパスにマッチするフロントマターテンプレート設定を選択する。
7
7
  * 複数マッチする場合は最も長いプレフィックスを優先する。
8
8
  *
9
- * @vocab: テンプレートマッチャー (docs/dictionary.md#テンプレートマッチャー)
9
+ * @vocab: テンプレートマッチャー
10
10
  * @test: tests/editor/editor-frontmatter-template.test.js
11
11
  *
12
12
  * @param {string} filePath - 新規ファイルのパス(例: "book/my-book.md")
@@ -32,7 +32,7 @@ export function matchTemplate(filePath, templates) {
32
32
  * フロントマターテンプレート設定をフロントマター文字列に変換する。
33
33
  * title はファイル名から自動生成する(fields に title がある場合でもファイル名を優先)。
34
34
  *
35
- * @vocab: テンプレートインジェクター (docs/dictionary.md#テンプレートインジェクター)
35
+ * @vocab: テンプレートインジェクター
36
36
  * @test: tests/editor/editor-frontmatter-template.test.js
37
37
  *
38
38
  * @param {FrontmatterTemplateConfig} template - マッチしたテンプレート設定
@@ -52,7 +52,7 @@ export function buildFrontmatterString(template, baseName) {
52
52
  * ファイルパスとテンプレート設定一覧からフロントマター文字列を生成する。
53
53
  * マッチするテンプレートがない場合は null を返す。
54
54
  *
55
- * @vocab: フロントマターテンプレートローダー (docs/dictionary.md#フロントマターテンプレートローダー)
55
+ * @vocab: フロントマターテンプレートローダー
56
56
  * @test: tests/editor/editor-frontmatter-template.test.js
57
57
  *
58
58
  * @param {string} filePath - 新規ファイルのパス(例: "book/my-book.md")
@@ -0,0 +1,19 @@
1
+
2
+ /**
3
+ * @vocab: 画像参照抽出器
4
+ * @test tests/editor/publish.test.js
5
+ * @param {string} markdownContent - Markdown テキスト
6
+ * @returns {string[]} http/https を除いたローカル画像パスの配列
7
+ */
8
+ export function extractImageReferences(markdownContent) {
9
+ const regex = /!\[.*?\]\(([^\s)]+)/g
10
+ const paths = []
11
+ let match
12
+ while ((match = regex.exec(markdownContent)) !== null) {
13
+ const url = match[1]
14
+ if (!url.startsWith('http://') && !url.startsWith('https://') && !url.startsWith('//')) {
15
+ paths.push(url)
16
+ }
17
+ }
18
+ return paths
19
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @vocab: Markdown挿入器 (docs/dictionary.md#Markdown挿入器)
2
+ * @vocab: Markdown挿入器
3
3
  * @test: tests/editor/editor-image-upload.test.js
4
4
  * @param {string} content
5
5
  * @param {number} cursorPos
@@ -6,9 +6,9 @@
6
6
  * @param {Array<{name: string, __filetype: string, url: string}>} files
7
7
  * @returns {TreeNode}
8
8
  */
9
- // @vocab: ツリービルダー (docs/dictionary.md#ツリービルダー)
10
- // @vocab: ファイルリスト (docs/dictionary.md#ファイルリスト)
11
- // @vocab: ディレクトリツリー (docs/dictionary.md#ディレクトリツリー)
9
+ // @vocab: ツリービルダー
10
+ // @vocab: ファイルリスト
11
+ // @vocab: ディレクトリツリー
12
12
  // @test: tests/editor/editor-sidebar.test.js
13
13
  export function buildTree(files) {
14
14
  const root = { dirs: {}, files: [] }
@@ -33,32 +33,47 @@ export function buildTree(files) {
33
33
  return root
34
34
  }
35
35
 
36
+ /**
37
+ * @param {string} str
38
+ * @returns {string}
39
+ */
40
+ function escapeHtml(str) {
41
+ return str
42
+ .replace(/&/g, '&amp;')
43
+ .replace(/</g, '&lt;')
44
+ .replace(/>/g, '&gt;')
45
+ .replace(/"/g, '&quot;')
46
+ }
47
+
36
48
  /**
37
49
  * @param {TreeNode} tree
38
50
  * @param {string} [activeFile]
51
+ * @param {Object.<string, 'new'|'modified'|'published'|'unknown'>} [statusMap]
39
52
  * @param {string} [_dirPath]
40
53
  * @returns {string}
41
54
  */
42
- // @vocab: ツリーレンダラー (docs/dictionary.md#ツリーレンダラー)
43
- // @vocab: ディレクトリツリー (docs/dictionary.md#ディレクトリツリー)
44
- // @vocab: ディレクトリノード (docs/dictionary.md#ディレクトリノード)
45
- // @vocab: ファイルノード (docs/dictionary.md#ファイルノード)
46
- // @vocab: アクティブファイル (docs/dictionary.md#アクティブファイル)
55
+ // @vocab: ツリーレンダラー
56
+ // @vocab: ディレクトリツリー
57
+ // @vocab: ディレクトリノード
58
+ // @vocab: ファイルノード
59
+ // @vocab: アクティブファイル
47
60
  // @test: tests/editor/editor-sidebar.test.js
48
- export function renderTreeHtml(tree, activeFile = '', _dirPath = '') {
61
+ export function renderTreeHtml(tree, activeFile = '', statusMap = {}, _dirPath = '') {
49
62
  let html = '<ul>'
50
63
 
51
64
  for (const [dirName, subtree] of Object.entries(tree.dirs)) {
52
65
  const dirPath = _dirPath ? `${_dirPath}/${dirName}` : dirName
53
- html += `<li class="dir-node"><details data-dir="${dirPath}"><summary>${dirName}</summary>`
54
- html += renderTreeHtml(subtree, activeFile, dirPath)
66
+ html += `<li class="dir-node"><details data-dir="${escapeHtml(dirPath)}"><summary>${escapeHtml(dirName)}</summary>`
67
+ html += renderTreeHtml(subtree, activeFile, statusMap, dirPath)
55
68
  html += `</details></li>`
56
69
  }
57
70
 
58
71
  for (const file of tree.files) {
59
72
  const isActive = file.path === activeFile
60
73
  const activeAttr = isActive ? ' class="active"' : ''
61
- html += `<li><a href="/editor?md=${file.path}"${activeAttr}>${file.label}</a></li>`
74
+ const status = statusMap[file.path]
75
+ const statusAttr = status ? ` data-status="${escapeHtml(status)}"` : ''
76
+ html += `<li><a href="/editor?md=${encodeURIComponent(file.path)}"${activeAttr}${statusAttr}>${escapeHtml(file.label)}</a></li>`
62
77
  }
63
78
 
64
79
  html += '</ul>'
@@ -0,0 +1,47 @@
1
+ /**
2
+ * @vocab: 変更反映器
3
+ * @test tests/editor/publish.test.js
4
+ */
5
+
6
+ /**
7
+ * 遷移実行体。commit と push を注入することで実際の git なしにテストできる。
8
+ * @typedef {object} PublishActions
9
+ * @property {(files: string[]) => Promise<boolean>} commit - ファイル群をステージしてコミットする。変更がなければ false を返す
10
+ * @property {() => Promise<{ success: boolean, error?: string }>} push - リモートへプッシュする
11
+ */
12
+
13
+ /**
14
+ * @param {string[]} files - コミット対象ファイルパスの配列
15
+ * @param {PublishActions} publishActions
16
+ * @returns {Promise<{ success: boolean, error?: string }>}
17
+ */
18
+ async function reflect(files, publishActions) {
19
+ const committed = await publishActions.commit(files)
20
+ // 変更がない(コミット不要)場合は成功とみなす
21
+ if (!committed) return { success: true }
22
+ return await publishActions.push()
23
+ }
24
+
25
+ /**
26
+ * @vocab: 公開する
27
+ * 未公開 → 公開済み 遷移。更新する と現在は同じ実装だが、
28
+ * 将来「非公開にする」「削除する」などの遷移が加わったとき実装が分岐する。
29
+ * @param {string[]} files - コミット対象ファイルパスの配列
30
+ * @param {PublishActions} publishActions
31
+ * @returns {Promise<{ success: boolean, error?: string }>}
32
+ */
33
+ export async function publish(files, publishActions) {
34
+ return reflect(files, publishActions)
35
+ }
36
+
37
+ /**
38
+ * @vocab: 更新する
39
+ * 更新あり → 公開済み 遷移。公開する と現在は同じ実装だが、
40
+ * 将来「非公開にする」「削除する」などの遷移が加わったとき実装が分岐する。
41
+ * @param {string[]} files - コミット対象ファイルパスの配列
42
+ * @param {PublishActions} publishActions
43
+ * @returns {Promise<{ success: boolean, error?: string }>}
44
+ */
45
+ export async function update(files, publishActions) {
46
+ return reflect(files, publishActions)
47
+ }
@@ -3,8 +3,8 @@ import nodePath from 'node:path'
3
3
  const rootDir = process.cwd()
4
4
 
5
5
  /**
6
- * @vocab: コンバーターファクトリー (docs/dictionary.md#コンバーターファクトリー)
7
- * @vocab: 画像コンバーター (docs/dictionary.md#画像コンバーター)
6
+ * @vocab: コンバーターファクトリー
7
+ * @vocab: 画像コンバーター
8
8
  * @test: tests/editor/editor-image-upload.test.js
9
9
  * @param {Function|string|null} converterOrName - 関数、ユーザーパス(./で始まる)、またはビルトイン名
10
10
  * @returns {Promise<{ fn: Function, ext: string|null }>}
@@ -1,7 +1,7 @@
1
1
  import { IncomingMessage, ServerResponse } from 'http'
2
2
  import fs from 'node:fs'
3
3
  import config from '@tenjuu99/blog/lib/config.js'
4
- import { pageDir } from '@tenjuu99/blog/lib/dir.js'
4
+ import { watch } from '@tenjuu99/blog/lib/dir.js'
5
5
 
6
6
  export const path = '/get_editor_target'
7
7
 
@@ -15,7 +15,7 @@ export const get = async (req, res) => {
15
15
  if (!target) {
16
16
  return
17
17
  }
18
- const file = `${pageDir}/${target}`
18
+ const file = `${watch.pageDir}/${target}`
19
19
  if (!fs.existsSync(`${file}`)) {
20
20
  return {
21
21
  status: 404,