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:
Valerio Ageno
2024-07-13 12:09:56 +02:00
committed by GitHub
parent 227662489b
commit 78990d3c08
14 changed files with 200 additions and 20 deletions
+1 -1
View File
@@ -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"
+2 -2
View File
@@ -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"] }
+75 -1
View File
@@ -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 -1
View File
@@ -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"]
+83
View File
@@ -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
+4 -1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2
View File
@@ -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
View File
@@ -0,0 +1,2 @@
declare module 'react-meta-tags'
declare module 'react-meta-tags/server'
+10 -2
View File
@@ -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>