@tenjuu99/blog 0.3.5 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/dev-server +3 -1
- package/bin/server +8 -1
- package/docs/develop.md +36 -6
- package/docs/dictionary.json +1806 -0
- package/lib/dir.js +3 -0
- package/lib/distribute.js +19 -5
- package/lib/files.js +2 -0
- package/lib/filter.js +7 -0
- package/lib/generate.js +5 -0
- package/lib/helper.js +3 -0
- package/lib/imageDistributor.js +59 -0
- package/lib/indexer.js +3 -0
- package/lib/pageData.js +7 -0
- package/lib/render.js +5 -0
- package/lib/replaceVariablesFilter.js +5 -0
- package/lib/server/helper/parseRequestBody.js +46 -0
- package/lib/server.js +7 -3
- package/lib/tryServer.js +3 -1
- package/package.json +8 -5
- package/packages/breadcrumbs/helper/breadcrumbs.js +3 -0
- package/packages/category/helper/category.js +13 -0
- package/packages/category/helper/categoryIndexer.js +12 -0
- package/packages/category/helper/pagination.js +5 -0
- package/packages/editor/css/editor.css +98 -10
- package/packages/editor/helper/sidebarTree.js +11 -0
- package/packages/editor/js/autoPreviewInitializer.js +11 -0
- package/packages/editor/js/autoSaveInitializer.js +15 -0
- package/packages/editor/js/debouncer.js +21 -0
- package/packages/editor/js/editor.js +370 -45
- package/packages/editor/js/frontmatter_template.js +67 -0
- package/packages/editor/js/imageReferenceExtractor.js +19 -0
- package/packages/editor/js/image_upload.js +12 -0
- package/packages/editor/js/tree.js +81 -0
- package/packages/editor/server/changeReflector.js +47 -0
- package/packages/editor/server/converters/sharp.js +14 -0
- package/packages/editor/server/createConverter.js +26 -0
- package/packages/editor/server/get_editor_target.js +2 -2
- package/packages/editor/server/get_frontmatter_templates.js +22 -0
- package/packages/editor/server/get_publication_status.js +29 -0
- package/packages/editor/server/get_sidebar.js +51 -0
- package/packages/editor/server/image_upload.js +122 -0
- package/packages/editor/server/preview.js +49 -19
- package/packages/editor/server/publicationStatus.js +27 -0
- package/packages/editor/server/publish.js +135 -0
- package/packages/editor/server/publishTargetCollector.js +19 -0
- package/packages/editor/server/save.js +97 -0
- package/packages/editor/server/sidebarStatusCollector.js +17 -0
- package/packages/editor/template/editor.html +28 -22
- package/src-sample/converters/webp.js +20 -0
- package/src-sample/helper/add.js +2 -0
- package/src-sample/helper/index.js +9 -0
- package/src-sample/image/test.jpg +0 -0
- package/packages/editor/server/editor.js +0 -43
package/lib/dir.js
CHANGED
package/lib/distribute.js
CHANGED
|
@@ -8,14 +8,22 @@ import { styleText } from 'node:util'
|
|
|
8
8
|
import config from './config.js'
|
|
9
9
|
import { cacheDir } from './dir.js'
|
|
10
10
|
import { applyTemplate } from './applyTemplate.js'
|
|
11
|
+
import { distributeImages } from './imageDistributor.js'
|
|
12
|
+
import { createConverter } from '../packages/editor/server/createConverter.js'
|
|
11
13
|
|
|
12
14
|
const indexFile = `${cacheDir}/index.json`
|
|
13
15
|
|
|
16
|
+
// @vocab: ページ
|
|
17
|
+
// @vocab: テンプレート
|
|
14
18
|
const renderPage = async (page) => {
|
|
15
19
|
const template = page.template
|
|
16
20
|
return [page.name, await render(template, page)]
|
|
17
21
|
}
|
|
18
22
|
|
|
23
|
+
// @vocab: ビルド
|
|
24
|
+
// @vocab: ページ
|
|
25
|
+
// @vocab: ページインデックス
|
|
26
|
+
// @test: tests/ssg-core/hooks.test.js
|
|
19
27
|
const distribute = async (data, srcDir, distDir) => {
|
|
20
28
|
const promises = []
|
|
21
29
|
const newIndex = []
|
|
@@ -36,13 +44,19 @@ const distribute = async (data, srcDir, distDir) => {
|
|
|
36
44
|
})
|
|
37
45
|
}
|
|
38
46
|
const distributeRaw = config.distribute_raw.split(',')
|
|
39
|
-
|
|
47
|
+
const imageConverter = config.image_converter ? await createConverter(config.image_converter) : null
|
|
48
|
+
for (const copyDir of distributeRaw) {
|
|
40
49
|
if (!existsSync(`${cacheDir}/${copyDir}`)) {
|
|
41
|
-
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
if (imageConverter) {
|
|
53
|
+
await distributeImages(`${cacheDir}/${copyDir}/`, `${distDir}/${copyDir}/`, imageConverter)
|
|
54
|
+
console.log(styleText('green', '[convert]'), `${cacheDir}/${copyDir}/ => ${distDir}/${copyDir}/`)
|
|
55
|
+
} else {
|
|
56
|
+
cpSync(`${cacheDir}/${copyDir}/`, `${distDir}/${copyDir}/`, { recursive: true, force: true })
|
|
57
|
+
console.log(styleText('green', '[copy]'), `${cacheDir}/${copyDir}/ => ${distDir}/${copyDir}/`)
|
|
42
58
|
}
|
|
43
|
-
|
|
44
|
-
console.log(styleText('green', '[copy]'), `${cacheDir}/${copyDir}/ => ${distDir}/${copyDir}/`)
|
|
45
|
-
})
|
|
59
|
+
}
|
|
46
60
|
|
|
47
61
|
if (!existsSync(cacheDir)) {
|
|
48
62
|
mkdirSync(cacheDir)
|
package/lib/files.js
CHANGED
package/lib/filter.js
CHANGED
|
@@ -70,6 +70,9 @@ const ifConditionEvaluator = (condition, variables) => {
|
|
|
70
70
|
* @param {object} variables
|
|
71
71
|
* @returns {string}
|
|
72
72
|
*/
|
|
73
|
+
// @vocab: テンプレート
|
|
74
|
+
// @vocab: ヘルパー関数
|
|
75
|
+
// @test: tests/ssg-core/filter.test.js
|
|
73
76
|
const replaceIfFilter = (text, variables) => {
|
|
74
77
|
const matched = [...text.matchAll(IF_REGEXP)]
|
|
75
78
|
for (const item of matched) {
|
|
@@ -87,6 +90,10 @@ const replaceIfFilter = (text, variables) => {
|
|
|
87
90
|
return text
|
|
88
91
|
}
|
|
89
92
|
|
|
93
|
+
// @vocab: SSGスクリプト
|
|
94
|
+
// @vocab: テンプレート
|
|
95
|
+
// @vocab: ヘルパー関数
|
|
96
|
+
// @test: tests/ssg-core/filter.test.js
|
|
90
97
|
const replaceScriptFilter = async (text, variables) => {
|
|
91
98
|
let replaced = text
|
|
92
99
|
const scripts = [...text.matchAll(SCRIPT_REGEXP)].map((matched) => {
|
package/lib/generate.js
CHANGED
|
@@ -12,6 +12,8 @@ const beforeGenerate = async () => {
|
|
|
12
12
|
await warmUpTemplate()
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
// @vocab: ビルド
|
|
16
|
+
// @test: tests/ssg-core/hooks.test.js
|
|
15
17
|
export const runHooks = async (hookName, customHelperDir = null, customAllData = null, customConfig = null) => {
|
|
16
18
|
const targetConfig = customConfig || config
|
|
17
19
|
const targetAllData = customAllData || allData
|
|
@@ -43,6 +45,9 @@ export const runHooks = async (hookName, customHelperDir = null, customAllData =
|
|
|
43
45
|
}
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
// @vocab: ビルド
|
|
49
|
+
// @vocab: ページインデックス
|
|
50
|
+
// @test: tests/ssg-core/hooks.test.js
|
|
46
51
|
const generate = async () => {
|
|
47
52
|
let start = performance.now()
|
|
48
53
|
await beforeGenerate()
|
package/lib/helper.js
CHANGED
|
@@ -7,6 +7,9 @@ const helper = {}
|
|
|
7
7
|
// top-level await を使うと循環依存がある場合に ESM のデッドロックが発生するため IIFE を使用する。
|
|
8
8
|
// helperReady をエクスポートすることで、ヘルパーのロード完了を外部から await できる。
|
|
9
9
|
// (Promise は一度 resolve すれば以降の await は即座に返るため、複数箇所で await しても問題ない)
|
|
10
|
+
// @vocab: ヘルパー関数
|
|
11
|
+
// @vocab: パッケージ
|
|
12
|
+
// @test: tests/ssg-core/helper.test.js
|
|
10
13
|
export const helperReady = (async () => {
|
|
11
14
|
if (config.helper) {
|
|
12
15
|
const files = config.helper.split(',')
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'tiff', 'heic', 'bmp'])
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @vocab ビルド画像配布器
|
|
8
|
+
* @test tests/ssg-core/imageDistributor.test.js
|
|
9
|
+
*/
|
|
10
|
+
export async function distributeImages(srcDir, distDir, { fn, ext }) {
|
|
11
|
+
const files = await collectFiles(srcDir)
|
|
12
|
+
for (const relPath of files) {
|
|
13
|
+
const srcPath = path.join(srcDir, relPath)
|
|
14
|
+
const fileExt = path.extname(relPath).slice(1).toLowerCase()
|
|
15
|
+
const isImage = IMAGE_EXTENSIONS.has(fileExt)
|
|
16
|
+
const baseName = path.basename(relPath, path.extname(relPath))
|
|
17
|
+
const subDir = path.dirname(relPath)
|
|
18
|
+
if (fn && isImage) {
|
|
19
|
+
const outputName = ext ? `${baseName}.${ext}` : path.basename(relPath)
|
|
20
|
+
const distPath = path.join(distDir, subDir, outputName)
|
|
21
|
+
await writeConvertedFile(srcPath, distPath, fn)
|
|
22
|
+
} else {
|
|
23
|
+
const distPath = path.join(distDir, relPath)
|
|
24
|
+
fs.mkdirSync(path.dirname(distPath), { recursive: true })
|
|
25
|
+
fs.copyFileSync(srcPath, distPath)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @vocab ビルド画像配布器
|
|
32
|
+
* @test tests/ssg-core/imageDistributor.test.js
|
|
33
|
+
*/
|
|
34
|
+
export async function collectFiles(dir) {
|
|
35
|
+
const result = []
|
|
36
|
+
const walk = (current, rel) => {
|
|
37
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
38
|
+
const relPath = rel ? path.join(rel, entry.name) : entry.name
|
|
39
|
+
if (entry.isDirectory()) {
|
|
40
|
+
walk(path.join(current, entry.name), relPath)
|
|
41
|
+
} else {
|
|
42
|
+
result.push(relPath)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
walk(dir, '')
|
|
47
|
+
return result
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @vocab ビルド画像配布器
|
|
52
|
+
* @test tests/ssg-core/imageDistributor.test.js
|
|
53
|
+
*/
|
|
54
|
+
export async function writeConvertedFile(srcPath, distPath, fn) {
|
|
55
|
+
const buffer = fs.readFileSync(srcPath)
|
|
56
|
+
const converted = await fn(buffer)
|
|
57
|
+
fs.mkdirSync(path.dirname(distPath), { recursive: true })
|
|
58
|
+
fs.writeFileSync(distPath, converted)
|
|
59
|
+
}
|
package/lib/indexer.js
CHANGED
package/lib/pageData.js
CHANGED
|
@@ -5,11 +5,16 @@ import config from './config.js'
|
|
|
5
5
|
const FRONTMATTER_REGEXP = /^(<!|-)--(?<variables>[\s\S]*?)--(-|>)/
|
|
6
6
|
const METADATA_KEY_REGEXP = /^([a-zA-Z\d_-]+):/
|
|
7
7
|
|
|
8
|
+
// @vocab: ページ
|
|
9
|
+
// @vocab: フロントマター
|
|
10
|
+
// @test: tests/ssg-core/pageData.test.js
|
|
8
11
|
const makePageData = (filename, content) => {
|
|
9
12
|
const [name, ext] = filename.split('.')
|
|
10
13
|
return parse(content, name, ext)
|
|
11
14
|
}
|
|
12
15
|
|
|
16
|
+
// @vocab: フロントマター
|
|
17
|
+
// @vocab: ページ
|
|
13
18
|
const parse = (content, name, ext) => {
|
|
14
19
|
const matched = content.match(FRONTMATTER_REGEXP)
|
|
15
20
|
const markdownReplaced = content.replace(FRONTMATTER_REGEXP, '')
|
|
@@ -65,6 +70,8 @@ const parse = (content, name, ext) => {
|
|
|
65
70
|
return metaDataMerged
|
|
66
71
|
}
|
|
67
72
|
|
|
73
|
+
// @vocab: フロントマター
|
|
74
|
+
// @test: tests/ssg-core/pageData.test.js
|
|
68
75
|
const parseMetaData = (data) => {
|
|
69
76
|
const metaData = {}
|
|
70
77
|
let isStringContinue = false
|
package/lib/render.js
CHANGED
|
@@ -11,6 +11,9 @@ const VARIABLE_REGEXP = /(\\)?{{[\s]*([^{}]+)[\s]*}}/g
|
|
|
11
11
|
* @params {object} variables
|
|
12
12
|
* @return {text}
|
|
13
13
|
*/
|
|
14
|
+
// @vocab: テンプレート
|
|
15
|
+
// @vocab: ヘルパー関数
|
|
16
|
+
// @test: tests/ssg-core/replaceVariablesFilter.test.js
|
|
14
17
|
const replaceVariablesFilter = (text, variables) => {
|
|
15
18
|
const matched = [...text.matchAll(VARIABLE_REGEXP)]
|
|
16
19
|
const replace = Object.fromEntries(matched.map(match => [match[0], {variableName: match[2].trim().toLowerCase(), backslash: !!match[1]}]))
|
|
@@ -29,6 +32,8 @@ const replaceVariablesFilter = (text, variables) => {
|
|
|
29
32
|
* @param {object} variables
|
|
30
33
|
* @return {Array.<string>}
|
|
31
34
|
*/
|
|
35
|
+
// @vocab: ヘルパー関数
|
|
36
|
+
// @vocab: テンプレート
|
|
32
37
|
const replaceVariable = (target, replaceTargetRawText, backslash, variables) => {
|
|
33
38
|
const toBeReplace = replaceTargetRawText
|
|
34
39
|
const toBeReplaceScript = toBeReplace.match(/([\w\d_]+)\((.*)\)/)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP リクエストボディを JSON としてパースする共通ヘルパー。
|
|
3
|
+
* Buffer.concat による安全な連結、サイズ制限、JSON.parse エラーハンドリングを提供する。
|
|
4
|
+
*
|
|
5
|
+
* @param {import('http').IncomingMessage} req
|
|
6
|
+
* @param {{ maxSize?: number }} [options] maxSize: バイト上限(デフォルト: 無制限)
|
|
7
|
+
* @returns {Promise<unknown>} パース済み JSON
|
|
8
|
+
* @throws {{ message: string, code: 'PAYLOAD_TOO_LARGE' | 'INVALID_JSON' }} パース失敗時
|
|
9
|
+
*/
|
|
10
|
+
export function parseJsonBody(req, options = {}) {
|
|
11
|
+
const { maxSize = Infinity } = options
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const chunks = []
|
|
14
|
+
let totalSize = 0
|
|
15
|
+
let aborted = false
|
|
16
|
+
req
|
|
17
|
+
.on('data', chunk => {
|
|
18
|
+
if (aborted) return
|
|
19
|
+
totalSize += chunk.length
|
|
20
|
+
if (totalSize > maxSize) {
|
|
21
|
+
aborted = true
|
|
22
|
+
req.destroy()
|
|
23
|
+
const err = new Error('リクエストサイズが上限を超えています')
|
|
24
|
+
err.code = 'PAYLOAD_TOO_LARGE'
|
|
25
|
+
reject(err)
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
chunks.push(chunk)
|
|
29
|
+
})
|
|
30
|
+
.on('end', () => {
|
|
31
|
+
if (aborted) return
|
|
32
|
+
try {
|
|
33
|
+
// setEncoding('utf8') 時は chunks が string[] になるため両ケースに対応する
|
|
34
|
+
const body = Buffer.isBuffer(chunks[0])
|
|
35
|
+
? Buffer.concat(chunks).toString('utf8')
|
|
36
|
+
: chunks.join('')
|
|
37
|
+
resolve(JSON.parse(body))
|
|
38
|
+
} catch {
|
|
39
|
+
const err = new Error('リクエストボディのJSON解析に失敗しました')
|
|
40
|
+
err.code = 'INVALID_JSON'
|
|
41
|
+
reject(err)
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
.on('error', reject)
|
|
45
|
+
})
|
|
46
|
+
}
|
package/lib/server.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import http from 'http'
|
|
2
2
|
import url from 'url'
|
|
3
3
|
import fs from 'node:fs'
|
|
4
|
-
import { distDir, serverDir } from './dir.js'
|
|
4
|
+
import { distDir, srcDir, serverDir } from './dir.js'
|
|
5
5
|
import { styleText } from 'node:util'
|
|
6
6
|
import handle from './tryServer.js'
|
|
7
7
|
import contentType from './contentType.js'
|
|
@@ -20,7 +20,11 @@ const server = () => {
|
|
|
20
20
|
}
|
|
21
21
|
path += '.html'
|
|
22
22
|
}
|
|
23
|
-
|
|
23
|
+
let filePath = `${distDir}${path}`
|
|
24
|
+
if (!fs.existsSync(filePath)) {
|
|
25
|
+
filePath = `${srcDir}${path}`
|
|
26
|
+
}
|
|
27
|
+
if (!fs.existsSync(filePath)) {
|
|
24
28
|
console.log(styleText('red', `[${request.method}] 404`), request.url)
|
|
25
29
|
const errorContent = fs.readFileSync(`${distDir}/404.html`)
|
|
26
30
|
response.writeHead(404)
|
|
@@ -28,7 +32,7 @@ const server = () => {
|
|
|
28
32
|
return
|
|
29
33
|
}
|
|
30
34
|
try {
|
|
31
|
-
const content = fs.readFileSync(
|
|
35
|
+
const content = fs.readFileSync(filePath, 'binary')
|
|
32
36
|
|
|
33
37
|
const ext = path.split('.')[1]
|
|
34
38
|
console.log(styleText('green', `[${request.method}] 200`), request.url)
|
package/lib/tryServer.js
CHANGED
|
@@ -9,7 +9,9 @@ const handlers = async (path) => {
|
|
|
9
9
|
if (handlersAlreadyRegistered) {
|
|
10
10
|
return registeredHandlers[path]
|
|
11
11
|
}
|
|
12
|
-
const serverFiles = fs.readdirSync(serverDir)
|
|
12
|
+
const serverFiles = fs.readdirSync(serverDir, { withFileTypes: true })
|
|
13
|
+
.filter(e => e.isFile())
|
|
14
|
+
.map(e => e.name)
|
|
13
15
|
const loaded = await Promise.all(serverFiles.map(file => import(`${serverDir}/${file}`)))
|
|
14
16
|
loaded.forEach(s => registeredHandlers[s.path] = s)
|
|
15
17
|
handlersAlreadyRegistered = true
|
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tenjuu99/blog",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "blog template",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"dev": "node bin/dev-server",
|
|
8
8
|
"server": "node bin/server",
|
|
9
9
|
"generate": "node bin/generate",
|
|
10
|
-
"test": "node --test --test-concurrency=1
|
|
11
|
-
"test:watch": "node --test --watch
|
|
10
|
+
"test": "node --test --test-concurrency=1 tests/**/*.test.js",
|
|
11
|
+
"test:watch": "node --test --watch tests/**/*.test.js"
|
|
12
12
|
},
|
|
13
13
|
"bin": {
|
|
14
14
|
"generate": "bin/generate",
|
|
@@ -23,10 +23,13 @@
|
|
|
23
23
|
"license": "MIT",
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"chokidar": "^4.0.x",
|
|
26
|
-
"marked": "^13.x"
|
|
26
|
+
"marked": "^13.x",
|
|
27
|
+
"sharp": "^0.35.2"
|
|
27
28
|
},
|
|
28
29
|
"devDependencies": {
|
|
29
|
-
"@
|
|
30
|
+
"@playwright/test": "^1.61.0",
|
|
31
|
+
"@tenjuu99/blog": "file:./",
|
|
32
|
+
"@types/node": "^25.9.3"
|
|
30
33
|
},
|
|
31
34
|
"type": "module",
|
|
32
35
|
"engines": {
|
|
@@ -2,6 +2,9 @@ import { allData, config } from '@tenjuu99/blog'
|
|
|
2
2
|
|
|
3
3
|
export function breadcrumbList(pageName, topPageName = 'top') {
|
|
4
4
|
const pageData = allData[pageName]
|
|
5
|
+
if (!pageData) {
|
|
6
|
+
return ''
|
|
7
|
+
}
|
|
5
8
|
const entries = Object.entries(allData)
|
|
6
9
|
const breadCrumbs = ['/']
|
|
7
10
|
if (pageData.breadcrumbs) {
|
|
@@ -8,6 +8,11 @@ let categoryTreeCache = null
|
|
|
8
8
|
* @param {Object} config - 設定オブジェクト
|
|
9
9
|
* @returns {Object} カテゴリーツリー
|
|
10
10
|
*/
|
|
11
|
+
// @vocab: カテゴリーシステム
|
|
12
|
+
// @vocab: カテゴリー
|
|
13
|
+
// @vocab: カテゴリーパス
|
|
14
|
+
// @vocab: サブカテゴリー
|
|
15
|
+
// @test: tests/category/category-tree.test.js
|
|
11
16
|
export function buildCategoryTree(data = allData, conf = config) {
|
|
12
17
|
const tree = {}
|
|
13
18
|
const urlCase = conf.category?.url_case || 'lower'
|
|
@@ -74,6 +79,10 @@ export function getCategoryTree() {
|
|
|
74
79
|
* @param {Array} categoryPath - カテゴリーパス(例: ["Art", "Painting"])
|
|
75
80
|
* @returns {Array} ページデータの配列
|
|
76
81
|
*/
|
|
82
|
+
// @vocab: カテゴリーパス
|
|
83
|
+
// @vocab: カテゴリー
|
|
84
|
+
// @vocab: ページ
|
|
85
|
+
// @test: tests/category/category-helper.test.js
|
|
77
86
|
export function getCategoryPages(categoryPath) {
|
|
78
87
|
const pages = []
|
|
79
88
|
|
|
@@ -95,6 +104,10 @@ export function getCategoryPages(categoryPath) {
|
|
|
95
104
|
* @param {Array} categoryPath - カテゴリーパス(例: ["Art"])
|
|
96
105
|
* @returns {Array} ページデータの配列
|
|
97
106
|
*/
|
|
107
|
+
// @vocab: サブカテゴリー
|
|
108
|
+
// @vocab: カテゴリーパス
|
|
109
|
+
// @vocab: ページ
|
|
110
|
+
// @test: tests/category/category-helper.test.js
|
|
98
111
|
export function getCategoryPagesRecursive(categoryPath) {
|
|
99
112
|
const pages = []
|
|
100
113
|
|
|
@@ -4,6 +4,8 @@ import { buildCategoryTree } from './category.js'
|
|
|
4
4
|
* 記事名配列を perPage 件ずつのページ配列に分割する
|
|
5
5
|
* 空の場合は [[]] を返す(ページ数ゼロを避けるため)
|
|
6
6
|
*/
|
|
7
|
+
// @vocab: ページネーション
|
|
8
|
+
// @test: tests/category/category-pagination.test.js
|
|
7
9
|
function sliceIntoPages(items, perPage) {
|
|
8
10
|
if (items.length === 0) return [[]]
|
|
9
11
|
const pages = []
|
|
@@ -37,6 +39,9 @@ function sliceIntoPages(items, perPage) {
|
|
|
37
39
|
* @param {Object} categoryConfig - 単一カテゴリー設定オブジェクト
|
|
38
40
|
* @param {Object} config - グローバル設定オブジェクト
|
|
39
41
|
*/
|
|
42
|
+
// @vocab: カテゴリーページ
|
|
43
|
+
// @vocab: ページネーション
|
|
44
|
+
// @test: tests/category/category-pagination.test.js
|
|
40
45
|
function buildVirtualPage(url, categoryData, children, currentPage, totalPages, categoryConfig, config) {
|
|
41
46
|
const pageName = url.replace(/^\//, '') + '/index'
|
|
42
47
|
const paginationBase = categoryData.baseUrl + '/'
|
|
@@ -82,6 +87,10 @@ function buildVirtualPage(url, categoryData, children, currentPage, totalPages,
|
|
|
82
87
|
* @param {Object} categoryConfig - 単一カテゴリー設定オブジェクト
|
|
83
88
|
* @param {Object} config - グローバル設定オブジェクト
|
|
84
89
|
*/
|
|
90
|
+
// @vocab: カテゴリーページ
|
|
91
|
+
// @vocab: カテゴリーシステム
|
|
92
|
+
// @vocab: ページネーション
|
|
93
|
+
// @test: tests/category/category-pagination.test.js
|
|
85
94
|
function generateCategoryPages(allData, categoryConfig, config) {
|
|
86
95
|
const pathFilter = categoryConfig.path_filter || ''
|
|
87
96
|
let filteredData = allData
|
|
@@ -140,6 +149,9 @@ function generateCategoryPages(allData, categoryConfig, config) {
|
|
|
140
149
|
* @param {Object} allData - 全ページデータ
|
|
141
150
|
* @param {Object} config - 設定オブジェクト
|
|
142
151
|
*/
|
|
152
|
+
// @vocab: カテゴリーシステム
|
|
153
|
+
// @vocab: ビルド
|
|
154
|
+
// @test: tests/category/category-indexer.test.js
|
|
143
155
|
export async function afterIndexing(allData, config) {
|
|
144
156
|
const categorySystems = config.categories
|
|
145
157
|
? config.categories
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
* getPaginationUrl: ページ番号から URL を生成する
|
|
3
3
|
* page === 1 なら basePath、それ以外なら basePath + page + '/'
|
|
4
4
|
*/
|
|
5
|
+
// @vocab: ページネーション
|
|
6
|
+
// @vocab: カテゴリーページ
|
|
7
|
+
// @test: tests/category/category-pagination.test.js
|
|
5
8
|
export function getPaginationUrl(basePath, page) {
|
|
6
9
|
const base = basePath.endsWith('/') ? basePath : `${basePath}/`
|
|
7
10
|
if (page === 1) return base
|
|
@@ -12,6 +15,8 @@ export function getPaginationUrl(basePath, page) {
|
|
|
12
15
|
* buildWindowedPages: ウィンドウ付きページ番号リストを生成する
|
|
13
16
|
* 各要素は {num, isCurrent} または {num: null, isEllipsis: true}
|
|
14
17
|
*/
|
|
18
|
+
// @vocab: ページネーション
|
|
19
|
+
// @test: tests/category/category-pagination.test.js
|
|
15
20
|
export function buildWindowedPages(totalPages, currentPage, windowSize = 2) {
|
|
16
21
|
if (totalPages <= 0) return []
|
|
17
22
|
if (totalPages === 1) return [{ num: 1, isCurrent: currentPage === 1 }]
|
|
@@ -3,6 +3,8 @@ body {
|
|
|
3
3
|
height: 100vh;
|
|
4
4
|
display: flex;
|
|
5
5
|
flex-direction: column;
|
|
6
|
+
font-family: Helvetica, "Noto Sans JP", "Hiragino Sans", system-ui, sans-serif;
|
|
7
|
+
font-weight: 300;
|
|
6
8
|
}
|
|
7
9
|
header {
|
|
8
10
|
text-align: center;
|
|
@@ -49,6 +51,42 @@ main.container {
|
|
|
49
51
|
.editor-options {
|
|
50
52
|
display: flex;
|
|
51
53
|
justify-content: space-between;
|
|
54
|
+
align-items: center;
|
|
55
|
+
gap: 8px;
|
|
56
|
+
margin-bottom: 5px;
|
|
57
|
+
}
|
|
58
|
+
.editor-options-left {
|
|
59
|
+
display: flex;
|
|
60
|
+
align-items: center;
|
|
61
|
+
gap: 8px;
|
|
62
|
+
}
|
|
63
|
+
.editor-options-right {
|
|
64
|
+
display: flex;
|
|
65
|
+
align-items: center;
|
|
66
|
+
gap: 8px;
|
|
67
|
+
}
|
|
68
|
+
#newFileDialog {
|
|
69
|
+
border: 1px solid #ccc;
|
|
70
|
+
border-radius: 8px;
|
|
71
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
|
72
|
+
padding: 24px;
|
|
73
|
+
min-width: 320px;
|
|
74
|
+
}
|
|
75
|
+
#newFileDialog form {
|
|
76
|
+
display: flex;
|
|
77
|
+
flex-direction: column;
|
|
78
|
+
gap: 12px;
|
|
79
|
+
}
|
|
80
|
+
#newFileDialog label {
|
|
81
|
+
display: flex;
|
|
82
|
+
flex-direction: column;
|
|
83
|
+
gap: 4px;
|
|
84
|
+
}
|
|
85
|
+
#newFileDialog #newFileError {
|
|
86
|
+
color: #c00;
|
|
87
|
+
font-size: 0.9em;
|
|
88
|
+
margin: 0;
|
|
89
|
+
min-height: 1.2em;
|
|
52
90
|
}
|
|
53
91
|
#editorTextArea {
|
|
54
92
|
resize: none;
|
|
@@ -81,20 +119,27 @@ form select {
|
|
|
81
119
|
border: none;
|
|
82
120
|
border-radius: 0 5px 5px 0;
|
|
83
121
|
}
|
|
122
|
+
|
|
84
123
|
.sidebar {
|
|
124
|
+
--sidebar-bg: #313d64;
|
|
125
|
+
--sidebar-dir: #79afb4;
|
|
126
|
+
--sidebar-file: #cbcbcb;
|
|
127
|
+
--sidebar-active-file-fg: #333;
|
|
128
|
+
--sidebar-active-file-bg: rgba(165,218,246,0.8);
|
|
129
|
+
--sidebar-modified-fg: #ff7777;
|
|
130
|
+
--sidebar-hover-bg: rgba(104, 117, 154, .3);
|
|
85
131
|
display: flex;
|
|
86
132
|
justify-content: space-between;
|
|
87
133
|
width: 300px;
|
|
88
|
-
overflow:
|
|
134
|
+
overflow: scroll;
|
|
89
135
|
left: 0;
|
|
90
|
-
background:
|
|
136
|
+
background: var(--sidebar-bg);
|
|
91
137
|
height: 100%;
|
|
92
138
|
padding: 0;
|
|
93
|
-
overflow-y: hidden;
|
|
94
139
|
flex-basis: 300px;
|
|
95
140
|
position: relative;
|
|
96
141
|
z-index: 3;
|
|
97
|
-
|
|
142
|
+
font-size: 0.85em;
|
|
98
143
|
}
|
|
99
144
|
.sidebar-close .sidebar {
|
|
100
145
|
left: -290px;
|
|
@@ -104,6 +149,9 @@ form select {
|
|
|
104
149
|
padding: 0;
|
|
105
150
|
margin: 0;
|
|
106
151
|
}
|
|
152
|
+
.sidebar > ul {
|
|
153
|
+
padding: 0 0 0 15px;
|
|
154
|
+
}
|
|
107
155
|
.sidebar-close .sidebar ul {
|
|
108
156
|
position: absolute;
|
|
109
157
|
left: -290px;
|
|
@@ -111,26 +159,66 @@ form select {
|
|
|
111
159
|
}
|
|
112
160
|
.sidebar li {
|
|
113
161
|
list-style: none;
|
|
114
|
-
padding-left:
|
|
162
|
+
padding-left: 10px;
|
|
163
|
+
white-space: nowrap;
|
|
164
|
+
text-overflow: ellipsis;
|
|
165
|
+
}
|
|
166
|
+
.sidebar li:has(a.active):not(:has(li)) {
|
|
167
|
+
background: var(--sidebar-active-file-bg);
|
|
115
168
|
}
|
|
116
169
|
.sidebar a {
|
|
117
|
-
color:
|
|
170
|
+
color: var(--sidebar-file);
|
|
118
171
|
text-decoration: none;
|
|
119
172
|
}
|
|
120
|
-
.sidebar
|
|
121
|
-
|
|
173
|
+
.sidebar a.active {
|
|
174
|
+
color: var(--sidebar-active-file-fg);
|
|
175
|
+
display: block;
|
|
176
|
+
}
|
|
177
|
+
.sidebar a[data-status="new"]::after {
|
|
178
|
+
content: " ●";
|
|
179
|
+
color: #e06c00;
|
|
180
|
+
}
|
|
181
|
+
.sidebar a[data-status="modified"]::after {
|
|
182
|
+
content: " ◆";
|
|
183
|
+
color: #0070c0;
|
|
184
|
+
}
|
|
185
|
+
.sidebar a[data-status="published"]::after {
|
|
186
|
+
content: " ✓";
|
|
187
|
+
color: #888;
|
|
188
|
+
}
|
|
189
|
+
.sidebar a[data-status="new"],
|
|
190
|
+
.sidebar a[data-status="modified"] {
|
|
191
|
+
color: var(--sidebar-modified-fg);
|
|
192
|
+
}
|
|
193
|
+
.sidebar li:not(:has(li)):hover,
|
|
194
|
+
.sidebar li.dir-node summary:hover {
|
|
195
|
+
background: var(--sidebar-hover-bg);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.sidebar details > summary {
|
|
199
|
+
cursor: pointer;
|
|
200
|
+
padding: 2px 0;
|
|
201
|
+
color: var(--sidebar-dir);
|
|
202
|
+
list-style: disclosure-closed;
|
|
203
|
+
}
|
|
204
|
+
.sidebar li.dir-node details:not([open]):has(a[data-status="new"]) summary,
|
|
205
|
+
.sidebar li.dir-node details:not([open]):has(a[data-status="modified"]) summary {
|
|
206
|
+
color: var(--sidebar-modified-fg);
|
|
207
|
+
}
|
|
208
|
+
.sidebar details[open] > summary {
|
|
209
|
+
list-style: disclosure-open;
|
|
122
210
|
}
|
|
123
211
|
.sidebar-toggle {
|
|
124
212
|
cursor: pointer;
|
|
125
213
|
width: 20px;
|
|
126
214
|
height: 100%;
|
|
127
215
|
position: absolute;
|
|
128
|
-
|
|
216
|
+
left: 0;
|
|
129
217
|
}
|
|
130
218
|
.sidebar-close .sidebar-toggle {
|
|
131
219
|
position: fixed;
|
|
132
220
|
left: 0;
|
|
133
|
-
background:
|
|
221
|
+
background: var(--sidebar-bg);
|
|
134
222
|
}
|
|
135
223
|
.sidebar-toggle:hover:after {
|
|
136
224
|
position: absolute;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { buildTree, renderTreeHtml } from '../js/tree.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {Array<{name: string, __filetype: string, __is_auto_category: boolean}>} files
|
|
5
|
+
* @param {Object.<string, 'new'|'modified'|'published'|'unknown'>} [statusMap]
|
|
6
|
+
* @returns {string}
|
|
7
|
+
*/
|
|
8
|
+
export function renderSidebarTree(files, statusMap = {}) {
|
|
9
|
+
const tree = buildTree(files.filter(f => !f.__is_auto_category))
|
|
10
|
+
return renderTreeHtml(tree, '', statusMap)
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vocab プレビュー自動更新器
|
|
3
|
+
* @test tests/editor/auto-preview.test.js
|
|
4
|
+
*/
|
|
5
|
+
import { createDebounce } from './debouncer.js'
|
|
6
|
+
|
|
7
|
+
export function initAutoPreview(textarea, onUpdate, delay = 500) {
|
|
8
|
+
const debouncedUpdate = createDebounce(onUpdate, delay)
|
|
9
|
+
textarea.addEventListener('input', debouncedUpdate)
|
|
10
|
+
return debouncedUpdate
|
|
11
|
+
}
|