青空文庫形式のバックアップデータは各話を別々のファイルとして管理されていないので、章や節を大見出し、中見だしと分かれていて、投稿日時と更新日時がある。それをastroで管理しやすいフロントマッターをつけた各話形式にする。
ファイル管理ではまとめたファイルのほうが管理しやすいが、長編になってくると文字数が多くなるので分割したほうがエディターの動作が軽い。
src/
src/
├── content/
│ └── novels/
│ ├── wagahai.txt
│ ├── sanshiro.txt
│ └── ...
└── pages/
└── index.astro```
const novelsDir =
path.join(
process.cwd(),
"src",
"content",
"novels"
);
const files =
(await fs.readdir(novelsDir))
.filter(file =>
file.endsWith(".txt")
);
const novels = files.map(file => ({
slug: file.replace(/.txt$/, ""),
filename: file,
const novelsDir =
path.join(
process.cwd(),
"src",
"content",
"novels"
);
const files =
(await fs.readdir(novelsDir))
.filter(file =>
file.endsWith(".txt")
);
const match =
file.match(
/^[(.?)]\s(.*?).txt$/
);
return {
slug:
encodeURIComponent(
file.replace(/.txt$/, "")
),
{
novels.map(novel => (
<h2>
<a href={`/books/${novel.slug}`}>
{novel.title}
</a>
</h2>
<p>{novel.author}</p>
</article>
))
}
```
<style>
<style>
body {
max-width: 900px;
margin: auto;
padding: 1rem;
}
.novel-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.card {
border: 1px solid #ddd;
padding: 1rem;
}
.card h2 {
margin: 0;
}
.card a {
text-decoration: none;
}
</style>```
```
const novels = await Promise.all(
const novels = await Promise.all(
files.map(async file => {
const stat =
await fs.stat(
path.join(novelsDir, file)
);
return {
file,
mtime: stat.mtime,
};
})
);
novels.sort(
(a, b) =>
b.mtime.getTime() -
a.mtime.getTime()
);```
const novels = await Promise.all(
const novels = await Promise.all(
files.map(async file => {
const stat =
await fs.stat(
path.join(novelsDir, file)
);
return {
file,
mtime: stat.mtime,
};
})
);
novels.sort(
(a, b) =>
b.mtime.getTime() -
a.mtime.getTime()
);```
もし
---
title:
author:
published:
updated:
本文```
title:
author:
published:
updated:
本文```
のような frontmatter をTXTの先頭に付けるなら、
npm
npm install gray-matter```
npm
npm install gray-matter```
して
import
import matter from "gray-matter";
const raw =
await fs.readFile(filePath, "utf-8");
const { data, content } =
matter(raw);```
import
import matter from "gray-matter";
const raw =
await fs.readFile(filePath, "utf-8");
const { data, content } =
matter(raw);```
で扱えます。
青空文庫パーサーと組み合わせるなら、この方式がかなり便利です。
ChatGPT:
毎回 meta.json を手動で書き換えるのは面倒なので、通常は次のどちらかにします。
meta.json には作品情報だけを持たせる。
{
{
"title": "異世界転移",
"author": "作者名",
"description": "あらすじ"
}```
{
{
"title": "異世界転移",
"author": "作者名",
"description": "あらすじ"
}```
話数情報は持たない。
Astro側で
const
const files = await fs.readdir(
"src/content/novels/isekai"
);
const episodes = files
.filter(f => f.endsWith(".txt"))
.sort();```
const
const files = await fs.readdir(
"src/content/novels/isekai"
);
const episodes = files
.filter(f => f.endsWith(".txt"))
.sort();```
として自動取得する。
新しく
0005.txt
0005.txt```
0005.txt
0005.txt```
を追加しても、
meta.json
meta.json```
meta.json
meta.json```
は変更不要です。
ZIP作成時に
{
{
"chapters": [
{
"title": "異世界転移",
"episodes": [
{
"id": 1,
"title": "登場人物紹介"
},
{
"id": 2,
"title": "始まりそして転移"
}
]
}
]
}```
{
{
"chapters": [
{
"title": "異世界転移",
"episodes": [
{
"id": 1,
"title": "登場人物紹介"
},
{
"id": 2,
"title": "始まりそして転移"
}
]
}
]
}```
を生成する。
人間は編集しない。
TXTを増やしたら分割ツールを再実行して作り直す。
各話に Frontmatter を持たせる。
0001.txt
---
chapter: 異世界転移
title: 登場人物紹介
published: 2020-03-02
本文```
chapter: 異世界転移
title: 登場人物紹介
published: 2020-03-02
本文```
0002.txt
---
chapter: 異世界転移
title: 始まりそして転移
published: 2020-03-03
本文```
chapter: 異世界転移
title: 始まりそして転移
published: 2020-03-03
本文```
Astro側で
import
import matter from "gray-matter";
const raw = await fs.readFile(file);
const { data } = matter(raw);```
import
import matter from "gray-matter";
const raw = await fs.readFile(file);
const { data } = matter(raw);```
して目次を組み立てる。
私なら
novels/
novels/
└─ isekai/
├─ novel.json
├─ 0001.txt
├─ 0002.txt
└─ 0003.txt```
novels/
novels/
└─ isekai/
├─ novel.json
├─ 0001.txt
├─ 0002.txt
└─ 0003.txt```
にします。
novel.json
{
{
"title": "異世界転移",
"author": "作者名",
"description": "あらすじ"
}```
{
{
"title": "異世界転移",
"author": "作者名",
"description": "あらすじ"
}```
各話
---
chapter: 異世界転移
title: 登場人物紹介
published: 2020-03-02
本文```
chapter: 異世界転移
title: 登場人物紹介
published: 2020-03-02
本文```
こうすると、
- 作品情報 → novel.json
- 話情報 → 各TXTのFrontmatter
作品情報 → novel.json
話情報 → 各TXTのFrontmatter
で分離できるので、
0004.txt
0004.txt
0005.txt```
0004.txt
0004.txt
0005.txt```
を追加しても novel.json は変更不要です。
青空文庫形式のWeb小説バックアップをAstroで読む用途なら、この構成が一番管理しやすいと思います。
あなた:
原稿の管理方法として青空文庫形式で一つのファイルを書いて変換させるのか、txtファイルを増やすのかどちらがいいのか
ChatGPT:
これは運用方針によりますが、Web小説バックアップや自作小説管理なら、それぞれに向いている用途があります。
無職転生.txt
無職転生.txt
[#改ページ]
[#大見出し]第一章[#大見出し終わり]
[#中見出し]第一話[#中見出し終わり]
本文
[#改ページ]
[#中見出し]第二話[#中見出し終わり]
本文```
無職転生.txt
無職転生.txt
[#改ページ]
[#大見出し]第一章[#大見出し終わり]
[#中見出し]第一話[#中見出し終わり]
本文
[#改ページ]
[#中見出し]第二話[#中見出し終わり]
本文```
- 原稿が1ファイルで管理できる
- 検索しやすい
- Git管理しやすい
- EPUB化しやすい
- 青空文庫形式との相性が良い
- バックアップが簡単
原稿が1ファイルで管理できる
検索しやすい
Git管理しやすい
EPUB化しやすい
青空文庫形式との相性が良い
バックアップが簡単
- 10万~100万文字になると重い
- 話単位の編集が少し面倒
- 差分が大きくなる
10万~100万文字になると重い
話単位の編集が少し面倒
差分が大きくなる
無職転生/
無職転生/
├─ novel.json
├─ 0001.txt
├─ 0002.txt
├─ 0003.txt
└─ ...```
無職転生/
無職転生/
├─ novel.json
├─ 0001.txt
├─ 0002.txt
├─ 0003.txt
└─ ...```
- 投稿サイトに近い
- 話単位で編集できる
- 更新管理しやすい
- Astroで扱いやすい
- 前話・次話生成が簡単
投稿サイトに近い
話単位で編集できる
更新管理しやすい
Astroで扱いやすい
前話・次話生成が簡単
- ファイル数が増える
- 全文検索が面倒
- 章移動時に整理が必要
ファイル数が増える
全文検索が面倒
章移動時に整理が必要
私は
原本
原本
↓
1ファイル```
原本
原本
↓
1ファイル```
をおすすめします。
例えば
src/source/
src/source/
└─ mushoku.txt```
src/source/
src/source/
└─ mushoku.txt```
を編集する。
そしてビルド時や変換時に
mushoku.txt
mushoku.txt
↓
分割
↓
dist/
├─ 0001.txt
├─ 0002.txt
└─ ...```
mushoku.txt
mushoku.txt
↓
分割
↓
dist/
├─ 0001.txt
├─ 0002.txt
└─ ...```
を生成する。
Gitなら
mushoku.txt
mushoku.txt```
mushoku.txt
mushoku.txt```
1つの方が管理しやすいです。
章構成変更も
第一章
第一章
↓
プロローグ```
第一章
第一章
↓
プロローグ```
だけで済みます。
公開用は
0001.txt
0001.txt
0002.txt
0003.txt```
0001.txt
0001.txt
0002.txt
0003.txt```
の方が便利です。
URL
/books/mushoku/1
/books/mushoku/1
/books/mushoku/2
/books/mushoku/3```
/books/mushoku/1
/books/mushoku/1
/books/mushoku/2
/books/mushoku/3```
を作りやすいからです。
編集用
source/
source/
└─ 無職転生.txt```
source/
source/
└─ 無職転生.txt```
公開用
content/
content/
└─ 無職転生/
├─ 0001.txt
├─ 0002.txt
└─ ...```
content/
content/
└─ 無職転生/
├─ 0001.txt
├─ 0002.txt
└─ ...```
という二段構成にします。
青空文庫形式原稿
青空文庫形式原稿
↓
分割ツール
↓
Astro用データ
↓
サイト生成```
青空文庫形式原稿
青空文庫形式原稿
↓
分割ツール
↓
Astro用データ
↓
サイト生成```
です。
あなたはすでに AozoraText2Html() や青空文庫記法を扱うコードを持っているので、原稿は1ファイルで管理し、サイト生成時に話ごとのTXTへ自動分割する方式 が最も保守しやすく、EPUB生成やバックアップにも流用しやすいと思います。
あなた:
小説個別のページにタイトルやあらすじと各話へのリンクを作るには
ChatGPT:
その場合は、作品ごとに
src/content/novels/
src/content/novels/
└── isekai/
├── novel.json
├── 0001.txt
├── 0002.txt
└── 0003.txt```
src/content/novels/
src/content/novels/
└── isekai/
├── novel.json
├── 0001.txt
├── 0002.txt
└── 0003.txt```
のような構成にして、
novel.json に作品情報を持たせると管理しやすいです。
{
{
"title": "異世界転移",
"author": "山田太郎",
"description": "平凡な高校生が異世界へ転移する物語。",
"status": "completed"
}```
{
{
"title": "異世界転移",
"author": "山田太郎",
"description": "平凡な高校生が異世界へ転移する物語。",
"status": "completed"
}```
src/pages/books/[novel]/index.astro
src/pages/books/[novel]/index.astro```
src/pages/books/[novel]/index.astro
src/pages/books/[novel]/index.astro```
/books/isekai/
/books/isekai/```
/books/isekai/
/books/isekai/```
---
import fs from "node:fs/promises";
import path from "node:path";
const { novel } = Astro.params;
const novelDir = path.join(
process.cwd(),
"src",
"content",
"novels",
novel
);
const meta = JSON.parse(
await fs.readFile(
path.join(novelDir, "novel.json"),
"utf-8"
)
);
const files = (
await fs.readdir(novelDir)
)
.filter(file => /^\d+.txt$/.test(file))
.sort();
const episodes = files.map((file, index) => ({
no: index + 1,
file
}));
<html lang="ja">
<head>
<meta charset="UTF-8" />
<title>{meta.title}</title>
</head>
<body>
<h1>{meta.title}</h1>
<p>作者:{meta.author}</p>
<p>{meta.description}</p>
<p>
状態:
{meta.status === "completed"
? "完結"
: "連載中"}
</p>
<hr>
<h2>目次</h2>
<ol>
{
episodes.map(ep => (
<li>
<a href={/books/${novel}/${ep.no}}>
第{ep.no}話
</a>
</li>
))
}
</ol>
</body>
</html>```
import fs from "node:fs/promises";
import path from "node:path";
const { novel } = Astro.params;
const novelDir = path.join(
process.cwd(),
"src",
"content",
"novels",
novel
);
const meta = JSON.parse(
await fs.readFile(
path.join(novelDir, "novel.json"),
"utf-8"
)
);
const files = (
await fs.readdir(novelDir)
)
.filter(file => /^\d+.txt$/.test(file))
.sort();
const episodes = files.map*1;
{meta.title}
作者:{meta.author}
{meta.description}
状態:
{meta.status === "completed"
? "完結"
: "連載中"}
目次
{
episodes.map(ep => (
-
第{ep.no}話
))
}
```
さらに各話の Frontmatter を読むと、
0001.txt
---
title: 登場人物紹介
chapter: 異世界転移
published: 2020-03-02
本文```
title: 登場人物紹介
chapter: 異世界転移
published: 2020-03-02
本文```
からタイトルを取得できます。
---
import matter from "gray-matter";
const episodes = await Promise.all(
files.map(async (file) => {
const raw = await fs.readFile(
path.join(novelDir, file),
"utf-8"
);
const { data } = matter(raw);
return {
no: Number(
file.replace(".txt", "")
),
title: data.title,
chapter: data.chapter
};
})
);
---```
import matter from "gray-matter";
const episodes = await Promise.all(
files.map(async (file) => {
const raw = await fs.readFile(
path.join(novelDir, file),
"utf-8"
);
const { data } = matter(raw);
return {
no: Number(
file.replace(".txt", "")
),
title: data.title,
chapter: data.chapter
};
})
);
---```
表示
<ol>
<ol>
{
episodes.map(ep => (
<li>
<a href={`/books/${novel}/${ep.no}`}>
{ep.title}
</a>
</li>
))
}
</ol>```
{
episodes.map(ep => (
-
{ep.title}
))
}
```
もし分割時に
---
chapter: 異世界転移
title: 登場人物紹介
---```
chapter: 異世界転移
title: 登場人物紹介
---```
を保存しているなら、
const chapters = {};
const chapters = {};
for (const ep of episodes) {
if (!chapters[ep.chapter]) {
chapters[ep.chapter] = [];
}
chapters[ep.chapter].push(ep);
}```
const chapters = {};
const chapters = {};
for (const ep of episodes) {
if (!chapters[ep.chapter]) {
chapters[ep.chapter] = [];
}
chapters[ep.chapter].push(ep);
}```
表示
{
{
Object.entries(chapters).map(
([chapter, eps]) => (
<>
<h2>{chapter}</h2>
<ol>
{
eps.map(ep => (
<li>
<a href={`/books/${novel}/${ep.no}`}>
{ep.title}
</a>
</li>
))
}
</ol>
</>
)
)
}```
{
{
Object.entries(chapters).map(
([chapter, eps]) => (
<>
{chapter}
<ol>
{
eps.map(ep => (
<li>
<a href={`/books/${novel}/${ep.no}`}>
{ep.title}
</a>
</li>
))
}
</ol>
</>
)
)
}```
結果は
異世界転移
異世界転移
登場人物紹介
始まりそして転移
魔王との戦い
王都へ
勇者召喚```
異世界転移
異世界転移
登場人物紹介
始まりそして転移
魔王との戦い
王都へ
勇者召喚```
のような小説投稿サイト風の目次になります。
青空文庫形式の1ファイルから分割するなら、分割時に各話の Frontmatter に
chapter:
chapter:
title:
published:
updated:```
chapter:
chapter:
title:
published:
updated:```
を書き込んでおくと、Astro側はそれを読むだけで作品ページと目次ページを生成できます。