@tenjuu99/blog 0.3.5 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @vocab: Markdown挿入器 (docs/dictionary.md#Markdown挿入器)
3
+ * @test: tests/editor/editor-image-upload.test.js
4
+ * @param {string} content
5
+ * @param {number} cursorPos
6
+ * @param {string} markdownUrl
7
+ * @returns {string}
8
+ */
9
+ export function insertImageMarkdown(content, cursorPos, markdownUrl) {
10
+ const imageRef = `![](${markdownUrl})`
11
+ return content.slice(0, cursorPos) + imageRef + content.slice(cursorPos)
12
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * @typedef {{ dirs: Object.<string, TreeNode>, files: Array<{path: string, label: string}> }} TreeNode
3
+ */
4
+
5
+ /**
6
+ * @param {Array<{name: string, __filetype: string, url: string}>} files
7
+ * @returns {TreeNode}
8
+ */
9
+ // @vocab: ツリービルダー (docs/dictionary.md#ツリービルダー)
10
+ // @vocab: ファイルリスト (docs/dictionary.md#ファイルリスト)
11
+ // @vocab: ディレクトリツリー (docs/dictionary.md#ディレクトリツリー)
12
+ // @test: tests/editor/editor-sidebar.test.js
13
+ export function buildTree(files) {
14
+ const root = { dirs: {}, files: [] }
15
+
16
+ for (const file of files) {
17
+ const parts = file.name.split('/')
18
+ const fileName = parts.pop()
19
+
20
+ let node = root
21
+ for (const dir of parts) {
22
+ if (!node.dirs[dir]) {
23
+ node.dirs[dir] = { dirs: {}, files: [] }
24
+ }
25
+ node = node.dirs[dir]
26
+ }
27
+ node.files.push({
28
+ path: `${file.name}.${file.__filetype}`,
29
+ label: `${fileName}.${file.__filetype}`,
30
+ })
31
+ }
32
+
33
+ return root
34
+ }
35
+
36
+ /**
37
+ * @param {TreeNode} tree
38
+ * @param {string} [activeFile]
39
+ * @param {string} [_dirPath]
40
+ * @returns {string}
41
+ */
42
+ // @vocab: ツリーレンダラー (docs/dictionary.md#ツリーレンダラー)
43
+ // @vocab: ディレクトリツリー (docs/dictionary.md#ディレクトリツリー)
44
+ // @vocab: ディレクトリノード (docs/dictionary.md#ディレクトリノード)
45
+ // @vocab: ファイルノード (docs/dictionary.md#ファイルノード)
46
+ // @vocab: アクティブファイル (docs/dictionary.md#アクティブファイル)
47
+ // @test: tests/editor/editor-sidebar.test.js
48
+ export function renderTreeHtml(tree, activeFile = '', _dirPath = '') {
49
+ let html = '<ul>'
50
+
51
+ for (const [dirName, subtree] of Object.entries(tree.dirs)) {
52
+ const dirPath = _dirPath ? `${_dirPath}/${dirName}` : dirName
53
+ html += `<li class="dir-node"><details data-dir="${dirPath}"><summary>${dirName}</summary>`
54
+ html += renderTreeHtml(subtree, activeFile, dirPath)
55
+ html += `</details></li>`
56
+ }
57
+
58
+ for (const file of tree.files) {
59
+ const isActive = file.path === activeFile
60
+ const activeAttr = isActive ? ' class="active"' : ''
61
+ html += `<li><a href="/editor?md=${file.path}"${activeAttr}>${file.label}</a></li>`
62
+ }
63
+
64
+ html += '</ul>'
65
+ return html
66
+ }
@@ -0,0 +1,14 @@
1
+ import sharp from 'sharp'
2
+
3
+ export const ext = 'webp'
4
+
5
+ /**
6
+ * @param {Buffer} buffer
7
+ * @returns {Promise<Buffer>}
8
+ */
9
+ export default async function convert(buffer) {
10
+ return await sharp(buffer)
11
+ .resize({ width: 1200, withoutEnlargement: true })
12
+ .webp()
13
+ .toBuffer()
14
+ }
@@ -0,0 +1,26 @@
1
+ import nodePath from 'node:path'
2
+
3
+ const rootDir = process.cwd()
4
+
5
+ /**
6
+ * @vocab: コンバーターファクトリー (docs/dictionary.md#コンバーターファクトリー)
7
+ * @vocab: 画像コンバーター (docs/dictionary.md#画像コンバーター)
8
+ * @test: tests/editor/editor-image-upload.test.js
9
+ * @param {Function|string|null} converterOrName - 関数、ユーザーパス(./で始まる)、またはビルトイン名
10
+ * @returns {Promise<{ fn: Function, ext: string|null }>}
11
+ */
12
+ export async function createConverter(converterOrName = null) {
13
+ if (!converterOrName) return { fn: (buffer) => buffer, ext: null }
14
+ if (typeof converterOrName === 'function') return { fn: converterOrName, ext: null }
15
+ try {
16
+ let module
17
+ if (converterOrName.startsWith('.') || nodePath.isAbsolute(converterOrName)) {
18
+ module = await import(nodePath.resolve(rootDir, converterOrName))
19
+ } else {
20
+ module = await import(`./converters/${converterOrName}.js`)
21
+ }
22
+ return { fn: module.default, ext: module.ext ?? null }
23
+ } catch {
24
+ return { fn: (buffer) => buffer, ext: null }
25
+ }
26
+ }
@@ -0,0 +1,22 @@
1
+ import config from '@tenjuu99/blog/lib/config.js'
2
+
3
+ export const path = '/get_frontmatter_templates'
4
+
5
+ /**
6
+ * @vocab: テンプレートレゾルバー (docs/dictionary.md#テンプレートレゾルバー)
7
+ * @test: tests/editor/editor-frontmatter-template.test.js
8
+ */
9
+ export const getTemplates = (cfg) => cfg.frontmatter_templates || []
10
+
11
+ /**
12
+ * @param {import('http').IncomingMessage} req
13
+ * @param {import('http').ServerResponse} res
14
+ */
15
+ export const get = async (req, res) => {
16
+ const templates = getTemplates(config)
17
+ return {
18
+ status: 200,
19
+ contentType: 'application/json',
20
+ body: JSON.stringify({ templates }),
21
+ }
22
+ }
@@ -0,0 +1,130 @@
1
+ import fs from 'node:fs'
2
+ import nodePath from 'node:path'
3
+ import { styleText } from 'node:util'
4
+ import config from '@tenjuu99/blog/lib/config.js'
5
+ import { createConverter } from './createConverter.js'
6
+
7
+ const rootDir = process.cwd()
8
+ const srcDir = nodePath.join(rootDir, config.src_dir)
9
+
10
+ export const path = '/upload-image'
11
+
12
+ const converterPromise = createConverter(config.image_converter ?? null)
13
+
14
+ const MAX_BODY_SIZE = 10 * 1024 * 1024 // 10MB
15
+
16
+ /**
17
+ * @vocab: アップロードエンドポイント (docs/dictionary.md#アップロードエンドポイント)
18
+ * @test: tests/editor/editor-image-upload.test.js
19
+ */
20
+ export const post = async (req, res) => {
21
+ const chunks = []
22
+ let totalSize = 0
23
+ let aborted = false
24
+ req
25
+ .on('data', chunk => {
26
+ if (aborted) return
27
+ totalSize += chunk.length
28
+ if (totalSize > MAX_BODY_SIZE) {
29
+ aborted = true
30
+ res.writeHead(413, { 'content-type': 'application/json' })
31
+ res.end(JSON.stringify({ message: 'ファイルサイズが上限を超えています' }))
32
+ req.destroy()
33
+ return
34
+ }
35
+ chunks.push(chunk)
36
+ })
37
+ .on('end', async () => {
38
+ if (aborted) return
39
+ try {
40
+ const { imageData, imageFilename, mdFile } = JSON.parse(chunks.join(''))
41
+ if (!imageData || !imageFilename || !mdFile) {
42
+ res.writeHead(400, { 'content-type': 'application/json' })
43
+ res.end(JSON.stringify({ message: '必須パラメーターが不足しています' }))
44
+ return
45
+ }
46
+ const { fn, ext } = await converterPromise
47
+ const result = await handleImageUpload({ imageData, imageFilename, mdFile }, { converterFn: fn, outputExt: ext })
48
+ console.log(styleText('blue', '[upload-image] finished'))
49
+ res.writeHead(200, { 'content-type': 'application/json' })
50
+ res.end(JSON.stringify(result))
51
+ } catch (e) {
52
+ console.error(e)
53
+ res.writeHead(500, { 'content-type': 'application/json' })
54
+ res.end(JSON.stringify({ message: '画像のアップロードに失敗しました' }))
55
+ }
56
+ })
57
+ return true
58
+ }
59
+
60
+ export { createConverter }
61
+
62
+ /**
63
+ * @vocab: パスリゾルバー (docs/dictionary.md#パスリゾルバー)
64
+ * @test: tests/editor/editor-image-upload.test.js
65
+ * @param {string} mdFilePath
66
+ * @param {string} imageFilename
67
+ * @param {string|null} [outputExt] - 出力拡張子(null のとき元の拡張子を保持)
68
+ * @returns {{ saveSubPath: string, markdownUrl: string }}
69
+ */
70
+ export function resolveImagePath(mdFilePath, imageFilename, outputExt = null) {
71
+ const normalizedMd = nodePath.normalize(mdFilePath)
72
+ if (nodePath.isAbsolute(normalizedMd) || normalizedMd.startsWith('..')) {
73
+ throw new Error(`不正な mdFilePath: ${mdFilePath}`)
74
+ }
75
+ const safeFilename = nodePath.basename(imageFilename)
76
+ const mdSlug = normalizedMd.replace(/\.[^.]+$/, '')
77
+ const imgBasename = safeFilename.replace(/\.[^.]+$/, '')
78
+ const originalExt = safeFilename.match(/\.[^.]+$/)?.[0] ?? ''
79
+ const finalExt = outputExt != null ? `.${outputExt}` : originalExt
80
+ const imageSubPath = `image/${mdSlug}/${imgBasename}${finalExt}`
81
+ return {
82
+ saveSubPath: imageSubPath,
83
+ markdownUrl: `/${imageSubPath}`
84
+ }
85
+ }
86
+
87
+ /**
88
+ * @vocab: ファイルライター (docs/dictionary.md#ファイルライター)
89
+ * @test: tests/editor/editor-image-upload.test.js
90
+ * @param {string} saveSubPath - src/ 以下の相対パス
91
+ * @param {Buffer} data
92
+ * @param {string} [baseDir]
93
+ */
94
+ export function writeImageFile(saveSubPath, data, baseDir = srcDir) {
95
+ const resolvedBase = nodePath.resolve(baseDir)
96
+ const fullPath = nodePath.resolve(baseDir, saveSubPath)
97
+ if (!fullPath.startsWith(resolvedBase + nodePath.sep)) {
98
+ throw new Error(`保存先パスが許可ディレクトリ外です: ${saveSubPath}`)
99
+ }
100
+ fs.mkdirSync(nodePath.dirname(fullPath), { recursive: true })
101
+ fs.writeFileSync(fullPath, data)
102
+ }
103
+
104
+ /**
105
+ * @vocab: アップロードエンドポイント (docs/dictionary.md#アップロードエンドポイント)
106
+ * @test: tests/editor/editor-image-upload.test.js
107
+ * @param {{ imageData: string, imageFilename: string, mdFile: string }} payload
108
+ * @param {{ converterFn?: Function, outputExt?: string|null, baseDir?: string }} [options]
109
+ * @returns {Promise<{ markdownUrl: string }>}
110
+ */
111
+ export async function handleImageUpload(payload, options = {}) {
112
+ const { imageData, imageFilename, mdFile } = payload
113
+ const baseDir = options.baseDir ?? srcDir
114
+
115
+ let converterFn, outputExt
116
+ if ('converterFn' in options) {
117
+ converterFn = options.converterFn
118
+ outputExt = options.outputExt ?? null
119
+ } else {
120
+ const created = await createConverter(config.image_converter ?? null)
121
+ converterFn = created.fn
122
+ outputExt = created.ext
123
+ }
124
+
125
+ const buffer = Buffer.from(imageData, 'base64')
126
+ const { saveSubPath, markdownUrl } = resolveImagePath(mdFile, imageFilename, outputExt)
127
+ const converted = await converterFn(buffer)
128
+ writeImageFile(saveSubPath, converted, baseDir)
129
+ return { markdownUrl }
130
+ }
@@ -11,13 +11,9 @@
11
11
  <body>
12
12
  <main class="container">
13
13
  <div class="sidebar">
14
- <ul>
15
- <script type="ssg">
16
- return helper.readIndex().map(p => {
17
- return `<li><a href="/editor?md=${p.name}.${p.__filetype}">${p.url}.${p.__filetype}</a></li>`
18
- }).join("\n")
19
- </script>
20
- </ul>
14
+ <script type="ssg">
15
+ return helper.renderSidebarTree(helper.readIndex())
16
+ </script>
21
17
  <div class="sidebar-toggle">&nbsp;</div>
22
18
  </div>
23
19
  <form action="/editor" class="editor" method="post" id="editor">
@@ -0,0 +1,20 @@
1
+ /**
2
+ * WebP変換コンバーターのサンプル実装。
3
+ * sharp を使って画像を WebP に変換する。
4
+ *
5
+ * 使い方:
6
+ * 1. sharp をインストール: npm install sharp
7
+ * 2. blog.json に設定: { "image_converter": "./converters/webp.js" }
8
+ * (ビルトインを使う場合は "sharp" のみ指定)
9
+ */
10
+
11
+ import sharp from 'sharp'
12
+
13
+ export const ext = 'webp'
14
+
15
+ export default async function convert(buffer) {
16
+ return await sharp(buffer)
17
+ .resize({ width: 1200, withoutEnlargement: true })
18
+ .webp()
19
+ .toBuffer()
20
+ }
@@ -1,3 +1,5 @@
1
+ // @vocab: ヘルパー関数 (docs/dictionary.md#ヘルパー関数)
2
+ // @test: tests/ssg-core/helper.test.js
1
3
  export function additionalHelper() {
2
4
  return 'これは追加ヘルパーによって出力されているメッセージです。'
3
5
  }
@@ -1,5 +1,8 @@
1
1
  import { allData, config } from '@tenjuu99/blog'
2
2
 
3
+ // @vocab: ページインデックス (docs/dictionary.md#ページインデックス)
4
+ // @vocab: ページ (docs/dictionary.md#ページ)
5
+ // @test: tests/ssg-core/indexer.test.js
3
6
  export function readIndex (filter = null) {
4
7
  const data = Object.entries(allData)
5
8
  .sort((a, b) => new Date(b[1].published) - new Date(a[1].published))
@@ -13,6 +16,9 @@ export function dateFormat(dateString) {
13
16
  return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
14
17
  }
15
18
 
19
+ // @vocab: ページ (docs/dictionary.md#ページ)
20
+ // @vocab: ページインデックス (docs/dictionary.md#ページインデックス)
21
+ // @test: tests/ssg-core/pageData.test.js
16
22
  export function getPageData(name) {
17
23
  return allData[name]
18
24
  }
@@ -41,6 +47,9 @@ export function arrayToList(arrayOrText) {
41
47
  return arrayOrText
42
48
  }
43
49
 
50
+ // @vocab: ページインデックス (docs/dictionary.md#ページインデックス)
51
+ // @vocab: ページ (docs/dictionary.md#ページ)
52
+ // @test: tests/ssg-core/indexer.test.js
44
53
  export function renderIndex(pages, nodate = 'nodate', headingTag = 'h3') {
45
54
  if (!pages) {
46
55
  pages = readIndex()
Binary file