@tenjuu99/blog 0.3.6 → 0.4.1

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.
Files changed (49) hide show
  1. package/bin/dev-server +3 -1
  2. package/bin/server +8 -1
  3. package/docs/develop.md +30 -0
  4. package/docs/dictionary.json +1806 -0
  5. package/lib/dir.js +2 -2
  6. package/lib/distribute.js +5 -5
  7. package/lib/files.js +1 -1
  8. package/lib/filter.js +5 -5
  9. package/lib/generate.js +3 -3
  10. package/lib/helper.js +2 -2
  11. package/lib/imageDistributor.js +3 -3
  12. package/lib/indexer.js +2 -2
  13. package/lib/pageData.js +5 -5
  14. package/lib/render.js +4 -4
  15. package/lib/replaceVariablesFilter.js +4 -4
  16. package/lib/server/helper/parseRequestBody.js +46 -0
  17. package/lib/server.js +7 -3
  18. package/package.json +3 -2
  19. package/packages/category/helper/category.js +10 -10
  20. package/packages/category/helper/categoryIndexer.js +8 -8
  21. package/packages/category/helper/pagination.js +3 -3
  22. package/packages/editor/css/editor.css +87 -18
  23. package/packages/editor/helper/sidebarTree.js +7 -2
  24. package/packages/editor/js/autoPreviewInitializer.js +11 -0
  25. package/packages/editor/js/autoSaveInitializer.js +15 -0
  26. package/packages/editor/js/debouncer.js +21 -0
  27. package/packages/editor/js/editor.js +261 -91
  28. package/packages/editor/js/frontmatter_template.js +3 -3
  29. package/packages/editor/js/imageReferenceExtractor.js +19 -0
  30. package/packages/editor/js/image_upload.js +1 -1
  31. package/packages/editor/js/tree.js +27 -12
  32. package/packages/editor/server/changeReflector.js +47 -0
  33. package/packages/editor/server/createConverter.js +2 -2
  34. package/packages/editor/server/get_editor_target.js +2 -2
  35. package/packages/editor/server/get_frontmatter_templates.js +1 -1
  36. package/packages/editor/server/get_publication_status.js +29 -0
  37. package/packages/editor/server/get_sidebar.js +51 -0
  38. package/packages/editor/server/image_upload.js +32 -40
  39. package/packages/editor/server/preview.js +49 -19
  40. package/packages/editor/server/publicationStatus.js +27 -0
  41. package/packages/editor/server/publish.js +135 -0
  42. package/packages/editor/server/publishTargetCollector.js +19 -0
  43. package/packages/editor/server/save.js +97 -0
  44. package/packages/editor/server/sidebarStatusCollector.js +17 -0
  45. package/packages/editor/template/editor.html +28 -18
  46. package/src-sample/helper/add.js +1 -1
  47. package/src-sample/helper/index.js +6 -6
  48. package/docs/dictionary.md +0 -553
  49. 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: テンプレートレゾルバー (docs/dictionary.md#テンプレートレゾルバー)
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: アップロードエンドポイント (docs/dictionary.md#アップロードエンドポイント)
18
+ * @vocab: アップロードエンドポイント
18
19
  * @test: tests/editor/editor-image-upload.test.js
19
20
  */
20
21
  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
- })
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: パスリゾルバー (docs/dictionary.md#パスリゾルバー)
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: ファイルライター (docs/dictionary.md#ファイルライター)
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: アップロードエンドポイント (docs/dictionary.md#アップロードエンドポイント)
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
- const chunks = []
14
- req
15
- .on('data', (chunk) => chunks.push(chunk))
16
- .on('end', async () => {
17
- const json = JSON.parse(chunks.join())
18
- const filename = json.inputFileName ? json.inputFileName : json.selectDataFile
19
- if (!filename) {
20
- res.writeHead(400, { 'content-type': 'application/json' })
21
- return res.end(JSON.stringify({
22
- message: 'filename is requried.'
23
- }))
24
- }
25
- const pageData = makePageData(filename, json.content)
26
- const rendered = await render(pageData.template, pageData)
27
- res.writeHead(200, { 'content-type': 'application/json' })
28
- res.end(JSON.stringify({
29
- 'preview': rendered
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" defer></script>
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">&nbsp;</div>
18
15
  </div>
19
16
  <form action="/editor" class="editor" method="post" id="editor">
20
17
  <div class="editor-options">
21
- <div>
22
- <input id="inputFileName" name="inputFileName" type="text" value="" placeholder="sample.md">
23
- <select id="selectDataFile" name="selectDataFile">
24
- <option value="">新規作成</option>
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="preview" data-url="/preview">
34
- <input type="submit" value="save" data-url="/editor">
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>
@@ -1,4 +1,4 @@
1
- // @vocab: ヘルパー関数 (docs/dictionary.md#ヘルパー関数)
1
+ // @vocab: ヘルパー関数
2
2
  // @test: tests/ssg-core/helper.test.js
3
3
  export function additionalHelper() {
4
4
  return 'これは追加ヘルパーによって出力されているメッセージです。'