@tenjuu99/blog 0.3.5 → 0.4.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 (53) hide show
  1. package/bin/dev-server +3 -1
  2. package/bin/server +8 -1
  3. package/docs/develop.md +36 -6
  4. package/docs/dictionary.json +1806 -0
  5. package/lib/dir.js +3 -0
  6. package/lib/distribute.js +19 -5
  7. package/lib/files.js +2 -0
  8. package/lib/filter.js +7 -0
  9. package/lib/generate.js +5 -0
  10. package/lib/helper.js +3 -0
  11. package/lib/imageDistributor.js +59 -0
  12. package/lib/indexer.js +3 -0
  13. package/lib/pageData.js +7 -0
  14. package/lib/render.js +5 -0
  15. package/lib/replaceVariablesFilter.js +5 -0
  16. package/lib/server/helper/parseRequestBody.js +46 -0
  17. package/lib/server.js +7 -3
  18. package/lib/tryServer.js +3 -1
  19. package/package.json +8 -5
  20. package/packages/breadcrumbs/helper/breadcrumbs.js +3 -0
  21. package/packages/category/helper/category.js +13 -0
  22. package/packages/category/helper/categoryIndexer.js +12 -0
  23. package/packages/category/helper/pagination.js +5 -0
  24. package/packages/editor/css/editor.css +98 -10
  25. package/packages/editor/helper/sidebarTree.js +11 -0
  26. package/packages/editor/js/autoPreviewInitializer.js +11 -0
  27. package/packages/editor/js/autoSaveInitializer.js +15 -0
  28. package/packages/editor/js/debouncer.js +21 -0
  29. package/packages/editor/js/editor.js +370 -45
  30. package/packages/editor/js/frontmatter_template.js +67 -0
  31. package/packages/editor/js/imageReferenceExtractor.js +19 -0
  32. package/packages/editor/js/image_upload.js +12 -0
  33. package/packages/editor/js/tree.js +81 -0
  34. package/packages/editor/server/changeReflector.js +47 -0
  35. package/packages/editor/server/converters/sharp.js +14 -0
  36. package/packages/editor/server/createConverter.js +26 -0
  37. package/packages/editor/server/get_editor_target.js +2 -2
  38. package/packages/editor/server/get_frontmatter_templates.js +22 -0
  39. package/packages/editor/server/get_publication_status.js +29 -0
  40. package/packages/editor/server/get_sidebar.js +51 -0
  41. package/packages/editor/server/image_upload.js +122 -0
  42. package/packages/editor/server/preview.js +49 -19
  43. package/packages/editor/server/publicationStatus.js +27 -0
  44. package/packages/editor/server/publish.js +135 -0
  45. package/packages/editor/server/publishTargetCollector.js +19 -0
  46. package/packages/editor/server/save.js +97 -0
  47. package/packages/editor/server/sidebarStatusCollector.js +17 -0
  48. package/packages/editor/template/editor.html +28 -22
  49. package/src-sample/converters/webp.js +20 -0
  50. package/src-sample/helper/add.js +2 -0
  51. package/src-sample/helper/index.js +9 -0
  52. package/src-sample/image/test.jpg +0 -0
  53. package/packages/editor/server/editor.js +0 -43
