@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/docs/develop.md +6 -6
- package/docs/dictionary.md +553 -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/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 +20 -1
- package/packages/editor/helper/sidebarTree.js +6 -0
- package/packages/editor/js/editor.js +163 -8
- package/packages/editor/js/frontmatter_template.js +67 -0
- package/packages/editor/js/image_upload.js +12 -0
- package/packages/editor/js/tree.js +66 -0
- package/packages/editor/server/converters/sharp.js +14 -0
- package/packages/editor/server/createConverter.js +26 -0
- package/packages/editor/server/get_frontmatter_templates.js +22 -0
- package/packages/editor/server/image_upload.js +130 -0
- package/packages/editor/template/editor.html +3 -7
- 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/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: テンプレート (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.
|
|
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
|
|
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: カテゴリーシステム (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:
|
|
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;
|
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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(
|
|
222
|
+
localStorage.setItem(SIDEBAR_OPEN_KEY, !main.classList.contains('sidebar-close'))
|
|
109
223
|
})
|
|
110
|
-
if (localStorage.getItem(
|
|
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
|
-
|
|
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) + `` + content.slice(start)
|
|
262
|
+
textarea.selectionStart = textarea.selectionEnd = start + ``.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
|
+
}
|