@tenjuu99/blog 0.3.5 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/dev-server +3 -1
- package/bin/server +8 -1
- package/docs/develop.md +36 -6
- package/docs/dictionary.json +1806 -0
- package/lib/dir.js +3 -0
- package/lib/distribute.js +19 -5
- package/lib/files.js +2 -0
- package/lib/filter.js +7 -0
- package/lib/generate.js +5 -0
- package/lib/helper.js +3 -0
- package/lib/imageDistributor.js +59 -0
- package/lib/indexer.js +3 -0
- package/lib/pageData.js +7 -0
- package/lib/render.js +5 -0
- package/lib/replaceVariablesFilter.js +5 -0
- package/lib/server/helper/parseRequestBody.js +46 -0
- package/lib/server.js +7 -3
- package/lib/tryServer.js +3 -1
- package/package.json +8 -5
- package/packages/breadcrumbs/helper/breadcrumbs.js +3 -0
- package/packages/category/helper/category.js +13 -0
- package/packages/category/helper/categoryIndexer.js +12 -0
- package/packages/category/helper/pagination.js +5 -0
- package/packages/editor/css/editor.css +98 -10
- package/packages/editor/helper/sidebarTree.js +11 -0
- package/packages/editor/js/autoPreviewInitializer.js +11 -0
- package/packages/editor/js/autoSaveInitializer.js +15 -0
- package/packages/editor/js/debouncer.js +21 -0
- package/packages/editor/js/editor.js +370 -45
- package/packages/editor/js/frontmatter_template.js +67 -0
- package/packages/editor/js/imageReferenceExtractor.js +19 -0
- package/packages/editor/js/image_upload.js +12 -0
- package/packages/editor/js/tree.js +81 -0
- package/packages/editor/server/changeReflector.js +47 -0
- package/packages/editor/server/converters/sharp.js +14 -0
- package/packages/editor/server/createConverter.js +26 -0
- package/packages/editor/server/get_editor_target.js +2 -2
- package/packages/editor/server/get_frontmatter_templates.js +22 -0
- package/packages/editor/server/get_publication_status.js +29 -0
- package/packages/editor/server/get_sidebar.js +51 -0
- package/packages/editor/server/image_upload.js +122 -0
- package/packages/editor/server/preview.js +49 -19
- package/packages/editor/server/publicationStatus.js +27 -0
- package/packages/editor/server/publish.js +135 -0
- package/packages/editor/server/publishTargetCollector.js +19 -0
- package/packages/editor/server/save.js +97 -0
- package/packages/editor/server/sidebarStatusCollector.js +17 -0
- package/packages/editor/template/editor.html +28 -22
- package/src-sample/converters/webp.js +20 -0
- package/src-sample/helper/add.js +2 -0
- package/src-sample/helper/index.js +9 -0
- package/src-sample/image/test.jpg +0 -0
- package/packages/editor/server/editor.js +0 -43
|
@@ -0,0 +1,81 @@
|
|
|
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: ツリービルダー
|
|
10
|
+
// @vocab: ファイルリスト
|
|
11
|
+
// @vocab: ディレクトリツリー
|
|
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 {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
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {TreeNode} tree
|
|
50
|
+
* @param {string} [activeFile]
|
|
51
|
+
* @param {Object.<string, 'new'|'modified'|'published'|'unknown'>} [statusMap]
|
|
52
|
+
* @param {string} [_dirPath]
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
// @vocab: ツリーレンダラー
|
|
56
|
+
// @vocab: ディレクトリツリー
|
|
57
|
+
// @vocab: ディレクトリノード
|
|
58
|
+
// @vocab: ファイルノード
|
|
59
|
+
// @vocab: アクティブファイル
|
|
60
|
+
// @test: tests/editor/editor-sidebar.test.js
|
|
61
|
+
export function renderTreeHtml(tree, activeFile = '', statusMap = {}, _dirPath = '') {
|
|
62
|
+
let html = '<ul>'
|
|
63
|
+
|
|
64
|
+
for (const [dirName, subtree] of Object.entries(tree.dirs)) {
|
|
65
|
+
const dirPath = _dirPath ? `${_dirPath}/${dirName}` : dirName
|
|
66
|
+
html += `<li class="dir-node"><details data-dir="${escapeHtml(dirPath)}"><summary>${escapeHtml(dirName)}</summary>`
|
|
67
|
+
html += renderTreeHtml(subtree, activeFile, statusMap, dirPath)
|
|
68
|
+
html += `</details></li>`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const file of tree.files) {
|
|
72
|
+
const isActive = file.path === activeFile
|
|
73
|
+
const activeAttr = isActive ? ' class="active"' : ''
|
|
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>`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
html += '</ul>'
|
|
80
|
+
return html
|
|
81
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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: コンバーターファクトリー
|
|
7
|
+
* @vocab: 画像コンバーター
|
|
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
|
+
}
|
|
@@ -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,
|
|
@@ -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: テンプレートレゾルバー
|
|
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,29 @@
|
|
|
1
|
+
import nodePath from 'node:path'
|
|
2
|
+
import config from '@tenjuu99/blog/lib/config.js'
|
|
3
|
+
import { rootDir } from '@tenjuu99/blog/lib/dir.js'
|
|
4
|
+
import { getPublicationStatus } from './publicationStatus.js'
|
|
5
|
+
import { createGitPublishedState } from './publish.js'
|
|
6
|
+
|
|
7
|
+
export const path = '/publication-status'
|
|
8
|
+
|
|
9
|
+
export const get = async (req, res) => {
|
|
10
|
+
const url = new URL(req.url, 'http://localhost')
|
|
11
|
+
const md = url.searchParams.get('md')
|
|
12
|
+
if (!md) {
|
|
13
|
+
res.writeHead(400, { 'content-type': 'application/json' })
|
|
14
|
+
res.end(JSON.stringify({ error: 'md パラメータが必要です' }))
|
|
15
|
+
return true
|
|
16
|
+
}
|
|
17
|
+
const pagesPrefix = `${config.src_dir}/pages`
|
|
18
|
+
const filePath = nodePath.normalize(`${config.src_dir}/pages/${md}`)
|
|
19
|
+
if (!filePath.startsWith(pagesPrefix + '/') && !filePath.startsWith(pagesPrefix + nodePath.sep)) {
|
|
20
|
+
res.writeHead(400, { 'content-type': 'application/json' })
|
|
21
|
+
res.end(JSON.stringify({ error: '不正なファイルパスです' }))
|
|
22
|
+
return true
|
|
23
|
+
}
|
|
24
|
+
const publishedState = await createGitPublishedState(rootDir)
|
|
25
|
+
const status = await getPublicationStatus(filePath, publishedState)
|
|
26
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
27
|
+
res.end(JSON.stringify({ status }))
|
|
28
|
+
return true
|
|
29
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { readdirSync, existsSync } from 'node:fs'
|
|
2
|
+
import { watch, rootDir } from '@tenjuu99/blog/lib/dir.js'
|
|
3
|
+
import config from '@tenjuu99/blog/lib/config.js'
|
|
4
|
+
import { renderSidebarTree } from '../helper/sidebarTree.js'
|
|
5
|
+
import { collectStatuses } from './sidebarStatusCollector.js'
|
|
6
|
+
import { createGitPublishedState } from './publish.js'
|
|
7
|
+
|
|
8
|
+
export const path = '/get_sidebar'
|
|
9
|
+
|
|
10
|
+
function scanFiles(dir, prefix = '') {
|
|
11
|
+
if (!existsSync(dir)) return []
|
|
12
|
+
const entries = readdirSync(dir, { withFileTypes: true })
|
|
13
|
+
const files = []
|
|
14
|
+
for (const entry of entries) {
|
|
15
|
+
if (entry.isDirectory()) {
|
|
16
|
+
const sub = prefix ? `${prefix}/${entry.name}` : entry.name
|
|
17
|
+
files.push(...scanFiles(`${dir}/${entry.name}`, sub))
|
|
18
|
+
} else {
|
|
19
|
+
const regexp = new RegExp(`\\.(${config.allowedSrcExt})$`)
|
|
20
|
+
if (entry.name.match(regexp)) {
|
|
21
|
+
const ext = entry.name.split('.').pop()
|
|
22
|
+
const nameWithoutExt = entry.name.slice(0, -(ext.length + 1))
|
|
23
|
+
files.push({
|
|
24
|
+
name: prefix ? `${prefix}/${nameWithoutExt}` : nameWithoutExt,
|
|
25
|
+
__filetype: ext,
|
|
26
|
+
__is_auto_category: false,
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return files
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @vocab サイドバー取得エンドポイント
|
|
36
|
+
*/
|
|
37
|
+
export const get = async (req, res) => {
|
|
38
|
+
const files = scanFiles(watch.pageDir)
|
|
39
|
+
const publishedState = await createGitPublishedState(rootDir)
|
|
40
|
+
const fileMappings = files.map(f => ({
|
|
41
|
+
treePath: `${f.name}.${f.__filetype}`,
|
|
42
|
+
gitPath: `${config.src_dir}/pages/${f.name}.${f.__filetype}`,
|
|
43
|
+
}))
|
|
44
|
+
const statusMap = await collectStatuses(fileMappings, publishedState)
|
|
45
|
+
const html = renderSidebarTree(files, statusMap)
|
|
46
|
+
return {
|
|
47
|
+
status: 200,
|
|
48
|
+
contentType: 'application/json',
|
|
49
|
+
body: JSON.stringify({ html })
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
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
|
+
import { parseJsonBody } from '@tenjuu99/blog/lib/server/helper/parseRequestBody.js'
|
|
7
|
+
|
|
8
|
+
const rootDir = process.cwd()
|
|
9
|
+
const srcDir = nodePath.join(rootDir, config.src_dir)
|
|
10
|
+
|
|
11
|
+
export const path = '/upload-image'
|
|
12
|
+
|
|
13
|
+
const converterPromise = createConverter(config.image_converter ?? null)
|
|
14
|
+
|
|
15
|
+
const MAX_BODY_SIZE = 10 * 1024 * 1024 // 10MB
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @vocab: アップロードエンドポイント
|
|
19
|
+
* @test: tests/editor/editor-image-upload.test.js
|
|
20
|
+
*/
|
|
21
|
+
export const post = async (req, res) => {
|
|
22
|
+
let json
|
|
23
|
+
try {
|
|
24
|
+
json = await parseJsonBody(req, { maxSize: MAX_BODY_SIZE })
|
|
25
|
+
} catch (e) {
|
|
26
|
+
const status = e.code === 'PAYLOAD_TOO_LARGE' ? 413 : 400
|
|
27
|
+
res.writeHead(status, { 'content-type': 'application/json' })
|
|
28
|
+
res.end(JSON.stringify({ message: e.message }))
|
|
29
|
+
return true
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const { imageData, imageFilename, mdFile } = json
|
|
34
|
+
if (!imageData || !imageFilename || !mdFile) {
|
|
35
|
+
res.writeHead(400, { 'content-type': 'application/json' })
|
|
36
|
+
res.end(JSON.stringify({ message: '必須パラメーターが不足しています' }))
|
|
37
|
+
return true
|
|
38
|
+
}
|
|
39
|
+
const { fn, ext } = await converterPromise
|
|
40
|
+
const result = await handleImageUpload({ imageData, imageFilename, mdFile }, { converterFn: fn, outputExt: ext })
|
|
41
|
+
console.log(styleText('blue', '[upload-image] finished'))
|
|
42
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
43
|
+
res.end(JSON.stringify(result))
|
|
44
|
+
} catch (e) {
|
|
45
|
+
console.error(e)
|
|
46
|
+
res.writeHead(500, { 'content-type': 'application/json' })
|
|
47
|
+
res.end(JSON.stringify({ message: '画像のアップロードに失敗しました' }))
|
|
48
|
+
}
|
|
49
|
+
return true
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export { createConverter }
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @vocab: パスリゾルバー
|
|
56
|
+
* @test: tests/editor/editor-image-upload.test.js
|
|
57
|
+
* @param {string} mdFilePath
|
|
58
|
+
* @param {string} imageFilename
|
|
59
|
+
* @param {string|null} [outputExt] - 出力拡張子(null のとき元の拡張子を保持)
|
|
60
|
+
* @returns {{ saveSubPath: string, markdownUrl: string }}
|
|
61
|
+
*/
|
|
62
|
+
export function resolveImagePath(mdFilePath, imageFilename, outputExt = null) {
|
|
63
|
+
const normalizedMd = nodePath.normalize(mdFilePath)
|
|
64
|
+
if (nodePath.isAbsolute(normalizedMd) || normalizedMd.startsWith('..')) {
|
|
65
|
+
throw new Error(`不正な mdFilePath: ${mdFilePath}`)
|
|
66
|
+
}
|
|
67
|
+
const safeFilename = nodePath.basename(imageFilename)
|
|
68
|
+
const mdSlug = normalizedMd.replace(/\.[^.]+$/, '')
|
|
69
|
+
const imgBasename = safeFilename.replace(/\.[^.]+$/, '')
|
|
70
|
+
const originalExt = safeFilename.match(/\.[^.]+$/)?.[0] ?? ''
|
|
71
|
+
const finalExt = outputExt != null ? `.${outputExt}` : originalExt
|
|
72
|
+
const imageSubPath = `image/${mdSlug}/${imgBasename}${finalExt}`
|
|
73
|
+
return {
|
|
74
|
+
saveSubPath: imageSubPath,
|
|
75
|
+
markdownUrl: `/${imageSubPath}`
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @vocab: ファイルライター
|
|
81
|
+
* @test: tests/editor/editor-image-upload.test.js
|
|
82
|
+
* @param {string} saveSubPath - src/ 以下の相対パス
|
|
83
|
+
* @param {Buffer} data
|
|
84
|
+
* @param {string} [baseDir]
|
|
85
|
+
*/
|
|
86
|
+
export function writeImageFile(saveSubPath, data, baseDir = srcDir) {
|
|
87
|
+
const resolvedBase = nodePath.resolve(baseDir)
|
|
88
|
+
const fullPath = nodePath.resolve(baseDir, saveSubPath)
|
|
89
|
+
if (!fullPath.startsWith(resolvedBase + nodePath.sep)) {
|
|
90
|
+
throw new Error(`保存先パスが許可ディレクトリ外です: ${saveSubPath}`)
|
|
91
|
+
}
|
|
92
|
+
fs.mkdirSync(nodePath.dirname(fullPath), { recursive: true })
|
|
93
|
+
fs.writeFileSync(fullPath, data)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @vocab: アップロードエンドポイント
|
|
98
|
+
* @test: tests/editor/editor-image-upload.test.js
|
|
99
|
+
* @param {{ imageData: string, imageFilename: string, mdFile: string }} payload
|
|
100
|
+
* @param {{ converterFn?: Function, outputExt?: string|null, baseDir?: string }} [options]
|
|
101
|
+
* @returns {Promise<{ markdownUrl: string }>}
|
|
102
|
+
*/
|
|
103
|
+
export async function handleImageUpload(payload, options = {}) {
|
|
104
|
+
const { imageData, imageFilename, mdFile } = payload
|
|
105
|
+
const baseDir = options.baseDir ?? srcDir
|
|
106
|
+
|
|
107
|
+
let converterFn, outputExt
|
|
108
|
+
if ('converterFn' in options) {
|
|
109
|
+
converterFn = options.converterFn
|
|
110
|
+
outputExt = options.outputExt ?? null
|
|
111
|
+
} else {
|
|
112
|
+
const created = await createConverter(config.image_converter ?? null)
|
|
113
|
+
converterFn = created.fn
|
|
114
|
+
outputExt = created.ext
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const buffer = Buffer.from(imageData, 'base64')
|
|
118
|
+
const { saveSubPath, markdownUrl } = resolveImagePath(mdFile, imageFilename, outputExt)
|
|
119
|
+
const converted = await converterFn(buffer)
|
|
120
|
+
writeImageFile(saveSubPath, converted, baseDir)
|
|
121
|
+
return { markdownUrl }
|
|
122
|
+
}
|
|
@@ -2,32 +2,62 @@ import { IncomingMessage, ServerResponse } from 'http'
|
|
|
2
2
|
import { styleText } from 'node:util'
|
|
3
3
|
import render from '@tenjuu99/blog/lib/render.js'
|
|
4
4
|
import makePageData from '@tenjuu99/blog/lib/pageData.js'
|
|
5
|
+
import { distDir, srcDir } from '@tenjuu99/blog/lib/dir.js'
|
|
6
|
+
import fs from 'node:fs'
|
|
7
|
+
import { parseJsonBody } from '@tenjuu99/blog/lib/server/helper/parseRequestBody.js'
|
|
5
8
|
|
|
6
9
|
export const path = '/preview'
|
|
7
10
|
|
|
11
|
+
/**
|
|
12
|
+
* <link rel="stylesheet"> タグを読み込んでインライン <style> に置換する。
|
|
13
|
+
* プレビューiframeがサーバー再起動中のCSSリクエスト失敗を起こさないようにするため。
|
|
14
|
+
* @param {string} html
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
function inlineStyles(html) {
|
|
18
|
+
return html.replace(
|
|
19
|
+
/<link\b[^>]*\brel=["']stylesheet["'][^>]*\/?>/gi,
|
|
20
|
+
(match) => {
|
|
21
|
+
const hrefMatch = match.match(/\bhref=["']([^"']+)["']/)
|
|
22
|
+
if (!hrefMatch) return match
|
|
23
|
+
const href = hrefMatch[1]
|
|
24
|
+
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//')) {
|
|
25
|
+
return match
|
|
26
|
+
}
|
|
27
|
+
const filePath = href.split('?')[0]
|
|
28
|
+
for (const dir of [distDir, srcDir]) {
|
|
29
|
+
const fullPath = `${dir}${filePath}`
|
|
30
|
+
if (fs.existsSync(fullPath)) {
|
|
31
|
+
return `<style>${fs.readFileSync(fullPath, 'utf8')}</style>`
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return match
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
8
39
|
/**
|
|
9
40
|
* @param {IncomingMessage} req
|
|
10
41
|
* @param {ServerResponse} res
|
|
11
42
|
*/
|
|
12
43
|
export const post = async (req, res) => {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
})
|
|
44
|
+
let json
|
|
45
|
+
try {
|
|
46
|
+
json = await parseJsonBody(req)
|
|
47
|
+
} catch (e) {
|
|
48
|
+
res.writeHead(400, { 'content-type': 'application/json' })
|
|
49
|
+
res.end(JSON.stringify({ message: e.message }))
|
|
50
|
+
return true
|
|
51
|
+
}
|
|
52
|
+
const filename = json.inputFileName ? json.inputFileName : json.selectDataFile
|
|
53
|
+
if (!filename) {
|
|
54
|
+
res.writeHead(400, { 'content-type': 'application/json' })
|
|
55
|
+
res.end(JSON.stringify({ message: 'filename is requried.' }))
|
|
56
|
+
return true
|
|
57
|
+
}
|
|
58
|
+
const pageData = makePageData(filename, json.content)
|
|
59
|
+
const rendered = await render(pageData.template, pageData)
|
|
60
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
61
|
+
res.end(JSON.stringify({ 'preview': inlineStyles(rendered) }))
|
|
32
62
|
return true
|
|
33
63
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { styleText } from 'node:util'
|
|
2
|
+
/**
|
|
3
|
+
* リモートの現在の内容を参照する読み取り専用の抽象。
|
|
4
|
+
* git 依存を外部から注入することで実際のリモートリポジトリなしにテストできる。
|
|
5
|
+
* @typedef {object} PublishedState
|
|
6
|
+
* @property {(filePath: string) => Promise<boolean>} existsInRemote - ファイルがリモートに存在するか
|
|
7
|
+
* @property {(filePath: string) => Promise<string>} diffFromRemote - リモートとの差分(差分なしなら空文字列)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @vocab: 公開ステータス判定器
|
|
12
|
+
* @test tests/editor/publish.test.js
|
|
13
|
+
* @param {string} filePath - git ルートからの相対パス(例: `src/pages/post/hello.md`)
|
|
14
|
+
* @param {PublishedState} publishedState
|
|
15
|
+
* @returns {Promise<'new'|'modified'|'published'|'unknown'>}
|
|
16
|
+
*/
|
|
17
|
+
export async function getPublicationStatus(filePath, publishedState) {
|
|
18
|
+
try {
|
|
19
|
+
const exists = await publishedState.existsInRemote(filePath)
|
|
20
|
+
if (!exists) return 'new'
|
|
21
|
+
const diff = await publishedState.diffFromRemote(filePath)
|
|
22
|
+
return diff ? 'modified' : 'published'
|
|
23
|
+
} catch (e) {
|
|
24
|
+
console.log(styleText('red', '[error]'), e)
|
|
25
|
+
return 'unknown'
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import nodePath from 'node:path'
|
|
3
|
+
import { execFile } from 'node:child_process'
|
|
4
|
+
import { promisify } from 'node:util'
|
|
5
|
+
import { styleText } from 'node:util'
|
|
6
|
+
import config from '@tenjuu99/blog/lib/config.js'
|
|
7
|
+
import { rootDir, srcDir } from '@tenjuu99/blog/lib/dir.js'
|
|
8
|
+
import { collectTarget } from './publishTargetCollector.js'
|
|
9
|
+
import { publish, update } from './changeReflector.js'
|
|
10
|
+
import { getPublicationStatus } from './publicationStatus.js'
|
|
11
|
+
import { parseJsonBody } from '@tenjuu99/blog/lib/server/helper/parseRequestBody.js'
|
|
12
|
+
|
|
13
|
+
const execFileAsync = promisify(execFile)
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @vocab: 公開ハンドラー
|
|
17
|
+
* @test tests/editor/publish.test.js
|
|
18
|
+
* @param {{ filePath: string, fileContent: string, srcDir?: string }} options
|
|
19
|
+
* @param {import('./publicationStatus.js').PublishedState} publishedState
|
|
20
|
+
* @param {import('./changeReflector.js').PublishActions} publishActions
|
|
21
|
+
* @returns {Promise<{ success: boolean, error?: string }>}
|
|
22
|
+
*/
|
|
23
|
+
export async function handlePublish({ filePath, fileContent, srcDir: srcDirParam = 'src' }, publishedState, publishActions) {
|
|
24
|
+
const target = collectTarget(filePath, fileContent, srcDirParam)
|
|
25
|
+
const files = [target.markdownFile, ...target.imageFiles]
|
|
26
|
+
const state = await getPublicationStatus(target.markdownFile, publishedState)
|
|
27
|
+
if (state === 'new') return await publish(files, publishActions)
|
|
28
|
+
if (state === 'modified') return await update(files, publishActions)
|
|
29
|
+
if (state === 'unknown') return { success: false, error: 'リモートへの接続に失敗しました(upstream branch が未設定の可能性があります)' }
|
|
30
|
+
// published 状態はローカルとリモートが一致しているため操作不要
|
|
31
|
+
return { success: true }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @vocab: 公開済み状態
|
|
36
|
+
* リモートの現在の内容を参照する読み取り専用の抽象
|
|
37
|
+
* @param {string} cwd - git リポジトリのルートパス
|
|
38
|
+
* @returns {import('./publicationStatus.js').PublishedState}
|
|
39
|
+
*/
|
|
40
|
+
export async function createGitPublishedState(cwd) {
|
|
41
|
+
try {
|
|
42
|
+
const { stdout: refOut } = await execFileAsync(
|
|
43
|
+
'git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], { cwd }
|
|
44
|
+
)
|
|
45
|
+
const ref = refOut.trim()
|
|
46
|
+
const [{ stdout: treeOut }, { stdout: diffOut }] = await Promise.all([
|
|
47
|
+
execFileAsync('git', ['ls-tree', '-r', '--name-only', ref], { cwd }),
|
|
48
|
+
execFileAsync('git', ['diff', '--name-only', ref], { cwd }),
|
|
49
|
+
])
|
|
50
|
+
const remoteFiles = new Set(treeOut.trim().split('\n').filter(Boolean))
|
|
51
|
+
const modifiedFiles = new Set(diffOut.trim().split('\n').filter(Boolean))
|
|
52
|
+
return {
|
|
53
|
+
existsInRemote: async (filePath) => remoteFiles.has(filePath),
|
|
54
|
+
diffFromRemote: async (filePath) => modifiedFiles.has(filePath) ? 'modified' : '',
|
|
55
|
+
}
|
|
56
|
+
} catch (e) {
|
|
57
|
+
// upstream 未設定など → getPublicationStatus が 'unknown' を返せるよう再スロー
|
|
58
|
+
return {
|
|
59
|
+
existsInRemote: async () => { throw e },
|
|
60
|
+
diffFromRemote: async () => { throw e },
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {string} cwd - git リポジトリのルートパス
|
|
67
|
+
* @returns {import('./changeReflector.js').PublishActions}
|
|
68
|
+
*/
|
|
69
|
+
function createGitPublishActions(cwd) {
|
|
70
|
+
return {
|
|
71
|
+
commit: async (files) => {
|
|
72
|
+
await execFileAsync('git', ['add', '--', ...files], { cwd })
|
|
73
|
+
const { stdout } = await execFileAsync('git', ['diff', '--cached', '--name-only'], { cwd })
|
|
74
|
+
if (!stdout.trim()) return false
|
|
75
|
+
await execFileAsync('git', ['commit', '-m', 'publish'], { cwd })
|
|
76
|
+
return true
|
|
77
|
+
},
|
|
78
|
+
push: async () => {
|
|
79
|
+
try {
|
|
80
|
+
await execFileAsync('git', ['push'], { cwd })
|
|
81
|
+
return { success: true }
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return { success: false, error: error.stderr || error.message }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const path = '/publish'
|
|
90
|
+
|
|
91
|
+
export const post = async (req, res) => {
|
|
92
|
+
let body
|
|
93
|
+
try {
|
|
94
|
+
body = await parseJsonBody(req)
|
|
95
|
+
} catch (e) {
|
|
96
|
+
res.writeHead(400, { 'content-type': 'application/json' })
|
|
97
|
+
res.end(JSON.stringify({ success: false, error: e.message }))
|
|
98
|
+
return true
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
const { filePath, fileContent } = body
|
|
102
|
+
if (!filePath) {
|
|
103
|
+
res.writeHead(400, { 'content-type': 'application/json' })
|
|
104
|
+
res.end(JSON.stringify({ success: false, error: 'ファイル名がありません' }))
|
|
105
|
+
return true
|
|
106
|
+
}
|
|
107
|
+
const pagesDir = nodePath.join(srcDir, 'pages')
|
|
108
|
+
const resolvedFilePath = nodePath.resolve(pagesDir, filePath)
|
|
109
|
+
if (!resolvedFilePath.startsWith(pagesDir + nodePath.sep)) {
|
|
110
|
+
res.writeHead(400, { 'content-type': 'application/json' })
|
|
111
|
+
res.end(JSON.stringify({ success: false, error: '不正なファイルパスです' }))
|
|
112
|
+
return true
|
|
113
|
+
}
|
|
114
|
+
const content = fileContent != null ? fileContent : fs.readFileSync(`${srcDir}/pages/${filePath}`, 'utf-8')
|
|
115
|
+
if (fileContent != null) {
|
|
116
|
+
const dir = `${srcDir}/pages/${filePath}`.split('/').slice(0, -1).join('/')
|
|
117
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
|
118
|
+
fs.writeFileSync(`${srcDir}/pages/${filePath}`, fileContent)
|
|
119
|
+
}
|
|
120
|
+
const publishedState = await createGitPublishedState(rootDir)
|
|
121
|
+
const publishActions = createGitPublishActions(rootDir)
|
|
122
|
+
const result = await handlePublish(
|
|
123
|
+
{ filePath, fileContent: content, srcDir: config.src_dir },
|
|
124
|
+
publishedState,
|
|
125
|
+
publishActions
|
|
126
|
+
)
|
|
127
|
+
console.log(styleText(result.success ? 'green' : 'red', `[publish] ${filePath} ${result.success ? 'ok' : result.error}`))
|
|
128
|
+
res.writeHead(result.success ? 200 : 500, { 'content-type': 'application/json' })
|
|
129
|
+
res.end(JSON.stringify(result))
|
|
130
|
+
} catch (error) {
|
|
131
|
+
res.writeHead(500, { 'content-type': 'application/json' })
|
|
132
|
+
res.end(JSON.stringify({ success: false, error: error.message }))
|
|
133
|
+
}
|
|
134
|
+
return true
|
|
135
|
+
}
|