fossbook 0.0.17 → 0.1.2

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 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
- createPost(config, title);
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.writeFile(
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 build(config) {
27
- // Remove the output directory
28
- if (fs.existsSync(config.dev.outdir))
29
- fs.rmSync(config.dev.outdir, { recursive: true });
30
- fs.mkdirSync(config.dev.outdir);
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 aboutPath = config.dev.about;
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 = { build };
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 = { loadConfig, resolveThemePath, formatDate };
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 + "/index.md";
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 (path.extname(filePath) === ".md") {
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 {
@@ -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, "&amp;")
131
+ .replace(/</g, "&lt;")
132
+ .replace(/>/g, "&gt;")
133
+ .replace(/"/g, "&quot;")
134
+ .replace(/'/g, "&#39;");
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
- if (fs.existsSync(postDir)) {
9
- console.error(`Error: Post "${title}" already exists at ${postDir}`);
10
- process.exit(1);
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
- fs.writeFileSync(path.join(postDir, "index.md"), frontMatter);
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
- console.log(`Created new post: ${postDir}`);
44
- console.log(` - ${path.join(postDir, "index.md")}`);
45
- console.log(` - ${path.join(postDir, "images/")}`);
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 indexPath = path.join(this.config.dev.postsdir, postPath, "index.md");
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 index.md found`);
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(path.join(this.config.dev.postsdir, postPath));
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.writeFile(
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.writeFile(
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,6 +1,6 @@
1
1
  {
2
2
  "name": "fossbook",
3
- "version": "0.0.17",
3
+ "version": "0.1.2",
4
4
  "description": "A lightweight static blog site generator for GitHub Pages",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {
@@ -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
 
@@ -117,6 +124,16 @@
117
124
  font-size: 1rem;
118
125
  }
119
126
 
127
+ html:lang(ko) blockquote {
128
+ font-family: 'NanumHimNaeRaNeunMarBoDan', 'NanumSquareNeo', sans-serif;
129
+ font-size: 1.3rem;
130
+ line-height: 1.2;
131
+ }
132
+
133
+ html:lang(ko) blockquote p {
134
+ line-height: inherit;
135
+ }
136
+
120
137
  @media print {
121
138
  .transcript-control {
122
139
  display: none;
@@ -274,6 +291,19 @@
274
291
  letter-spacing: -0.5px;
275
292
  }
276
293
 
294
+ .language-switcher {
295
+ display: flex;
296
+ flex-wrap: wrap;
297
+ gap: 0.75rem;
298
+ margin-top: 0.75rem;
299
+ }
300
+
301
+ .language-switcher a[aria-current="page"] {
302
+ border-bottom-color: transparent;
303
+ color: #777;
304
+ pointer-events: none;
305
+ }
306
+
277
307
  .description {
278
308
  font-size: 1rem;
279
309
  }
@@ -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" />
@@ -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" />
@@ -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",
@@ -51,6 +53,7 @@
51
53
  <div class="title">
52
54
  <h1 class="title">${page.title}</h1>
53
55
  <div class="meta">Posted on ${page.formatDate(new Date(page.date))}</div>
56
+ ${page.languageSwitcher()}
54
57
  </div>
55
58
  ${page.body.includes('class="blockquote-container image-dialogue"') ? `<div class="transcript-control">
56
59
  <div class="transcript-toggle">
@@ -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" />
@@ -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" />