fossbook 0.0.16 → 0.1.1
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/README.md +142 -0
- package/bin/fossbook.js +11 -1
- package/lib/home.js +3 -6
- package/lib/index.js +82 -8
- package/lib/init.js +8 -0
- package/lib/mod/config.js +70 -1
- package/lib/mod/page.js +5 -3
- package/lib/mod/page_base.js +39 -1
- package/lib/new.js +44 -11
- package/lib/posts.js +7 -3
- package/lib/tag/index.js +2 -5
- package/lib/tag/tag.js +2 -5
- package/package.json +1 -1
- package/themes/archie/assets/styles/main.css +94 -0
- package/themes/archie/layouts/all_posts.html +1 -1
- package/themes/archie/layouts/home.html +1 -1
- package/themes/archie/layouts/page.html +4 -1
- package/themes/archie/layouts/post.html +33 -2
- package/themes/archie/layouts/tag.html +1 -1
- package/themes/archie/layouts/tag_list.html +1 -1
package/README.md
CHANGED
|
@@ -10,6 +10,7 @@ It was originally part of the [F/OSS Comics blog](https://fosscomics.com) and is
|
|
|
10
10
|
- **Tags** — Tag-based categorization with tag index and per-tag listing pages
|
|
11
11
|
- **Theming** — Bundled Archie theme with support for custom themes
|
|
12
12
|
- **SEO** — Open Graph and Twitter Card meta tags out of the box
|
|
13
|
+
- **Multilingual sites** — Generate language-specific pages with translation links and `hreflang` metadata
|
|
13
14
|
- **GitHub Pages** — Built-in CNAME support for custom domains
|
|
14
15
|
- **Dev server** — Local preview server with Express
|
|
15
16
|
- **Syntax highlighting** — Code block highlighting via highlight.js
|
|
@@ -51,6 +52,14 @@ fossbook new "My First Post"
|
|
|
51
52
|
|
|
52
53
|
This creates `content/posts/My First Post/index.md` with pre-filled front-matter and an `images/` directory.
|
|
53
54
|
|
|
55
|
+
To create the default article and one or more configured translations together:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
fossbook new "My First Post" --lang ko,ja
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
This also creates `index.ko.md` and `index.ja.md` in the same post directory. Running the command again preserves existing files and creates only missing translations.
|
|
62
|
+
|
|
54
63
|
### Build the site
|
|
55
64
|
|
|
56
65
|
```bash
|
|
@@ -91,6 +100,19 @@ module.exports = {
|
|
|
91
100
|
// Set to "" to serve posts at the site root, /<slug>/.
|
|
92
101
|
postsPath: "posts",
|
|
93
102
|
|
|
103
|
+
// Optional multilingual configuration
|
|
104
|
+
defaultLanguage: "en",
|
|
105
|
+
defaultLanguageInSubdir: false,
|
|
106
|
+
languages: {
|
|
107
|
+
en: { languageName: "English", locale: "en-US" },
|
|
108
|
+
ko: {
|
|
109
|
+
languageName: "한국어",
|
|
110
|
+
locale: "ko-KR",
|
|
111
|
+
blogName: "나의 블로그",
|
|
112
|
+
blogDescription: "한국어 블로그",
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
|
|
94
116
|
// Optional comments (see "Comments" below)
|
|
95
117
|
comments: {
|
|
96
118
|
provider: "utterances",
|
|
@@ -148,6 +170,125 @@ This affects the generated output directory, the post URL, post links on the
|
|
|
148
170
|
home/all-posts/tag pages, and image paths. The on-disk source layout under
|
|
149
171
|
`content/posts/` does not change.
|
|
150
172
|
|
|
173
|
+
## Multilingual Sites
|
|
174
|
+
|
|
175
|
+
Fossbook generates a separate static page for each available language. The
|
|
176
|
+
browser loads only the selected language page; changing languages follows a
|
|
177
|
+
normal link and does not require JavaScript.
|
|
178
|
+
|
|
179
|
+
### Configuration
|
|
180
|
+
|
|
181
|
+
Add the languages supported by the site to `fossbook.config.js`:
|
|
182
|
+
|
|
183
|
+
```js
|
|
184
|
+
module.exports = {
|
|
185
|
+
defaultLanguage: "en",
|
|
186
|
+
defaultLanguageInSubdir: false,
|
|
187
|
+
languages: {
|
|
188
|
+
en: {
|
|
189
|
+
languageName: "English",
|
|
190
|
+
locale: "en-US",
|
|
191
|
+
},
|
|
192
|
+
ko: {
|
|
193
|
+
languageName: "한국어",
|
|
194
|
+
locale: "ko-KR",
|
|
195
|
+
blogName: "나의 블로그",
|
|
196
|
+
blogDescription: "한국어 블로그",
|
|
197
|
+
},
|
|
198
|
+
ja: {
|
|
199
|
+
languageName: "日本語",
|
|
200
|
+
locale: "ja-JP",
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
- `defaultLanguage` identifies the language represented by `index.md` and
|
|
207
|
+
`about.md`.
|
|
208
|
+
- `defaultLanguageInSubdir: false` keeps the default language at the site root.
|
|
209
|
+
Other languages are generated below their language code, such as `/ko/`.
|
|
210
|
+
- Set `defaultLanguageInSubdir: true` to generate the default language below
|
|
211
|
+
its code as well, such as `/en/`.
|
|
212
|
+
- Each language can override site values such as `blogName` and
|
|
213
|
+
`blogDescription`.
|
|
214
|
+
- `locale` controls language-specific date formatting.
|
|
215
|
+
- Language keys should use standard language tags such as `en`, `ko`, `ja`, or
|
|
216
|
+
`zh-Hant`.
|
|
217
|
+
|
|
218
|
+
If `languages` is omitted, Fossbook keeps its existing single-language
|
|
219
|
+
behavior and URLs.
|
|
220
|
+
|
|
221
|
+
### Create translated posts
|
|
222
|
+
|
|
223
|
+
Create only the default-language article:
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
fossbook new "Charles Babbage and Ada Lovelace"
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Create the default article and multiple translations together:
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
fossbook new "Charles Babbage and Ada Lovelace" --lang ko,ja
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
The languages passed to `--lang` must be present in the `languages`
|
|
236
|
+
configuration. The command always ensures that the default `index.md` exists,
|
|
237
|
+
then creates the requested translation files. Existing Markdown files and
|
|
238
|
+
images are never overwritten, so the command can be run later to add another
|
|
239
|
+
translation:
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
fossbook new "Charles Babbage and Ada Lovelace" --lang ja
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Source structure
|
|
246
|
+
|
|
247
|
+
Translations live in the same post bundle and share its `images/` directory:
|
|
248
|
+
|
|
249
|
+
```text
|
|
250
|
+
content/
|
|
251
|
+
├── about.md
|
|
252
|
+
├── about.ko.md
|
|
253
|
+
└── posts/
|
|
254
|
+
└── Charles Babbage and Ada Lovelace/
|
|
255
|
+
├── index.md
|
|
256
|
+
├── index.ko.md
|
|
257
|
+
├── index.ja.md
|
|
258
|
+
└── images/
|
|
259
|
+
└── feature.png
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
- `index.md` is the default-language article.
|
|
263
|
+
- `index.<language>.md` is a translated article.
|
|
264
|
+
- `about.md` is the default About page.
|
|
265
|
+
- `about.<language>.md` is a translated About page.
|
|
266
|
+
- A translation is generated only when its Markdown source exists. Fossbook
|
|
267
|
+
does not substitute default-language content for a missing translation.
|
|
268
|
+
|
|
269
|
+
### Generated output
|
|
270
|
+
|
|
271
|
+
With `defaultLanguage: "en"` and `defaultLanguageInSubdir: false`, the example
|
|
272
|
+
above generates:
|
|
273
|
+
|
|
274
|
+
```text
|
|
275
|
+
public/
|
|
276
|
+
├── index.html
|
|
277
|
+
├── about/index.html
|
|
278
|
+
├── posts/Charles Babbage and Ada Lovelace/index.html
|
|
279
|
+
├── ko/
|
|
280
|
+
│ ├── index.html
|
|
281
|
+
│ ├── about/index.html
|
|
282
|
+
│ └── posts/Charles Babbage and Ada Lovelace/index.html
|
|
283
|
+
└── ja/
|
|
284
|
+
├── index.html
|
|
285
|
+
└── posts/Charles Babbage and Ada Lovelace/index.html
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Translated article and About pages display ISO language links such as `EN`,
|
|
289
|
+
`KO`, and `JA`. Only translations that exist are shown. These pages also emit
|
|
290
|
+
canonical URLs and `hreflang` alternate links for search engines.
|
|
291
|
+
|
|
151
292
|
### Mermaid diagrams
|
|
152
293
|
|
|
153
294
|
Fenced code blocks tagged `mermaid` are rendered as diagrams instead of code.
|
|
@@ -177,6 +318,7 @@ Options:
|
|
|
177
318
|
-c, --config Path to config file (default: ./fossbook.config.js)
|
|
178
319
|
-o, --output Output directory (default: ./public)
|
|
179
320
|
-p, --port Dev server port (default: 3000)
|
|
321
|
+
--lang (new) Comma-separated translation languages, e.g. ko,ja
|
|
180
322
|
-v, --version Show version number
|
|
181
323
|
-h, --help Show help
|
|
182
324
|
```
|
package/bin/fossbook.js
CHANGED
|
@@ -23,6 +23,7 @@ Options:
|
|
|
23
23
|
-p, --port Dev server port (default: 3000)
|
|
24
24
|
--clean Remove output directory before build (default: true)
|
|
25
25
|
--github (init) Also create a GitHub repo and push
|
|
26
|
+
--lang (new) Comma-separated translation languages, e.g. ko,ja
|
|
26
27
|
-m, --message (deploy) Custom commit message
|
|
27
28
|
--no-wait (deploy) Push without waiting for CI status
|
|
28
29
|
--draft (deploy) Commit locally without pushing
|
|
@@ -89,9 +90,18 @@ switch (command) {
|
|
|
89
90
|
console.error('Error: Please provide a post title. Usage: fossbook new "My Post Title"');
|
|
90
91
|
process.exit(1);
|
|
91
92
|
}
|
|
93
|
+
if (hasFlag("--lang") && !getOption("--lang")) {
|
|
94
|
+
console.error("Error: Please provide at least one language after --lang");
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
92
97
|
const config = loadConfig(configPath);
|
|
93
98
|
const { createPost } = require("../lib/new");
|
|
94
|
-
|
|
99
|
+
try {
|
|
100
|
+
createPost(config, title, getOption("--lang"));
|
|
101
|
+
} catch (error) {
|
|
102
|
+
console.error(`Error: ${error.message}`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
95
105
|
break;
|
|
96
106
|
}
|
|
97
107
|
|
package/lib/home.js
CHANGED
|
@@ -9,7 +9,7 @@ module.exports = class Pagination extends PageBase {
|
|
|
9
9
|
|
|
10
10
|
generateContent(posts) {
|
|
11
11
|
const postsPerPage = 5;
|
|
12
|
-
const numPages = Math.ceil(posts.length / postsPerPage);
|
|
12
|
+
const numPages = Math.max(1, Math.ceil(posts.length / postsPerPage));
|
|
13
13
|
|
|
14
14
|
const pageDir = path.join(this.config.dev.outdir, "page");
|
|
15
15
|
if (fs.existsSync(pageDir))
|
|
@@ -47,14 +47,11 @@ module.exports = class Pagination extends PageBase {
|
|
|
47
47
|
const data = { posts: pagePosts, prev: prev, next: next };
|
|
48
48
|
|
|
49
49
|
const templatePath = path.join(this.config.themePath, "layouts", "home.html");
|
|
50
|
-
fs.
|
|
50
|
+
fs.writeFileSync(
|
|
51
51
|
`${filePath}`,
|
|
52
52
|
this.generateHTML(templatePath, data),
|
|
53
|
-
(e) => {
|
|
54
|
-
if (e) throw e;
|
|
55
|
-
console.log(`page/${i + 1}.html was created successfully`);
|
|
56
|
-
},
|
|
57
53
|
);
|
|
54
|
+
console.log(`page/${i + 1}.html was created successfully`);
|
|
58
55
|
}
|
|
59
56
|
}
|
|
60
57
|
};
|
package/lib/index.js
CHANGED
|
@@ -23,11 +23,60 @@ function copyDirectoryRecursive(src, dest) {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
function
|
|
27
|
-
|
|
28
|
-
if (
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
function createPostTranslationIndex(languageConfigs) {
|
|
27
|
+
const translationIndex = new Map();
|
|
28
|
+
if (languageConfigs.length === 0) return translationIndex;
|
|
29
|
+
|
|
30
|
+
const postsDir = languageConfigs[0].dev.postsdir;
|
|
31
|
+
if (!fs.existsSync(postsDir)) return translationIndex;
|
|
32
|
+
|
|
33
|
+
for (const entry of fs.readdirSync(postsDir, { withFileTypes: true })) {
|
|
34
|
+
if (!entry.isDirectory()) continue;
|
|
35
|
+
|
|
36
|
+
const translations = [];
|
|
37
|
+
for (const config of languageConfigs) {
|
|
38
|
+
const sourceFile = config.language === config.defaultLanguage
|
|
39
|
+
? "index.md"
|
|
40
|
+
: `index.${config.language}.md`;
|
|
41
|
+
if (!fs.existsSync(path.join(postsDir, entry.name, sourceFile))) continue;
|
|
42
|
+
|
|
43
|
+
const prefix = config.postsPath ? `${config.postsPath}/` : "";
|
|
44
|
+
translations.push({
|
|
45
|
+
language: config.language,
|
|
46
|
+
languageName: config.languageName,
|
|
47
|
+
path: `${config.basePath}${prefix}${entry.name}/`,
|
|
48
|
+
url: `${config.blogsite}/${prefix}${entry.name}/`,
|
|
49
|
+
isDefault: config.language === config.defaultLanguage,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
translationIndex.set(entry.name, translations);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return translationIndex;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function createAboutTranslations(languageConfigs) {
|
|
59
|
+
if (languageConfigs.length === 0) return [];
|
|
60
|
+
|
|
61
|
+
const contentDir = languageConfigs[0].dev.content;
|
|
62
|
+
return languageConfigs.flatMap((config) => {
|
|
63
|
+
const sourceFile = config.language === config.defaultLanguage
|
|
64
|
+
? "about.md"
|
|
65
|
+
: `about.${config.language}.md`;
|
|
66
|
+
if (!fs.existsSync(path.join(contentDir, sourceFile))) return [];
|
|
67
|
+
|
|
68
|
+
return [{
|
|
69
|
+
language: config.language,
|
|
70
|
+
languageName: config.languageName,
|
|
71
|
+
path: `${config.basePath || "/"}about/`,
|
|
72
|
+
url: `${config.blogsite}/about/`,
|
|
73
|
+
isDefault: config.language === config.defaultLanguage,
|
|
74
|
+
}];
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildLanguage(config) {
|
|
79
|
+
fs.mkdirSync(config.dev.outdir, { recursive: true });
|
|
31
80
|
|
|
32
81
|
// Create post pages in output directory
|
|
33
82
|
const posts = new Posts(config);
|
|
@@ -50,10 +99,14 @@ function build(config) {
|
|
|
50
99
|
tagPages.generateContent(postObjects);
|
|
51
100
|
|
|
52
101
|
// Create about page in output/about directory
|
|
53
|
-
const
|
|
102
|
+
const aboutFile = config.language === config.defaultLanguage
|
|
103
|
+
? "about.md"
|
|
104
|
+
: `about.${config.language}.md`;
|
|
105
|
+
const aboutPath = path.resolve(config.dev.content, aboutFile);
|
|
54
106
|
if (fs.existsSync(aboutPath)) {
|
|
55
107
|
const aboutPage = new Page(config);
|
|
56
|
-
aboutPage.readSource(aboutPath);
|
|
108
|
+
aboutPage.readSource(aboutPath, "about");
|
|
109
|
+
aboutPage.translations = config.aboutTranslations || [];
|
|
57
110
|
aboutPage.generateContent("page.html", "about");
|
|
58
111
|
}
|
|
59
112
|
|
|
@@ -68,6 +121,22 @@ function build(config) {
|
|
|
68
121
|
if (fs.existsSync(themeAssetsDir)) {
|
|
69
122
|
copyDirectoryRecursive(themeAssetsDir, config.dev.outdir);
|
|
70
123
|
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function build(config) {
|
|
127
|
+
// Remove the output directory
|
|
128
|
+
if (fs.existsSync(config.dev.outdir))
|
|
129
|
+
fs.rmSync(config.dev.outdir, { recursive: true });
|
|
130
|
+
fs.mkdirSync(config.dev.outdir, { recursive: true });
|
|
131
|
+
|
|
132
|
+
const languageConfigs = config.languageConfigs || [config];
|
|
133
|
+
const postTranslationIndex = createPostTranslationIndex(languageConfigs);
|
|
134
|
+
const aboutTranslations = createAboutTranslations(languageConfigs);
|
|
135
|
+
languageConfigs.forEach((languageConfig) => {
|
|
136
|
+
languageConfig.postTranslationIndex = postTranslationIndex;
|
|
137
|
+
languageConfig.aboutTranslations = aboutTranslations;
|
|
138
|
+
});
|
|
139
|
+
languageConfigs.forEach(buildLanguage);
|
|
71
140
|
|
|
72
141
|
// Create CNAME file for GitHub Pages
|
|
73
142
|
if (config.githubCNAME)
|
|
@@ -76,4 +145,9 @@ function build(config) {
|
|
|
76
145
|
console.log("Build completed successfully");
|
|
77
146
|
}
|
|
78
147
|
|
|
79
|
-
module.exports = {
|
|
148
|
+
module.exports = {
|
|
149
|
+
build,
|
|
150
|
+
buildLanguage,
|
|
151
|
+
createPostTranslationIndex,
|
|
152
|
+
createAboutTranslations,
|
|
153
|
+
};
|
package/lib/init.js
CHANGED
|
@@ -119,6 +119,14 @@ function initProject(options = {}) {
|
|
|
119
119
|
image: "",
|
|
120
120
|
theme: "archie",
|
|
121
121
|
|
|
122
|
+
// Languages (optional)
|
|
123
|
+
defaultLanguage: "en",
|
|
124
|
+
defaultLanguageInSubdir: false,
|
|
125
|
+
// languages: {
|
|
126
|
+
// en: { languageName: "English", locale: "en-US" },
|
|
127
|
+
// ko: { languageName: "한국어", locale: "ko-KR", blogName: "나의 블로그" },
|
|
128
|
+
// },
|
|
129
|
+
|
|
122
130
|
// Comment system (optional)
|
|
123
131
|
// comments: { provider: "utterances", repo: "user/repo", issueTerm: "pathname", theme: "github-light" },
|
|
124
132
|
|
package/lib/mod/config.js
CHANGED
|
@@ -16,6 +16,9 @@ const defaults = {
|
|
|
16
16
|
githubRepository: "",
|
|
17
17
|
image: "",
|
|
18
18
|
theme: "archie",
|
|
19
|
+
defaultLanguage: "en",
|
|
20
|
+
defaultLanguageInSubdir: false,
|
|
21
|
+
languages: null,
|
|
19
22
|
basePath: "", // auto-detected for GitHub Pages project sites, e.g. "/repo-name/"
|
|
20
23
|
postsPath: "posts", // URL prefix for posts: "posts" serves articles at /posts/<slug>/; set "" for root
|
|
21
24
|
comments: null, // { provider: "utterances", repo: "user/repo", issueTerm: "pathname", theme: "github-light" }
|
|
@@ -95,9 +98,70 @@ function loadConfig(configPath) {
|
|
|
95
98
|
// Normalize postsPath into a bare segment without surrounding slashes
|
|
96
99
|
merged.postsPath = (merged.postsPath || "").replace(/^\/+|\/+$/g, "");
|
|
97
100
|
|
|
101
|
+
merged.languageConfigs = createLanguageConfigs(merged);
|
|
102
|
+
|
|
98
103
|
return merged;
|
|
99
104
|
}
|
|
100
105
|
|
|
106
|
+
function createLanguageConfigs(config) {
|
|
107
|
+
const configuredLanguages = config.languages;
|
|
108
|
+
const hasMultipleLanguages = configuredLanguages !== null && configuredLanguages !== undefined;
|
|
109
|
+
const languages = hasMultipleLanguages
|
|
110
|
+
? configuredLanguages
|
|
111
|
+
: { [config.defaultLanguage]: {} };
|
|
112
|
+
|
|
113
|
+
if (typeof languages !== "object" || Array.isArray(languages) || Object.keys(languages).length === 0) {
|
|
114
|
+
throw new Error("languages must be a non-empty object");
|
|
115
|
+
}
|
|
116
|
+
if (!Object.prototype.hasOwnProperty.call(languages, config.defaultLanguage)) {
|
|
117
|
+
throw new Error(`defaultLanguage "${config.defaultLanguage}" is not defined in languages`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return Object.entries(languages).map(([language, overrides]) => {
|
|
121
|
+
if (!/^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/.test(language)) {
|
|
122
|
+
throw new Error(`Invalid language key "${language}"`);
|
|
123
|
+
}
|
|
124
|
+
if (!overrides || typeof overrides !== "object" || Array.isArray(overrides)) {
|
|
125
|
+
throw new Error(`languages.${language} must be an object`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const useSubdirectory = config.defaultLanguageInSubdir || language !== config.defaultLanguage;
|
|
129
|
+
const languagePrefix = useSubdirectory ? language : "";
|
|
130
|
+
const basePath = appendPathSegment(config.basePath, languagePrefix);
|
|
131
|
+
const blogsite = appendUrlSegment(config.blogsite, languagePrefix);
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
...config,
|
|
135
|
+
...overrides,
|
|
136
|
+
language,
|
|
137
|
+
languageName: overrides.languageName || language,
|
|
138
|
+
locale: overrides.locale || language,
|
|
139
|
+
languagePrefix,
|
|
140
|
+
basePath,
|
|
141
|
+
blogsite,
|
|
142
|
+
dev: {
|
|
143
|
+
...config.dev,
|
|
144
|
+
outdir: languagePrefix
|
|
145
|
+
? path.join(config.dev.outdir, languagePrefix)
|
|
146
|
+
: config.dev.outdir,
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function appendPathSegment(basePath, segment) {
|
|
153
|
+
const normalizedBase = `/${basePath || ""}`.replace(/\/{2,}/g, "/");
|
|
154
|
+
const withTrailingSlash = normalizedBase.endsWith("/")
|
|
155
|
+
? normalizedBase
|
|
156
|
+
: `${normalizedBase}/`;
|
|
157
|
+
return segment ? `${withTrailingSlash}${segment}/` : withTrailingSlash;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function appendUrlSegment(url, segment) {
|
|
161
|
+
const normalizedUrl = (url || "").replace(/\/+$/g, "");
|
|
162
|
+
return segment ? `${normalizedUrl}/${segment}` : normalizedUrl;
|
|
163
|
+
}
|
|
164
|
+
|
|
101
165
|
function formatDate(
|
|
102
166
|
date,
|
|
103
167
|
locale = "en-US",
|
|
@@ -137,4 +201,9 @@ function detectBasePath(config) {
|
|
|
137
201
|
return "/";
|
|
138
202
|
}
|
|
139
203
|
|
|
140
|
-
module.exports = {
|
|
204
|
+
module.exports = {
|
|
205
|
+
loadConfig,
|
|
206
|
+
resolveThemePath,
|
|
207
|
+
formatDate,
|
|
208
|
+
createLanguageConfigs,
|
|
209
|
+
};
|
package/lib/mod/page.js
CHANGED
|
@@ -12,14 +12,16 @@ module.exports = class Page extends PageBase {
|
|
|
12
12
|
this.contentPath = config.dev.content;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
readSource(filePath) {
|
|
15
|
+
readSource(filePath, sourceSlug) {
|
|
16
16
|
if (filePath.indexOf(".md") === -1)
|
|
17
|
-
this.srcFilePath = filePath
|
|
17
|
+
this.srcFilePath = path.join(filePath, "index.md");
|
|
18
18
|
else this.srcFilePath = filePath;
|
|
19
19
|
|
|
20
20
|
const mdContent = fs.readFileSync(this.srcFilePath, "utf8");
|
|
21
21
|
|
|
22
|
-
if (
|
|
22
|
+
if (sourceSlug) {
|
|
23
|
+
this.path = sourceSlug;
|
|
24
|
+
} else if (path.extname(filePath) === ".md") {
|
|
23
25
|
// If the file has a .md extension, extract the file name without the extension
|
|
24
26
|
this.path = path.basename(filePath, ".md");
|
|
25
27
|
} else {
|
package/lib/mod/page_base.js
CHANGED
|
@@ -12,11 +12,40 @@ module.exports = class PageBase {
|
|
|
12
12
|
this.content = "";
|
|
13
13
|
this.url = "";
|
|
14
14
|
this.theme = this.config.theme;
|
|
15
|
+
this.translations = [];
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
formatDate(date) {
|
|
18
19
|
const options = { year: "numeric", month: "short", day: "numeric" };
|
|
19
|
-
return date.toLocaleDateString("en-US", options);
|
|
20
|
+
return date.toLocaleDateString(this.config.locale || "en-US", options);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
alternateLanguageLinks() {
|
|
24
|
+
if (this.translations.length < 2) return "";
|
|
25
|
+
|
|
26
|
+
const alternates = this.translations.map((translation) =>
|
|
27
|
+
`<link rel="alternate" hreflang="${escapeHtml(translation.language)}" href="${escapeHtml(translation.url)}" />`,
|
|
28
|
+
);
|
|
29
|
+
const defaultTranslation = this.translations.find((translation) => translation.isDefault);
|
|
30
|
+
if (defaultTranslation) {
|
|
31
|
+
alternates.push(
|
|
32
|
+
`<link rel="alternate" hreflang="x-default" href="${escapeHtml(defaultTranslation.url)}" />`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return alternates.join("\n ");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
languageSwitcher() {
|
|
39
|
+
if (this.translations.length < 2) return "";
|
|
40
|
+
|
|
41
|
+
const links = this.translations.map((translation) => {
|
|
42
|
+
const current = translation.language === this.config.language;
|
|
43
|
+
const language = escapeHtml(translation.language);
|
|
44
|
+
const label = escapeHtml(translation.languageName);
|
|
45
|
+
const code = escapeHtml(translation.language.toUpperCase());
|
|
46
|
+
return `<a href="${escapeHtml(translation.path || translation.url)}" lang="${language}" hreflang="${language}" aria-label="${label}"${current ? ' aria-current="page"' : ""}>${code}</a>`;
|
|
47
|
+
});
|
|
48
|
+
return `<nav class="language-switcher" aria-label="Languages">${links.join("")}</nav>`;
|
|
20
49
|
}
|
|
21
50
|
|
|
22
51
|
googleAnalytics(trackingId) {
|
|
@@ -95,3 +124,12 @@ module.exports = class PageBase {
|
|
|
95
124
|
return postHTML;
|
|
96
125
|
}
|
|
97
126
|
};
|
|
127
|
+
|
|
128
|
+
function escapeHtml(value) {
|
|
129
|
+
return String(value)
|
|
130
|
+
.replace(/&/g, "&")
|
|
131
|
+
.replace(/</g, "<")
|
|
132
|
+
.replace(/>/g, ">")
|
|
133
|
+
.replace(/"/g, """)
|
|
134
|
+
.replace(/'/g, "'");
|
|
135
|
+
}
|
package/lib/new.js
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
const fs = require("fs");
|
|
2
2
|
const path = require("path");
|
|
3
3
|
|
|
4
|
-
function createPost(config, title) {
|
|
4
|
+
function createPost(config, title, languageOption) {
|
|
5
5
|
const postsDir = config.dev.postsdir;
|
|
6
6
|
const postDir = path.join(postsDir, title);
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
const defaultLanguage = config.defaultLanguage || "en";
|
|
8
|
+
const requestedLanguages = parseLanguages(languageOption);
|
|
9
|
+
const configuredLanguages = config.languages
|
|
10
|
+
? Object.keys(config.languages)
|
|
11
|
+
: [defaultLanguage];
|
|
12
|
+
const unsupportedLanguages = requestedLanguages.filter(
|
|
13
|
+
(language) => !configuredLanguages.includes(language),
|
|
14
|
+
);
|
|
15
|
+
if (unsupportedLanguages.length > 0) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
`Language${unsupportedLanguages.length > 1 ? "s" : ""} "${unsupportedLanguages.join(", ")}" ` +
|
|
18
|
+
`not configured. Available languages: ${configuredLanguages.join(", ")}`,
|
|
19
|
+
);
|
|
11
20
|
}
|
|
12
21
|
|
|
13
22
|
// Ensure the posts directory exists
|
|
@@ -24,7 +33,7 @@ function createPost(config, title) {
|
|
|
24
33
|
// Copy placeholder image to the post's images directory
|
|
25
34
|
const placeholderSrc = path.join(__dirname, "assets", "placeholder.svg");
|
|
26
35
|
const placeholderDest = path.join(postDir, "images", "placeholder.svg");
|
|
27
|
-
if (fs.existsSync(placeholderSrc)) {
|
|
36
|
+
if (fs.existsSync(placeholderSrc) && !fs.existsSync(placeholderDest)) {
|
|
28
37
|
fs.copyFileSync(placeholderSrc, placeholderDest);
|
|
29
38
|
}
|
|
30
39
|
|
|
@@ -38,11 +47,35 @@ tags: ""
|
|
|
38
47
|
---
|
|
39
48
|
`;
|
|
40
49
|
|
|
41
|
-
|
|
50
|
+
const languages = [defaultLanguage, ...requestedLanguages];
|
|
51
|
+
const uniqueLanguages = [...new Set(languages)];
|
|
52
|
+
const results = uniqueLanguages.map((language) => {
|
|
53
|
+
const fileName = language === defaultLanguage
|
|
54
|
+
? "index.md"
|
|
55
|
+
: `index.${language}.md`;
|
|
56
|
+
const filePath = path.join(postDir, fileName);
|
|
57
|
+
if (fs.existsSync(filePath)) {
|
|
58
|
+
console.log(`Skipped existing post: ${filePath}`);
|
|
59
|
+
return { language, filePath, created: false };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
fs.writeFileSync(filePath, frontMatter);
|
|
63
|
+
console.log(`Created new post: ${filePath}`);
|
|
64
|
+
return { language, filePath, created: true };
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
console.log(`Images: ${path.join(postDir, "images/")}`);
|
|
68
|
+
return results;
|
|
69
|
+
}
|
|
42
70
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
71
|
+
function parseLanguages(languageOption) {
|
|
72
|
+
if (!languageOption) return [];
|
|
73
|
+
return [...new Set(
|
|
74
|
+
languageOption
|
|
75
|
+
.split(",")
|
|
76
|
+
.map((language) => language.trim())
|
|
77
|
+
.filter(Boolean),
|
|
78
|
+
)];
|
|
46
79
|
}
|
|
47
80
|
|
|
48
|
-
module.exports = { createPost };
|
|
81
|
+
module.exports = { createPost, parseLanguages };
|
package/lib/posts.js
CHANGED
|
@@ -16,19 +16,23 @@ module.exports = class Posts {
|
|
|
16
16
|
}
|
|
17
17
|
const postPaths = fs.readdirSync(this.config.dev.postsdir);
|
|
18
18
|
postPaths.forEach((postPath) => {
|
|
19
|
-
const
|
|
19
|
+
const sourceFile = this.config.language === this.config.defaultLanguage
|
|
20
|
+
? "index.md"
|
|
21
|
+
: `index.${this.config.language}.md`;
|
|
22
|
+
const indexPath = path.join(this.config.dev.postsdir, postPath, sourceFile);
|
|
20
23
|
if (!fs.existsSync(indexPath)) {
|
|
21
|
-
console.warn(`Skipping "${postPath}": no
|
|
24
|
+
console.warn(`Skipping "${postPath}" for ${this.config.language}: no ${sourceFile} found`);
|
|
22
25
|
return;
|
|
23
26
|
}
|
|
24
27
|
const post = new Page(this.config);
|
|
25
|
-
post.readSource(
|
|
28
|
+
post.readSource(indexPath, postPath);
|
|
26
29
|
// Optionally nest posts under a URL prefix (e.g. "posts" -> /posts/<slug>/).
|
|
27
30
|
// post.slug stays the source folder name so image lookups remain correct.
|
|
28
31
|
const prefix = this.config.postsPath ? `${this.config.postsPath}/` : "";
|
|
29
32
|
post.path = `${prefix}${post.slug}`;
|
|
30
33
|
post.url = `${this.config.blogsite}/${post.path}/`;
|
|
31
34
|
post.imageURL = `${this.config.blogsite}/${post.path}/images/${post.image}`;
|
|
35
|
+
post.translations = this.config.postTranslationIndex?.get(post.slug) || [];
|
|
32
36
|
this.posts.push(post);
|
|
33
37
|
});
|
|
34
38
|
// sort by date
|
package/lib/tag/index.js
CHANGED
|
@@ -53,14 +53,11 @@ module.exports = class TagList extends PageBase {
|
|
|
53
53
|
this.title = `${this.config.blogName}: ${data.pageTitle}`;
|
|
54
54
|
|
|
55
55
|
const templatePath = path.join(this.config.themePath, "layouts", "tag_list.html");
|
|
56
|
-
fs.
|
|
56
|
+
fs.writeFileSync(
|
|
57
57
|
path.join(tagsDir, "index.html"),
|
|
58
58
|
this.generateHTML(templatePath, data),
|
|
59
|
-
(e) => {
|
|
60
|
-
if (e) throw e;
|
|
61
|
-
console.log(`tags/index.html for tags was created successfully`);
|
|
62
|
-
},
|
|
63
59
|
);
|
|
60
|
+
console.log(`tags/index.html for tags was created successfully`);
|
|
64
61
|
|
|
65
62
|
const tagPage = new TagPage(this.config);
|
|
66
63
|
for (let [tag, posts] of tagMap) {
|
package/lib/tag/tag.js
CHANGED
|
@@ -18,13 +18,10 @@ module.exports = class TagPage extends PageBase {
|
|
|
18
18
|
this.description = `List up all posts including '${data.tag}' tag.`;
|
|
19
19
|
|
|
20
20
|
const templatePath = path.join(this.config.themePath, "layouts", "tag.html");
|
|
21
|
-
fs.
|
|
21
|
+
fs.writeFileSync(
|
|
22
22
|
path.join(tagDir, "index.html"),
|
|
23
23
|
this.generateHTML(templatePath, data),
|
|
24
|
-
(e) => {
|
|
25
|
-
if (e) throw e;
|
|
26
|
-
console.log(`/tags/${tagPath}/index.html was created successfully`);
|
|
27
|
-
},
|
|
28
24
|
);
|
|
25
|
+
console.log(`/tags/${tagPath}/index.html was created successfully`);
|
|
29
26
|
}
|
|
30
27
|
};
|
package/package.json
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
@import url("https://hangeul.pstatic.net/hangeul_static/css/nanum-square-neo.css");
|
|
2
|
+
@import url("https://hangeul.pstatic.net/hangeul_static/css/NanumHimNaeRaNeunMarBoDan.css");
|
|
3
|
+
|
|
1
4
|
/* Markdown */
|
|
2
5
|
:root{
|
|
3
6
|
--maincolor: #bcc9fc;
|
|
@@ -13,6 +16,10 @@
|
|
|
13
16
|
line-height: 1.6em;
|
|
14
17
|
}
|
|
15
18
|
|
|
19
|
+
html:lang(ko) {
|
|
20
|
+
font-family: 'NanumSquareNeo', sans-serif;
|
|
21
|
+
}
|
|
22
|
+
|
|
16
23
|
/* for iOS */
|
|
17
24
|
@media only screen and (hover: none) and (pointer: coarse){
|
|
18
25
|
|
|
@@ -49,6 +56,64 @@
|
|
|
49
56
|
width: var(--dialogue-width, 100%);
|
|
50
57
|
}
|
|
51
58
|
|
|
59
|
+
.transcript-control {
|
|
60
|
+
display: flex;
|
|
61
|
+
justify-content: flex-end;
|
|
62
|
+
margin: .75rem 0 1.5rem;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.transcript-toggle {
|
|
66
|
+
align-items: center;
|
|
67
|
+
display: inline-flex;
|
|
68
|
+
font-size: .85rem;
|
|
69
|
+
gap: .65rem;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.transcript-toggle label {
|
|
73
|
+
cursor: pointer;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.transcript-toggle input {
|
|
77
|
+
appearance: none;
|
|
78
|
+
background: #d8dce2;
|
|
79
|
+
border: 1px solid var(--bordercl);
|
|
80
|
+
border-radius: 999px;
|
|
81
|
+
cursor: pointer;
|
|
82
|
+
height: 1.35rem;
|
|
83
|
+
margin: 0;
|
|
84
|
+
position: relative;
|
|
85
|
+
width: 2.5rem;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.transcript-toggle input::before {
|
|
89
|
+
background: #fff;
|
|
90
|
+
border-radius: 50%;
|
|
91
|
+
content: "";
|
|
92
|
+
height: .95rem;
|
|
93
|
+
left: .15rem;
|
|
94
|
+
position: absolute;
|
|
95
|
+
top: .15rem;
|
|
96
|
+
transition: transform 160ms ease;
|
|
97
|
+
width: .95rem;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.transcript-toggle input:checked {
|
|
101
|
+
background: var(--maincolor);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.transcript-toggle input:checked::before {
|
|
105
|
+
transform: translateX(1.1rem);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.transcript-toggle input:focus-visible {
|
|
109
|
+
outline: 2px solid var(--hovercolor);
|
|
110
|
+
outline-offset: 2px;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.transcripts-hidden .image-dialogue {
|
|
114
|
+
display: none;
|
|
115
|
+
}
|
|
116
|
+
|
|
52
117
|
|
|
53
118
|
blockquote {
|
|
54
119
|
margin: 0;
|
|
@@ -58,6 +123,22 @@
|
|
|
58
123
|
font-style: normal;
|
|
59
124
|
font-size: 1rem;
|
|
60
125
|
}
|
|
126
|
+
|
|
127
|
+
html:lang(ko) blockquote {
|
|
128
|
+
font-family: 'NanumHimNaeRaNeunMarBoDan', 'NanumSquareNeo', sans-serif;
|
|
129
|
+
font-size: 1.5rem;
|
|
130
|
+
line-height: 1;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
@media print {
|
|
134
|
+
.transcript-control {
|
|
135
|
+
display: none;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.transcripts-hidden .image-dialogue {
|
|
139
|
+
display: flex;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
61
142
|
|
|
62
143
|
a {
|
|
63
144
|
border-bottom: 3px solid var(--maincolor);
|
|
@@ -206,6 +287,19 @@
|
|
|
206
287
|
letter-spacing: -0.5px;
|
|
207
288
|
}
|
|
208
289
|
|
|
290
|
+
.language-switcher {
|
|
291
|
+
display: flex;
|
|
292
|
+
flex-wrap: wrap;
|
|
293
|
+
gap: 0.75rem;
|
|
294
|
+
margin-top: 0.75rem;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
.language-switcher a[aria-current="page"] {
|
|
298
|
+
border-bottom-color: transparent;
|
|
299
|
+
color: #777;
|
|
300
|
+
pointer-events: none;
|
|
301
|
+
}
|
|
302
|
+
|
|
209
303
|
.description {
|
|
210
304
|
font-size: 1rem;
|
|
211
305
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
|
-
<html lang="en">
|
|
2
|
+
<html lang="${page.config.language || "en"}">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
${page.config.googleAnalyticsID ? page.googleAnalytics(page.config.googleAnalyticsID) : ""}
|
|
12
12
|
<title>${page.config.blogName}: ${page.title}</title>
|
|
13
13
|
<meta name="description" content="${page.config.blogDescription}" />
|
|
14
|
+
<link rel="canonical" href="${page.url}" />
|
|
15
|
+
${page.alternateLanguageLinks()}
|
|
14
16
|
${page.openGraph("website")}
|
|
15
17
|
|
|
16
18
|
<meta name="twitter:card" content="summary_large_image" />
|
|
@@ -36,6 +38,7 @@
|
|
|
36
38
|
<article class="content">
|
|
37
39
|
<div class="title">
|
|
38
40
|
<h1 class="title">${page.title}</h1>
|
|
41
|
+
${page.languageSwitcher()}
|
|
39
42
|
</div>
|
|
40
43
|
<section class="body">
|
|
41
44
|
${page.body}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
|
-
<html lang="en">
|
|
2
|
+
<html lang="${page.config.language || "en"}">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
${page.config.googleAnalyticsID ? page.googleAnalytics(page.config.googleAnalyticsID) : ""}
|
|
15
15
|
<title>${page.title}</title>
|
|
16
16
|
<meta name="description" content="${page.description}" />
|
|
17
|
+
<link rel="canonical" href="${page.url}" />
|
|
18
|
+
${page.alternateLanguageLinks()}
|
|
17
19
|
|
|
18
20
|
${page.openGraph(
|
|
19
21
|
"article",
|
|
@@ -25,6 +27,13 @@
|
|
|
25
27
|
)}
|
|
26
28
|
|
|
27
29
|
${page.twitterCard("summary")}
|
|
30
|
+
${page.body.includes('class="blockquote-container image-dialogue"') ? `<script>
|
|
31
|
+
try {
|
|
32
|
+
if (localStorage.getItem("fossbook-comic-transcript") === "hidden") {
|
|
33
|
+
document.documentElement.classList.add("transcripts-hidden");
|
|
34
|
+
}
|
|
35
|
+
} catch (error) {}
|
|
36
|
+
</script>` : ""}
|
|
28
37
|
</head>
|
|
29
38
|
<body>
|
|
30
39
|
<div class="content">
|
|
@@ -44,8 +53,15 @@
|
|
|
44
53
|
<div class="title">
|
|
45
54
|
<h1 class="title">${page.title}</h1>
|
|
46
55
|
<div class="meta">Posted on ${page.formatDate(new Date(page.date))}</div>
|
|
56
|
+
${page.languageSwitcher()}
|
|
47
57
|
</div>
|
|
48
|
-
|
|
58
|
+
${page.body.includes('class="blockquote-container image-dialogue"') ? `<div class="transcript-control">
|
|
59
|
+
<div class="transcript-toggle">
|
|
60
|
+
<label for="comic-transcript-toggle">Comic transcript</label>
|
|
61
|
+
<input id="comic-transcript-toggle" type="checkbox" role="switch" aria-controls="comic-transcript" checked>
|
|
62
|
+
</div>
|
|
63
|
+
</div>` : ""}
|
|
64
|
+
<section class="body" id="comic-transcript">
|
|
49
65
|
${page.body}
|
|
50
66
|
</section>
|
|
51
67
|
<div class="post-tags">
|
|
@@ -84,6 +100,21 @@
|
|
|
84
100
|
mermaid.initialize({ startOnLoad: true });
|
|
85
101
|
</script>` : ""}
|
|
86
102
|
${page.body.includes('class="blockquote-container image-dialogue"') ? `<script>
|
|
103
|
+
const transcriptToggle = document.querySelector("#comic-transcript-toggle");
|
|
104
|
+
const transcriptStorageKey = "fossbook-comic-transcript";
|
|
105
|
+
const setTranscriptVisibility = (visible) => {
|
|
106
|
+
document.documentElement.classList.toggle("transcripts-hidden", !visible);
|
|
107
|
+
transcriptToggle.checked = visible;
|
|
108
|
+
};
|
|
109
|
+
setTranscriptVisibility(!document.documentElement.classList.contains("transcripts-hidden"));
|
|
110
|
+
transcriptToggle.addEventListener("change", () => {
|
|
111
|
+
const visible = transcriptToggle.checked;
|
|
112
|
+
setTranscriptVisibility(visible);
|
|
113
|
+
try {
|
|
114
|
+
localStorage.setItem(transcriptStorageKey, visible ? "visible" : "hidden");
|
|
115
|
+
} catch (error) {}
|
|
116
|
+
});
|
|
117
|
+
|
|
87
118
|
document.querySelectorAll(".image-container + .image-dialogue").forEach((dialogue) => {
|
|
88
119
|
const image = dialogue.previousElementSibling.querySelector("img");
|
|
89
120
|
const syncDialogueWidth = () => {
|