config i18n

This commit is contained in:
juancmandev 2024-07-31 13:41:41 -06:00
parent 16f07bf63b
commit 373a4af4b1
24 changed files with 400 additions and 96 deletions

View File

@ -47,7 +47,14 @@ export default function Footer(props: Props) {
variant="link"
className="flex flex-col justify-center"
>
<a target="_blank" href="https://juancman.dev/rss.xml">
<a
target="_blank"
href={
props.lang == "en"
? "https://juancman.dev/feed.xml"
: "https://juancman.dev/es/feed.xml"
}
>
<RssIcon className="w-6" />
RSS feed
</a>

View File

@ -21,7 +21,7 @@ const locales = {
top: "Arriba",
navigation: "Navevación",
},
} as const;
};
const { lang } = Astro.props;
---
@ -34,7 +34,7 @@ const { lang } = Astro.props;
>
<section class="flex max-w-max">
<LinkButton
href="/"
href={lang === "en" ? "/" : "/es"}
size="icon"
variant="link"
className="rounded-full px-0"

View File

@ -8,75 +8,38 @@ import {
Info,
Mail,
} from "lucide-react";
import { useTranslations, useTranslatedPath } from "@/i18n/utils";
type TNavItem = {
to: string;
child: React.ReactNode;
type: string;
icon: React.ReactNode;
};
export const navItems: TNavItem[] = [
{
to: "/blog",
child: (
<>
<NotebookText />
Blog
</>
),
type: "blog",
icon: <NotebookText />,
},
{ type: "portfolio", icon: <BriefcaseBusiness /> },
{
type: "videos",
icon: <MonitorPlay />,
},
{
to: "/portfolio",
child: (
<>
<BriefcaseBusiness />
Portfolio
</>
),
type: "microblog",
icon: <Newspaper />,
},
{
to: "es/videos",
child: (
<>
<MonitorPlay />
Videos
</>
),
type: "resources",
icon: <PocketKnife />,
},
{
to: "/microblog",
child: (
<>
<Newspaper />
Microblog
</>
),
type: "about",
icon: <Info />,
},
{
to: "/resources",
child: (
<>
<PocketKnife />
Resources
</>
),
},
{
to: "/about",
child: (
<>
<Info />
About
</>
),
},
{
to: "/contact",
child: (
<>
<Mail />
Contact
</>
),
type: "contact",
icon: <Mail />,
},
];
@ -84,19 +47,46 @@ type Props = {
lang: "en" | "es";
};
const locales = {
en: {
navigation: "Navigation",
blog: { label: "Blog", to: "/blog" },
portfolio: { label: "Portfolio", to: "/portfolio" },
videos: { label: "Videos", to: "/es/videos" },
microblog: { label: "Microblog", to: "/microblog" },
resources: { label: "Resources", to: "/resources" },
about: { label: "About", to: "/about" },
contact: { label: "Contact", to: "/contact" },
},
es: {
navigation: "Navegación",
blog: { label: "Blog", to: "/es/blog" },
portfolio: { label: "Portfolio", to: "/es/portfolio" },
videos: { label: "Videos", to: "/es/videos" },
microblog: { label: "Microblog", to: "/microblog" },
resources: { label: "Recursos", to: "/es/recursos" },
about: { label: "Acerca de", to: "/es/acerca-de" },
contact: { label: "Contacto", to: "/es/contacto" },
},
} as const;
export default function Navigation(props: Props) {
const t = useTranslations(props.lang);
const translatePath = useTranslatedPath(props.lang);
return (
<nav className="px-4 sm:px-0 max-w-[65ch] mx-auto prose prose-invert pt-5 pb-20">
<h2 id="navigation">Navigation</h2>
<h2 id="navigation">{locales[props.lang].navigation}</h2>
<ul className="list-none p-0 flex flex-wrap gap-4">
{navItems.map((navItem, index) => (
<li key={index} className="m-0 p-0">
<LinkButton
variant="link"
href={navItem.to}
href={locales[props.lang][navItem.type].to}
className="p-0 text-base gap-1"
>
{navItem.child}
{navItem.icon}
{locales[props.lang][navItem.type].label}
</LinkButton>
</li>
))}

View File

@ -1,6 +1,9 @@
---
title: About
description: This website was first created as a portfolio, but learning about how the personal website is the digital form of the house tree, I like the idea of going that way instead of a generic landing with my social media.
description:
This website was first created as a portfolio, but learning about how the
personal website is the digital form of the house tree, I like the idea of
going that way instead of a generic landing with my social media.
---
# About
@ -9,11 +12,8 @@ This website was first created as a portfolio, but learning about how the
personal website is the digital form of the house tree, I like the idea of going
that way instead of a generic landing with my social media.
I'm trying to expand my skills, as I'm a Frontend Developer (with knowledge on
Backend), but skills like writing are important.
This website is in English to reach more people and put it into practice, but
Spanish is my mother tongue.
This website is in English to reach more people; and in Spanish, because is my
mother tongue.
All content written here is without AI; I don't use it for generating ideas; the
only exception is [LanguageTool](https://languagetool.org/) for validating my

View File

@ -0,0 +1,22 @@
---
title: Acerca de
description:
Este website fue creado en un principio como portfolio, pero luego de aprender
como los websites personales son la forma difgital de la casa del árbol, me
gustó más esa idea en lugar de una página genérica con mis redes sociales.
---
# Acerca de
Este website fue creado en un principio como portfolio, pero luego de aprender
como los websites personales son la forma difgital de la casa del árbol, me
gustó más esa idea en lugar de una página genérica con mis redes sociales.
Este website está en Inglés para llegar a más gente; y en Español, ya que es mi
lengua materna.
Todo el contenido escrito aquí es sin AI; no la uso para generar ideas; la única
excepción es [LanguageTool](https://languagetool.org/) para validar mi
gramática.
[![Written by human, not by AI](@/assets/about/written-by-human-not-by-ai.svg)](https://notbyai.fyi/)

View File

@ -0,0 +1,36 @@
---
title: Contact
description: You can contact me if you want me to work, or just say hello.
---
# Contacto
You can contact me if:
- You want me to work
- Just say hello
Please consider that **I don't**:
- Work for free
- Work on your startup idea and just get equity in return (I can't pay my bills
with lottery tickets)
- Work for you and get "exposure" (I can't pay my bills with exposure)
- Communicate via phone number; all communication must be via email (we can use
Discord, Slack, etc. once you hire me).
## My email
Just change `[at]` for `@` and `[dot]` for `.`. This is for preventing web
crawlers from getting my email:
```
contact[at]juancman[dot]dev
```
## Social media
You can send me a direct message:
- [LinkedIn](https://www.linkedin.com/in/juancmandev)
- [GitHub](https://github.com/juancmandev)

View File

@ -0,0 +1,61 @@
---
title: Resources
description:
Here you can find websites, YouTube channels, courses and more stuff that I
consume or find interesting.
---
# Recursos
Here you can find **websites**, **YouTube channels**, **courses** and **more**
stuff that I consume or find interesting.
## Courses and Documentation
- [fireship.io](https://fireship.io)
- [MDN Web Docs](https://developer.mozilla.org/en-US)
## Tech Stack
- [Astro](https://astro.build/) - Tool for building websites, that's how I built
this one, really useful when you want a static website, but you can do Server
Side Rendering too
- [Next.js](https://nextjs.org) - Dynamic and flexible React meta-framework,
previously used on this Website
- [PocketBase](https://pocketbase.io/) - Fast and light database.
- [Supabase](https://supabase.com) - Open Source Backend as a Service
alternative for Firebase, uses PostgreSQL and is really good if you're working
alone or you want a solid backend without investing to much
- [TailwindCSS](https://tailwindcss.com) - Best way to write CSS
- [shadcn/ui](https://ui.shadcn.com) - Best components for React, sinergy with
TailwindCSS
## YouTube channels
- [Mental Outlaw](https://www.youtube.com/channel/UC7YOGHUfC1Tb6E4pudI9STA)
- [Eric Murphy](https://www.youtube.com/channel/UC5KDiSAFxrDWhmysBcNqtMA)
- [Luke Smith](https://www.youtube.com/channel/UC2eYFnH61tmytImy1mTYvhA)
## Personal Websites
- [Eric Murphy](https://ericmurphy.xyz)
- [Luke Smith](https://lukesmith.xyz/)
## Favorite Blogs
- [Why I Will Never Join Mastodon (or the rest of the Fediverse)](https://ericmurphy.xyz/blog/mastodon)
- [Create More, Consume Less](https://www.bikobatanari.art/posts/2020/create-more) -
_Currently offline_
- [My Website is a Personal Museum](https://www.bikobatanari.art/posts/2020/personal-museum) -
_Currently offline_

16
src/i18n/ui.ts Normal file
View File

@ -0,0 +1,16 @@
export const languages = {
en: 'English',
es: 'Español',
};
export const defaultLang = 'en';
export const showDefaultLang = false;
export const ui = {
en: {
'hello': 'Hello',
},
es: {
'hello': 'Hola',
},
} as const;

19
src/i18n/utils.ts Normal file
View File

@ -0,0 +1,19 @@
import { ui, defaultLang, showDefaultLang } from './ui';
export function getLangFromUrl(url: URL) {
const [, lang] = url.pathname.split('/');
if (lang in ui) return lang as keyof typeof ui;
return defaultLang;
}
export function useTranslations(lang: keyof typeof ui) {
return function t(key: keyof typeof ui[typeof defaultLang]) {
return ui[lang][key] || ui[defaultLang][key];
}
}
export function useTranslatedPath(lang: keyof typeof ui) {
return function translatePath(path: string, l: string = lang) {
return !showDefaultLang && l === defaultLang ? path : `/${l}${path}`
}
}

View File

@ -3,17 +3,18 @@ import Header from "@/components/header.astro";
import Navigation from "@/components/navigation";
import Footer from "@/components/footer";
import "@/styles/globals.css";
import { getLangFromUrl } from "../i18n/utils";
interface Props {
title: string;
description: string;
lang: "en" | "es";
}
const { title, description, lang } = Astro.props;
const lang = getLangFromUrl(Astro.url);
const { title, description } = Astro.props;
---
<!doctype html>
<html lang={lang}>
<head>
<meta charset="UTF-8" />
@ -30,7 +31,13 @@ const { title, description, lang } = Astro.props;
rel="alternate"
title="juancmandev"
type="application/rss+xml"
href={new URL("rss.xml", Astro.site)}
href={new URL("feed.xml", Astro.site)}
/>
<link
rel="alternate"
title="juancmandev"
type="application/rss+xml"
href={new URL("feed.xml", `${Astro.site}/es/`)}
/>
<link rel="sitemap" href="/sitemap-index.xml" />
<meta name="generator" content={Astro.generator} />

View File

@ -1,14 +1,9 @@
---
import Layout from "@/layouts/Layout.astro";
import LinkButton from "@/components/link-button";
---
<Layout lang="en" title="juancmandev" description="Error 404. Not found.">
<Layout title="Not found" description="Error 404: Not found.">
<div class="prose prose-invert">
<h1 class="">Error 404: Not found</h1>
<p>Do not worry, you can <strong>go back to home</strong>.</p>
<LinkButton variant="default" href="/" className="no-underline"
>Home</LinkButton
>
</div>
</Layout>

View File

@ -21,7 +21,7 @@ const { page } = Astro.props;
const { Content } = await page.render();
---
<Layout lang="en" title={page.data.title} description={page.data.description}>
<Layout {...page.data}>
<article class="prose prose-invert">
<Content components={{ ...components }} />
</article>

View File

@ -25,7 +25,7 @@ const { post } = Astro.props;
const { Content } = await post.render();
---
<Layout lang="en" title={post.data.title} description={post.data.description}>
<Layout title={post.data.title} description={post.data.description}>
<article class="prose prose-invert">
<h1>{post.data.title}</h1>
<Content components={{ ...components }} />

View File

@ -13,7 +13,7 @@ const allPosts = await getCollection("blog", ({ data }) => data.draft !== true);
sortContentByDate(allPosts);
---
<Layout {...pageData} lang="en">
<Layout {...pageData}>
<section class="prose prose-invert">
<h1>{pageData.title}</h1>
<p>{pageData.description}</p>

View File

@ -0,0 +1,28 @@
---
import Layout from "@/layouts/Layout.astro";
import { getCollection } from "astro:content";
import type { CollectionEntry } from "astro:content";
import components from "@/components/mdx/wrapper";
interface Props {
page: CollectionEntry<"pages">;
}
export async function getStaticPaths() {
const allPages = await getCollection("pages");
return allPages.map((page: CollectionEntry<"pages">) => ({
params: { slug: page.slug },
props: { page },
}));
}
const { page } = Astro.props;
const { Content } = await page.render();
---
<Layout {...page.data}>
<article class="prose prose-invert">
<Content components={{ ...components }} />
</article>
</Layout>

129
src/pages/es/feed.xml.ts Normal file
View File

@ -0,0 +1,129 @@
import rss from "@astrojs/rss";
import type { RSSFeedItem } from "@astrojs/rss";
import { getCollection } from "astro:content";
import sanitizeHtml from "sanitize-html";
import MarkdownIt from "markdown-it";
import { parse as htmlParser } from "node-html-parser";
import { getImage } from "astro:assets";
import type { ImageMetadata } from "astro";
const markdownParser = new MarkdownIt();
const imagesBlog = import.meta.glob<{ default: ImageMetadata }>(
"/src/assets/blog/**/**/*.{jpeg,jpg,png,gif,webp}",
);
const imagesPortfolio = import.meta.glob<{ default: ImageMetadata }>(
"/src/assets/portfolio/**/**/*.{jpeg,jpg,png,gif,webp}",
);
export async function GET(context: any) {
const items: RSSFeedItem[] = [];
const blog = await getCollection(
"blog",
({ data }) => data.draft !== true && data.rss === true,
);
const portfolio = await getCollection(
"portfolio",
({ data }) => data.draft !== true && data.rss === true,
);
for await (const post of blog) {
const body = markdownParser.render(post.body);
const html = htmlParser.parse(body);
const images = html.querySelectorAll("img");
for await (const img of images) {
const src = img.getAttribute("src")!;
if (src.startsWith("@/")) {
const prefixRemoved = src.replace("@/", "");
const imagePathPrefix = `/src/${prefixRemoved}`;
const imagePath = await imagesBlog[imagePathPrefix]?.()?.then(
(res: any) => res.default,
);
if (imagePath) {
const optimizedImg = await getImage({ src: imagePath });
img.setAttribute(
"src",
context.site + optimizedImg.src.replace("/", ""),
);
}
} else if (src.startsWith("/images")) {
img.setAttribute("src", context.site + src.replace("/", ""));
} else {
throw Error("src unknown");
}
}
items.push({
title: post.data.title,
pubDate: post.data.date,
description: post.data.description,
link: `/blog/${post.slug}/`,
content: sanitizeHtml(html.toString(), {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]),
}),
});
}
for await (const project of portfolio) {
const body = markdownParser.render(project.body);
const html = htmlParser.parse(body);
const images = html.querySelectorAll("img");
for await (const img of images) {
const src = img.getAttribute("src")!;
if (src.startsWith("@/")) {
const prefixRemoved = src.replace("@/", "");
const imagePathPrefix = `/src/${prefixRemoved}`;
const imagePath = await imagesPortfolio[imagePathPrefix]?.()?.then(
(res: any) => res.default,
);
if (imagePath) {
const optimizedImg = await getImage({ src: imagePath });
img.setAttribute(
"src",
context.site + optimizedImg.src.replace("/", ""),
);
}
} else if (src.startsWith("/images")) {
// images starting with `/images/` is the public dir
img.setAttribute("src", context.site + src.replace("/", ""));
} else {
throw Error("src unknown");
}
}
items.push({
title: project.data.title,
pubDate: project.data.date,
description: project.data.description,
link: `/portfolio/${project.slug}/`,
content: sanitizeHtml(html.toString(), {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]),
}),
});
}
return rss({
xmlns: { atom: "http://www.w3.org/2005/Atom" },
title: "juancmandev",
description: "Bienvenido a mi dominio, extraño.",
site: `${context.site}es/`,
customData: [
"<language>es-mx</language>",
`<image>
<url>https://juancman.dev/logo.png</url>
<title>juancmandev</title>
<link>https://juancman.dev</link>
</image>`,
`<atom:link href="${context.site}es/feed.xml" rel="self" type="application/rss+xml"/>`,
].join(""),
items,
trailingSlash: false,
});
}

View File

@ -3,7 +3,6 @@ import Layout from "@/layouts/Layout.astro";
---
<Layout
lang="es"
title="juancmandev"
description="Bienvenido a mi dominio, extraño. Soy juancmandev; Desarrollador Web, entusiasta de Linux, y defensor de la privacidad."
>
@ -16,5 +15,10 @@ import Layout from "@/layouts/Layout.astro";
>privacidad.</strong
>
</p>
<p>
Este es mi <strong>website</strong>, un pedazo de Internet al que puedo
llamar <strong>hogar</strong>. Aquí comparto mi pasión por proyectos open
source y otros temas.
</p>
</div>
</Layout>

View File

@ -25,11 +25,7 @@ const { project } = Astro.props;
const { Content } = await project.render();
---
<Layout
lang="es"
title={project.data.title}
description={project.data.description}
>
<Layout title={project.data.title} description={project.data.description}>
<article class="prose prose-invert">
<h1>{project.data.title}</h1>
<Content components={{ ...components }} />

View File

@ -16,7 +16,7 @@ const allVideos = await getCollection(
sortContentByDate(allVideos);
---
<Layout {...pageData} lang="es">
<Layout {...pageData}>
<section class="prose prose-invert">
<h1>{pageData.title}</h1>
<p>{pageData.description}</p>

View File

@ -121,7 +121,7 @@ export async function GET(context: any) {
<title>juancmandev</title>
<link>https://juancman.dev</link>
</image>`,
`<atom:link href="${context.site}rss.xml" rel="self" type="application/rss+xml"/>`,
`<atom:link href="${context.site}feed.xml" rel="self" type="application/rss+xml"/>`,
].join(""),
items,
trailingSlash: false,

View File

@ -18,7 +18,6 @@ const last3Projects = allProjects.slice(0, 3);
---
<Layout
lang="en"
title="juancmandev"
description="Welcome to my domain, stranger. I am juancmandev; Web Developer, Linux enthusiast, and privacy defender."
>

View File

@ -11,7 +11,6 @@ const data = await pb.collection("microblogs").getFullList({
---
<Layout
lang="en"
title="Microblog"
description="Short-format writing. Instead of using shitty social media."
>

View File

@ -25,11 +25,7 @@ const { project } = Astro.props;
const { Content } = await project.render();
---
<Layout
lang="en"
title={project.data.title}
description={project.data.description}
>
<Layout title={project.data.title} description={project.data.description}>
<article class="prose prose-invert">
<h1>{project.data.title}</h1>
<Content components={{ ...components }} />

View File

@ -16,7 +16,7 @@ const allProjects = await getCollection(
sortContentByDate(allProjects);
---
<Layout {...pageData} lang="en">
<Layout {...pageData}>
<section class="prose prose-invert">
<h1>{pageData.title}</h1>
<p>{pageData.description}</p>