@@ -0,0 +1,15 @@
1
+ import { createDebounce } from './debouncer.js'
2
+
3
+ /**
4
+ * @vocab 自動保存初期化器
5
+ * @test tests/editor/editor-ui-cleanup.test.js
6
+ * @param {EventTarget} textarea
7
+ * @param {() => void} onSave
8
+ * @param {number} delay
9
+ * @returns {import('./debouncer.js').Debounce}
10
+ */
11
+ export function initAutoSave(textarea, onSave, delay = 500) {
12
+ const debouncedSave = createDebounce(onSave, delay)
13
+ textarea.addEventListener('input', debouncedSave)
14
+ return debouncedSave
15
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @vocab デバウンサー
3
+ * @test tests/editor/auto-preview.test.js
4
+ */
5
+ export function createDebounce(fn, delay) {
6
+ let timer = null
7
+ function debounced(...args) {
8
+ if (timer) clearTimeout(timer)
9
+ timer = setTimeout(() => {
10
+ timer = null
11
+ fn(...args)
12
+ }, delay)
13
+ }
14
+ debounced.cancel = () => {
15
+ if (timer) {
16
+ clearTimeout(timer)
17
+ timer = null
18
+ }
19
+ }
20
+ return debounced
21
+ }
@@ -1,15 +1,30 @@
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'
4
+
5
+ // @vocab: テンプレートレゾルバー
6
+ // @test: tests/editor/editor-frontmatter-template.test.js
7
+ let _frontmatterTemplates = []
8
+ const initFrontmatterTemplate = async () => {
9
+ try {
10
+ const res = await fetch('/get_frontmatter_templates')
11
+ if (res.ok) {
12
+ const json = await res.json()
13
+ _frontmatterTemplates = json.templates || []
14
+ }
15
+ } catch (e) {
16
+ console.log('[frontmatter-template] 設定の取得に失敗しました', e)
17
+ }
18
+ }
2
19
 
3
20
  const fetchData = (target) => {
4
- return fetch(`/get_editor_target?md=${target}`)
21
+ return fetch(`/get_editor_target?md=${encodeURIComponent(target)}`)
5
22
  .then(async res => {
6
23
  if (!res.ok) {
7
- document.querySelector('#inputFileName').value = target
8
- document.querySelector('#editorTextArea').value = `---
9
- title: ${target.split('.')[0].split('/').pop()}
10
- ---
11
- ${target.split('.')[0].split('/').pop()} についての記事を作成しましょう`
12
- // submit('/preview', form)
24
+ const baseName = target.split('/').pop().replace(/\.[^.]+$/, '')
25
+ const initialContent = loadFrontmatterTemplate(target, _frontmatterTemplates)
26
+ ?? `---\ntitle: ${baseName}\n---\n${baseName} についての記事を作成しましょう`
27
+ document.querySelector('#editorTextArea').value = initialContent
13
28
  throw new Error(`${target} does not exist.`)
14
29
  } else {
15
30
  const json = await res.json()
@@ -17,46 +32,33 @@ ${target.split('.')[0].split('/').pop()} についての記事を作成しまし
17
32
  }
18
33
  })
19
34
  }
35
+
20
36
  const onloadFunction = async (e) => {
21
37
  const form = document.querySelector('#editor')
22
38
  const textarea = form.querySelector('#editorTextArea')
23
- const select = form.querySelector('#selectDataFile')
24
39
  const inputFileName = form.querySelector('#inputFileName')
40
+ const currentFileName = document.querySelector('#currentFileName')
25
41
  const preview = document.querySelector('#previewContent')
26
42
  const url = new URL(location)
27
43
  const target = url.searchParams.get('md')
44
+
45
+ const setCurrentFile = (filename) => {
46
+ inputFileName.value = filename
47
+ currentFileName.textContent = filename
48
+ }
49
+
28
50
  if (target) {
29
51
  fetchData(target).then(json => {
30
52
  textarea.value = json.content
31
- select.value = json.filename
32
- inputFileName.value = json.filename
33
- inputFileName.setAttribute('disabled', true)
53
+ setCurrentFile(json.filename)
34
54
  submit('/preview', form)
55
+ fetchPublicationStatus(target)
35
56
  }).catch(e => {
36
57
  console.log('error!!!')
37
58
  console.log(e)
59
+ setCurrentFile(target)
38
60
  })
39
61
  }
40
- select.addEventListener('change', async (event) => {
41
- if (select.value) {
42
- const json = await fetchData(select.value)
43
- textarea.value = json.content
44
- inputFileName.value = json.filename
45
- inputFileName.setAttribute('disabled', true)
46
- url.searchParams.set('md', select.value)
47
- submit('/preview', form)
48
- } else {
49
- inputFileName.value = ""
50
- inputFileName.removeAttribute('disabled')
51
- textarea.value = ''
52
- url.searchParams.set('md', "")
53
- const iframe = preview.querySelector('iframe')
54
- if (iframe) {
55
- iframe.srcdoc = ''
56
- }
57
- }
58
- history.pushState({}, "", url)
59
- })
60
62
 
61
63
  const submit = (fetchUrl, form) => {
62
64
  const formData = new FormData(form)
@@ -73,31 +75,310 @@ const onloadFunction = async (e) => {
73
75
  alert(json.message)
74
76
  return
75
77
  }
76
- if (json.href) {
77
- await sleep(300)
78
- location.href = json.href
79
- }
80
78
  if (json.preview) {
81
- const iframe = document.createElement('iframe')
82
- iframe.setAttribute('srcdoc', json.preview)
83
- iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts')
84
79
  const old = preview.querySelector('iframe')
85
80
  if (!old) {
81
+ const iframe = document.createElement('iframe')
82
+ iframe.setAttribute('srcdoc', json.preview)
83
+ iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts')
86
84
  preview.appendChild(iframe)
85
+ } else {
86
+ old.setAttribute('srcdoc', json.preview)
87
87
  }
88
- old.setAttribute('srcdoc', json.preview)
89
88
  }
90
89
  }).catch(e => {
91
90
  console.log(e.message)
92
91
  })
93
92
  }
