@tenjuu99/blog 0.3.5 → 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/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/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 +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
|
@@ -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
|