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); + };