@tenjuu99/blog 0.3.6 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/bin/dev-server +3 -1
  2. package/bin/server +8 -1
  3. package/docs/develop.md +30 -0
  4. package/docs/dictionary.json +1806 -0
  5. package/lib/dir.js +2 -2
  6. package/lib/distribute.js +5 -5
  7. package/lib/files.js +1 -1
  8. package/lib/filter.js +5 -5
  9. package/lib/generate.js +3 -3
  10. package/lib/helper.js +2 -2
  11. package/lib/imageDistributor.js +3 -3
  12. package/lib/indexer.js +2 -2
  13. package/lib/pageData.js +5 -5
  14. package/lib/render.js +4 -4
  15. package/lib/replaceVariablesFilter.js +4 -4
  16. package/lib/server/helper/parseRequestBody.js +46 -0
  17. package/lib/server.js +7 -3
  18. package/package.json +3 -2
  19. package/packages/category/helper/category.js +10 -10
  20. package/packages/category/helper/categoryIndexer.js +8 -8
  21. package/packages/category/helper/pagination.js +3 -3
  22. package/packages/editor/css/editor.css +87 -18
  23. package/packages/editor/helper/sidebarTree.js +7 -2
  24. package/packages/editor/js/autoPreviewInitializer.js +11 -0
  25. package/packages/editor/js/autoSaveInitializer.js +15 -0
  26. package/packages/editor/js/debouncer.js +21 -0
  27. package/packages/editor/js/editor.js +261 -91
  28. package/packages/editor/js/frontmatter_template.js +3 -3
  29. package/packages/editor/js/imageReferenceExtractor.js +19 -0
  30. package/packages/editor/js/image_upload.js +1 -1
  31. package/packages/editor/js/tree.js +27 -12
  32. package/packages/editor/server/changeReflector.js +47 -0
  33. package/packages/editor/server/createConverter.js +2 -2
  34. package/packages/editor/server/get_editor_target.js +2 -2
  35. package/packages/editor/server/get_frontmatter_templates.js +1 -1
  36. package/packages/editor/server/get_publication_status.js +29 -0
  37. package/packages/editor/server/get_sidebar.js +51 -0
  38. package/packages/editor/server/image_upload.js +32 -40
  39. package/packages/editor/server/preview.js +49 -19
  40. package/packages/editor/server/publicationStatus.js +27 -0
  41. package/packages/editor/server/publish.js +135 -0
  42. package/packages/editor/server/publishTargetCollector.js +19 -0
  43. package/packages/editor/server/save.js +97 -0
  44. package/packages/editor/server/sidebarStatusCollector.js +17 -0
  45. package/packages/editor/template/editor.html +28 -18
  46. package/src-sample/helper/add.js +1 -1
  47. package/src-sample/helper/index.js +6 -6
  48. package/docs/dictionary.md +0 -553
  49. package/packages/editor/server/editor.js +0 -43
package/lib/dir.js CHANGED
@@ -27,8 +27,8 @@ const packageDirCore = path.dirname(__filename) + '/../packages'
27
27
 
28
28
  let alreadyCached = false
29
29
 
30
- // @vocab: パッケージ (docs/dictionary.md#パッケージ)
31
- // @vocab: ビルド (docs/dictionary.md#ビルド)
30
+ // @vocab: パッケージ
31
+ // @vocab: ビルド
32
32
  // @test: tests/ssg-core/hooks.test.js
