diff --git a/docs/design.md b/docs/design.md
index 92d2fb8f..45d9f0ed 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -6,4 +6,4 @@
## HTMLの動的生成
OGPタグの動的生成を実現するため、EJSテンプレートによるレンダリングをバックエンドに導入する。\
ViteはデフォルトではHTMLファイルを生成するため、生成されたHTMLファイルをベースにEJSファイルを生成する。\
-バックエンドはEJSのレンダリングで使用できるいくつかのパラメータを提供する。\
+バックエンドはEJSのレンダリングで使用できるいくつかのパラメータを提供する。
diff --git a/frontend/.gitignore b/frontend/.gitignore
index a547bf36..2017843c 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -22,3 +22,6 @@ dist-ssr
*.njsproj
*.sln
*.sw?
+
+# generate-template-html.js で生成
+/index.html
diff --git a/frontend/docs/regulations.md b/frontend/docs/regulations.md
index ac2f49e8..4c0bcc1b 100644
--- a/frontend/docs/regulations.md
+++ b/frontend/docs/regulations.md
@@ -4,8 +4,9 @@
ドキュメント: https://tanstack.com/router/latest/docs/framework/react/overview
-- ページのファイルは`route.tsx`か`route.lazy.tsx`とし、`createFileRoute`か`createLazyFileRoute`を使って`Route`コンポーネントをエクスポートする
- - lazyで使う場合はlazyを使う
+- ページのファイルは`route.tsx`(lazyで使う場合は`route.lazy.tsx`も)とし\
+ `createFileRoute`(lazyで使う場合は`createLazyFileRoute`も)を使って`Route`コンポーネントをエクスポートする
+- `createFileRoute`@`route.tsx`のオプションの`staticData`プロパティーでページのOpen Graph Protocol情報を定義する
- ルートを出力しないファイル・フォルダーに関してはファイル先頭に`-`を付ける(`-components/`)など
- 型定義ファイルを出力するために、`pnpm dev`のほかに`pnpm generate-routes`もしくは`pnpm watch-routes`コマンドを実行する
- `__root.tsx`はすべてのルートで適用されます
diff --git a/frontend/index.html b/frontend/index.html
deleted file mode 100644
index 81380b62..00000000
--- a/frontend/index.html
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
-
-
- Frost
-
-
-
-
-
-
diff --git a/frontend/package.json b/frontend/package.json
index 801a3c02..04758536 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -5,9 +5,10 @@
"type": "module",
"scripts": {
"generate-routes": "tsr generate",
+ "generate-html-template": "pnpm exec tsx --tsconfig tsconfig.app.json --import=./scripts/ignore-assets-loader/register.js scripts/generate-template-html.js",
"watch-routes": "tsr watch",
"dev": "vite",
- "build": "generate-routes && tsc -b && vite build && node ./scripts/generate-ejs.js",
+ "build": "pnpm run generate-routes && pnpm run generate-html-template && tsc -b && vite build && node ./scripts/generate-ejs.js",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint:fix": "pnpm lint --fix",
@@ -38,6 +39,7 @@
"eslint-plugin-react-refresh": "^0.4.7",
"eslint-plugin-unused-imports": "^4.0.0",
"prettier": "^3.3.2",
+ "tsx": "^4.16.0",
"typescript": "^5.5.2",
"vite": "^5.3.1",
"vite-tsconfig-paths": "^4.3.2"
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index cb4e2eb7..c3985aa2 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -75,6 +75,9 @@ importers:
prettier:
specifier: ^3.3.2
version: 3.3.2
+ tsx:
+ specifier: ^4.16.0
+ version: 4.16.0
typescript:
specifier: ^5.5.2
version: 5.5.2
@@ -1184,6 +1187,9 @@ packages:
resolution: {integrity: sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==}
engines: {node: '>= 0.4'}
+ get-tsconfig@4.7.5:
+ resolution: {integrity: sha512-ZCuZCnlqNzjb4QprAzXKdpp/gh6KTxSJuw3IBsPnV/7fV4NxC9ckB+vPTt8w7fJA0TaSD7c55BR47JD6MEDyDw==}
+
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
@@ -1676,6 +1682,9 @@ packages:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
+ resolve-pkg-maps@1.0.0:
+ resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
+
resolve@1.22.8:
resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==}
hasBin: true
@@ -1834,6 +1843,11 @@ packages:
tslib@2.6.3:
resolution: {integrity: sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==}
+ tsx@4.16.0:
+ resolution: {integrity: sha512-MPgN+CuY+4iKxGoJNPv+1pyo5YWZAQ5XfsyobUG+zoKG7IkvCPLZDEyoIb8yLS2FcWci1nlxAqmvPlFWD5AFiQ==}
+ engines: {node: '>=18.0.0'}
+ hasBin: true
+
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -3284,6 +3298,10 @@ snapshots:
es-errors: 1.3.0
get-intrinsic: 1.2.4
+ get-tsconfig@4.7.5:
+ dependencies:
+ resolve-pkg-maps: 1.0.0
+
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
@@ -3755,6 +3773,8 @@ snapshots:
resolve-from@4.0.0: {}
+ resolve-pkg-maps@1.0.0: {}
+
resolve@1.22.8:
dependencies:
is-core-module: 2.14.0
@@ -3942,6 +3962,13 @@ snapshots:
tslib@2.6.3: {}
+ tsx@4.16.0:
+ dependencies:
+ esbuild: 0.21.5
+ get-tsconfig: 4.7.5
+ optionalDependencies:
+ fsevents: 2.3.3
+
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
diff --git a/frontend/scripts/generate-ejs.js b/frontend/scripts/generate-ejs.js
index 622139e5..89e88df7 100644
--- a/frontend/scripts/generate-ejs.js
+++ b/frontend/scripts/generate-ejs.js
@@ -8,7 +8,7 @@ const distFile = process.cwd() + '/dist/index.ejs';
const source = fs.readFileSync(sourceFile, { encoding: 'utf8' });
// ejsのコメントを全てejs構文に置換
-const dist = source.replace(//g, '<$1>');
+const dist = source.replace(//gs, '<$1>');
// ejsファイルへ書き込み
fs.writeFileSync(distFile, dist);
diff --git a/frontend/scripts/generate-template-html.js b/frontend/scripts/generate-template-html.js
new file mode 100644
index 00000000..ddd7b439
--- /dev/null
+++ b/frontend/scripts/generate-template-html.js
@@ -0,0 +1,143 @@
+// @ts-check
+
+import fs from "node:fs";
+import path from "node:path";
+import process from "node:process";
+
+import { routeTree } from "../src/routeTree.gen";
+import { BACKEND_EJS_VARIABLE_KEYS } from "../src/staticDataRouteOption";
+
+// Constants
+
+/** 書き出すHTMLファイルのパス */
+const distFile = path.resolve(process.cwd(), "index.html");
+
+// Utilities
+
+/**
+ * 半角スペースとそれに続く `|` がある行はそれらを取り除き\
+ * ない行は行ごと取り除く
+ *
+ * @param {string} text
+ */
+const trimMargin = (text) =>
+ text
+ .split("\n")
+ // 行頭が半角スペース (ない or 任意個数)と | ではない行を取り除く
+ .filter((line) => line.match(/^\s*\|/))
+ // 行頭の半角スペース (ない or 任意個数)と | を取り払う
+ .map((line) => line.replace(/^\s*\|/, ""))
+ .join("\n");
+
+/**
+ * 全ての行にインデントを付与した文字列を返す
+ * @param {string} indentChar
+ * @param {number} indentLevel インデントの個数
+ * @param {string} sourceText
+ */
+const withIndent = (indentChar, indentLevel, sourceText) =>
+ sourceText
+ .split("\n")
+ .map((line) =>
+ line !== "" //
+ ? indentChar.repeat(indentLevel) + line
+ : line,
+ )
+ .join("\n");
+
+/** @typedef {import("../src/staticDataRouteOption").CustomStaticDataRouteOption} CustomStaticDataRouteOption */
+
+/**
+ * @typedef {{
+ * isRoot: boolean;
+ * options: {
+ * path?: string;
+ * staticData: CustomStaticDataRouteOption;
+ * }
+ * children?: RouteTree[];
+ * }} RouteTree TanStack Router の `routeTree.children` の型が謎なのでとりあえず想定の型
+ */
+
+/**
+ * @typedef {{
+ * fullPath: string;
+ * openGraph: CustomStaticDataRouteOption["openGraph"];
+ * }} FlattenRoute
+ */
+
+/**
+ * `RouteTree[]` を `FlattenRoute` の一次元配列にフラット化したものを返す
+ *
+ * ネストされた `children` を収集する
+ * @param {Readonly} routes
+ * @param {string} [pathPrefix='']
+ * @return {FlattenRoute[]}
+ */
+const getFlattenRoutes = (routes, pathPrefix = "") =>
+ routes.flatMap((route) => {
+ const fullPath = pathPrefix + route.options.path;
+ return [
+ {
+ fullPath: fullPath,
+ openGraph: route.options.staticData.openGraph,
+ },
+ // 子ルートを収集
+ ...(route.children != null
+ ? getFlattenRoutes(route.children, fullPath)
+ : []),
+ ];
+ });
+const flattenRoutes = getFlattenRoutes(
+ /** @type {RouteTree[]} */ (/** @type {unknown} */ (routeTree.children)),
+);
+
+const html = (() => {
+ const INDENT = " ";
+
+ const ogs = trimMargin(`
+ |
+ |
+ |
+ |
+ |${
+ route.openGraph.imageUrl != null
+ ? `|
+ |`
+ : ""
+ }
+ |
+ |
+ `);
+ return (
+ trimMargin(`
+ |
+ |
+ |
+ |
+ |
+ |
+ | Frost
+ |
+ `) +
+ withIndent(INDENT, 1, ogs) +
+ trimMargin(`
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ `)
+ );
+})();
+
+// htmlファイルへ書き込み
+fs.writeFileSync(distFile, html);
diff --git a/frontend/scripts/ignore-assets-loader/implement.js b/frontend/scripts/ignore-assets-loader/implement.js
new file mode 100644
index 00000000..554991db
--- /dev/null
+++ b/frontend/scripts/ignore-assets-loader/implement.js
@@ -0,0 +1,5 @@
+// @ts-check
+
+import { createMockResolver } from "../../../nodejs-mock-loader/src/index.js";
+
+export const resolve = createMockResolver(["GIF"]);
diff --git a/frontend/scripts/ignore-assets-loader/register.js b/frontend/scripts/ignore-assets-loader/register.js
new file mode 100644
index 00000000..884118e9
--- /dev/null
+++ b/frontend/scripts/ignore-assets-loader/register.js
@@ -0,0 +1,6 @@
+// @ts-check
+
+import { register } from "node:module";
+import { pathToFileURL } from "node:url";
+
+register(pathToFileURL("./scripts/ignore-assets-loader/implement.js"));
diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts
index 78b0c701..f07f08bc 100644
--- a/frontend/src/routeTree.gen.ts
+++ b/frontend/src/routeTree.gen.ts
@@ -8,20 +8,15 @@
// This file is auto-generated by TanStack Router
-import { createFileRoute } from '@tanstack/react-router'
-
// Import Routes
import { Route as rootRoute } from './routes/__root'
+import { Route as LoginRouteImport } from './routes/login/route'
import { Route as RouteImport } from './routes/route'
-// Create Virtual Routes
-
-const LoginRouteLazyImport = createFileRoute('/login')()
-
// Create/Update Routes
-const LoginRouteLazyRoute = LoginRouteLazyImport.update({
+const LoginRouteRoute = LoginRouteImport.update({
path: '/login',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/login/route.lazy').then((d) => d.Route))
@@ -46,7 +41,7 @@ declare module '@tanstack/react-router' {
id: '/login'
path: '/login'
fullPath: '/login'
- preLoaderRoute: typeof LoginRouteLazyImport
+ preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRoute
}
}
@@ -54,10 +49,7 @@ declare module '@tanstack/react-router' {
// Create and export the route tree
-export const routeTree = rootRoute.addChildren({
- RouteRoute,
- LoginRouteLazyRoute,
-})
+export const routeTree = rootRoute.addChildren({ RouteRoute, LoginRouteRoute })
/* prettier-ignore-end */
@@ -75,7 +67,7 @@ export const routeTree = rootRoute.addChildren({
"filePath": "route.tsx"
},
"/login": {
- "filePath": "login/route.lazy.tsx"
+ "filePath": "login/route.tsx"
}
}
}
diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx
index dbf901cf..c35a6178 100644
--- a/frontend/src/routes/__root.tsx
+++ b/frontend/src/routes/__root.tsx
@@ -1,6 +1,8 @@
import { createRootRoute, Outlet } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/router-devtools";
+import type { OpenGraphProtocolType } from "@/staticDataRouteOption";
+
import { Header } from "@/components/Header";
export const Route = createRootRoute({
@@ -11,4 +13,12 @@ export const Route = createRootRoute({
>
),
+ staticData: {
+ // RootRouteのOGP情報は使用しないが型定義上何かしら入れる必要がある
+ openGraph: {
+ title: "Don't Care",
+ type: "Don't Care" as OpenGraphProtocolType,
+ description: "Don't Care",
+ },
+ },
});
diff --git a/frontend/src/routes/login/route.lazy.tsx b/frontend/src/routes/login/route.lazy.tsx
index 71a3171f..e5afa64b 100644
--- a/frontend/src/routes/login/route.lazy.tsx
+++ b/frontend/src/routes/login/route.lazy.tsx
@@ -1,17 +1,7 @@
-import { Button } from "@mantine/core";
-import { Link, createLazyFileRoute } from "@tanstack/react-router";
+import { createLazyFileRoute } from "@tanstack/react-router";
+
+import { Login } from "./route";
export const Route = createLazyFileRoute("/login")({
component: Login,
});
-
-function Login() {
- return (
- <>
- Login Page
-
-
-
- >
- );
-}
diff --git a/frontend/src/routes/login/route.tsx b/frontend/src/routes/login/route.tsx
new file mode 100644
index 00000000..c8ed7305
--- /dev/null
+++ b/frontend/src/routes/login/route.tsx
@@ -0,0 +1,26 @@
+import { Button } from "@mantine/core";
+import { Link, createFileRoute } from "@tanstack/react-router";
+
+import { BACKEND_EJS_VARIABLE_KEYS } from "@/staticDataRouteOption";
+
+export const Route = createFileRoute("/login")({
+ component: Login,
+ staticData: {
+ openGraph: {
+ title: `<%= ${BACKEND_EJS_VARIABLE_KEYS.siteName} + ' | Login' %>`,
+ type: "website",
+ description: "Login page",
+ },
+ },
+});
+
+export function Login() {
+ return (
+ <>
+ Login Page
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/routes/route.tsx b/frontend/src/routes/route.tsx
index 8e2870f6..51703bd6 100644
--- a/frontend/src/routes/route.tsx
+++ b/frontend/src/routes/route.tsx
@@ -3,8 +3,17 @@ import { Link, createFileRoute } from "@tanstack/react-router";
import { DeleteParrot } from "./-components/DeleteParrot";
+import { BACKEND_EJS_VARIABLE_KEYS } from "@/staticDataRouteOption";
+
export const Route = createFileRoute("/")({
component: Home,
+ staticData: {
+ openGraph: {
+ title: `<%= ${BACKEND_EJS_VARIABLE_KEYS.siteName} %>`,
+ type: "website",
+ description: "Top",
+ },
+ },
});
function Home() {
diff --git a/frontend/src/staticDataRouteOption.ts b/frontend/src/staticDataRouteOption.ts
new file mode 100644
index 00000000..358bf4b7
--- /dev/null
+++ b/frontend/src/staticDataRouteOption.ts
@@ -0,0 +1,66 @@
+export interface CustomStaticDataRouteOption {
+ /**
+ * @see https://ogp.me/
+ */
+ openGraph: {
+ /** Raw text or EJS */
+ title: string;
+ type: OpenGraphProtocolType;
+ /** Raw text or EJS */
+ description: string;
+ /** Raw text or EJS */
+ imageUrl?: string;
+ };
+}
+
+/**
+ * backend から EJS レンダリング時に置換される変数のキー
+ *
+ * @todo backend のパッケージから提供したいがとりあえずここで定義
+ */
+export const BACKEND_EJS_VARIABLE_KEYS = {
+ /**
+ * サイトがホストされている URL の origin 部
+ *
+ * 末尾スラッシュなし
+ * @example https://frost.example.com
+ */
+ origin: "origin",
+ /** サイト名 */
+ siteName: "siteName",
+ /**
+ * リクエストのパス
+ *
+ * `/` で始まる
+ */
+ path: "path",
+} as const;
+
+/**
+ * @see https://ogp.me/
+ */
+export const OPEN_GRAPH_PROTOCOL = {
+ type: {
+ // Music
+ musicSong: "music.song",
+ musicAlbum: "music.album",
+ musicPlaylist: "music.playlist",
+ musicRadioStation: "music.radio_station",
+ // Video
+ videoMovie: "video.movie",
+ videoEpisode: "video.episode",
+ videoTvShow: "video.tv_show",
+ videoOther: "video.other",
+ // No Vertical
+ article: "article",
+ book: "book",
+ profile: "profile",
+ website: "website",
+ },
+} as const;
+export type OpenGraphProtocolType =
+ (typeof OPEN_GRAPH_PROTOCOL.type)[keyof typeof OPEN_GRAPH_PROTOCOL.type];
+
+declare module "@tanstack/react-router" {
+ interface StaticDataRouteOption extends CustomStaticDataRouteOption {}
+}
diff --git a/nodejs-mock-loader/README.md b/nodejs-mock-loader/README.md
new file mode 100644
index 00000000..c298bf0f
--- /dev/null
+++ b/nodejs-mock-loader/README.md
@@ -0,0 +1,6 @@
+# nodejs-mock-loader
+
+バンドラーでのバンドルを想定した JavaScript ソースをバンドラーを使わず利用する際\
+Node.js に任意のインポートファイルの解析を無視させたい。
+
+それを行うローダーを作成するための関数を提供する
diff --git a/nodejs-mock-loader/package.json b/nodejs-mock-loader/package.json
new file mode 100644
index 00000000..fd6eb44a
--- /dev/null
+++ b/nodejs-mock-loader/package.json
@@ -0,0 +1,7 @@
+{
+ "name": "nodejs-mock-loader",
+ "private": true,
+ "packageManager": "pnpm@9.4.0+sha512.f549b8a52c9d2b8536762f99c0722205efc5af913e77835dbccc3b0b0b2ca9e7dc8022b78062c17291c48e88749c70ce88eb5a74f1fa8c4bf5e18bb46c8bd83a",
+ "type": "module",
+ "main": "src/index.js"
+}
diff --git a/nodejs-mock-loader/src/index.js b/nodejs-mock-loader/src/index.js
new file mode 100644
index 00000000..0460ab42
--- /dev/null
+++ b/nodejs-mock-loader/src/index.js
@@ -0,0 +1,25 @@
+// @ts-check
+
+/**
+ * 特定の拡張子のインポートを空のファイルに置き換える Resolver を返す
+ * @param {Readonly} ignoreExts ex. `["CSS", "PNG"]`
+ * @returns Resolver
+ */
+export const createMockResolver =
+ (ignoreExts) =>
+ /**
+ * @param {string} specifier
+ * @param {unknown} context
+ * @param {(specifier: string, context: unknown) => unknown} next
+ */
+ async (specifier, context, next) => {
+ if (
+ ignoreExts.some((ignoreExt) =>
+ specifier.toUpperCase().endsWith(ignoreExt)
+ )
+ ) {
+ return next("/dev/null", context);
+ }
+
+ return next(specifier, context);
+ };