@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
package/lib/dir.js CHANGED
@@ -27,6 +27,9 @@ const packageDirCore = path.dirname(__filename) + '/../packages'
27
27
 
28
28
  let alreadyCached = false
29
29
 
30
+ // @vocab: パッケージ
31
+ // @vocab: ビルド
32
+ // @test: tests/ssg-core/hooks.test.js
30
33
  const cache = () => {
31
34
  if (alreadyCached) {
32
35
  return
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
- distributeRaw.forEach((copyDir) => {
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
- return
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
- cpSync(`${cacheDir}/${copyDir}/`, `${distDir}/${copyDir}/`, { recursive: true, force: true })
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
@@ -4,6 +4,8 @@ import { templateDir, cssDir } from './dir.js'
4
4
  let staticFilesContainer = {}
5
5
  let loaded = false
6
6
 
7
+ // @vocab: テンプレート
8
+ // @test: tests/editor/editor-sidebar.test.js
7
9
  const warmUp = async () => {
8
10
  if (loaded) {
9
11
  return
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
@@ -31,6 +31,9 @@ const collect = (dir, namePrefix = '', promises = []) => {
31
31
  return promises
32
32
  }
33
33
 
34
+ // @vocab: ページインデックス
35
+ // @vocab: ページ
36
+ // @test: tests/ssg-core/indexer.test.js
34
37
  const indexing = async () => {
35
38
  allData = {}
36
39
 
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
@@ -13,6 +13,11 @@ marked.use({
13
13
  breaks: true
14
14
  })
15
15
 
16
+ // @vocab: テンプレート
17
+ // @vocab: ページ
18
+ // @vocab: SSGスクリプト
19
+ // @vocab: ヘルパー関数
20
+ // @test: tests/ssg-core/helper.test.js
16
21
  const render = async (templateName, data) => {
17
22
  await helperReady
18
23
  let template
@@ -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
- if (!fs.existsSync(`${distDir}${path}`)) {
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(`${distDir}${path}`, 'binary')
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.5",
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 test/**/*.test.js",
11
- "test:watch": "node --test --watch test/**/*.test.js"
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
- "@tenjuu99/blog": "file:./"
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: hidden;
134
+ overflow: scroll;
89
135
  left: 0;
90
- background: rgb(192, 198, 217);
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
- word-break: break-all;
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: 20px;
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: #666;
170
+ color: var(--sidebar-file);
118
171
  text-decoration: none;
119
172
  }
120
- .sidebar li:hover {
121
- background: rgba(104, 117, 154, .3);
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
- right: 0;
216
+ left: 0;
129
217
  }
130
218
  .sidebar-close .sidebar-toggle {
131
219
  position: fixed;
132
220
  left: 0;
133
- background: rgb(192, 198, 217);
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
+ }