@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.
- package/bin/dev-server +3 -1
- package/bin/server +8 -1
- package/docs/develop.md +30 -0
- package/docs/dictionary.json +1806 -0
- package/lib/dir.js +2 -2
- package/lib/distribute.js +5 -5
- package/lib/files.js +1 -1
- package/lib/filter.js +5 -5
- package/lib/generate.js +3 -3
- package/lib/helper.js +2 -2
- package/lib/imageDistributor.js +3 -3
- package/lib/indexer.js +2 -2
- package/lib/pageData.js +5 -5
- package/lib/render.js +4 -4
- package/lib/replaceVariablesFilter.js +4 -4
- package/lib/server/helper/parseRequestBody.js +46 -0
- package/lib/server.js +7 -3
- package/package.json +3 -2
- package/packages/category/helper/category.js +10 -10
- package/packages/category/helper/categoryIndexer.js +8 -8
- package/packages/category/helper/pagination.js +3 -3
- package/packages/editor/css/editor.css +87 -18
- package/packages/editor/helper/sidebarTree.js +7 -2
- 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 +261 -91
- package/packages/editor/js/frontmatter_template.js +3 -3
- package/packages/editor/js/imageReferenceExtractor.js +19 -0
- package/packages/editor/js/image_upload.js +1 -1
- package/packages/editor/js/tree.js +27 -12
- package/packages/editor/server/changeReflector.js +47 -0
- package/packages/editor/server/createConverter.js +2 -2
- package/packages/editor/server/get_editor_target.js +2 -2
- package/packages/editor/server/get_frontmatter_templates.js +1 -1
- 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 +32 -40
- 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 -18
- package/src-sample/helper/add.js +1 -1
- package/src-sample/helper/index.js +6 -6
- package/docs/dictionary.md +0 -553
- package/packages/editor/server/editor.js +0 -43
|
@@ -1,39 +1,8 @@
|
|
|
1
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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: 展開状態
|
|
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: 展開状態
|
|
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: サイドバー
|
|
175
|
-
// @vocab: アクティブファイル
|
|
176
|
-
// @vocab: 展開状態
|
|
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=${
|
|
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:
|
|
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
|
-
|
|
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: ドロップレシーバー
|
|
249
|
-
|
|
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) + `` + content.slice(start)
|
|
262
433
|
textarea.selectionStart = textarea.selectionEnd = start + ``.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: テンプレートマッチャー
|
|
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: テンプレートインジェクター
|
|
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: フロントマターテンプレートローダー
|
|
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
|
+
}
|
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* @param {Array<{name: string, __filetype: string, url: string}>} files
|
|
7
7
|
* @returns {TreeNode}
|
|
8
8
|
*/
|
|
9
|
-
// @vocab: ツリービルダー
|
|
10
|
-
// @vocab: ファイルリスト
|
|
11
|
-
// @vocab: ディレクトリツリー
|
|
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, '&')
|
|
43
|
+
.replace(/</g, '<')
|
|
44
|
+
.replace(/>/g, '>')
|
|
45
|
+
.replace(/"/g, '"')
|
|
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: ツリーレンダラー
|
|
43
|
-
// @vocab: ディレクトリツリー
|
|
44
|
-
// @vocab: ディレクトリノード
|
|
45
|
-
// @vocab: ファイルノード
|
|
46
|
-
// @vocab: アクティブファイル
|
|
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
|
-
|
|
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: コンバーターファクトリー
|
|
7
|
-
* @vocab: 画像コンバーター
|
|
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 {
|
|
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,
|