@tenjuu99/blog 0.3.6 → 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 +30 -0
- package/docs/dictionary.json +1806 -0
- package/lib/dir.js +2 -2
- package/lib/distribute.js +5 -5
- package/lib/files.js +1 -1
- package/lib/filter.js +5 -5
- package/lib/generate.js +3 -3
- package/lib/helper.js +2 -2
- package/lib/imageDistributor.js +3 -3
- package/lib/indexer.js +2 -2
- package/lib/pageData.js +5 -5
- package/lib/render.js +4 -4
- package/lib/replaceVariablesFilter.js +4 -4
- package/lib/server/helper/parseRequestBody.js +46 -0
- package/lib/server.js +7 -3
- package/package.json +1 -1
- package/packages/category/helper/category.js +10 -10
- package/packages/category/helper/categoryIndexer.js +8 -8
- package/packages/category/helper/pagination.js +3 -3
- package/packages/editor/css/editor.css +87 -18
- package/packages/editor/helper/sidebarTree.js +7 -2
- package/packages/editor/js/autoPreviewInitializer.js +11 -0
- package/packages/editor/js/autoSaveInitializer.js +15 -0
- package/packages/editor/js/debouncer.js +21 -0
- package/packages/editor/js/editor.js +261 -91
- package/packages/editor/js/frontmatter_template.js +3 -3
- package/packages/editor/js/imageReferenceExtractor.js +19 -0
- package/packages/editor/js/image_upload.js +1 -1
- package/packages/editor/js/tree.js +27 -12
- package/packages/editor/server/changeReflector.js +47 -0
- package/packages/editor/server/createConverter.js +2 -2
- package/packages/editor/server/get_editor_target.js +2 -2
- package/packages/editor/server/get_frontmatter_templates.js +1 -1
- package/packages/editor/server/get_publication_status.js +29 -0
- package/packages/editor/server/get_sidebar.js +51 -0
- package/packages/editor/server/image_upload.js +32 -40
- package/packages/editor/server/preview.js +49 -19
- package/packages/editor/server/publicationStatus.js +27 -0
- package/packages/editor/server/publish.js +135 -0
- package/packages/editor/server/publishTargetCollector.js +19 -0
- package/packages/editor/server/save.js +97 -0
- package/packages/editor/server/sidebarStatusCollector.js +17 -0
- package/packages/editor/template/editor.html +28 -18
- package/src-sample/helper/add.js +1 -1
- package/src-sample/helper/index.js +6 -6
- package/docs/dictionary.md +0 -553
- package/packages/editor/server/editor.js +0 -43
|
@@ -3,7 +3,7 @@ import config from '@tenjuu99/blog/lib/config.js'
|
|
|
3
3
|
export const path = '/get_frontmatter_templates'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* @vocab: テンプレートレゾルバー
|
|
6
|
+
* @vocab: テンプレートレゾルバー
|
|
7
7
|
* @test: tests/editor/editor-frontmatter-template.test.js
|
|
8
8
|
*/
|
|
9
9
|
export const getTemplates = (cfg) => cfg.frontmatter_templates || []
|
|
@@ -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
|
+
}
|
|
@@ -3,6 +3,7 @@ import nodePath from 'node:path'
|
|
|
3
3
|
import { styleText } from 'node:util'
|
|
4
4
|
import config from '@tenjuu99/blog/lib/config.js'
|
|
5
5
|
import { createConverter } from './createConverter.js'
|
|
6
|
+
import { parseJsonBody } from '@tenjuu99/blog/lib/server/helper/parseRequestBody.js'
|
|
6
7
|
|
|
7
8
|
const rootDir = process.cwd()
|
|
8
9
|
const srcDir = nodePath.join(rootDir, config.src_dir)
|
|
@@ -14,53 +15,44 @@ const converterPromise = createConverter(config.image_converter ?? null)
|
|
|
14
15
|
const MAX_BODY_SIZE = 10 * 1024 * 1024 // 10MB
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
|
-
* @vocab: アップロードエンドポイント
|
|
18
|
+
* @vocab: アップロードエンドポイント
|
|
18
19
|
* @test: tests/editor/editor-image-upload.test.js
|
|
19
20
|
*/
|
|
20
21
|
export const post = async (req, res) => {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
})
|
|
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
|
+
}
|
|
57
49
|
return true
|
|
58
50
|
}
|
|
59
51
|
|
|
60
52
|
export { createConverter }
|
|
61
53
|
|
|
62
54
|
/**
|
|
63
|
-
* @vocab: パスリゾルバー
|
|
55
|
+
* @vocab: パスリゾルバー
|
|
64
56
|
* @test: tests/editor/editor-image-upload.test.js
|
|
65
57
|
* @param {string} mdFilePath
|
|
66
58
|
* @param {string} imageFilename
|
|
@@ -85,7 +77,7 @@ export function resolveImagePath(mdFilePath, imageFilename, outputExt = null) {
|
|
|
85
77
|
}
|
|
86
78
|
|
|
87
79
|
/**
|
|
88
|
-
* @vocab: ファイルライター
|
|
80
|
+
* @vocab: ファイルライター
|
|
89
81
|
* @test: tests/editor/editor-image-upload.test.js
|
|
90
82
|
* @param {string} saveSubPath - src/ 以下の相対パス
|
|
91
83
|
* @param {Buffer} data
|
|
@@ -102,7 +94,7 @@ export function writeImageFile(saveSubPath, data, baseDir = srcDir) {
|
|
|
102
94
|
}
|
|
103
95
|
|
|
104
96
|
/**
|
|
105
|
-
* @vocab: アップロードエンドポイント
|
|
97
|
+
* @vocab: アップロードエンドポイント
|
|
106
98
|
* @test: tests/editor/editor-image-upload.test.js
|
|
107
99
|
* @param {{ imageData: string, imageFilename: string, mdFile: string }} payload
|
|
108
100
|
* @param {{ converterFn?: Function, outputExt?: string|null, baseDir?: string }} [options]
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { extractImageReferences } from '../js/imageReferenceExtractor.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @vocab: 公開対象コレクター
|
|
5
|
+
* @test tests/editor/publish.test.js
|
|
6
|
+
* @param {string} filePath - pages ディレクトリからの相対パス(例: `post/hello.md`)
|
|
7
|
+
* @param {string} fileContent - Markdown 本文
|
|
8
|
+
* @param {string} [srcDir='src'] - ソースディレクトリ名
|
|
9
|
+
* @returns {{ markdownFile: string, imageFiles: string[] }}
|
|
10
|
+
*/
|
|
11
|
+
export function collectTarget(filePath, fileContent, srcDir = 'src') {
|
|
12
|
+
const imageUrls = extractImageReferences(fileContent)
|
|
13
|
+
return {
|
|
14
|
+
markdownFile: `${srcDir}/pages/${filePath}`,
|
|
15
|
+
imageFiles: imageUrls
|
|
16
|
+
.filter(url => !url.includes('..'))
|
|
17
|
+
.map(url => url.startsWith('/') ? `${srcDir}${url}` : `${srcDir}/${url}`)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { IncomingMessage, ServerResponse } from 'http'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import { styleText } from 'node:util'
|
|
4
|
+
import { watch, pageDir as cachePageDir } from '@tenjuu99/blog/lib/dir.js'
|
|
5
|
+
import { indexing } from '@tenjuu99/blog/lib/indexer.js'
|
|
6
|
+
import { parseJsonBody } from '@tenjuu99/blog/lib/server/helper/parseRequestBody.js'
|
|
7
|
+
|
|
8
|
+
export const path = '/save'
|
|
9
|
+
|
|
10
|
+
const MAX_BODY_SIZE = 1 * 1024 * 1024 // 1MB
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `..` や空セグメント等のパストラバーサル要素を含む場合はエラーメッセージを返す。
|
|
14
|
+
* @param {string} filename
|
|
15
|
+
* @returns {string|null} エラーメッセージ、問題なければ null
|
|
16
|
+
*/
|
|
17
|
+
function validateFilename(filename) {
|
|
18
|
+
if (!filename || typeof filename !== 'string') return 'ファイル名がありません'
|
|
19
|
+
const parts = filename.split('/')
|
|
20
|
+
for (const part of parts) {
|
|
21
|
+
if (part === '' || part === '.' || part === '..') return '不正なパスが含まれています'
|
|
22
|
+
if (part.includes('\\')) return '不正なパスが含まれています'
|
|
23
|
+
}
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @vocab 保存エンドポイント
|
|
29
|
+
* @test tests/editor/editor-ui-cleanup.test.js
|
|
30
|
+
* @param {string} filename
|
|
31
|
+
* @param {string} content
|
|
32
|
+
* @param {string} pageDir
|
|
33
|
+
* @param {{ createOnly?: boolean }} [options]
|
|
34
|
+
* @returns {Promise<{success: true}|{success: false, error: string, code?: string}>}
|
|
35
|
+
*/
|
|
36
|
+
export async function saveFile(filename, content, pageDir, options = {}) {
|
|
37
|
+
const validationError = validateFilename(filename)
|
|
38
|
+
if (validationError) {
|
|
39
|
+
return { success: false, error: validationError, code: 'INVALID_FILENAME' }
|
|
40
|
+
}
|
|
41
|
+
const parts = filename.split('/')
|
|
42
|
+
const basename = parts.pop()
|
|
43
|
+
const dir = [pageDir, ...parts].join('/')
|
|
44
|
+
const filepath = `${dir}/${basename}`
|
|
45
|
+
if (options.createOnly && fs.existsSync(filepath)) {
|
|
46
|
+
return { success: false, error: 'ファイルが既に存在します', code: 'FILE_EXISTS' }
|
|
47
|
+
}
|
|
48
|
+
if (!fs.existsSync(dir)) {
|
|
49
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
50
|
+
}
|
|
51
|
+
fs.writeFileSync(filepath, content)
|
|
52
|
+
console.log(styleText('blue', `[save] ${filename}`))
|
|
53
|
+
return { success: true }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {IncomingMessage} req
|
|
58
|
+
* @param {ServerResponse} res
|
|
59
|
+
*/
|
|
60
|
+
export const post = async (req, res) => {
|
|
61
|
+
let json
|
|
62
|
+
try {
|
|
63
|
+
json = await parseJsonBody(req, { maxSize: MAX_BODY_SIZE })
|
|
64
|
+
} catch (e) {
|
|
65
|
+
const status = e.code === 'PAYLOAD_TOO_LARGE' ? 413 : 400
|
|
66
|
+
res.writeHead(status, { 'content-type': 'application/json' })
|
|
67
|
+
res.end(JSON.stringify({ error: e.message }))
|
|
68
|
+
return true
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!json.filename) {
|
|
72
|
+
res.writeHead(400, { 'content-type': 'application/json' })
|
|
73
|
+
res.end(JSON.stringify({ error: 'ファイル名がありません' }))
|
|
74
|
+
return true
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const result = await saveFile(json.filename, json.content ?? '', watch.pageDir, { createOnly: !!json.createOnly })
|
|
78
|
+
if (!result.success) {
|
|
79
|
+
const status = result.code === 'FILE_EXISTS' ? 409 : 400
|
|
80
|
+
res.writeHead(status, { 'content-type': 'application/json' })
|
|
81
|
+
res.end(JSON.stringify(result))
|
|
82
|
+
return true
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (json.createOnly) {
|
|
86
|
+
// .cache/pages/ にも書き込んで indexing() が新規ファイルを認識できるようにする
|
|
87
|
+
const cacheParts = json.filename.split('/')
|
|
88
|
+
const cacheBasename = cacheParts.pop()
|
|
89
|
+
const cacheDirPath = [cachePageDir, ...cacheParts].join('/')
|
|
90
|
+
if (!fs.existsSync(cacheDirPath)) fs.mkdirSync(cacheDirPath, { recursive: true })
|
|
91
|
+
fs.writeFileSync(`${cacheDirPath}/${cacheBasename}`, json.content ?? '')
|
|
92
|
+
await indexing()
|
|
93
|
+
}
|
|
94
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
95
|
+
res.end(JSON.stringify({ success: true }))
|
|
96
|
+
return true
|
|
97
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { getPublicationStatus } from './publicationStatus.js'
|
|
2
|
+
/**
|
|
3
|
+
* @vocab ファイルステータスコレクター
|
|
4
|
+
* @test tests/editor/editor-sidebar-status.test.js
|
|
5
|
+
* @param {Array<{treePath: string, gitPath: string}>} fileMappings
|
|
6
|
+
* @param {import('./publicationStatus.js').PublishedState} publishedState
|
|
7
|
+
* @returns {Promise<Object.<string, 'new'|'modified'|'published'|'unknown'>>}
|
|
8
|
+
*/
|
|
9
|
+
export async function collectStatuses(fileMappings, publishedState) {
|
|
10
|
+
const entries = await Promise.all(
|
|
11
|
+
fileMappings.map(async ({ treePath, gitPath }) => {
|
|
12
|
+
const status = await getPublicationStatus(gitPath, publishedState)
|
|
13
|
+
return [treePath, status]
|
|
14
|
+
})
|
|
15
|
+
)
|
|
16
|
+
return Object.fromEntries(entries)
|
|
17
|
+
}
|
|
@@ -5,34 +5,24 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
<title>{{SITE_NAME}}</title>
|
|
7
7
|
<link rel="stylesheet" href="${/css/editor.css<<reset.css,editor.css}">
|
|
8
|
-
<script src="/js/editor.js"
|
|
8
|
+
<script type="module" src="/js/editor.js"></script>
|
|
9
9
|
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<main class="container">
|
|
13
13
|
<div class="sidebar">
|
|
14
|
-
<script type="ssg">
|
|
15
|
-
return helper.renderSidebarTree(helper.readIndex())
|
|
16
|
-
</script>
|
|
17
14
|
<div class="sidebar-toggle"> </div>
|
|
18
15
|
</div>
|
|
19
16
|
<form action="/editor" class="editor" method="post" id="editor">
|
|
20
17
|
<div class="editor-options">
|
|
21
|
-
<div>
|
|
22
|
-
<
|
|
23
|
-
<
|
|
24
|
-
|
|
25
|
-
<script type="ssg">
|
|
26
|
-
return helper.readIndex().map(p => {
|
|
27
|
-
return `<option value="${p.name}.${p.__filetype}">${p.url}.${p.__filetype}</option>`
|
|
28
|
-
}).join("\n")
|
|
29
|
-
</script>
|
|
30
|
-
</select>
|
|
18
|
+
<div class="editor-options-left">
|
|
19
|
+
<span id="fileStatus"><span id="currentFileName"></span><span id="publicationStatus"></span></span>
|
|
20
|
+
<button type="button" id="newFileBtn">新規作成</button>
|
|
21
|
+
<input type="hidden" id="inputFileName" name="inputFileName" value="">
|
|
31
22
|
</div>
|
|
32
|
-
<div>
|
|
33
|
-
<input type="submit" value="
|
|
34
|
-
<
|
|
35
|
-
<a href="/">戻る</a>
|
|
23
|
+
<div class="editor-options-right">
|
|
24
|
+
<input type="submit" value="publish" data-url="/publish" id="publishBtn">
|
|
25
|
+
<span id="publishFeedback"></span>
|
|
36
26
|
</div>
|
|
37
27
|
</div>
|
|
38
28
|
<div class="textareaAndPreview">
|
|
@@ -45,6 +35,26 @@
|
|
|
45
35
|
</div>
|
|
46
36
|
<input type="hidden" name="token" value="{{TOKEN}}">
|
|
47
37
|
</form>
|
|
38
|
+
<dialog id="newFileDialog">
|
|
39
|
+
<form method="dialog">
|
|
40
|
+
<h3>新規ファイルを作成</h3>
|
|
41
|
+
<label>
|
|
42
|
+
ファイル名(例: book/my-book.md)
|
|
43
|
+
<input type="text" id="newFileName" placeholder="path/to/file.md" autocomplete="off">
|
|
44
|
+
</label>
|
|
45
|
+
<label>
|
|
46
|
+
テンプレート
|
|
47
|
+
<select id="newFileTemplate">
|
|
48
|
+
<option value="">テンプレートなし</option>
|
|
49
|
+
</select>
|
|
50
|
+
</label>
|
|
51
|
+
<p id="newFileError" role="alert" aria-live="assertive"></p>
|
|
52
|
+
<div>
|
|
53
|
+
<button type="button" id="confirmNewFile">作成</button>
|
|
54
|
+
<button type="button" id="cancelNewFile">キャンセル</button>
|
|
55
|
+
</div>
|
|
56
|
+
</form>
|
|
57
|
+
</dialog>
|
|
48
58
|
<div class="hamburger-menu">
|
|
49
59
|
<input type="checkbox" id="menu-btn-check">
|
|
50
60
|
<label for="menu-btn-check" class="menu-btn"><span></span></label>
|
package/src-sample/helper/add.js
CHANGED