@tenjuu99/blog 0.3.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/bin/dev-server +3 -1
  2. package/bin/server +8 -1
  3. package/docs/develop.md +36 -6
  4. package/docs/dictionary.json +1806 -0
  5. package/lib/dir.js +3 -0
  6. package/lib/distribute.js +19 -5
  7. package/lib/files.js +2 -0
  8. package/lib/filter.js +7 -0
  9. package/lib/generate.js +5 -0
  10. package/lib/helper.js +3 -0
  11. package/lib/imageDistributor.js +59 -0
  12. package/lib/indexer.js +3 -0
  13. package/lib/pageData.js +7 -0
  14. package/lib/render.js +5 -0
  15. package/lib/replaceVariablesFilter.js +5 -0
  16. package/lib/server/helper/parseRequestBody.js +46 -0
  17. package/lib/server.js +7 -3
  18. package/lib/tryServer.js +3 -1
  19. package/package.json +8 -5
  20. package/packages/breadcrumbs/helper/breadcrumbs.js +3 -0
  21. package/packages/category/helper/category.js +13 -0
  22. package/packages/category/helper/categoryIndexer.js +12 -0
  23. package/packages/category/helper/pagination.js +5 -0
  24. package/packages/editor/css/editor.css +98 -10
  25. package/packages/editor/helper/sidebarTree.js +11 -0
  26. package/packages/editor/js/autoPreviewInitializer.js +11 -0
  27. package/packages/editor/js/autoSaveInitializer.js +15 -0
  28. package/packages/editor/js/debouncer.js +21 -0
  29. package/packages/editor/js/editor.js +370 -45
  30. package/packages/editor/js/frontmatter_template.js +67 -0
  31. package/packages/editor/js/imageReferenceExtractor.js +19 -0
  32. package/packages/editor/js/image_upload.js +12 -0
  33. package/packages/editor/js/tree.js +81 -0
  34. package/packages/editor/server/changeReflector.js +47 -0
  35. package/packages/editor/server/converters/sharp.js +14 -0
  36. package/packages/editor/server/createConverter.js +26 -0
  37. package/packages/editor/server/get_editor_target.js +2 -2
  38. package/packages/editor/server/get_frontmatter_templates.js +22 -0
  39. package/packages/editor/server/get_publication_status.js +29 -0
  40. package/packages/editor/server/get_sidebar.js +51 -0
  41. package/packages/editor/server/image_upload.js +122 -0
  42. package/packages/editor/server/preview.js +49 -19
  43. package/packages/editor/server/publicationStatus.js +27 -0
  44. package/packages/editor/server/publish.js +135 -0
  45. package/packages/editor/server/publishTargetCollector.js +19 -0
  46. package/packages/editor/server/save.js +97 -0
  47. package/packages/editor/server/sidebarStatusCollector.js +17 -0
  48. package/packages/editor/template/editor.html +28 -22
  49. package/src-sample/converters/webp.js +20 -0
  50. package/src-sample/helper/add.js +2 -0
  51. package/src-sample/helper/index.js +9 -0
  52. package/src-sample/image/test.jpg +0 -0
  53. package/packages/editor/server/editor.js +0 -43
@@ -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" defer></script>
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">&nbsp;</div>
22
15
  </div>
23
16
  <form action="/editor" class="editor" method="post" id="editor">
24
17
  <div class="editor-options">
25
- <div>
26
- <input id="inputFileName" name="inputFileName" type="text" value="" placeholder="sample.md">
27
- <select id="selectDataFile" name="selectDataFile">
28
- <option value="">新規作成</option>
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="preview" data-url="/preview">
38
- <input type="submit" value="save" data-url="/editor">
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
+ }
@@ -1,3 +1,5 @@
1
+ // @vocab: ヘルパー関数
2
+ // @test: tests/ssg-core/helper.test.js
1
3
  export function additionalHelper() {
2
4
  return 'これは追加ヘルパーによって出力されているメッセージです。'
3
5
  }
@@ -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
- }