mirror of
https://github.com/tuono-labs/tuono
synced 2026-07-25 12:52:47 -07:00
Add support for meta tags (#14)
* feat: added support for head meta tags * doc: update tutorial * feat: add support to no css manifest entries * feat: update version to v0.5.0 * doc: add mention to add the imports field * feat: add filter to workspace package.json * fix: add types declarations for react-meta-tags
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tuono"
|
name = "tuono"
|
||||||
version = "0.4.6"
|
version = "0.5.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
||||||
description = "The react/rust fullstack framework"
|
description = "The react/rust fullstack framework"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tuono_lib"
|
name = "tuono_lib"
|
||||||
version = "0.4.6"
|
version = "0.5.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
||||||
description = "The react/rust fullstack framework"
|
description = "The react/rust fullstack framework"
|
||||||
@@ -32,7 +32,7 @@ regex = "1.10.5"
|
|||||||
either = "1.13.0"
|
either = "1.13.0"
|
||||||
tower-http = {version = "0.5.2", features = ["fs"]}
|
tower-http = {version = "0.5.2", features = ["fs"]}
|
||||||
|
|
||||||
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.4.6"}
|
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.5.0"}
|
||||||
# Match the same version used by axum
|
# Match the same version used by axum
|
||||||
tokio-tungstenite = "0.21.0"
|
tokio-tungstenite = "0.21.0"
|
||||||
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
|
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
|
||||||
|
|||||||
@@ -7,12 +7,19 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
const VITE_MANIFEST_PATH: &str = "./out/client/.vite/manifest.json";
|
const VITE_MANIFEST_PATH: &str = "./out/client/.vite/manifest.json";
|
||||||
|
|
||||||
#[derive(Deserialize, Debug, Clone)]
|
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct BundleInfo {
|
pub struct BundleInfo {
|
||||||
|
/// TODO: Add also the import field and load the dynamic
|
||||||
|
/// values in the payload bundles.
|
||||||
pub file: String,
|
pub file: String,
|
||||||
|
#[serde(default = "default_css_vector")]
|
||||||
pub css: Vec<String>,
|
pub css: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_css_vector() -> Vec<String> {
|
||||||
|
Vec::with_capacity(0)
|
||||||
|
}
|
||||||
|
|
||||||
/// Manifest is the mapping between the vite output bundled files
|
/// Manifest is the mapping between the vite output bundled files
|
||||||
/// and the originals.
|
/// and the originals.
|
||||||
/// Vite doc: https://vitejs.dev/config/build-options.html#build-manifest
|
/// Vite doc: https://vitejs.dev/config/build-options.html#build-manifest
|
||||||
@@ -45,6 +52,73 @@ fn remap_manifest_keys(manifest: HashMap<String, BundleInfo>) -> HashMap<String,
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn correctly_parse_the_manifest_json() {
|
||||||
|
let manifest_example = r#"{
|
||||||
|
"../src/routes/index.tsx": {
|
||||||
|
"file": "assets/index.js",
|
||||||
|
"name": "index",
|
||||||
|
"src": "../src/routes/index.tsx",
|
||||||
|
"isDynamicEntry": true,
|
||||||
|
"imports": [
|
||||||
|
"client-main.tsx"
|
||||||
|
],
|
||||||
|
"css": [
|
||||||
|
"assets/index.css"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"meta-tags-lib": {
|
||||||
|
"file": "assets/meta-lib.js",
|
||||||
|
"name": "meta-tags-lib",
|
||||||
|
"imports": [
|
||||||
|
"client-main.tsx"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"client-main.tsx": {
|
||||||
|
"file": "assets/client-main.js",
|
||||||
|
"name": "client-main",
|
||||||
|
"src": "client-main.tsx",
|
||||||
|
"isEntry": true,
|
||||||
|
"dynamicImports": [
|
||||||
|
"../src/routes/index.tsx",
|
||||||
|
"../src/routes/pokemons/[pokemon].tsx"
|
||||||
|
],
|
||||||
|
"css": [
|
||||||
|
"assets/client-main.css"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let parsed_manifest =
|
||||||
|
serde_json::from_str::<HashMap<String, BundleInfo>>(manifest_example).unwrap();
|
||||||
|
|
||||||
|
let mut result = HashMap::new();
|
||||||
|
result.insert(
|
||||||
|
"../src/routes/index.tsx".to_string(),
|
||||||
|
BundleInfo {
|
||||||
|
file: "assets/index.js".to_string(),
|
||||||
|
css: vec!["assets/index.css".to_string()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
result.insert(
|
||||||
|
"client-main.tsx".to_string(),
|
||||||
|
BundleInfo {
|
||||||
|
file: "assets/client-main.js".to_string(),
|
||||||
|
css: vec!["assets/client-main.css".to_string()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
result.insert(
|
||||||
|
"meta-tags-lib".to_string(),
|
||||||
|
BundleInfo {
|
||||||
|
file: "assets/meta-lib.js".to_string(),
|
||||||
|
css: Vec::new(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(parsed_manifest, result);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn should_correctly_remap_the_manifest() {
|
fn should_correctly_remap_the_manifest() {
|
||||||
let mut parsed_manifest: HashMap<String, BundleInfo> = HashMap::new();
|
let mut parsed_manifest: HashMap<String, BundleInfo> = HashMap::new();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tuono_lib_macros"
|
name = "tuono_lib_macros"
|
||||||
version = "0.4.6"
|
version = "0.5.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "The react/rust fullstack framework"
|
description = "The react/rust fullstack framework"
|
||||||
keywords = [ "react", "typescript", "fullstack", "web", "ssr"]
|
keywords = [ "react", "typescript", "fullstack", "web", "ssr"]
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ Typescript and Rust knowledge is not a requirement though!
|
|||||||
* [Create a stand-alone component](#create-a-stand-alone-component)
|
* [Create a stand-alone component](#create-a-stand-alone-component)
|
||||||
* [Create the /pokemons/[pokemon] route](#create-the-pokemonspokemon-route)
|
* [Create the /pokemons/[pokemon] route](#create-the-pokemonspokemon-route)
|
||||||
* [Error handling](#error-handling)
|
* [Error handling](#error-handling)
|
||||||
|
* [Seo and meta tags](#seo-and-meta-tags)
|
||||||
* [Handle redirections](#handle-redirections)
|
* [Handle redirections](#handle-redirections)
|
||||||
* [Building for production](#building-for-production)
|
* [Building for production](#building-for-production)
|
||||||
* [Conclusion](#conclusion)
|
* [Conclusion](#conclusion)
|
||||||
@@ -547,6 +548,88 @@ async fn get_all_pokemons(_req: Request, fetch: Client) -> Response {
|
|||||||
If you now try to load a not existing pokemon (`http://localhost:3000/pokemons/tuono-pokemon`) you will
|
If you now try to load a not existing pokemon (`http://localhost:3000/pokemons/tuono-pokemon`) you will
|
||||||
correctly receive a 404 status code in the console.
|
correctly receive a 404 status code in the console.
|
||||||
|
|
||||||
|
## Seo and meta tags
|
||||||
|
|
||||||
|
The website now works and the http errors are meaningful but we should also take care to be meaningful
|
||||||
|
for the web crawlers. The best way to do it is to enrich the meta tags like the `<title>` and the
|
||||||
|
`<description>`.
|
||||||
|
|
||||||
|
To do so `tuono` exposes also the `<Head />` component useful exactly for handling this scenario. Let's update the `/` and the
|
||||||
|
`/pokemons/[pokemon]` routes with this.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
// src/routes/index.tsx
|
||||||
|
import type { TuonoProps } from "tuono";
|
||||||
|
++ import { Head } from "tuono"
|
||||||
|
|
||||||
|
interface Pokemon {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IndexProps {
|
||||||
|
results: Pokemon[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IndexPage({
|
||||||
|
data,
|
||||||
|
}: TuonoProps<IndexProps>): JSX.Element {
|
||||||
|
if (!data?.results) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
++ <Head>
|
||||||
|
++ <title>Tuono tutorial</title>
|
||||||
|
++ </Head>
|
||||||
|
<header className="header">
|
||||||
|
<a href="https://crates.io/crates/tuono" target="_blank">
|
||||||
|
Crates
|
||||||
|
</a>
|
||||||
|
<a href="https://www.npmjs.com/package/tuono" target="_blank">
|
||||||
|
Npm
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
<div className="title-wrap">
|
||||||
|
<h1 className="title">
|
||||||
|
TU<span>O</span>NO
|
||||||
|
</h1>
|
||||||
|
<div className="logo">
|
||||||
|
<img src="rust.svg" className="rust" />
|
||||||
|
<img src="react.svg" className="react" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul style={{ flexWrap: "wrap", display: "flex", gap: 10 }}>
|
||||||
|
{data.results.map((pokemon) => {
|
||||||
|
return pokemon.name;
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```diff
|
||||||
|
// src/routes/pokemons/[pokemon].tsx
|
||||||
|
-- import { TuonoProps } from "tuono";
|
||||||
|
++ import { TuonoProps, Head } from "tuono";
|
||||||
|
import PokemonView from "../../components/PokemonView";
|
||||||
|
|
||||||
|
export default function Pokemon({ data }: TuonoProps): JSX.Element {
|
||||||
|
-- return <PokemonView pokemon={data} />;
|
||||||
|
++ return (
|
||||||
|
++ <>
|
||||||
|
++ <Head>
|
||||||
|
++ <title>Pokemon: ${data?.name}</title>
|
||||||
|
++ </Head>
|
||||||
|
++ <PokemonView pokemon={data} />
|
||||||
|
++ </>
|
||||||
|
++ )
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `Head` component takes as children any valid HTML meta tag.
|
||||||
|
|
||||||
## Handle redirections
|
## Handle redirections
|
||||||
|
|
||||||
What if there is a pokemon among all of them that should be considered the GOAT? What
|
What if there is a pokemon among all of them that should be considered the GOAT? What
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// src/routes/index.tsx
|
// src/routes/index.tsx
|
||||||
import type { TuonoProps } from 'tuono'
|
import { Head, type TuonoProps } from 'tuono'
|
||||||
import PokemonLink from '../components/PokemonLink'
|
import PokemonLink from '../components/PokemonLink'
|
||||||
|
|
||||||
interface Pokemon {
|
interface Pokemon {
|
||||||
@@ -19,6 +19,9 @@ export default function IndexPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Tuono tutorial</title>
|
||||||
|
</Head>
|
||||||
<header className="header">
|
<header className="header">
|
||||||
<a href="https://crates.io/crates/tuono" target="_blank">
|
<a href="https://crates.io/crates/tuono" target="_blank">
|
||||||
Crates
|
Crates
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { TuonoProps } from 'tuono'
|
import { Head, type TuonoProps } from 'tuono'
|
||||||
import PokemonView from '../../components/PokemonView'
|
import PokemonView from '../../components/PokemonView'
|
||||||
|
|
||||||
interface Pokemon {
|
interface Pokemon {
|
||||||
@@ -11,5 +11,12 @@ interface Pokemon {
|
|||||||
export default function PokemonPage({
|
export default function PokemonPage({
|
||||||
data,
|
data,
|
||||||
}: TuonoProps<Pokemon>): JSX.Element {
|
}: TuonoProps<Pokemon>): JSX.Element {
|
||||||
return <PokemonView pokemon={data} />
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Pokemon: {data?.name}</title>
|
||||||
|
</Head>
|
||||||
|
<PokemonView pokemon={data} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-7
@@ -3,13 +3,13 @@
|
|||||||
"packageManager": "pnpm@9.1.1",
|
"packageManager": "pnpm@9.1.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "turbo dev --filter tuono",
|
"dev": "turbo dev --filter tuono",
|
||||||
"build": "turbo build",
|
"build": "turbo build --filter tuono",
|
||||||
"lint": "turbo lint",
|
"lint": "turbo lint --filter tuono",
|
||||||
"format": "turbo format",
|
"format": "turbo format --filter tuono",
|
||||||
"format:check": "turbo format:check",
|
"format:check": "turbo format:check --filter tuono",
|
||||||
"types": "turbo types",
|
"types": "turbo types --filter tuono",
|
||||||
"test": "turbo test",
|
"test": "turbo test --filter tuono",
|
||||||
"test:watch": "turbo test:watch"
|
"test:watch": "turbo test:watch --filter tuono"
|
||||||
},
|
},
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"tuono",
|
"tuono",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tuono-fs-router-vite-plugin",
|
"name": "tuono-fs-router-vite-plugin",
|
||||||
"version": "0.4.6",
|
"version": "0.5.0",
|
||||||
"description": "Plugin for the tuono's file system router. Tuono is the react/rust fullstack framework",
|
"description": "Plugin for the tuono's file system router. Tuono is the react/rust fullstack framework",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite build --watch",
|
"dev": "vite build --watch",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tuono-lazy-fn-vite-plugin",
|
"name": "tuono-lazy-fn-vite-plugin",
|
||||||
"version": "0.4.6",
|
"version": "0.5.0",
|
||||||
"description": "Plugin for the tuono's lazy fn. Tuono is the react/rust fullstack framework",
|
"description": "Plugin for the tuono's lazy fn. Tuono is the react/rust fullstack framework",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite build --watch",
|
"dev": "vite build --watch",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tuono",
|
"name": "tuono",
|
||||||
"version": "0.4.6",
|
"version": "0.5.0",
|
||||||
"description": "The react/rust fullstack framework",
|
"description": "The react/rust fullstack framework",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite build --watch",
|
"dev": "vite build --watch",
|
||||||
@@ -88,6 +88,7 @@
|
|||||||
"@types/node": "^20.12.7",
|
"@types/node": "^20.12.7",
|
||||||
"@vitejs/plugin-react-swc": "^3.7.0",
|
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||||
"fast-text-encoding": "^1.0.6",
|
"fast-text-encoding": "^1.0.6",
|
||||||
|
"react-meta-tags": "^1.0.1",
|
||||||
"tuono-fs-router-vite-plugin": "workspace:*",
|
"tuono-fs-router-vite-plugin": "workspace:*",
|
||||||
"tuono-lazy-fn-vite-plugin": "workspace:*",
|
"tuono-lazy-fn-vite-plugin": "workspace:*",
|
||||||
"vite": "^5.2.11",
|
"vite": "^5.2.11",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Head from 'react-meta-tags'
|
||||||
import {
|
import {
|
||||||
createRoute,
|
createRoute,
|
||||||
createRootRoute,
|
createRootRoute,
|
||||||
@@ -18,4 +19,5 @@ export {
|
|||||||
RouterProvider,
|
RouterProvider,
|
||||||
dynamic,
|
dynamic,
|
||||||
useRouter,
|
useRouter,
|
||||||
|
Head,
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
declare module 'react-meta-tags'
|
||||||
|
declare module 'react-meta-tags/server'
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'fast-text-encoding' // Mandatory for React18
|
import 'fast-text-encoding' // Mandatory for React18
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { renderToString, renderToStaticMarkup } from 'react-dom/server'
|
import { renderToString, renderToStaticMarkup } from 'react-dom/server'
|
||||||
|
import MetaTagsServer from 'react-meta-tags/server'
|
||||||
|
import { MetaTagsContext } from 'react-meta-tags'
|
||||||
import { RouterProvider, createRouter } from '../router'
|
import { RouterProvider, createRouter } from '../router'
|
||||||
|
|
||||||
type RouteTree = any
|
type RouteTree = any
|
||||||
@@ -42,16 +44,22 @@ export function serverSideRendering(routeTree: RouteTree) {
|
|||||||
const cssBundles = props.cssBundles as string[]
|
const cssBundles = props.cssBundles as string[]
|
||||||
const router = createRouter({ routeTree }) // Render the app
|
const router = createRouter({ routeTree }) // Render the app
|
||||||
|
|
||||||
|
const metaTagsInstance = MetaTagsServer()
|
||||||
|
|
||||||
const app = renderToString(
|
const app = renderToString(
|
||||||
<RouterProvider router={router} serverProps={props} />,
|
<MetaTagsContext extract={metaTagsInstance.extract}>
|
||||||
|
<RouterProvider router={router} serverProps={props} />
|
||||||
|
</MetaTagsContext>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const metaTags = metaTagsInstance.renderToString()
|
||||||
|
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Playground</title>
|
${metaTags}
|
||||||
${generateCssLinks(cssBundles, mode)}
|
${generateCssLinks(cssBundles, mode)}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Reference in New Issue
Block a user