@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.
Files changed (53) hide show
  1. package/bin/dev-server +3 -1
  2. package/bin/server +8 -1
  3. package/docs/develop.md +36 -6
  4. package/docs/dictionary.json +1806 -0
  5. package/lib/dir.js +3 -0
  6. package/lib/distribute.js +19 -5
  7. package/lib/files.js +2 -0
  8. package/lib/filter.js +7 -0
  9. package/lib/generate.js +5 -0
  10. package/lib/helper.js +3 -0
  11. package/lib/imageDistributor.js +59 -0
  12. package/lib/indexer.js +3 -0
  13. package/lib/pageData.js +7 -0
  14. package/lib/render.js +5 -0
  15. package/lib/replaceVariablesFilter.js +5 -0
  16. package/lib/server/helper/parseRequestBody.js +46 -0
  17. package/lib/server.js +7 -3
  18. package/lib/tryServer.js +3 -1
  19. package/package.json +8 -5
  20. package/packages/breadcrumbs/helper/breadcrumbs.js +3 -0
  21. package/packages/category/helper/category.js +13 -0
  22. package/packages/category/helper/categoryIndexer.js +12 -0
  23. package/packages/category/helper/pagination.js +5 -0
  24. package/packages/editor/css/editor.css +98 -10
  25. package/packages/editor/helper/sidebarTree.js +11 -0
  26. package/packages/editor/js/autoPreviewInitializer.js +11 -0
  27. package/packages/editor/js/autoSaveInitializer.js +15 -0
  28. package/packages/editor/js/debouncer.js +21 -0
  29. package/packages/editor/js/editor.js +370 -45
  30. package/packages/editor/js/frontmatter_template.js +67 -0
  31. package/packages/editor/js/imageReferenceExtractor.js +19 -0
  32. package/packages/editor/js/image_upload.js +12 -0
  33. package/packages/editor/js/tree.js +81 -0
  34. package/packages/editor/server/changeReflector.js +47 -0
  35. package/packages/editor/server/converters/sharp.js +14 -0
  36. package/packages/editor/server/createConverter.js +26 -0
  37. package/packages/editor/server/get_editor_target.js +2 -2
  38. package/packages/editor/server/get_frontmatter_templates.js +22 -0
  39. package/packages/editor/server/get_publication_status.js +29 -0
  40. package/packages/editor/server/get_sidebar.js +51 -0
  41. package/packages/editor/server/image_upload.js +122 -0
  42. package/packages/editor/server/preview.js +49 -19
  43. package/packages/editor/server/publicationStatus.js +27 -0
  44. package/packages/editor/server/publish.js +135 -0
  45. package/packages/editor/server/publishTargetCollector.js +19 -0
  46. package/packages/editor/server/save.js +97 -0
  47. package/packages/editor/server/sidebarStatusCollector.js +17 -0
  48. package/packages/editor/template/editor.html +28 -22
  49. package/src-sample/converters/webp.js +20 -0
  50. package/src-sample/helper/add.js +2 -0
  51. package/src-sample/helper/index.js +9 -0
  52. package/src-sample/image/test.jpg +0 -0
  53. 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, '&amp;')
43
+ .replace(/</g, '&lt;')
44
+ .replace(/>/g, '&gt;')
45
+ .replace(/"/g, '&quot;')
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 { pageDir } from '@tenjuu99/blog/lib/dir.js'
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
- 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
+ }