Skip to content

Commit

Permalink
feature: i18n (#343)
Browse files Browse the repository at this point in the history
* feat: i18n

* fix: fallback

* fix: link locale

* feat: i18n switcher

* i18n: add ja version of a post

* i18n: index page

* i18n: rss feed

* i18n: rewrite in middleware for rss

* i18n: chore enhancement

* rss: fix link for i18n

* rss: apply locale for feed link

* i18n: filter locales in config

* chore: update articles

* chore: test only one default language
  • Loading branch information
la3rence authored Feb 23, 2024
1 parent f712771 commit b6319cd
Show file tree
Hide file tree
Showing 36 changed files with 495 additions and 152 deletions.
2 changes: 2 additions & 0 deletions components/a.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import Link from "next/link";
import config from "../lib/config.mjs";

const A = props => {
return (
<Link
{...props}
className="no-underline hover:underline text-black dark:text-white font-normal cursor-pointer"
target={props.self ? "_self" : "_blank"}
locale={config.defaultLocale}
/>
);
};
Expand Down
35 changes: 32 additions & 3 deletions components/blog.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,19 @@ import Disqus from "./disqus";
import Comments from "./comments";

export default withView(props => {
const { children, title, date, author, view, id, tags, pageURL, image } =
props;
const {
children,
title,
date,
author,
view,
id,
tags,
pageURL,
image,
i18n,
locale,
} = props;
const [replies, setReplies] = useState([]);
const [likes, setLikes] = useState([]);

Expand Down Expand Up @@ -75,8 +86,26 @@ export default withView(props => {
</Link>
</div>
<div className="flex-1" />
{i18n?.length > 1 && (
<>
{i18n
.filter(language => language !== locale)
.map(language => {
return (
<Link
className="mr-2 no-underline"
key={language}
href={id}
locale={language}
>
<small>🌐 {language.toUpperCase()}</small>
</Link>
);
})}
</>
)}
<div className={`justify-end ${withImageColor}`} id="views">
{view > 0 && <small>{view} views</small>}
{view > 10 && <small>{view} views</small>}
</div>
</div>
)}
Expand Down
15 changes: 9 additions & 6 deletions components/header.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,15 @@ export default function Header({
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: structuredData }}
/>
<link
rel="alternate"
type="application/atom+xml"
title={siteTitle}
href={config.feedPath}
/>
{config.locales.map(locale => (
<link
key={locale}
rel="alternate"
type="application/atom+xml"
title={`${siteTitle} - ${siteDescription} (${locale})`}
href={`/${locale}/${config.feedFile}`}
/>
))}
{enableAdsense && <Adsense />}
</Head>
{image && (
Expand Down
2 changes: 2 additions & 0 deletions components/tag.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Link from "next/link";
import config from "../lib/config.mjs";

export default function Tag(props) {
const highlight = props.highlight
Expand All @@ -8,6 +9,7 @@ export default function Tag(props) {
<Link
href={`/tag/${props.tag.toLowerCase().trim()}`}
className="no-underline"
locale={config.defaultLocale}
>
<span
className={`before:content-['#'] duration-100 transition rounded inline-block p-1 mx-1 text-sm font-mono ${highlight}`}
Expand Down
7 changes: 5 additions & 2 deletions lib/config.mjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
// eslint-disable-next-line import/no-anonymous-default-export
import nextConfig from "../next.config.js";

const config = {
siteTitle: "Lawrence Li",
authorName: "Lawrence",
domain: "lawrenceli.me",
authorEmail: "[email protected]",
baseURL: "https://lawrenceli.me",
feedPath: "/atom.xml",
defaultLocale: nextConfig.i18n.defaultLocale,
locales: nextConfig.i18n.locales,
feedFile: "atom.xml",
feedItemsCount: 10,
siteDescription: "Blog",
activityPubUser: "lawrence",
Expand Down
54 changes: 38 additions & 16 deletions lib/feed.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,38 +4,52 @@ import {
defaultMarkdownDirectory,
} from "./ssg.mjs";
import config from "./config.mjs";
import fs from "node:fs";

const {
siteTitle,
authorName,
authorEmail,
baseURL,
websubHub,
defaultLocale,
feedFile,
feedItemsCount,
siteTitle,
siteDescription,
websubHub,
locales,
} = config;

// https://datatracker.ietf.org/doc/html/rfc4287
const buildFeed = async () => {
const markdownData = getMdPostsData();
const postContents = await Promise.all(
markdownData.map(async item => {
return await getMdContentById(item.id, defaultMarkdownDirectory);
return await getMdContentById(item.fileName, defaultMarkdownDirectory);
}),
);
// only output the recent blog posts
const feed = createRSS(
postContents.slice(0, Math.min(feedItemsCount, postContents.length)),
);
console.log(feed);
locales.forEach(locale => {
console.log("Building feed for", locale);
const feed = createRSS(postContents, locale);
const fileName = `./public/atom.${locale}.xml`;
fs.openSync(fileName, "w");
fs.writeFileSync(fileName, feed);
if (locale === defaultLocale) {
// just an alternate for default locale feed (to keep old link working)
fs.writeFileSync(`./public/${feedFile}`, feed);
}
});
};

function mapToAtomEntry(post) {
const categories = post.tags?.split(",");
return `
<entry>
let postLink = `${baseURL}/${post.locale}/blog/${post.id}`;
if (post.locale === defaultLocale) {
postLink = `${baseURL}/blog/${post.id}`;
}
return `<entry>
<title>${decode(post.title)}</title>
<id>${baseURL}/blog/${post.id}</id>
<link href="${baseURL}/blog/${post.id}"/>
<id>${postLink}</id>
<link href="${postLink}"/>
<published>${post.date}T00:00:00.000Z</published>
<updated>${
post.modified ? post.modified : post.date
Expand Down Expand Up @@ -63,13 +77,21 @@ function decode(string) {
.replace(/'/g, "&apos;");
}

function createRSS(blogPosts = []) {
const postsString = blogPosts.map(mapToAtomEntry).reduce((a, b) => a + b, "");
function createRSS(blogPosts, locale) {
const postsString = blogPosts
.filter(post => post.locale === locale)
.map(mapToAtomEntry)
.slice(0, Math.min(feedItemsCount, blogPosts.length))
.reduce((a, b) => a + b, "");
let feedURL = `${baseURL}/${locale}/${feedFile}`;
if (locale === defaultLocale) {
feedURL = `${baseURL}/${feedFile}`;
}
return `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>${siteTitle}</title>
<subtitle>Blog</subtitle>
<link href="${baseURL}/atom.xml" rel="self" type="application/atom+xml" />
<subtitle>${siteDescription}</subtitle>
<link href="${feedURL}" rel="self" type="application/atom+xml" />
<link href="${websubHub}" rel="hub" />
<link href="${baseURL}/"/>
<id>${baseURL}/</id>
Expand Down
37 changes: 34 additions & 3 deletions lib/ssg.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,18 +39,36 @@ export const getMdPostsData = mdDirectory => {
.filter(fileName => fileName.includes(".md"))
.map(mdPostName => {
const id = mdPostName.replace(/\.md$/, "");
const locale = id.includes(".") ? id.split(".")[1] : config.defaultLocale;
const idWithoutLocale = id.replace(`.${locale}`, "");
const fullPath = path.join(mdDirectory, mdPostName);
const mdContent = fs.readFileSync(fullPath, "utf8");
const matterResult = matter(mdContent);
return {
id,
id: idWithoutLocale,
fileName: id,
locale,
...matterResult.data,
content: matterResult.content,
};
});
return mdPostsData.sort(sortByDate).filter(filterVisibility);
};

const getI18nLanguagesByTitle = (i18nTitle, mdPostNames) => {
const languages = new Set();
mdPostNames.forEach(fileName => {
if (fileName.startsWith(i18nTitle)) {
if (fileName.split(".")[1] === "md") {
languages.add(config.defaultLocale);
} else {
languages.add(fileName.split(".")[1]);
}
}
});
return [...languages];
};

export const getMdContentById = (id, mdDirectory, withHTMLString = true) => {
// default using the `./posts` for markdown directory
if (!mdDirectory) {
Expand All @@ -59,22 +77,35 @@ export const getMdContentById = (id, mdDirectory, withHTMLString = true) => {
const isBlog = mdDirectory === defaultMarkdownDirectory; // if the markdown is blog article
const mdPostNames = fs.readdirSync(mdDirectory);
const mdPostsData = mdPostNames
.filter(name => name === id + ".md")
.filter(fileName => {
if (fileName.includes(`.${config.defaultLocale}`)) {
return fileName === id + ".md";
}
return fileName === id.replace(`.${config.defaultLocale}`, "") + ".md";
})
.map(async mdPostName => {
const fullPath = path.join(mdDirectory, mdPostName);
const mdContent = fs.readFileSync(fullPath, "utf8");
const fileStat = fs.statSync(fullPath);
const matterResult = matter(mdContent);
const locale = id.includes(".") ? id.split(".")[1] : config.defaultLocale;
const idWithoutLocale = id.replace(`.${locale}`, "");
const languages = getI18nLanguagesByTitle(
mdPostName.split(".")[0],
mdPostNames,
).filter(language => config.locales.includes(language));
const htmlResult = await renderHTMLfromMarkdownString(
matterResult.content,
isBlog,
);
return {
id,
id: idWithoutLocale,
birthTime: fileStat.birthtime.toISOString(),
// modifiedTime: fileStat.mtime.toISOString(),
author: config.authorName, // dafult authorName
// content: matterResult.content, // original markdown string
i18n: languages,
locale,
htmlStringContent: withHTMLString ? htmlResult.value : "", // rendered html string
htmlAst: isBlog
? fromHtml(htmlResult.value, {
Expand Down
4 changes: 2 additions & 2 deletions lib/websub.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import config from "./config.mjs";
import { getMdPostsData } from "./ssg.mjs";

const { websubHub, baseURL, feedPath } = config;
const URL = baseURL + feedPath;
const { websubHub, baseURL, feedFile } = config;
const URL = baseURL + "/" + feedFile;

/**
* Determine if we need to publish the websub.
Expand Down
6 changes: 6 additions & 0 deletions middleware.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import cfg from "./lib/config.mjs";

// This function can be marked `async` if using `await` inside
export function middleware(req) {
Expand All @@ -11,6 +12,11 @@ export function middleware(req) {
new URL(`/api/activitypub/blog/${blogId}`, req.url),
);
}
// RSS Feed i18n
if (path.endsWith(cfg.feedFile)) {
const locale = req.nextUrl.locale;
return NextResponse.rewrite(new URL(`/atom.${locale}.xml`, req.url));
}
}

// export const config = {
Expand Down
20 changes: 11 additions & 9 deletions next.config.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
// next.config.js

module.exports = {
// experimental: {
// appDir: true,
// },
// i18n: {
// locales: ["zh"],
// defaultLocale: "zh",
// },
i18n: {
locales: ["zh"], // post.en.md, post.ja.md
defaultLocale: "zh", // post.zh.md or post.md
localeDetection: false,
},
transpilePackages: ["react-tweet"],
async headers() {
return [
Expand All @@ -27,11 +29,11 @@ module.exports = {
source: "/.well-known/:param",
destination: "/api/.well-known/:param",
},
{
source: "/feed",
destination: "/atom.xml",
},

// {
// source: "/:locale/atom.xml",
// destination: "/atom.:locale.xml",
// locale: false,
// },
// {
// source: "/blog/:path",
// has: [
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"markdown": "github",
"scripts": {
"dev": "pnpm feed && next dev -H 0.0.0.0",
"feed": "node ./lib/feed.mjs > ./public/atom.xml",
"feed": "node ./lib/feed.mjs",
"websub": "node ./lib/websub.mjs",
"start": "next start",
"build": "NEXT_PUBLIC_BUILDTIME=$(date '+%s') next build && pnpm feed && next-sitemap",
Expand Down
14 changes: 11 additions & 3 deletions pages/blog/[id].js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import path from "path";
import { lazy } from "react";
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
import { toJsxRuntime } from "hast-util-to-jsx-runtime";
import config from "../../lib/config.mjs";
const Douban = lazy(() => import("../../components/douban"));
const Bilibili = lazy(() => import("../../components/bilibili"));
const Tweet = lazy(() => import("../../components/twitter"));
Expand Down Expand Up @@ -40,7 +41,11 @@ export default PathId;

export const getStaticProps = async context => {
const { id } = context.params;
const mdData = await getMdContentById(id, defaultMarkdownDirectory, false);
const mdData = await getMdContentById(
`${id}.${context.locale}`,
defaultMarkdownDirectory,
false,
);
return {
props: mdData,
};
Expand All @@ -50,11 +55,14 @@ export const getStaticPaths = async () => {
const mdPostsData = getMdPostsData(path.join(process.cwd(), "posts"));
const paths = mdPostsData.map(data => {
return {
params: data,
params: { id: data.id },
locale: config.locales.includes(data.locale)
? data.locale
: config.defaultLocale,
};
});
// const paths = [
// { params: { id: "hi" } },
// { params: { id: "hi" }, locale: "zh" },
// ];
return {
paths,
Expand Down
Loading

0 comments on commit b6319cd

Please sign in to comment.