@tenjuu99/blog 0.3.5 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/dev-server +3 -1
- package/bin/server +8 -1
- package/docs/develop.md +36 -6
- package/docs/dictionary.json +1806 -0
- package/lib/dir.js +3 -0
- package/lib/distribute.js +19 -5
- package/lib/files.js +2 -0
- package/lib/filter.js +7 -0
- package/lib/generate.js +5 -0
- package/lib/helper.js +3 -0
- package/lib/imageDistributor.js +59 -0
- package/lib/indexer.js +3 -0
- package/lib/pageData.js +7 -0
- package/lib/render.js +5 -0
- package/lib/replaceVariablesFilter.js +5 -0
- package/lib/server/helper/parseRequestBody.js +46 -0
- package/lib/server.js +7 -3
- package/lib/tryServer.js +3 -1
- package/package.json +8 -5
- package/packages/breadcrumbs/helper/breadcrumbs.js +3 -0
- package/packages/category/helper/category.js +13 -0
- package/packages/category/helper/categoryIndexer.js +12 -0
- package/packages/category/helper/pagination.js +5 -0
- package/packages/editor/css/editor.css +98 -10
- package/packages/editor/helper/sidebarTree.js +11 -0
- package/packages/editor/js/autoPreviewInitializer.js +11 -0
- package/packages/editor/js/autoSaveInitializer.js +15 -0
- package/packages/editor/js/debouncer.js +21 -0
- package/packages/editor/js/editor.js +370 -45
- package/packages/editor/js/frontmatter_template.js +67 -0
- package/packages/editor/js/imageReferenceExtractor.js +19 -0
- package/packages/editor/js/image_upload.js +12 -0
- package/packages/editor/js/tree.js +81 -0
- package/packages/editor/server/changeReflector.js +47 -0
- package/packages/editor/server/converters/sharp.js +14 -0
- package/packages/editor/server/createConverter.js +26 -0
- package/packages/editor/server/get_editor_target.js +2 -2
- package/packages/editor/server/get_frontmatter_templates.js +22 -0
- package/packages/editor/server/get_publication_status.js +29 -0
- package/packages/editor/server/get_sidebar.js +51 -0
- package/packages/editor/server/image_upload.js +122 -0
- package/packages/editor/server/preview.js +49 -19
- package/packages/editor/server/publicationStatus.js +27 -0
- package/packages/editor/server/publish.js +135 -0
- package/packages/editor/server/publishTargetCollector.js +19 -0
- package/packages/editor/server/save.js +97 -0
- package/packages/editor/server/sidebarStatusCollector.js +17 -0
- package/packages/editor/template/editor.html +28 -22
- package/src-sample/converters/webp.js +20 -0
- package/src-sample/helper/add.js +2 -0
- package/src-sample/helper/index.js +9 -0
- package/src-sample/image/test.jpg +0 -0
- package/packages/editor/server/editor.js +0 -43
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { extractImageReferences } from '../js/imageReferenceExtractor.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @vocab: 公開対象コレクター
|
|
5
|
+
* @test tests/editor/publish.test.js
|
|
6
|
+
* @param {string} filePath - pages ディレクトリからの相対パス(例: `post/hello.md`)
|
|
7
|
+
* @param {string} fileContent - Markdown 本文
|
|
8
|
+
* @param {string} [srcDir='src'] - ソースディレクトリ名
|
|
9
|
+
* @returns {{ markdownFile: string, imageFiles: string[] }}
|
|
10
|
+
*/
|
|
11
|
+
export function collectTarget(filePath, fileContent, srcDir = 'src') {
|
|
12
|
+
const imageUrls = extractImageReferences(fileContent)
|
|
13
|
+
return {
|
|
14
|
+
markdownFile: `${srcDir}/pages/${filePath}`,
|
|
15
|
+
imageFiles: imageUrls
|
|
16
|
+
.filter(url => !url.includes('..'))
|
|
17
|
+
.map(url => url.startsWith('/') ? `${srcDir}${url}` : `${srcDir}/${url}`)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { IncomingMessage, ServerResponse } from 'http'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import { styleText } from 'node:util'
|
|
4
|
+
import { watch, pageDir as cachePageDir } from '@tenjuu99/blog/lib/dir.js'
|
|
5
|
+
import { indexing } from '@tenjuu99/blog/lib/indexer.js'
|
|
6
|
+
import { parseJsonBody } from '@tenjuu99/blog/lib/server/helper/parseRequestBody.js'
|
|
7
|
+
|
|
8
|
+
export const path = '/save'
|
|
9
|
+
|
|
10
|
+
const MAX_BODY_SIZE = 1 * 1024 * 1024 // 1MB
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `..` や空セグメント等のパストラバーサル要素を含む場合はエラーメッセージを返す。
|
|
14
|
+
* @param {string} filename
|
|
15
|
+
* @returns {string|null} エラーメッセージ、問題なければ null
|
|
16
|
+
*/
|
|
17
|
+
function validateFilename(filename) {
|
|
18
|
+
if (!filename || typeof filename !== 'string') return 'ファイル名がありません'
|
|
19
|
+
const parts = filename.split('/')
|
|
20
|
+
for (const part of parts) {
|
|
21
|
+
if (part === '' || part === '.' || part === '..') return '不正なパスが含まれています'
|
|
22
|
+
if (part.includes('\\')) return '不正なパスが含まれています'
|
|
23
|
+
}
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @vocab 保存エンドポイント
|
|
29
|
+
* @test tests/editor/editor-ui-cleanup.test.js
|
|
30
|
+
* @param {string} filename
|
|
31
|
+
* @param {string} content
|
|
32
|
+
* @param {string} pageDir
|
|
33
|
+
* @param {{ createOnly?: boolean }} [options]
|
|
34
|
+
* @returns {Promise<{success: true}|{success: false, error: string, code?: string}>}
|
|
35
|
+
*/
|
|
36
|
+
export async function saveFile(filename, content, pageDir, options = {}) {
|
|
37
|
+
const validationError = validateFilename(filename)
|
|
38
|
+
if (validationError) {
|
|
39
|
+
return { success: false, error: validationError, code: 'INVALID_FILENAME' }
|
|
40
|
+
}
|
|
41
|
+
const parts = filename.split('/')
|
|
42
|
+
const basename = parts.pop()
|
|
43
|
+
const dir = [pageDir, ...parts].join('/')
|
|
44
|
+
const filepath = `${dir}/${basename}`
|
|
45
|
+
if (options.createOnly && fs.existsSync(filepath)) {
|
|
46
|
+
return { success: false, error: 'ファイルが既に存在します', code: 'FILE_EXISTS' }
|
|
47
|
+
}
|
|
48
|
+
if (!fs.existsSync(dir)) {
|
|
49
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
50
|
+
}
|
|
51
|
+
fs.writeFileSync(filepath, content)
|
|
52
|
+
console.log(styleText('blue', `[save] ${filename}`))
|
|
53
|
+
return { success: true }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {IncomingMessage} req
|
|
58
|
+
* @param {ServerResponse} res
|
|
59
|
+
*/
|
|
60
|
+
export const post = async (req, res) => {
|
|
61
|
+
let json
|
|
62
|
+
try {
|
|
63
|
+
json = await parseJsonBody(req, { maxSize: MAX_BODY_SIZE })
|
|
64
|
+
} catch (e) {
|
|
65
|
+
const status = e.code === 'PAYLOAD_TOO_LARGE' ? 413 : 400
|
|
66
|
+
res.writeHead(status, { 'content-type': 'application/json' })
|
|
67
|
+
res.end(JSON.stringify({ error: e.message }))
|
|
68
|
+
return true
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!json.filename) {
|
|
72
|
+
res.writeHead(400, { 'content-type': 'application/json' })
|
|
73
|
+
res.end(JSON.stringify({ error: 'ファイル名がありません' }))
|
|
74
|
+
return true
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const result = await saveFile(json.filename, json.content ?? '', watch.pageDir, { createOnly: !!json.createOnly })
|
|
78
|
+
if (!result.success) {
|
|
79
|
+
const status = result.code === 'FILE_EXISTS' ? 409 : 400
|
|
80
|
+
res.writeHead(status, { 'content-type': 'application/json' })
|
|
81
|
+
res.end(JSON.stringify(result))
|
|
82
|
+
return true
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (json.createOnly) {
|
|
86
|
+
// .cache/pages/ にも書き込んで indexing() が新規ファイルを認識できるようにする
|
|
87
|
+
const cacheParts = json.filename.split('/')
|
|
88
|
+
const cacheBasename = cacheParts.pop()
|
|
89
|
+
const cacheDirPath = [cachePageDir, ...cacheParts].join('/')
|
|
90
|
+
if (!fs.existsSync(cacheDirPath)) fs.mkdirSync(cacheDirPath, { recursive: true })
|
|
91
|
+
fs.writeFileSync(`${cacheDirPath}/${cacheBasename}`, json.content ?? '')
|
|
92
|
+
await indexing()
|
|
93
|
+
}
|
|
94
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
95
|
+
res.end(JSON.stringify({ success: true }))
|
|
96
|
+
return true
|
|
97
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { getPublicationStatus } from './publicationStatus.js'
|
|
2
|
+
/**
|
|
3
|
+
* @vocab ファイルステータスコレクター
|
|
4
|
+
* @test tests/editor/editor-sidebar-status.test.js
|
|
5
|
+
* @param {Array<{treePath: string, gitPath: string}>} fileMappings
|
|
6
|
+
* @param {import('./publicationStatus.js').PublishedState} publishedState
|
|
7
|
+
* @returns {Promise<Object.<string, 'new'|'modified'|'published'|'unknown'>>}
|
|
8
|
+
*/
|
|
9
|
+
export async function collectStatuses(fileMappings, publishedState) {
|
|
10
|
+
const entries = await Promise.all(
|
|
11
|
+
fileMappings.map(async ({ treePath, gitPath }) => {
|
|
12
|
+
const status = await getPublicationStatus(gitPath, publishedState)
|
|
13
|
+
return [treePath, status]
|
|
14
|
+
})
|
|
15
|
+
)
|
|
16
|
+
return Object.fromEntries(entries)
|
|
17
|
+
}
|
|
@@ -5,38 +5,24 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
<title>{{SITE_NAME}}</title>
|
|
7
7
|
<link rel="stylesheet" href="${/css/editor.css<<reset.css,editor.css}">
|
|
8
|
-
<script src="/js/editor.js"
|
|
8
|
+
<script type="module" src="/js/editor.js"></script>
|
|
9
9
|
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<main class="container">
|
|
13
13
|
<div class="sidebar">
|
|
14
|
-
<ul>
|
|
15
|
-
<script type="ssg">
|
|
16
|
-
return helper.readIndex().map(p => {
|
|
17
|
-
return `<li><a href="/editor?md=${p.name}.${p.__filetype}">${p.url}.${p.__filetype}</a></li>`
|
|
18
|
-
}).join("\n")
|
|
19
|
-
</script>
|
|
20
|
-
</ul>
|
|
21
14
|
<div class="sidebar-toggle"> </div>
|
|
22
15
|
</div>
|
|
23
16
|
<form action="/editor" class="editor" method="post" id="editor">
|
|
24
17
|
<div class="editor-options">
|
|
25
|
-
<div>
|
|
26
|
-
<
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
<script type="ssg">
|
|
30
|
-
return helper.readIndex().map(p => {
|
|
31
|
-
return `<option value="${p.name}.${p.__filetype}">${p.url}.${p.__filetype}</option>`
|
|
32
|
-
}).join("\n")
|
|
33
|
-
</script>
|
|
34
|
-
</select>
|
|
18
|
+
<div class="editor-options-left">
|
|
19
|
+
<span id="fileStatus"><span id="currentFileName"></span><span id="publicationStatus"></span></span>
|
|
20
|
+
<button type="button" id="newFileBtn">新規作成</button>
|
|
21
|
+
<input type="hidden" id="inputFileName" name="inputFileName" value="">
|
|
35
22
|
</div>
|
|
36
|
-
<div>
|
|
37
|
-
<input type="submit" value="
|
|
38
|
-
<
|
|
39
|
-
<a href="/">戻る</a>
|
|
23
|
+
<div class="editor-options-right">
|
|
24
|
+
<input type="submit" value="publish" data-url="/publish" id="publishBtn">
|
|
25
|
+
<span id="publishFeedback"></span>
|
|
40
26
|
</div>
|
|
41
27
|
</div>
|
|
42
28
|
<div class="textareaAndPreview">
|
|
@@ -49,6 +35,26 @@
|
|
|
49
35
|
</div>
|
|
50
36
|
<input type="hidden" name="token" value="{{TOKEN}}">
|
|
51
37
|
</form>
|
|
38
|
+
<dialog id="newFileDialog">
|
|
39
|
+
<form method="dialog">
|
|
40
|
+
<h3>新規ファイルを作成</h3>
|
|
41
|
+
<label>
|
|
42
|
+
ファイル名(例: book/my-book.md)
|
|
43
|
+
<input type="text" id="newFileName" placeholder="path/to/file.md" autocomplete="off">
|
|
44
|
+
</label>
|
|
45
|
+
<label>
|
|
46
|
+
テンプレート
|
|
47
|
+
<select id="newFileTemplate">
|
|
48
|
+
<option value="">テンプレートなし</option>
|
|
49
|
+
</select>
|
|
50
|
+
</label>
|
|
51
|
+
<p id="newFileError" role="alert" aria-live="assertive"></p>
|
|
52
|
+
<div>
|
|
53
|
+
<button type="button" id="confirmNewFile">作成</button>
|
|
54
|
+
<button type="button" id="cancelNewFile">キャンセル</button>
|
|
55
|
+
</div>
|
|
56
|
+
</form>
|
|
57
|
+
</dialog>
|
|
52
58
|
<div class="hamburger-menu">
|
|
53
59
|
<input type="checkbox" id="menu-btn-check">
|
|
54
60
|
<label for="menu-btn-check" class="menu-btn"><span></span></label>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebP変換コンバーターのサンプル実装。
|
|
3
|
+
* sharp を使って画像を WebP に変換する。
|
|
4
|
+
*
|
|
5
|
+
* 使い方:
|
|
6
|
+
* 1. sharp をインストール: npm install sharp
|
|
7
|
+
* 2. blog.json に設定: { "image_converter": "./converters/webp.js" }
|
|
8
|
+
* (ビルトインを使う場合は "sharp" のみ指定)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import sharp from 'sharp'
|
|
12
|
+
|
|
13
|
+
export const ext = 'webp'
|
|
14
|
+
|
|
15
|
+
export default async function convert(buffer) {
|
|
16
|
+
return await sharp(buffer)
|
|
17
|
+
.resize({ width: 1200, withoutEnlargement: true })
|
|
18
|
+
.webp()
|
|
19
|
+
.toBuffer()
|
|
20
|
+
}
|
package/src-sample/helper/add.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { allData, config } from '@tenjuu99/blog'
|
|
2
2
|
|
|
3
|
+
// @vocab: ページインデックス
|
|
4
|
+
// @vocab: ページ
|
|
5
|
+
// @test: tests/ssg-core/indexer.test.js
|
|
3
6
|
export function readIndex (filter = null) {
|
|
4
7
|
const data = Object.entries(allData)
|
|
5
8
|
.sort((a, b) => new Date(b[1].published) - new Date(a[1].published))
|
|
@@ -13,6 +16,9 @@ export function dateFormat(dateString) {
|
|
|
13
16
|
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
|
|
14
17
|
}
|
|
15
18
|
|
|
19
|
+
// @vocab: ページ
|
|
20
|
+
// @vocab: ページインデックス
|
|
21
|
+
// @test: tests/ssg-core/pageData.test.js
|
|
16
22
|
export function getPageData(name) {
|
|
17
23
|
return allData[name]
|
|
18
24
|
}
|
|
@@ -41,6 +47,9 @@ export function arrayToList(arrayOrText) {
|
|
|
41
47
|
return arrayOrText
|
|
42
48
|
}
|
|
43
49
|
|
|
50
|
+
// @vocab: ページインデックス
|
|
51
|
+
// @vocab: ページ
|
|
52
|
+
// @test: tests/ssg-core/indexer.test.js
|
|
44
53
|
export function renderIndex(pages, nodate = 'nodate', headingTag = 'h3') {
|
|
45
54
|
if (!pages) {
|
|
46
55
|
pages = readIndex()
|
|
Binary file
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { IncomingMessage, ServerResponse } from 'http'
|
|
2
|
-
import fs from 'node:fs'
|
|
3
|
-
import { styleText } from 'node:util'
|
|
4
|
-
import config from '@tenjuu99/blog/lib/config.js'
|
|
5
|
-
import { watch } from '@tenjuu99/blog/lib/dir.js'
|
|
6
|
-
|
|
7
|
-
export const path = '/editor'
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* @param {IncomingMessage} req
|
|
11
|
-
* @param {ServerResponse} res
|
|
12
|
-
*/
|
|
13
|
-
export const post = async (req, res) => {
|
|
14
|
-
const chunks = []
|
|
15
|
-
req
|
|
16
|
-
.on('data', (chunk) => chunks.push(chunk))
|
|
17
|
-
.on('end', async () => {
|
|
18
|
-
const json = JSON.parse(chunks.join())
|
|
19
|
-
const file = json.inputFileName ? json.inputFileName : json.selectDataFile
|
|
20
|
-
if (!file) {
|
|
21
|
-
res.writeHead(400, { 'content-type': 'application/json' })
|
|
22
|
-
res.end(JSON.stringify({
|
|
23
|
-
'message': 'ファイル名がありません'
|
|
24
|
-
}))
|
|
25
|
-
return
|
|
26
|
-
}
|
|
27
|
-
const filenameSplitted = file.split('/')
|
|
28
|
-
const filename = filenameSplitted.pop()
|
|
29
|
-
const dir = [watch.pageDir, ...filenameSplitted].join('/')
|
|
30
|
-
if (!fs.existsSync(dir)) {
|
|
31
|
-
fs.mkdirSync(dir, { recursive: true })
|
|
32
|
-
}
|
|
33
|
-
fs.writeFileSync(`${dir}/${filename}`, json.content)
|
|
34
|
-
console.log(styleText('blue', '[editor/post] finished'))
|
|
35
|
-
|
|
36
|
-
const href = file.split('.')[0]
|
|
37
|
-
res.writeHead(200, { 'content-type': 'application/json' })
|
|
38
|
-
res.end(JSON.stringify({
|
|
39
|
-
'href': `/${href}`
|
|
40
|
-
}))
|
|
41
|
-
})
|
|
42
|
-
return true
|
|
43
|
-
}
|