33
33
  const cache = () => {
34
34
  if (alreadyCached) {
package/lib/distribute.js CHANGED
@@ -13,16 +13,16 @@ import { createConverter } from '../packages/editor/server/createConverter.js'
13
13
 
14
14
  const indexFile = `${cacheDir}/index.json`
15
15
 
16
- // @vocab: ページ (docs/dictionary.md#ページ)
17
- // @vocab: テンプレート (docs/dictionary.md#テンプレート)
16
+ // @vocab: ページ
17
+ // @vocab: テンプレート
18
18
  const renderPage = async (page) => {
19
19
  const template = page.template
20
20
  return [page.name, await render(template, page)]
21
21
  }
22
22
 
23
- // @vocab: ビルド (docs/dictionary.md#ビルド)
24
- // @vocab: ページ (docs/dictionary.md#ページ)
25
- // @vocab: ページインデックス (docs/dictionary.md#ページインデックス)
23
+ // @vocab: ビルド
24
+ // @vocab: ページ
25
+ // @vocab: ページインデックス
26
26
  // @test: tests/ssg-core/hooks.test.js
27
27
  const distribute = async (data, srcDir, distDir) => {
28
28
  const promises = []
package/lib/files.js CHANGED
@@ -4,7 +4,7 @@ import { templateDir, cssDir } from './dir.js'
4
4
  let staticFilesContainer = {}
5
5
  let loaded = false
6
6
 
7
- // @vocab: テンプレート (docs/dictionary.md#テンプレート)
7
+ // @vocab: テンプレート
8
8
  // @test: tests/editor/editor-sidebar.test.js
9
9
  const warmUp = async () => {
10
10
  if (loaded) {
package/lib/filter.js CHANGED
@@ -70,8 +70,8 @@ const ifConditionEvaluator = (condition, variables) => {
70
70
  * @param {object} variables
71
71
  * @returns {string}
72
72
  */
73
- // @vocab: テンプレート (docs/dictionary.md#テンプレート)
74
- // @vocab: ヘルパー関数 (docs/dictionary.md#ヘルパー関数)
73
+ // @vocab: テンプレート
74
+ // @vocab: ヘルパー関数
75
75
  // @test: tests/ssg-core/filter.test.js
76
76
  const replaceIfFilter = (text, variables) => {
77
77
  const matched = [...text.matchAll(IF_REGEXP)]
@@ -90,9 +90,9 @@ const replaceIfFilter = (text, variables) => {
90
90
  return text
91
91
  }
92
92
 
93
- // @vocab: SSGスクリプト (docs/dictionary.md#ssgスクリプト)
94
- // @vocab: テンプレート (docs/dictionary.md#テンプレート)
95
- // @vocab: ヘルパー関数 (docs/dictionary.md#ヘルパー関数)
93
+ // @vocab: SSGスクリプト
94
+ // @vocab: テンプレート
95
+ // @vocab: ヘルパー関数
96
96
  // @test: tests/ssg-core/filter.test.js
97
97
  const replaceScriptFilter = async (text, variables) => {
98
98
  let replaced = text
package/lib/generate.js CHANGED
@@ -12,7 +12,7 @@ const beforeGenerate = async () => {
12
12
  await warmUpTemplate()
13
13
  }
14
14
 
15
- // @vocab: ビルド (docs/dictionary.md#ビルド)
15
+ // @vocab: ビルド
16
16
  // @test: tests/ssg-core/hooks.test.js
17
17
  export const runHooks = async (hookName, customHelperDir = null, customAllData = null, customConfig = null) => {
18
18
  const targetConfig = customConfig || config
@@ -45,8 +45,8 @@ export const runHooks = async (hookName, customHelperDir = null, customAllData =
45
45
  }
46
46
  }
47
47
 
48
- // @vocab: ビルド (docs/dictionary.md#ビルド)
49
- // @vocab: ページインデックス (docs/dictionary.md#ページインデックス)
48
+ // @vocab: ビルド
49
+ // @vocab: ページインデックス
50
50
  // @test: tests/ssg-core/hooks.test.js
51
51
  const generate = async () => {
52
52
  let start = performance.now()
package/lib/helper.js CHANGED
@@ -7,8 +7,8 @@ const helper = {}
7
7
  // top-level await を使うと循環依存がある場合に ESM のデッドロックが発生するため IIFE を使用する。
8
8
  // helperReady をエクスポートすることで、ヘルパーのロード完了を外部から await できる。
9
9
  // (Promise は一度 resolve すれば以降の await は即座に返るため、複数箇所で await しても問題ない)
10
- // @vocab: ヘルパー関数 (docs/dictionary.md#ヘルパー関数)
11
- // @vocab: パッケージ (docs/dictionary.md#パッケージ)
10
+ // @vocab: ヘルパー関数
11
+ // @vocab: パッケージ
12
12
  // @test: tests/ssg-core/helper.test.js
13
13
  export const helperReady = (async () => {
14
14
  if (config.helper) {
@@ -4,7 +4,7 @@ import path from 'node:path'
4
4
  const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'tiff', 'heic', 'bmp'])
5
5
 
6
6
  /**
7
- * @vocab ビルド画像配布器 (docs/dictionary.md)
7
+ * @vocab ビルド画像配布器
8
8
  * @test tests/ssg-core/imageDistributor.test.js
9
9
  */
10
10
  export async function distributeImages(srcDir, distDir, { fn, ext }) {
@@ -28,7 +28,7 @@ export async function distributeImages(srcDir, distDir, { fn, ext }) {
28
28
  }
29
29
 
30
30
  /**
31
- * @vocab ビルド画像配布器 (docs/dictionary.md)
31
+ * @vocab ビルド画像配布器
32
32
  * @test tests/ssg-core/imageDistributor.test.js
33
33
  */
34
34
  export async function collectFiles(dir) {
@@ -48,7 +48,7 @@ export async function collectFiles(dir) {
48
48
  }
49
49
 
50
50
  /**
51
- * @vocab ビルド画像配布器 (docs/dictionary.md)
51
+ * @vocab ビルド画像配布器
52
52
  * @test tests/ssg-core/imageDistributor.test.js
53
53
  */
54
54
  export async function writeConvertedFile(srcPath, distPath, fn) {
package/lib/indexer.js CHANGED
@@ -31,8 +31,8 @@ const collect = (dir, namePrefix = '', promises = []) => {
31
31
  return promises
32
32
  }
33
33
 
34
- // @vocab: ページインデックス (docs/dictionary.md#ページインデックス)
35
- // @vocab: ページ (docs/dictionary.md#ページ)
34
+ // @vocab: ページインデックス
35
+ // @vocab: ページ
36
36
  // @test: tests/ssg-core/indexer.test.js
37
37
  const indexing = async () => {
38
38
  allData = {}
package/lib/pageData.js CHANGED
@@ -5,16 +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: ページ (docs/dictionary.md#ページ)
9
- // @vocab: フロントマター (docs/dictionary.md#フロントマター)
8
+ // @vocab: ページ
9
+ // @vocab: フロントマター
10
10
  // @test: tests/ssg-core/pageData.test.js
11
11
  const makePageData = (filename, content) => {
12
12
  const [name, ext] = filename.split('.')
13
13
  return parse(content, name, ext)
14
14
  }
15
15
 
16
- // @vocab: フロントマター (docs/dictionary.md#フロントマター)
17
- // @vocab: ページ (docs/dictionary.md#ページ)
16
+ // @vocab: フロントマター
17
+ // @vocab: ページ
18
18
  const parse = (content, name, ext) => {
19
19
  const matched = content.match(FRONTMATTER_REGEXP)
20
20
  const markdownReplaced = content.replace(FRONTMATTER_REGEXP, '')
@@ -70,7 +70,7 @@ const parse = (content, name, ext) => {
70
70
  return metaDataMerged
71
71
  }
72
72
 
73
- // @vocab: フロントマター (docs/dictionary.md#フロントマター)
73
+ // @vocab: フロントマター
74
74
  // @test: tests/ssg-core/pageData.test.js
75
75
  const parseMetaData = (data) => {
76
76
  const metaData = {}
package/lib/render.js CHANGED
@@ -13,10 +13,10 @@ marked.use({
13
13
  breaks: true
14
14
  })
15
15
 
16
- // @vocab: テンプレート (docs/dictionary.md#テンプレート)
17
- // @vocab: ページ (docs/dictionary.md#ページ)
18
- // @vocab: SSGスクリプト (docs/dictionary.md#ssgスクリプト)
19
- // @vocab: ヘルパー関数 (docs/dictionary.md#ヘルパー関数)
16
+ // @vocab: テンプレート
17
+ // @vocab: ページ
18
+ // @vocab: SSGスクリプト
19
+ // @vocab: ヘルパー関数
20
20
  // @test: tests/ssg-core/helper.test.js
21
21
  const render = async (templateName, data) => {
22
22
  await helperReady
@@ -11,8 +11,8 @@ const VARIABLE_REGEXP = /(\\)?{{[\s]*([^{}]+)[\s]*}}/g
11
11
  * @params {object} variables
12
12
  * @return {text}
13
13
  */
14
- // @vocab: テンプレート (docs/dictionary.md#テンプレート)
15
- // @vocab: ヘルパー関数 (docs/dictionary.md#ヘルパー関数)
14
+ // @vocab: テンプレート
15
+ // @vocab: ヘルパー関数
16
16
  // @test: tests/ssg-core/replaceVariablesFilter.test.js
17
17
  const replaceVariablesFilter = (text, variables) => {
18
18
  const matched = [...text.matchAll(VARIABLE_REGEXP)]
@@ -32,8 +32,8 @@ const replaceVariablesFilter = (text, variables) => {
32
32
  * @param {object} variables
33
33
  * @return {Array.<string>}
34
34
  */
35
- // @vocab: ヘルパー関数 (docs/dictionary.md#ヘルパー関数)
36
- // @vocab: テンプレート (docs/dictionary.md#テンプレート)
35
+ // @vocab: ヘルパー関数
36
+ // @vocab: テンプレート
37
37
  const replaceVariable = (target, replaceTargetRawText, backslash, variables) => {
38
38
  const toBeReplace = replaceTargetRawText
39
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenjuu99/blog",
3
- "version": "0.3.6",
3
+ "version": "0.4.1",
4
4
  "description": "blog template",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -12,7 +12,8 @@
12
12
  },
13
13
  "bin": {
14
14
  "generate": "bin/generate",
15
- "server": "bin/dev-server",
15
+ "server": "bin/server",
16
+ "dev-server": "bin/dev-server",
16
17
  "create-blog": "bin/new"
17
18
  },
18
19
  "author": "AmashigeSeiji",
@@ -8,10 +8,10 @@ let categoryTreeCache = null
8
8
  * @param {Object} config - 設定オブジェクト
9
9
  * @returns {Object} カテゴリーツリー
10
10
  */
11
- // @vocab: カテゴリーシステム (docs/dictionary.md#カテゴリーシステム)
12
- // @vocab: カテゴリー (docs/dictionary.md#カテゴリー)
13
- // @vocab: カテゴリーパス (docs/dictionary.md#カテゴリーパス)
14
- // @vocab: サブカテゴリー (docs/dictionary.md#サブカテゴリー)
11
+ // @vocab: カテゴリーシステム
12
+ // @vocab: カテゴリー
13
+ // @vocab: カテゴリーパス
14
+ // @vocab: サブカテゴリー
15
15
  // @test: tests/category/category-tree.test.js
16
16
  export function buildCategoryTree(data = allData, conf = config) {
17
17
  const tree = {}
@@ -79,9 +79,9 @@ export function getCategoryTree() {
79
79
  * @param {Array} categoryPath - カテゴリーパス(例: ["Art", "Painting"])
80
80
  * @returns {Array} ページデータの配列
81
81
  */
82
- // @vocab: カテゴリーパス (docs/dictionary.md#カテゴリーパス)
83
- // @vocab: カテゴリー (docs/dictionary.md#カテゴリー)
84
- // @vocab: ページ (docs/dictionary.md#ページ)
82
+ // @vocab: カテゴリーパス
83
+ // @vocab: カテゴリー
84
+ // @vocab: ページ
85
85
  // @test: tests/category/category-helper.test.js
86
86
  export function getCategoryPages(categoryPath) {
87
87
  const pages = []
@@ -104,9 +104,9 @@ export function getCategoryPages(categoryPath) {
104
104
  * @param {Array} categoryPath - カテゴリーパス(例: ["Art"])
105
105
  * @returns {Array} ページデータの配列
106
106
  */
107
- // @vocab: サブカテゴリー (docs/dictionary.md#サブカテゴリー)
108
- // @vocab: カテゴリーパス (docs/dictionary.md#カテゴリーパス)
109
- // @vocab: ページ (docs/dictionary.md#ページ)
107
+ // @vocab: サブカテゴリー
108
+ // @vocab: カテゴリーパス
109
+ // @vocab: ページ
110
110
  // @test: tests/category/category-helper.test.js
111
111
  export function getCategoryPagesRecursive(categoryPath) {
112
112
  const pages = []
@@ -4,7 +4,7 @@ import { buildCategoryTree } from './category.js'
4
4
  * 記事名配列を perPage 件ずつのページ配列に分割する
5
5
  * 空の場合は [[]] を返す(ページ数ゼロを避けるため)
6
6
  */
7
- // @vocab: ページネーション (docs/dictionary.md#ページネーション)
7
+ // @vocab: ページネーション
8
8
  // @test: tests/category/category-pagination.test.js
9
9
  function sliceIntoPages(items, perPage) {
10
10
  if (items.length === 0) return [[]]
@@ -39,8 +39,8 @@ function sliceIntoPages(items, perPage) {
39
39
  * @param {Object} categoryConfig - 単一カテゴリー設定オブジェクト
40
40
  * @param {Object} config - グローバル設定オブジェクト
41
41
  */
42
- // @vocab: カテゴリーページ (docs/dictionary.md#カテゴリーページ)
43
- // @vocab: ページネーション (docs/dictionary.md#ページネーション)
42
+ // @vocab: カテゴリーページ
43
+ // @vocab: ページネーション
44
44
  // @test: tests/category/category-pagination.test.js
45
45
  function buildVirtualPage(url, categoryData, children, currentPage, totalPages, categoryConfig, config) {
46
46
  const pageName = url.replace(/^\//, '') + '/index'
@@ -87,9 +87,9 @@ function buildVirtualPage(url, categoryData, children, currentPage, totalPages,
87
87
  * @param {Object} categoryConfig - 単一カテゴリー設定オブジェクト
88
88
  * @param {Object} config - グローバル設定オブジェクト
89
89
  */
90
- // @vocab: カテゴリーページ (docs/dictionary.md#カテゴリーページ)
91
- // @vocab: カテゴリーシステム (docs/dictionary.md#カテゴリーシステム)
92
- // @vocab: ページネーション (docs/dictionary.md#ページネーション)
90
+ // @vocab: カテゴリーページ
91
+ // @vocab: カテゴリーシステム
92
+ // @vocab: ページネーション
93
93
  // @test: tests/category/category-pagination.test.js
94
94
  function generateCategoryPages(allData, categoryConfig, config) {
95
95
  const pathFilter = categoryConfig.path_filter || ''
@@ -149,8 +149,8 @@ function generateCategoryPages(allData, categoryConfig, config) {
149
149
  * @param {Object} allData - 全ページデータ
150
150
  * @param {Object} config - 設定オブジェクト
151
151
  */
152
- // @vocab: カテゴリーシステム (docs/dictionary.md#カテゴリーシステム)
153
- // @vocab: ビルド (docs/dictionary.md#ビルド)
152
+ // @vocab: カテゴリーシステム
153
+ // @vocab: ビルド
154
154
  // @test: tests/category/category-indexer.test.js
155
155
  export async function afterIndexing(allData, config) {
156
156
  const categorySystems = config.categories
@@ -2,8 +2,8 @@
2
2
  * getPaginationUrl: ページ番号から URL を生成する
3
3
  * page === 1 なら basePath、それ以外なら basePath + page + '/'
4
4
  */
5
- // @vocab: ページネーション (docs/dictionary.md#ページネーション)
6
- // @vocab: カテゴリーページ (docs/dictionary.md#カテゴリーページ)
5
+ // @vocab: ページネーション
6
+ // @vocab: カテゴリーページ
7
7
  // @test: tests/category/category-pagination.test.js
8
8
  export function getPaginationUrl(basePath, page) {
9
9
  const base = basePath.endsWith('/') ? basePath : `${basePath}/`
@@ -15,7 +15,7 @@ export function getPaginationUrl(basePath, page) {
15
15
  * buildWindowedPages: ウィンドウ付きページ番号リストを生成する
16
16
  * 各要素は {num, isCurrent} または {num: null, isEllipsis: true}
17
17
  */
18
- // @vocab: ページネーション (docs/dictionary.md#ページネーション)
18
+ // @vocab: ページネーション
19
19
  // @test: tests/category/category-pagination.test.js
20
20
  export function buildWindowedPages(totalPages, currentPage, windowSize = 2) {
21
21
  if (totalPages <= 0) return []
@@ -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: auto;
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,45 +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
173
  .sidebar a.active {
121
- font-weight: bold;
122
- color: #333;
123
- background: rgba(104, 117, 154, .2);
174
+ color: var(--sidebar-active-file-fg);
124
175
  display: block;
125
176
  }
126
- .sidebar li:hover {
127
- background: rgba(104, 117, 154, .3);
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);
128
192
  }
193
+ .sidebar li:not(:has(li)):hover,
194
+ .sidebar li.dir-node summary:hover {
195
+ background: var(--sidebar-hover-bg);
196
+ }
197
+
129
198
  .sidebar details > summary {
130
199
  cursor: pointer;
131
200
  padding: 2px 0;
132
- color: #444;
133
- font-size: 0.9em;
201
+ color: var(--sidebar-dir);
134
202
  list-style: disclosure-closed;
135
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
+ }
136
208
  .sidebar details[open] > summary {
137
209
  list-style: disclosure-open;
138
210
  }
139
- .sidebar details > ul {
140
- padding-left: 8px;
141
- }
142
211
  .sidebar-toggle {
143
212
  cursor: pointer;
144
213
  width: 20px;
145
214
  height: 100%;
146
215
  position: absolute;
147
- right: 0;
216
+ left: 0;
148
217
  }
149
218
  .sidebar-close .sidebar-toggle {
150
219
  position: fixed;
151
220
  left: 0;
152
- background: rgb(192, 198, 217);
221
+ background: var(--sidebar-bg);
153
222
  }
154
223
  .sidebar-toggle:hover:after {
155
224
  position: absolute;
@@ -1,6 +1,11 @@
1
1
  import { buildTree, renderTreeHtml } from '../js/tree.js'
2
2
 
3
- export function renderSidebarTree(files) {
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 = {}) {
4
9
  const tree = buildTree(files.filter(f => !f.__is_auto_category))
5
- return renderTreeHtml(tree)
10
+ return renderTreeHtml(tree, '', statusMap)
6
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
+ }
@@ -0,0 +1,15 @@
1
+ import { createDebounce } from './debouncer.js'
2
+
3
+ /**
4
+ * @vocab 自動保存初期化器
5
+ * @test tests/editor/editor-ui-cleanup.test.js
6
+ * @param {EventTarget} textarea
7
+ * @param {() => void} onSave
8
+ * @param {number} delay
9
+ * @returns {import('./debouncer.js').Debounce}
10
+ */
11
+ export function initAutoSave(textarea, onSave, delay = 500) {
12
+ const debouncedSave = createDebounce(onSave, delay)
13
+ textarea.addEventListener('input', debouncedSave)
14
+ return debouncedSave
15
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @vocab デバウンサー
3
+ * @test tests/editor/auto-preview.test.js
4
+ */
5
+ export function createDebounce(fn, delay) {
6
+ let timer = null
7
+ function debounced(...args) {
8
+ if (timer) clearTimeout(timer)
9
+ timer = setTimeout(() => {
10
+ timer = null
11
+ fn(...args)
12
+ }, delay)
13
+ }
14
+ debounced.cancel = () => {
15
+ if (timer) {
16
+ clearTimeout(timer)
17
+ timer = null
18
+ }
19
+ }
20
+ return debounced
21
+ }