@tenjuu99/blog 0.3.4 → 0.3.6
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/docs/develop.md +6 -6
- package/docs/dictionary.md +553 -0
- package/docs/spec.md +34 -2
- 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 +12 -1
- package/lib/render.js +5 -0
- package/lib/replaceVariablesFilter.js +5 -0
- 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 +15 -3
- package/packages/category/helper/pagination.js +5 -0
- package/packages/editor/css/editor.css +20 -1
- package/packages/editor/helper/sidebarTree.js +6 -0
- package/packages/editor/js/editor.js +163 -8
- package/packages/editor/js/frontmatter_template.js +67 -0
- package/packages/editor/js/image_upload.js +12 -0
- package/packages/editor/js/tree.js +66 -0
- package/packages/editor/server/converters/sharp.js +14 -0
- package/packages/editor/server/createConverter.js +26 -0
- package/packages/editor/server/get_frontmatter_templates.js +22 -0
- package/packages/editor/server/image_upload.js +130 -0
- package/packages/editor/template/editor.html +3 -7
- 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
|
@@ -1,14 +1,62 @@
|
|
|
1
1
|
const sleep = waitTime => new Promise( resolve => setTimeout(resolve, waitTime) );
|
|
2
2
|
|
|
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#テンプレートレゾルバー)
|
|
37
|
+
// @test: tests/editor/editor-frontmatter-template.test.js
|
|
38
|
+
let _frontmatterTemplates = []
|
|
39
|
+
const initFrontmatterTemplate = async () => {
|
|
40
|
+
try {
|
|
41
|
+
const res = await fetch('/get_frontmatter_templates')
|
|
42
|
+
if (res.ok) {
|
|
43
|
+
const json = await res.json()
|
|
44
|
+
_frontmatterTemplates = json.templates || []
|
|
45
|
+
}
|
|
46
|
+
} catch (e) {
|
|
47
|
+
console.log('[frontmatter-template] 設定の取得に失敗しました', e)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
3
51
|
const fetchData = (target) => {
|
|
4
|
-
return fetch(`/get_editor_target?md=${target}`)
|
|
52
|
+
return fetch(`/get_editor_target?md=${encodeURIComponent(target)}`)
|
|
5
53
|
.then(async res => {
|
|
6
54
|
if (!res.ok) {
|
|
7
55
|
document.querySelector('#inputFileName').value = target
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
56
|
+
const baseName = target.split('/').pop().replace(/\.[^.]+$/, '')
|
|
57
|
+
const initialContent = loadFrontmatterTemplate(target, _frontmatterTemplates)
|
|
58
|
+
?? `---\ntitle: ${baseName}\n---\n${baseName} についての記事を作成しましょう`
|
|
59
|
+
document.querySelector('#editorTextArea').value = initialContent
|
|
12
60
|
// submit('/preview', form)
|
|
13
61
|
throw new Error(`${target} does not exist.`)
|
|
14
62
|
} else {
|
|
@@ -58,6 +106,12 @@ const onloadFunction = async (e) => {
|
|
|
58
106
|
history.pushState({}, "", url)
|
|
59
107
|
})
|
|
60
108
|
|
|
109
|
+
inputFileName.addEventListener('blur', async () => {
|
|
110
|
+
if (inputFileName.value && !select.value) {
|
|
111
|
+
fetchData(inputFileName.value).catch(() => {})
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
|
|
61
115
|
const submit = (fetchUrl, form) => {
|
|
62
116
|
const formData = new FormData(form)
|
|
63
117
|
const obj = {}
|
|
@@ -98,6 +152,66 @@ const onloadFunction = async (e) => {
|
|
|
98
152
|
})
|
|
99
153
|
}
|
|
100
154
|
|
|
155
|
+
const SIDEBAR_OPEN_KEY = 'sidebar-is-open'
|
|
156
|
+
const DIR_OPEN_KEY = 'sidebar-dir-open'
|
|
157
|
+
|
|
158
|
+
// @vocab: 展開状態 (docs/dictionary.md#展開状態)
|
|
159
|
+
// @test: tests/editor/editor-sidebar.test.js
|
|
160
|
+
const loadDirOpenState = () => {
|
|
161
|
+
try {
|
|
162
|
+
return JSON.parse(localStorage.getItem(DIR_OPEN_KEY) || '{}')
|
|
163
|
+
} catch {
|
|
164
|
+
return {}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// @vocab: 展開状態 (docs/dictionary.md#展開状態)
|
|
169
|
+
// @test: tests/editor/editor-sidebar.test.js
|
|
170
|
+
const saveDirOpenState = (state) => {
|
|
171
|
+
localStorage.setItem(DIR_OPEN_KEY, JSON.stringify(state))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// @vocab: サイドバー (docs/dictionary.md#サイドバー)
|
|
175
|
+
// @vocab: アクティブファイル (docs/dictionary.md#アクティブファイル)
|
|
176
|
+
// @vocab: 展開状態 (docs/dictionary.md#展開状態)
|
|
177
|
+
// @test: tests/editor/editor-sidebar.test.js
|
|
178
|
+
const initSidebarTree = (activeFile) => {
|
|
179
|
+
const state = loadDirOpenState()
|
|
180
|
+
|
|
181
|
+
// アクティブファイルの親ディレクトリを開いた状態にする
|
|
182
|
+
if (activeFile) {
|
|
183
|
+
const parts = activeFile.split('/')
|
|
184
|
+
let path = ''
|
|
185
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
186
|
+
path = path ? `${path}/${parts[i]}` : parts[i]
|
|
187
|
+
state[path] = true
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// localStorage の状態を <details> に反映し、toggle イベントで保存する
|
|
192
|
+
document.querySelectorAll('.sidebar details[data-dir]').forEach(details => {
|
|
193
|
+
const dir = details.dataset.dir
|
|
194
|
+
if (state[dir]) {
|
|
195
|
+
details.open = true
|
|
196
|
+
}
|
|
197
|
+
details.addEventListener('toggle', () => {
|
|
198
|
+
const current = loadDirOpenState()
|
|
199
|
+
current[dir] = details.open
|
|
200
|
+
saveDirOpenState(current)
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
// アクティブファイルに class="active" を付与(SSG では静的に付与できないため JS で補完)
|
|
205
|
+
if (activeFile) {
|
|
206
|
+
const link = document.querySelector(`.sidebar a[href="/editor?md=${CSS.escape(activeFile)}"]`)
|
|
207
|
+
if (link) {
|
|
208
|
+
link.classList.add('active')
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// @vocab: サイドバー (docs/dictionary.md#サイドバー)
|
|
214
|
+
// @test: tests/editor/editor-sidebar.test.js
|
|
101
215
|
const sidebarToggle = (e) => {
|
|
102
216
|
const sidebar = document.querySelector('.sidebar')
|
|
103
217
|
const main = document.querySelector('main')
|
|
@@ -105,9 +219,9 @@ const sidebarToggle = (e) => {
|
|
|
105
219
|
toggle.addEventListener('click', (e) => {
|
|
106
220
|
e.preventDefault()
|
|
107
221
|
main.classList.toggle('sidebar-close')
|
|
108
|
-
localStorage.setItem(
|
|
222
|
+
localStorage.setItem(SIDEBAR_OPEN_KEY, !main.classList.contains('sidebar-close'))
|
|
109
223
|
})
|
|
110
|
-
if (localStorage.getItem(
|
|
224
|
+
if (localStorage.getItem(SIDEBAR_OPEN_KEY) === 'true') {
|
|
111
225
|
main.classList.remove('sidebar-close')
|
|
112
226
|
} else {
|
|
113
227
|
main.classList.add('sidebar-close')
|
|
@@ -117,7 +231,48 @@ const sidebarToggle = (e) => {
|
|
|
117
231
|
main.classList.toggle('sidebar-close')
|
|
118
232
|
})
|
|
119
233
|
}
|
|
120
|
-
|
|
234
|
+
// @vocab: 画像アップローダー (docs/dictionary.md#画像アップローダー)
|
|
235
|
+
const uploadImage = async (file, mdFile) => {
|
|
236
|
+
const buffer = await file.arrayBuffer()
|
|
237
|
+
const base64 = btoa(new Uint8Array(buffer).reduce((s, b) => s + String.fromCharCode(b), ''))
|
|
238
|
+
const res = await fetch('/upload-image', {
|
|
239
|
+
method: 'POST',
|
|
240
|
+
headers: { 'content-type': 'application/json' },
|
|
241
|
+
body: JSON.stringify({ imageData: base64, imageFilename: file.name, mdFile })
|
|
242
|
+
})
|
|
243
|
+
if (!res.ok) return null
|
|
244
|
+
const json = await res.json()
|
|
245
|
+
return json.markdownUrl
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// @vocab: ドロップレシーバー (docs/dictionary.md#ドロップレシーバー)
|
|
249
|
+
const initDropReceiver = (textarea, getMdFile) => {
|
|
250
|
+
textarea.addEventListener('dragover', (e) => {
|
|
251
|
+
e.preventDefault()
|
|
252
|
+
})
|
|
253
|
+
textarea.addEventListener('drop', async (e) => {
|
|
254
|
+
e.preventDefault()
|
|
255
|
+
const files = [...e.dataTransfer.files].filter(f => f.type.startsWith('image/'))
|
|
256
|
+
for (const file of files) {
|
|
257
|
+
const markdownUrl = await uploadImage(file, getMdFile())
|
|
258
|
+
if (markdownUrl) {
|
|
259
|
+
const start = textarea.selectionStart
|
|
260
|
+
const content = textarea.value
|
|
261
|
+
textarea.value = content.slice(0, start) + `` + content.slice(start)
|
|
262
|
+
textarea.selectionStart = textarea.selectionEnd = start + ``.length
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
document.addEventListener('DOMContentLoaded', async (event) => {
|
|
269
|
+
const url = new URL(location)
|
|
270
|
+
const activeFile = url.searchParams.get('md') || ''
|
|
271
|
+
await initFrontmatterTemplate()
|
|
121
272
|
onloadFunction(event)
|
|
122
273
|
sidebarToggle(event)
|
|
274
|
+
initSidebarTree(activeFile)
|
|
275
|
+
const textarea = document.querySelector('#editorTextArea')
|
|
276
|
+
const inputFileName = document.querySelector('#inputFileName')
|
|
277
|
+
initDropReceiver(textarea, () => inputFileName.value)
|
|
123
278
|
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {{ path_prefix: string, fields: Object.<string, string> }} FrontmatterTemplateConfig
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* ファイルパスにマッチするフロントマターテンプレート設定を選択する。
|
|
7
|
+
* 複数マッチする場合は最も長いプレフィックスを優先する。
|
|
8
|
+
*
|
|
9
|
+
* @vocab: テンプレートマッチャー (docs/dictionary.md#テンプレートマッチャー)
|
|
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: テンプレートインジェクター (docs/dictionary.md#テンプレートインジェクター)
|
|
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: フロントマターテンプレートローダー (docs/dictionary.md#フロントマターテンプレートローダー)
|
|
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,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vocab: Markdown挿入器 (docs/dictionary.md#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
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {{ dirs: Object.<string, TreeNode>, files: Array<{path: string, label: string}> }} TreeNode
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {Array<{name: string, __filetype: string, url: string}>} files
|
|
7
|
+
* @returns {TreeNode}
|
|
8
|
+
*/
|
|
9
|
+
// @vocab: ツリービルダー (docs/dictionary.md#ツリービルダー)
|
|
10
|
+
// @vocab: ファイルリスト (docs/dictionary.md#ファイルリスト)
|
|
11
|
+
// @vocab: ディレクトリツリー (docs/dictionary.md#ディレクトリツリー)
|
|
12
|
+
// @test: tests/editor/editor-sidebar.test.js
|
|
13
|
+
export function buildTree(files) {
|
|
14
|
+
const root = { dirs: {}, files: [] }
|
|
15
|
+
|
|
16
|
+
for (const file of files) {
|
|
17
|
+
const parts = file.name.split('/')
|
|
18
|
+
const fileName = parts.pop()
|
|
19
|
+
|
|
20
|
+
let node = root
|
|
21
|
+
for (const dir of parts) {
|
|
22
|
+
if (!node.dirs[dir]) {
|
|
23
|
+
node.dirs[dir] = { dirs: {}, files: [] }
|
|
24
|
+
}
|
|
25
|
+
node = node.dirs[dir]
|
|
26
|
+
}
|
|
27
|
+
node.files.push({
|
|
28
|
+
path: `${file.name}.${file.__filetype}`,
|
|
29
|
+
label: `${fileName}.${file.__filetype}`,
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return root
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {TreeNode} tree
|
|
38
|
+
* @param {string} [activeFile]
|
|
39
|
+
* @param {string} [_dirPath]
|
|
40
|
+
* @returns {string}
|
|
41
|
+
*/
|
|
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#アクティブファイル)
|
|
47
|
+
// @test: tests/editor/editor-sidebar.test.js
|
|
48
|
+
export function renderTreeHtml(tree, activeFile = '', _dirPath = '') {
|
|
49
|
+
let html = '<ul>'
|
|
50
|
+
|
|
51
|
+
for (const [dirName, subtree] of Object.entries(tree.dirs)) {
|
|
52
|
+
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)
|
|
55
|
+
html += `</details></li>`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const file of tree.files) {
|
|
59
|
+
const isActive = file.path === activeFile
|
|
60
|
+
const activeAttr = isActive ? ' class="active"' : ''
|
|
61
|
+
html += `<li><a href="/editor?md=${file.path}"${activeAttr}>${file.label}</a></li>`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
html += '</ul>'
|
|
65
|
+
return html
|
|
66
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import sharp from 'sharp'
|
|
2
|
+
|
|
3
|
+
export const ext = 'webp'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {Buffer} buffer
|
|
7
|
+
* @returns {Promise<Buffer>}
|
|
8
|
+
*/
|
|
9
|
+
export default async function convert(buffer) {
|
|
10
|
+
return await sharp(buffer)
|
|
11
|
+
.resize({ width: 1200, withoutEnlargement: true })
|
|
12
|
+
.webp()
|
|
13
|
+
.toBuffer()
|
|
14
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import nodePath from 'node:path'
|
|
2
|
+
|
|
3
|
+
const rootDir = process.cwd()
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @vocab: コンバーターファクトリー (docs/dictionary.md#コンバーターファクトリー)
|
|
7
|
+
* @vocab: 画像コンバーター (docs/dictionary.md#画像コンバーター)
|
|
8
|
+
* @test: tests/editor/editor-image-upload.test.js
|
|
9
|
+
* @param {Function|string|null} converterOrName - 関数、ユーザーパス(./で始まる)、またはビルトイン名
|
|
10
|
+
* @returns {Promise<{ fn: Function, ext: string|null }>}
|
|
11
|
+
*/
|
|
12
|
+
export async function createConverter(converterOrName = null) {
|
|
13
|
+
if (!converterOrName) return { fn: (buffer) => buffer, ext: null }
|
|
14
|
+
if (typeof converterOrName === 'function') return { fn: converterOrName, ext: null }
|
|
15
|
+
try {
|
|
16
|
+
let module
|
|
17
|
+
if (converterOrName.startsWith('.') || nodePath.isAbsolute(converterOrName)) {
|
|
18
|
+
module = await import(nodePath.resolve(rootDir, converterOrName))
|
|
19
|
+
} else {
|
|
20
|
+
module = await import(`./converters/${converterOrName}.js`)
|
|
21
|
+
}
|
|
22
|
+
return { fn: module.default, ext: module.ext ?? null }
|
|
23
|
+
} catch {
|
|
24
|
+
return { fn: (buffer) => buffer, ext: null }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import config from '@tenjuu99/blog/lib/config.js'
|
|
2
|
+
|
|
3
|
+
export const path = '/get_frontmatter_templates'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @vocab: テンプレートレゾルバー (docs/dictionary.md#テンプレートレゾルバー)
|
|
7
|
+
* @test: tests/editor/editor-frontmatter-template.test.js
|
|
8
|
+
*/
|
|
9
|
+
export const getTemplates = (cfg) => cfg.frontmatter_templates || []
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {import('http').IncomingMessage} req
|
|
13
|
+
* @param {import('http').ServerResponse} res
|
|
14
|
+
*/
|
|
15
|
+
export const get = async (req, res) => {
|
|
16
|
+
const templates = getTemplates(config)
|
|
17
|
+
return {
|
|
18
|
+
status: 200,
|
|
19
|
+
contentType: 'application/json',
|
|
20
|
+
body: JSON.stringify({ templates }),
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import nodePath from 'node:path'
|
|
3
|
+
import { styleText } from 'node:util'
|
|
4
|
+
import config from '@tenjuu99/blog/lib/config.js'
|
|
5
|
+
import { createConverter } from './createConverter.js'
|
|
6
|
+
|
|
7
|
+
const rootDir = process.cwd()
|
|
8
|
+
const srcDir = nodePath.join(rootDir, config.src_dir)
|
|
9
|
+
|
|
10
|
+
export const path = '/upload-image'
|
|
11
|
+
|
|
12
|
+
const converterPromise = createConverter(config.image_converter ?? null)
|
|
13
|
+
|
|
14
|
+
const MAX_BODY_SIZE = 10 * 1024 * 1024 // 10MB
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @vocab: アップロードエンドポイント (docs/dictionary.md#アップロードエンドポイント)
|
|
18
|
+
* @test: tests/editor/editor-image-upload.test.js
|
|
19
|
+
*/
|
|
20
|
+
export const post = async (req, res) => {
|
|
21
|
+
const chunks = []
|
|
22
|
+
let totalSize = 0
|
|
23
|
+
let aborted = false
|
|
24
|
+
req
|
|
25
|
+
.on('data', chunk => {
|
|
26
|
+
if (aborted) return
|
|
27
|
+
totalSize += chunk.length
|
|
28
|
+
if (totalSize > MAX_BODY_SIZE) {
|
|
29
|
+
aborted = true
|
|
30
|
+
res.writeHead(413, { 'content-type': 'application/json' })
|
|
31
|
+
res.end(JSON.stringify({ message: 'ファイルサイズが上限を超えています' }))
|
|
32
|
+
req.destroy()
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
chunks.push(chunk)
|
|
36
|
+
})
|
|
37
|
+
.on('end', async () => {
|
|
38
|
+
if (aborted) return
|
|
39
|
+
try {
|
|
40
|
+
const { imageData, imageFilename, mdFile } = JSON.parse(chunks.join(''))
|
|
41
|
+
if (!imageData || !imageFilename || !mdFile) {
|
|
42
|
+
res.writeHead(400, { 'content-type': 'application/json' })
|
|
43
|
+
res.end(JSON.stringify({ message: '必須パラメーターが不足しています' }))
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
const { fn, ext } = await converterPromise
|
|
47
|
+
const result = await handleImageUpload({ imageData, imageFilename, mdFile }, { converterFn: fn, outputExt: ext })
|
|
48
|
+
console.log(styleText('blue', '[upload-image] finished'))
|
|
49
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
50
|
+
res.end(JSON.stringify(result))
|
|
51
|
+
} catch (e) {
|
|
52
|
+
console.error(e)
|
|
53
|
+
res.writeHead(500, { 'content-type': 'application/json' })
|
|
54
|
+
res.end(JSON.stringify({ message: '画像のアップロードに失敗しました' }))
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
return true
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export { createConverter }
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @vocab: パスリゾルバー (docs/dictionary.md#パスリゾルバー)
|
|
64
|
+
* @test: tests/editor/editor-image-upload.test.js
|
|
65
|
+
* @param {string} mdFilePath
|
|
66
|
+
* @param {string} imageFilename
|
|
67
|
+
* @param {string|null} [outputExt] - 出力拡張子(null のとき元の拡張子を保持)
|
|
68
|
+
* @returns {{ saveSubPath: string, markdownUrl: string }}
|
|
69
|
+
*/
|
|
70
|
+
export function resolveImagePath(mdFilePath, imageFilename, outputExt = null) {
|
|
71
|
+
const normalizedMd = nodePath.normalize(mdFilePath)
|
|
72
|
+
if (nodePath.isAbsolute(normalizedMd) || normalizedMd.startsWith('..')) {
|
|
73
|
+
throw new Error(`不正な mdFilePath: ${mdFilePath}`)
|
|
74
|
+
}
|
|
75
|
+
const safeFilename = nodePath.basename(imageFilename)
|
|
76
|
+
const mdSlug = normalizedMd.replace(/\.[^.]+$/, '')
|
|
77
|
+
const imgBasename = safeFilename.replace(/\.[^.]+$/, '')
|
|
78
|
+
const originalExt = safeFilename.match(/\.[^.]+$/)?.[0] ?? ''
|
|
79
|
+
const finalExt = outputExt != null ? `.${outputExt}` : originalExt
|
|
80
|
+
const imageSubPath = `image/${mdSlug}/${imgBasename}${finalExt}`
|
|
81
|
+
return {
|
|
82
|
+
saveSubPath: imageSubPath,
|
|
83
|
+
markdownUrl: `/${imageSubPath}`
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @vocab: ファイルライター (docs/dictionary.md#ファイルライター)
|
|
89
|
+
* @test: tests/editor/editor-image-upload.test.js
|
|
90
|
+
* @param {string} saveSubPath - src/ 以下の相対パス
|
|
91
|
+
* @param {Buffer} data
|
|
92
|
+
* @param {string} [baseDir]
|
|
93
|
+
*/
|
|
94
|
+
export function writeImageFile(saveSubPath, data, baseDir = srcDir) {
|
|
95
|
+
const resolvedBase = nodePath.resolve(baseDir)
|
|
96
|
+
const fullPath = nodePath.resolve(baseDir, saveSubPath)
|
|
97
|
+
if (!fullPath.startsWith(resolvedBase + nodePath.sep)) {
|
|
98
|
+
throw new Error(`保存先パスが許可ディレクトリ外です: ${saveSubPath}`)
|
|
99
|
+
}
|
|
100
|
+
fs.mkdirSync(nodePath.dirname(fullPath), { recursive: true })
|
|
101
|
+
fs.writeFileSync(fullPath, data)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @vocab: アップロードエンドポイント (docs/dictionary.md#アップロードエンドポイント)
|
|
106
|
+
* @test: tests/editor/editor-image-upload.test.js
|
|
107
|
+
* @param {{ imageData: string, imageFilename: string, mdFile: string }} payload
|
|
108
|
+
* @param {{ converterFn?: Function, outputExt?: string|null, baseDir?: string }} [options]
|
|
109
|
+
* @returns {Promise<{ markdownUrl: string }>}
|
|
110
|
+
*/
|
|
111
|
+
export async function handleImageUpload(payload, options = {}) {
|
|
112
|
+
const { imageData, imageFilename, mdFile } = payload
|
|
113
|
+
const baseDir = options.baseDir ?? srcDir
|
|
114
|
+
|
|
115
|
+
let converterFn, outputExt
|
|
116
|
+
if ('converterFn' in options) {
|
|
117
|
+
converterFn = options.converterFn
|
|
118
|
+
outputExt = options.outputExt ?? null
|
|
119
|
+
} else {
|
|
120
|
+
const created = await createConverter(config.image_converter ?? null)
|
|
121
|
+
converterFn = created.fn
|
|
122
|
+
outputExt = created.ext
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const buffer = Buffer.from(imageData, 'base64')
|
|
126
|
+
const { saveSubPath, markdownUrl } = resolveImagePath(mdFile, imageFilename, outputExt)
|
|
127
|
+
const converted = await converterFn(buffer)
|
|
128
|
+
writeImageFile(saveSubPath, converted, baseDir)
|
|
129
|
+
return { markdownUrl }
|
|
130
|
+
}
|
|
@@ -11,13 +11,9 @@
|
|
|
11
11
|
<body>
|
|
12
12
|
<main class="container">
|
|
13
13
|
<div class="sidebar">
|
|
14
|
-
<
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return `<li><a href="/editor?md=${p.name}.${p.__filetype}">${p.url}.${p.__filetype}</a></li>`
|
|
18
|
-
}).join("\n")
|
|
19
|
-
</script>
|
|
20
|
-
</ul>
|
|
14
|
+
<script type="ssg">
|
|
15
|
+
return helper.renderSidebarTree(helper.readIndex())
|
|
16
|
+
</script>
|
|
21
17
|
<div class="sidebar-toggle"> </div>
|
|
22
18
|
</div>
|
|
23
19
|
<form action="/editor" class="editor" method="post" id="editor">
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebP変換コンバーターのサンプル実装。
|
|
3
|
+
* sharp を使って画像を WebP に変換する。
|
|
4
|
+
*
|
|
5
|
+
* 使い方:
|
|
6
|
+
* 1. sharp をインストール: npm install sharp
|
|
7
|
+
* 2. blog.json に設定: { "image_converter": "./converters/webp.js" }
|
|
8
|
+
* (ビルトインを使う場合は "sharp" のみ指定)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import sharp from 'sharp'
|
|
12
|
+
|
|
13
|
+
export const ext = 'webp'
|
|
14
|
+
|
|
15
|
+
export default async function convert(buffer) {
|
|
16
|
+
return await sharp(buffer)
|
|
17
|
+
.resize({ width: 1200, withoutEnlargement: true })
|
|
18
|
+
.webp()
|
|
19
|
+
.toBuffer()
|
|
20
|
+
}
|
package/src-sample/helper/add.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { allData, config } from '@tenjuu99/blog'
|
|
2
2
|
|
|
3
|
+
// @vocab: ページインデックス (docs/dictionary.md#ページインデックス)
|
|
4
|
+
// @vocab: ページ (docs/dictionary.md#ページ)
|
|
5
|
+
// @test: tests/ssg-core/indexer.test.js
|
|
3
6
|
export function readIndex (filter = null) {
|
|
4
7
|
const data = Object.entries(allData)
|
|
5
8
|
.sort((a, b) => new Date(b[1].published) - new Date(a[1].published))
|
|
@@ -13,6 +16,9 @@ export function dateFormat(dateString) {
|
|
|
13
16
|
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
|
|
14
17
|
}
|
|
15
18
|
|
|
19
|
+
// @vocab: ページ (docs/dictionary.md#ページ)
|
|
20
|
+
// @vocab: ページインデックス (docs/dictionary.md#ページインデックス)
|
|
21
|
+
// @test: tests/ssg-core/pageData.test.js
|
|
16
22
|
export function getPageData(name) {
|
|
17
23
|
return allData[name]
|
|
18
24
|
}
|
|
@@ -41,6 +47,9 @@ export function arrayToList(arrayOrText) {
|
|
|
41
47
|
return arrayOrText
|
|
42
48
|
}
|
|
43
49
|
|
|
50
|
+
// @vocab: ページインデックス (docs/dictionary.md#ページインデックス)
|
|
51
|
+
// @vocab: ページ (docs/dictionary.md#ページ)
|
|
52
|
+
// @test: tests/ssg-core/indexer.test.js
|
|
44
53
|
export function renderIndex(pages, nodate = 'nodate', headingTag = 'h3') {
|
|
45
54
|
if (!pages) {
|
|
46
55
|
pages = readIndex()
|
|
Binary file
|