@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.
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: テンプレート (docs/dictionary.md#テンプレート)
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: テンプレート (docs/dictionary.md#テンプレート)
74
+ // @vocab: ヘルパー関数 (docs/dictionary.md#ヘルパー関数)
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スクリプト (docs/dictionary.md#ssgスクリプト)
94
+ // @vocab: テンプレート (docs/dictionary.md#テンプレート)
95
+ // @vocab: ヘルパー関数 (docs/dictionary.md#ヘルパー関数)
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: ビルド (docs/dictionary.md#ビルド)
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: ビルド (docs/dictionary.md#ビルド)
49
+ // @vocab: ページインデックス (docs/dictionary.md#ページインデックス)
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: ヘルパー関数 (docs/dictionary.md#ヘルパー関数)
11
+ // @vocab: パッケージ (docs/dictionary.md#パッケージ)
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 ビルド画像配布器 (docs/dictionary.md)
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 ビルド画像配布器 (docs/dictionary.md)
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 ビルド画像配布器 (docs/dictionary.md)
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: ページインデックス (docs/dictionary.md#ページインデックス)
35
+ // @vocab: ページ (docs/dictionary.md#ページ)
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: ページ (docs/dictionary.md#ページ)
9
+ // @vocab: フロントマター (docs/dictionary.md#フロントマター)
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: フロントマター (docs/dictionary.md#フロントマター)
17
+ // @vocab: ページ (docs/dictionary.md#ページ)
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: フロントマター (docs/dictionary.md#フロントマター)
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: テンプレート (docs/dictionary.md#テンプレート)
17
+ // @vocab: ページ (docs/dictionary.md#ページ)
18
+ // @vocab: SSGスクリプト (docs/dictionary.md#ssgスクリプト)
19
+ // @vocab: ヘルパー関数 (docs/dictionary.md#ヘルパー関数)
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: テンプレート (docs/dictionary.md#テンプレート)
15
+ // @vocab: ヘルパー関数 (docs/dictionary.md#ヘルパー関数)
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: ヘルパー関数 (docs/dictionary.md#ヘルパー関数)
36
+ // @vocab: テンプレート (docs/dictionary.md#テンプレート)
32
37
  const replaceVariable = (target, replaceTargetRawText, backslash, variables) => {
33
38
  const toBeReplace = replaceTargetRawText
34
39
  const toBeReplaceScript = toBeReplace.match(/([\w\d_]+)\((.*)\)/)
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.3.6",
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: カテゴリーシステム (docs/dictionary.md#カテゴリーシステム)
12
+ // @vocab: カテゴリー (docs/dictionary.md#カテゴリー)
13
+ // @vocab: カテゴリーパス (docs/dictionary.md#カテゴリーパス)
14
+ // @vocab: サブカテゴリー (docs/dictionary.md#サブカテゴリー)
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: カテゴリーパス (docs/dictionary.md#カテゴリーパス)
83
+ // @vocab: カテゴリー (docs/dictionary.md#カテゴリー)
84
+ // @vocab: ページ (docs/dictionary.md#ページ)
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: サブカテゴリー (docs/dictionary.md#サブカテゴリー)
108
+ // @vocab: カテゴリーパス (docs/dictionary.md#カテゴリーパス)
109
+ // @vocab: ページ (docs/dictionary.md#ページ)
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: ページネーション (docs/dictionary.md#ページネーション)
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: カテゴリーページ (docs/dictionary.md#カテゴリーページ)
43
+ // @vocab: ページネーション (docs/dictionary.md#ページネーション)
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: カテゴリーページ (docs/dictionary.md#カテゴリーページ)
91
+ // @vocab: カテゴリーシステム (docs/dictionary.md#カテゴリーシステム)
92
+ // @vocab: ページネーション (docs/dictionary.md#ページネーション)
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: カテゴリーシステム (docs/dictionary.md#カテゴリーシステム)
153
+ // @vocab: ビルド (docs/dictionary.md#ビルド)
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: ページネーション (docs/dictionary.md#ページネーション)
6
+ // @vocab: カテゴリーページ (docs/dictionary.md#カテゴリーページ)
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: ページネーション (docs/dictionary.md#ページネーション)
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 }]
@@ -90,7 +90,7 @@ form select {
90
90
  background: rgb(192, 198, 217);
91
91
  height: 100%;
92
92
  padding: 0;
93
- overflow-y: hidden;
93
+ overflow-y: auto;
94
94
  flex-basis: 300px;
95
95
  position: relative;
96
96
  z-index: 3;
@@ -117,9 +117,28 @@ form select {
117
117
  color: #666;
118
118
  text-decoration: none;
119
119
  }
120
+ .sidebar a.active {
121
+ font-weight: bold;
122
+ color: #333;
123
+ background: rgba(104, 117, 154, .2);
124
+ display: block;
125
+ }
120
126
  .sidebar li:hover {
121
127
  background: rgba(104, 117, 154, .3);
122
128
  }
129
+ .sidebar details > summary {
130
+ cursor: pointer;
131
+ padding: 2px 0;
132
+ color: #444;
133
+ font-size: 0.9em;
134
+ list-style: disclosure-closed;
135
+ }
136
+ .sidebar details[open] > summary {
137
+ list-style: disclosure-open;
138
+ }
139
+ .sidebar details > ul {
140
+ padding-left: 8px;
141
+ }
123
142
  .sidebar-toggle {
124
143
  cursor: pointer;
125
144
  width: 20px;
@@ -0,0 +1,6 @@
1
+ import { buildTree, renderTreeHtml } from '../js/tree.js'
2
+
3
+ export function renderSidebarTree(files) {
4
+ const tree = buildTree(files.filter(f => !f.__is_auto_category))
5
+ return renderTreeHtml(tree)
6
+ }
@@ -1,14 +1,62 @@
1
1
  const sleep = waitTime => new Promise( resolve => setTimeout(resolve, waitTime) );
2
2
 
3
+ // @vocab: テンプレートマッチャー (docs/dictionary.md#テンプレートマッチャー)
4
+ // @test: tests/editor/editor-frontmatter-template.test.js
5
+ const matchTemplate = (filePath, templates) => {
6
+ if (!filePath || !templates || templates.length === 0) return null
7
+ let best = null
8
+ for (const tmpl of templates) {
9
+ if (filePath.startsWith(tmpl.path_prefix)) {
10
+ if (!best || tmpl.path_prefix.length > best.path_prefix.length) {
11
+ best = tmpl
12
+ }
13
+ }
14
+ }
15
+ return best
16
+ }
17
+
18
+ // @vocab: テンプレートインジェクター (docs/dictionary.md#テンプレートインジェクター)
19
+ // @test: tests/editor/editor-frontmatter-template.test.js
20
+ const buildFrontmatterString = (template, baseName) => {
21
+ const fields = { ...template.fields }
22
+ fields.title = baseName
23
+ const lines = Object.entries(fields).map(([key, value]) => `${key}: ${value}`)
24
+ return `---\n${lines.join('\n')}\n---\n`
25
+ }
26
+
27
+ // @vocab: フロントマターテンプレートローダー (docs/dictionary.md#フロントマターテンプレートローダー)
28
+ // @test: tests/editor/editor-frontmatter-template.test.js
29
+ const loadFrontmatterTemplate = (filePath, templates) => {
30
+ const matched = matchTemplate(filePath, templates)
31
+ if (!matched) return null
32
+ const baseName = filePath.split('/').pop().replace(/\.[^.]+$/, '')
33
+ return buildFrontmatterString(matched, baseName)
34
+ }
35
+
36
+ // @vocab: テンプレートレゾルバー (docs/dictionary.md#テンプレートレゾルバー)
37
+ // @test: tests/editor/editor-frontmatter-template.test.js
38
+ let _frontmatterTemplates = []
39
+ const initFrontmatterTemplate = async () => {
40
+ try {
41
+ const res = await fetch('/get_frontmatter_templates')
42
+ if (res.ok) {
43
+ const json = await res.json()
44
+ _frontmatterTemplates = json.templates || []
45
+ }
46
+ } catch (e) {
47
+ console.log('[frontmatter-template] 設定の取得に失敗しました', e)
48
+ }
49
+ }
50
+
3
51
  const fetchData = (target) => {
4
- return fetch(`/get_editor_target?md=${target}`)
52
+ return fetch(`/get_editor_target?md=${encodeURIComponent(target)}`)
5
53
  .then(async res => {
6
54
  if (!res.ok) {
7
55
  document.querySelector('#inputFileName').value = target
8
- document.querySelector('#editorTextArea').value = `---
9
- title: ${target.split('.')[0].split('/').pop()}
10
- ---
11
- ${target.split('.')[0].split('/').pop()} についての記事を作成しましょう`
56
+ const baseName = target.split('/').pop().replace(/\.[^.]+$/, '')
57
+ const initialContent = loadFrontmatterTemplate(target, _frontmatterTemplates)
58
+ ?? `---\ntitle: ${baseName}\n---\n${baseName} についての記事を作成しましょう`
59
+ document.querySelector('#editorTextArea').value = initialContent
12
60
  // submit('/preview', form)
13
61
  throw new Error(`${target} does not exist.`)
14
62
  } else {
@@ -58,6 +106,12 @@ const onloadFunction = async (e) => {
58
106
  history.pushState({}, "", url)
59
107
  })
60
108
 
109
+ inputFileName.addEventListener('blur', async () => {
110
+ if (inputFileName.value && !select.value) {
111
+ fetchData(inputFileName.value).catch(() => {})
112
+ }
113
+ })
114
+
61
115
  const submit = (fetchUrl, form) => {
62
116
  const formData = new FormData(form)
63
117
  const obj = {}
@@ -98,6 +152,66 @@ const onloadFunction = async (e) => {
98
152
  })
99
153
  }
100
154
 
155
+ const SIDEBAR_OPEN_KEY = 'sidebar-is-open'
156
+ const DIR_OPEN_KEY = 'sidebar-dir-open'
157
+
158
+ // @vocab: 展開状態 (docs/dictionary.md#展開状態)
159
+ // @test: tests/editor/editor-sidebar.test.js
160
+ const loadDirOpenState = () => {
161
+ try {
162
+ return JSON.parse(localStorage.getItem(DIR_OPEN_KEY) || '{}')
163
+ } catch {
164
+ return {}
165
+ }
166
+ }
167
+
168
+ // @vocab: 展開状態 (docs/dictionary.md#展開状態)
169
+ // @test: tests/editor/editor-sidebar.test.js
170
+ const saveDirOpenState = (state) => {
171
+ localStorage.setItem(DIR_OPEN_KEY, JSON.stringify(state))
172
+ }
173
+
174
+ // @vocab: サイドバー (docs/dictionary.md#サイドバー)
175
+ // @vocab: アクティブファイル (docs/dictionary.md#アクティブファイル)
176
+ // @vocab: 展開状態 (docs/dictionary.md#展開状態)
177
+ // @test: tests/editor/editor-sidebar.test.js
178
+ const initSidebarTree = (activeFile) => {
179
+ const state = loadDirOpenState()
180
+
181
+ // アクティブファイルの親ディレクトリを開いた状態にする
182
+ if (activeFile) {
183
+ const parts = activeFile.split('/')
184
+ let path = ''
185
+ for (let i = 0; i < parts.length - 1; i++) {
186
+ path = path ? `${path}/${parts[i]}` : parts[i]
187
+ state[path] = true
188
+ }
189
+ }
190
+
191
+ // localStorage の状態を <details> に反映し、toggle イベントで保存する
192
+ document.querySelectorAll('.sidebar details[data-dir]').forEach(details => {
193
+ const dir = details.dataset.dir
194
+ if (state[dir]) {
195
+ details.open = true
196
+ }
197
+ details.addEventListener('toggle', () => {
198
+ const current = loadDirOpenState()
199
+ current[dir] = details.open
200
+ saveDirOpenState(current)
201
+ })
202
+ })
203
+
204
+ // アクティブファイルに class="active" を付与(SSG では静的に付与できないため JS で補完)
205
+ if (activeFile) {
206
+ const link = document.querySelector(`.sidebar a[href="/editor?md=${CSS.escape(activeFile)}"]`)
207
+ if (link) {
208
+ link.classList.add('active')
209
+ }
210
+ }
211
+ }
212
+
213
+ // @vocab: サイドバー (docs/dictionary.md#サイドバー)
214
+ // @test: tests/editor/editor-sidebar.test.js
101
215
  const sidebarToggle = (e) => {
102
216
  const sidebar = document.querySelector('.sidebar')
103
217
  const main = document.querySelector('main')
@@ -105,9 +219,9 @@ const sidebarToggle = (e) => {
105
219
  toggle.addEventListener('click', (e) => {
106
220
  e.preventDefault()
107
221
  main.classList.toggle('sidebar-close')
108
- localStorage.setItem('sidebar-is-open', !main.classList.contains('sidebar-close'))
222
+ localStorage.setItem(SIDEBAR_OPEN_KEY, !main.classList.contains('sidebar-close'))
109
223
  })
110
- if (localStorage.getItem('sidebar-is-open') === 'true') {
224
+ if (localStorage.getItem(SIDEBAR_OPEN_KEY) === 'true') {
111
225
  main.classList.remove('sidebar-close')
112
226
  } else {
113
227
  main.classList.add('sidebar-close')
@@ -117,7 +231,48 @@ const sidebarToggle = (e) => {
117
231
  main.classList.toggle('sidebar-close')
118
232
  })
119
233
  }
120
- document.addEventListener('DOMContentLoaded', (event) => {
234
+ // @vocab: 画像アップローダー (docs/dictionary.md#画像アップローダー)
235
+ const uploadImage = async (file, mdFile) => {
236
+ const buffer = await file.arrayBuffer()
237
+ const base64 = btoa(new Uint8Array(buffer).reduce((s, b) => s + String.fromCharCode(b), ''))
238
+ const res = await fetch('/upload-image', {
239
+ method: 'POST',
240
+ headers: { 'content-type': 'application/json' },
241
+ body: JSON.stringify({ imageData: base64, imageFilename: file.name, mdFile })
242
+ })
243
+ if (!res.ok) return null
244
+ const json = await res.json()
245
+ return json.markdownUrl
246
+ }
247
+
248
+ // @vocab: ドロップレシーバー (docs/dictionary.md#ドロップレシーバー)
249
+ const initDropReceiver = (textarea, getMdFile) => {
250
+ textarea.addEventListener('dragover', (e) => {
251
+ e.preventDefault()
252
+ })
253
+ textarea.addEventListener('drop', async (e) => {
254
+ e.preventDefault()
255
+ const files = [...e.dataTransfer.files].filter(f => f.type.startsWith('image/'))
256
+ for (const file of files) {
257
+ const markdownUrl = await uploadImage(file, getMdFile())
258
+ if (markdownUrl) {
259
+ const start = textarea.selectionStart
260
+ const content = textarea.value
261
+ textarea.value = content.slice(0, start) + `![](${markdownUrl})` + content.slice(start)
262
+ textarea.selectionStart = textarea.selectionEnd = start + `![](${markdownUrl})`.length
263
+ }
264
+ }
265
+ })
266
+ }
267
+
268
+ document.addEventListener('DOMContentLoaded', async (event) => {
269
+ const url = new URL(location)
270
+ const activeFile = url.searchParams.get('md') || ''
271
+ await initFrontmatterTemplate()
121
272
  onloadFunction(event)
122
273
  sidebarToggle(event)
274
+ initSidebarTree(activeFile)
275
+ const textarea = document.querySelector('#editorTextArea')
276
+ const inputFileName = document.querySelector('#inputFileName')
277
+ initDropReceiver(textarea, () => inputFileName.value)
123
278
  })
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @typedef {{ path_prefix: string, fields: Object.<string, string> }} FrontmatterTemplateConfig
3
+ */
4
+
5
+ /**
6
+ * ファイルパスにマッチするフロントマターテンプレート設定を選択する。
7
+ * 複数マッチする場合は最も長いプレフィックスを優先する。
8
+ *
9
+ * @vocab: テンプレートマッチャー (docs/dictionary.md#テンプレートマッチャー)
10
+ * @test: tests/editor/editor-frontmatter-template.test.js
11
+ *
12
+ * @param {string} filePath - 新規ファイルのパス(例: "book/my-book.md")
13
+ * @param {FrontmatterTemplateConfig[]} templates - テンプレート設定の配列
14
+ * @returns {FrontmatterTemplateConfig|null} マッチした設定、またはnull
15
+ */
16
+ export function matchTemplate(filePath, templates) {
17
+ if (!filePath || !templates || templates.length === 0) {
18
+ return null
19
+ }
20
+ let best = null
21
+ for (const tmpl of templates) {
22
+ if (filePath.startsWith(tmpl.path_prefix)) {
23
+ if (!best || tmpl.path_prefix.length > best.path_prefix.length) {
24
+ best = tmpl
25
+ }
26
+ }
27
+ }
28
+ return best
29
+ }
30
+
31
+ /**
32
+ * フロントマターテンプレート設定をフロントマター文字列に変換する。
33
+ * title はファイル名から自動生成する(fields に title がある場合でもファイル名を優先)。
34
+ *
35
+ * @vocab: テンプレートインジェクター (docs/dictionary.md#テンプレートインジェクター)
36
+ * @test: tests/editor/editor-frontmatter-template.test.js
37
+ *
38
+ * @param {FrontmatterTemplateConfig} template - マッチしたテンプレート設定
39
+ * @param {string} baseName - ファイル名(拡張子・ディレクトリなし、例: "my-book")
40
+ * @returns {string} `---\n...\n---\n` 形式のフロントマター文字列
41
+ */
42
+ export function buildFrontmatterString(template, baseName) {
43
+ const fields = { ...template.fields }
44
+ // title はファイル名から自動生成
45
+ fields.title = baseName
46
+
47
+ const lines = Object.entries(fields).map(([key, value]) => `${key}: ${value}`)
48
+ return `---\n${lines.join('\n')}\n---\n`
49
+ }
50
+
51
+ /**
52
+ * ファイルパスとテンプレート設定一覧からフロントマター文字列を生成する。
53
+ * マッチするテンプレートがない場合は null を返す。
54
+ *
55
+ * @vocab: フロントマターテンプレートローダー (docs/dictionary.md#フロントマターテンプレートローダー)
56
+ * @test: tests/editor/editor-frontmatter-template.test.js
57
+ *
58
+ * @param {string} filePath - 新規ファイルのパス(例: "book/my-book.md")
59
+ * @param {FrontmatterTemplateConfig[]} templates - テンプレート設定の配列
60
+ * @returns {string|null} フロントマター文字列、またはマッチしない場合は null
61
+ */
62
+ export function loadFrontmatterTemplate(filePath, templates) {
63
+ const matched = matchTemplate(filePath, templates)
64
+ if (!matched) return null
65
+ const baseName = filePath.split('/').pop().replace(/\.[^.]+$/, '')
66
+ return buildFrontmatterString(matched, baseName)
67
+ }