@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.
@@ -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
- document.querySelector('#editorTextArea').value = `---
9
- title: ${target.split('.')[0].split('/').pop()}
10
- ---
11
- ${target.split('.')[0].split('/').pop()} についての記事を作成しましょう`
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('sidebar-is-open', !main.classList.contains('sidebar-close'))
222
+ localStorage.setItem(SIDEBAR_OPEN_KEY, !main.classList.contains('sidebar-close'))
109
223
  })
110
- if (localStorage.getItem('sidebar-is-open') === 'true') {
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
- document.addEventListener('DOMContentLoaded', (event) => {
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) + `![](${markdownUrl})` + content.slice(start)
262
+ textarea.selectionStart = textarea.selectionEnd = start + `![](${markdownUrl})`.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 = `![](${markdownUrl})`
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
- <ul>
15
- <script type="ssg">
16
- return helper.readIndex().map(p => {
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">&nbsp;</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
+ }
@@ -1,3 +1,5 @@
1
+ // @vocab: ヘルパー関数 (docs/dictionary.md#ヘルパー関数)
2
+ // @test: tests/ssg-core/helper.test.js
1
3
  export function additionalHelper() {
2
4
  return 'これは追加ヘルパーによって出力されているメッセージです。'
3
5
  }
@@ -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