93
+
94
94
  form.addEventListener('submit', (event) => {
95
95
  event.preventDefault()
96
- const fetchUrl = event.submitter.dataset.url
97
- 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)
98
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
+ }
303
+ })
304
+ }
305
+
306
+ const SIDEBAR_OPEN_KEY = 'sidebar-is-open'
307
+ const DIR_OPEN_KEY = 'sidebar-dir-open'
308
+
309
+ // @vocab: 展開状態
310
+ // @test: tests/editor/editor-sidebar.test.js
311
+ const loadDirOpenState = () => {
312
+ try {
313
+ return JSON.parse(localStorage.getItem(DIR_OPEN_KEY) || '{}')
314
+ } catch {
315
+ return {}
316
+ }
99
317
  }
100
318
 
319
+ // @vocab: 展開状態
320
+ // @test: tests/editor/editor-sidebar.test.js
321
+ const saveDirOpenState = (state) => {
322
+ localStorage.setItem(DIR_OPEN_KEY, JSON.stringify(state))
323
+ }
324
+
325
+ // @vocab: サイドバー
326
+ // @vocab: アクティブファイル
327
+ // @vocab: 展開状態
328
+ // @test: tests/editor/editor-sidebar.test.js
329
+ const initSidebarTree = (activeFile) => {
330
+ const state = loadDirOpenState()
331
+
332
+ // アクティブファイルの親ディレクトリを開いた状態にする
333
+ if (activeFile) {
334
+ const parts = activeFile.split('/')
335
+ let path = ''
336
+ for (let i = 0; i < parts.length - 1; i++) {
337
+ path = path ? `${path}/${parts[i]}` : parts[i]
338
+ state[path] = true
339
+ }
340
+ }
341
+
342
+ // localStorage の状態を <details> に反映し、toggle イベントで保存する
343
+ document.querySelectorAll('.sidebar details[data-dir]').forEach(details => {
344
+ const dir = details.dataset.dir
345
+ if (state[dir]) {
346
+ details.open = true
347
+ }
348
+ details.addEventListener('toggle', () => {
349
+ const current = loadDirOpenState()
350
+ current[dir] = details.open
351
+ saveDirOpenState(current)
352
+ })
353
+ })
354
+
355
+ // アクティブファイルに class="active" を付与(SSG では静的に付与できないため JS で補完)
356
+ if (activeFile) {
357
+ const link = document.querySelector(`.sidebar a[href="/editor?md=${encodeURIComponent(activeFile)}"]`)
358
+ if (link) {
359
+ link.classList.add('active')
360
+ }
361
+ }
362
+ }
363
+
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: サイドバー
381
+ // @test: tests/editor/editor-sidebar.test.js
101
382
  const sidebarToggle = (e) => {
102
383
  const sidebar = document.querySelector('.sidebar')
103
384
  const main = document.querySelector('main')
@@ -105,9 +386,9 @@ const sidebarToggle = (e) => {
105
386
  toggle.addEventListener('click', (e) => {
106
387
  e.preventDefault()
107
388
  main.classList.toggle('sidebar-close')
108
- localStorage.setItem('sidebar-is-open', !main.classList.contains('sidebar-close'))
389
+ localStorage.setItem(SIDEBAR_OPEN_KEY, !main.classList.contains('sidebar-close'))
109
390
  })
110
- if (localStorage.getItem('sidebar-is-open') === 'true') {
391
+ if (localStorage.getItem(SIDEBAR_OPEN_KEY) === 'true') {
111
392
  main.classList.remove('sidebar-close')
112
393
  } else {
113
394
  main.classList.add('sidebar-close')
@@ -117,7 +398,51 @@ const sidebarToggle = (e) => {
117
398
  main.classList.toggle('sidebar-close')
118
399
  })
119
400
  }
120
- document.addEventListener('DOMContentLoaded', (event) => {
401
+
402
+ // @vocab: 画像アップローダー
403
+ const uploadImage = async (file, mdFile) => {
404
+ const buffer = await file.arrayBuffer()
405
+ const base64 = btoa(new Uint8Array(buffer).reduce((s, b) => s + String.fromCharCode(b), ''))
406
+ const res = await fetch('/upload-image', {
407
+ method: 'POST',
408
+ headers: { 'content-type': 'application/json' },
409
+ body: JSON.stringify({ imageData: base64, imageFilename: file.name, mdFile })
410
+ })
411
+ if (!res.ok) return null
412
+ const json = await res.json()
413
+ return json.markdownUrl
414
+ }
415
+
416
+ // @vocab: ドロップレシーバー
417
+ // @vocab: ドロップ後更新
418
+ const initDropReceiver = (textarea, getMdFile, onUpdate, cancelPendingDebounce) => {
419
+ textarea.addEventListener('dragover', (e) => {
420
+ e.preventDefault()
421
+ })
422
+ textarea.addEventListener('drop', async (e) => {
423
+ e.preventDefault()
424
+ if (cancelPendingDebounce) cancelPendingDebounce()
425
+ const files = [...e.dataTransfer.files].filter(f => f.type.startsWith('image/'))
426
+ let inserted = false
427
+ for (const file of files) {
428
+ const markdownUrl = await uploadImage(file, getMdFile())
429
+ if (markdownUrl) {
430
+ const start = textarea.selectionStart
431
+ const content = textarea.value
432
+ textarea.value = content.slice(0, start) + `![](${markdownUrl})` + content.slice(start)
433
+ textarea.selectionStart = textarea.selectionEnd = start + `![](${markdownUrl})`.length
434
+ inserted = true
435
+ }
436
+ }
437
+ if (inserted && onUpdate) onUpdate()
438
+ })
439
+ }
440
+
441
+ document.addEventListener('DOMContentLoaded', async (event) => {
442
+ const url = new URL(location)
443
+ const activeFile = url.searchParams.get('md') || ''
444
+ await initSidebarContent(activeFile)
445
+ await initFrontmatterTemplate()
121
446
  onloadFunction(event)
122
447
  sidebarToggle(event)
123
448
  })
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @typedef {{ path_prefix: string, fields: Object.<string, string> }} FrontmatterTemplateConfig
3
+ */
4
+
5
+ /**
6
+ * ファイルパスにマッチするフロントマターテンプレート設定を選択する。
7
+ * 複数マッチする場合は最も長いプレフィックスを優先する。
8
+ *
9
+ * @vocab: テンプレートマッチャー
10
+ * @test: tests/editor/editor-frontmatter-template.test.js
11
+ *
12
+ * @param {string} filePath - 新規ファイルのパス(例: "book/my-book.md")
13
+ * @param {FrontmatterTemplateConfig[]} templates - テンプレート設定の配列
14
+ * @returns {FrontmatterTemplateConfig|null} マッチした設定、またはnull
15
+ */
16
+ export function matchTemplate(filePath, templates) {
17
+ if (!filePath || !templates || templates.length === 0) {
18
+ return null
19
+ }
20
+ let best = null
21
+ for (const tmpl of templates) {
22
+ if (filePath.startsWith(tmpl.path_prefix)) {
23
+ if (!best || tmpl.path_prefix.length > best.path_prefix.length) {
24
+ best = tmpl
25
+ }
26
+ }
27
+ }
28
+ return best
29
+ }
30
+
31
+ /**
32
+ * フロントマターテンプレート設定をフロントマター文字列に変換する。
33
+ * title はファイル名から自動生成する(fields に title がある場合でもファイル名を優先)。
34
+ *
35
+ * @vocab: テンプレートインジェクター
36
+ * @test: tests/editor/editor-frontmatter-template.test.js
37
+ *
38
+ * @param {FrontmatterTemplateConfig} template - マッチしたテンプレート設定
39
+ * @param {string} baseName - ファイル名(拡張子・ディレクトリなし、例: "my-book")
40
+ * @returns {string} `---\n...\n---\n` 形式のフロントマター文字列
41
+ */
42
+ export function buildFrontmatterString(template, baseName) {
43
+ const fields = { ...template.fields }
44
+ // title はファイル名から自動生成
45
+ fields.title = baseName
46
+
47
+ const lines = Object.entries(fields).map(([key, value]) => `${key}: ${value}`)
48
+ return `---\n${lines.join('\n')}\n---\n`
49
+ }
50
+
51
+ /**
52
+ * ファイルパスとテンプレート設定一覧からフロントマター文字列を生成する。
53
+ * マッチするテンプレートがない場合は null を返す。
54
+ *
55
+ * @vocab: フロントマターテンプレートローダー
56
+ * @test: tests/editor/editor-frontmatter-template.test.js
57
+ *
58
+ * @param {string} filePath - 新規ファイルのパス(例: "book/my-book.md")
59
+ * @param {FrontmatterTemplateConfig[]} templates - テンプレート設定の配列
60
+ * @returns {string|null} フロントマター文字列、またはマッチしない場合は null
61
+ */
62
+ export function loadFrontmatterTemplate(filePath, templates) {
63
+ const matched = matchTemplate(filePath, templates)
64
+ if (!matched) return null
65
+ const baseName = filePath.split('/').pop().replace(/\.[^.]+$/, '')
66
+ return buildFrontmatterString(matched, baseName)
67
+ }
@@ -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
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @vocab: Markdown挿入器
3
+ * @test: tests/editor/editor-image-upload.test.js
4
+ * @param {string} content
5
+ * @param {number} cursorPos
6
+ * @param {string} markdownUrl
7
+ * @returns {string}
8
+ */
9
+ export function insertImageMarkdown(content, cursorPos, markdownUrl) {
10
+ const imageRef = `![](${markdownUrl})`
11
+ return content.slice(0, cursorPos) + imageRef + content.slice(cursorPos)
12
+ }