@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.
- package/bin/dev-server +3 -1
- package/bin/server +8 -1
- package/docs/develop.md +36 -6
- package/docs/dictionary.json +1806 -0
- package/lib/dir.js +3 -0
- package/lib/distribute.js +19 -5
- package/lib/files.js +2 -0
- package/lib/filter.js +7 -0
- package/lib/generate.js +5 -0
- package/lib/helper.js +3 -0
- package/lib/imageDistributor.js +59 -0
- package/lib/indexer.js +3 -0
- package/lib/pageData.js +7 -0
- package/lib/render.js +5 -0
- package/lib/replaceVariablesFilter.js +5 -0
- package/lib/server/helper/parseRequestBody.js +46 -0
- package/lib/server.js +7 -3
- package/lib/tryServer.js +3 -1
- package/package.json +8 -5
- package/packages/breadcrumbs/helper/breadcrumbs.js +3 -0
- package/packages/category/helper/category.js +13 -0
- package/packages/category/helper/categoryIndexer.js +12 -0
- package/packages/category/helper/pagination.js +5 -0
- package/packages/editor/css/editor.css +98 -10
- package/packages/editor/helper/sidebarTree.js +11 -0
- package/packages/editor/js/autoPreviewInitializer.js +11 -0
- package/packages/editor/js/autoSaveInitializer.js +15 -0
- package/packages/editor/js/debouncer.js +21 -0
- package/packages/editor/js/editor.js +370 -45
- package/packages/editor/js/frontmatter_template.js +67 -0
- package/packages/editor/js/imageReferenceExtractor.js +19 -0
- package/packages/editor/js/image_upload.js +12 -0
- package/packages/editor/js/tree.js +81 -0
- package/packages/editor/server/changeReflector.js +47 -0
- package/packages/editor/server/converters/sharp.js +14 -0
- package/packages/editor/server/createConverter.js +26 -0
- package/packages/editor/server/get_editor_target.js +2 -2
- package/packages/editor/server/get_frontmatter_templates.js +22 -0
- package/packages/editor/server/get_publication_status.js +29 -0
- package/packages/editor/server/get_sidebar.js +51 -0
- package/packages/editor/server/image_upload.js +122 -0
- package/packages/editor/server/preview.js +49 -19
- package/packages/editor/server/publicationStatus.js +27 -0
- package/packages/editor/server/publish.js +135 -0
- package/packages/editor/server/publishTargetCollector.js +19 -0
- package/packages/editor/server/save.js +97 -0
- package/packages/editor/server/sidebarStatusCollector.js +17 -0
- package/packages/editor/template/editor.html +28 -22
- package/src-sample/converters/webp.js +20 -0
- package/src-sample/helper/add.js +2 -0
- package/src-sample/helper/index.js +9 -0
- package/src-sample/image/test.jpg +0 -0
- 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
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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(
|
|
389
|
+
localStorage.setItem(SIDEBAR_OPEN_KEY, !main.classList.contains('sidebar-close'))
|
|
109
390
|
})
|
|
110
|
-
if (localStorage.getItem(
|
|
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
|
-
|
|
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) + `` + content.slice(start)
|
|
433
|
+
textarea.selectionStart = textarea.selectionEnd = start + ``.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 = ``
|
|
11
|
+
return content.slice(0, cursorPos) + imageRef + content.slice(cursorPos)
|
|
12
|
+
}
|