├── tsconfig.json ├── src ├── pages │ ├── index.astro │ ├── robots.txt.ts │ ├── 404.astro │ ├── [lang] │ │ ├── blog │ │ │ ├── utils.ts │ │ │ ├── [...slug].astro │ │ │ └── index.astro │ │ ├── faq.astro │ │ ├── contact.astro │ │ ├── index.astro │ │ └── components.astro │ └── api │ │ └── contactFormSubmit.ts ├── env.d.ts ├── entrypoint.ts ├── content │ ├── config.ts │ └── blog │ │ ├── en │ │ └── test.mdx │ │ ├── nl │ │ └── test.mdx │ │ └── fr │ │ └── test.mdx ├── components │ ├── CardGrid.astro │ ├── AutoSlider.astro │ ├── LangSwitch.astro │ ├── FullWidthBanner.astro │ ├── TextCard.astro │ ├── DropdownItem.astro │ ├── Footer.astro │ ├── IconCard.astro │ ├── BaseHead.astro │ └── Navigation.astro ├── i18n │ ├── utils.ts │ └── ui.ts └── layouts │ └── Layout.astro ├── .vscode ├── settings.json ├── extensions.json └── launch.json ├── public ├── fonts │ ├── Raleway.ttf │ ├── OpenSans.ttf │ ├── OpenSans-Italic.ttf │ └── Raleway-Italic.ttf ├── icons │ ├── vercel.svg │ ├── mdx.svg │ ├── sitemap.svg │ ├── alpinejs.svg │ ├── zero.svg │ ├── link.svg │ ├── search.svg │ ├── pointer.svg │ ├── tailwind.svg │ ├── globe.svg │ └── mailerSend.svg ├── noise.svg └── favicon.svg ├── .prettierrc.mjs ├── .gitignore ├── astro.config.mjs ├── package.json ├── tailwind.config.mjs └── README.md /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict" 3 | } 4 | -------------------------------------------------------------------------------- /src/pages/index.astro: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /public/fonts/Raleway.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ruben-Winant/astro_spark_template/HEAD/public/fonts/Raleway.ttf -------------------------------------------------------------------------------- /public/fonts/OpenSans.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ruben-Winant/astro_spark_template/HEAD/public/fonts/OpenSans.ttf -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["astro-build.astro-vscode"], 3 | "unwantedRecommendations": [] 4 | } 5 | -------------------------------------------------------------------------------- /public/fonts/OpenSans-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ruben-Winant/astro_spark_template/HEAD/public/fonts/OpenSans-Italic.ttf -------------------------------------------------------------------------------- /public/fonts/Raleway-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ruben-Winant/astro_spark_template/HEAD/public/fonts/Raleway-Italic.ttf -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | interface Window { 5 | Alpine: import("alpinejs").Alpine; 6 | } 7 | -------------------------------------------------------------------------------- /public/icons/vercel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/entrypoint.ts: -------------------------------------------------------------------------------- 1 | import collapsePlugin from "@alpinejs/collapse"; 2 | import type { Alpine } from "alpinejs"; 3 | 4 | export default (Alpine: Alpine) => { 5 | Alpine.plugin(collapsePlugin); 6 | }; 7 | -------------------------------------------------------------------------------- /public/icons/mdx.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /.prettierrc.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import("prettier").Config} */ 2 | export default { 3 | plugins: ["prettier-plugin-astro"], 4 | overrides: [ 5 | { 6 | files: "*.astro", 7 | options: { 8 | parser: "astro", 9 | }, 10 | }, 11 | ], 12 | }; 13 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "command": "./node_modules/.bin/astro dev", 6 | "name": "Development server", 7 | "request": "launch", 8 | "type": "node-terminal" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /public/icons/sitemap.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /public/icons/alpinejs.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | -------------------------------------------------------------------------------- /public/noise.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | 4 | # generated types 5 | .astro/ 6 | 7 | # dependencies 8 | node_modules/ 9 | 10 | # logs 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | 23 | # jetbrains setting folder 24 | .idea/ 25 | 26 | astro_tmp_pages_* -------------------------------------------------------------------------------- /src/pages/robots.txt.ts: -------------------------------------------------------------------------------- 1 | import type { APIRoute } from "astro"; 2 | 3 | const robotsTxt = ` 4 | User-agent: * 5 | Allow: / 6 | 7 | Sitemap: ${new URL("sitemap-index.xml", import.meta.env.SITE).href} 8 | `.trim(); 9 | 10 | export const GET: APIRoute = () => { 11 | return new Response(robotsTxt, { 12 | headers: { 13 | "Content-Type": "text/plain; charset=utf-8", 14 | }, 15 | }); 16 | }; 17 | -------------------------------------------------------------------------------- /public/icons/zero.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | -------------------------------------------------------------------------------- /public/icons/link.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | -------------------------------------------------------------------------------- /public/icons/search.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | -------------------------------------------------------------------------------- /public/icons/pointer.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | -------------------------------------------------------------------------------- /src/content/config.ts: -------------------------------------------------------------------------------- 1 | import { defineCollection, z } from "astro:content"; 2 | 3 | const blogCollection = defineCollection({ 4 | type: "content", 5 | schema: z.object({ 6 | title: z.string(), 7 | date: z.string().transform((str) => { 8 | const [day, month, year] = str.split("/"); 9 | return new Date(`${year}-${month}-${day}`).toLocaleDateString(); 10 | }), 11 | lang: z.enum(["en", "nl", "fr"]), 12 | }), 13 | }); 14 | 15 | export const collections = { 16 | blog: blogCollection, 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/CardGrid.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { useTranslations, getLangFromUrl } from "../i18n/utils"; 3 | ("../i18n/utils.ts"); 4 | 5 | const { title, description } = Astro.props; 6 | const t = useTranslations(getLangFromUrl(Astro.url)); 7 | --- 8 | 9 |
10 | {title ?

{t(title)}

: ""} 11 | {description ?

{t(description)}

: ""} 12 |
13 | 14 |
15 |
16 | 17 |
18 |
19 | -------------------------------------------------------------------------------- /src/components/AutoSlider.astro: -------------------------------------------------------------------------------- 1 | --- 2 | const { title, description } = Astro.props; 3 | --- 4 | 5 |
6 | {title ?

{title}

: ""} 7 | {description ?

{description}

: ""} 8 |
9 |
    12 | 13 |
14 |
15 |
16 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/pages/404.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Image from "astro/components/Image.astro"; 3 | import Layout from "../layouts/Layout.astro"; 4 | --- 5 | 6 | 7 |
8 |
9 |
10 | 4 11 | spark logo 12 | 4 13 |
14 | not found 15 |
16 |
17 |
18 | -------------------------------------------------------------------------------- /public/icons/tailwind.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 10 | -------------------------------------------------------------------------------- /src/components/LangSwitch.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { LOCALES, getLocalizedPathname } from "../i18n/utils"; 3 | 4 | const { pathname } = Astro.url; 5 | 6 | const lang = Astro.params?.lang || "nl"; 7 | --- 8 | 9 | 24 | -------------------------------------------------------------------------------- /src/components/FullWidthBanner.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { getLangFromUrl, useTranslations } from "../i18n/utils"; 3 | 4 | const { title, description, ctaTitle, ctaLink } = Astro.props; 5 | const t = useTranslations(getLangFromUrl(Astro.url)); 6 | --- 7 | 8 |
9 |
12 |
13 |
14 |

{t(title)}

15 |

{t(description)}

16 | { 17 | ctaLink && ctaTitle && ( 18 | 19 | {t(ctaTitle)} 20 | 21 | ) 22 | } 23 |
24 |
25 | -------------------------------------------------------------------------------- /src/components/TextCard.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { Image } from "astro:assets"; 3 | 4 | const { title, description, classes, icon } = Astro.props; 5 | --- 6 | 7 |
  • 11 | { 12 | icon ? ( 13 | 14 | 22 | 23 | ) : ( 24 | "" 25 | ) 26 | } 27 |

    {title}

    28 |

    {description}

    29 |
  • 30 | -------------------------------------------------------------------------------- /src/components/DropdownItem.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { getLangFromUrl, useTranslations } from "../i18n/utils"; 3 | 4 | const { title, content } = Astro.props; 5 | const lang = getLangFromUrl(Astro.url); 6 | const t = useTranslations(lang); 7 | --- 8 | 9 |
    14 | 25 | 26 |
    27 |

    {t(content)}

    28 |
    29 |
    30 | -------------------------------------------------------------------------------- /astro.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "astro/config"; 2 | import tailwind from "@astrojs/tailwind"; 3 | import sitemap from "@astrojs/sitemap"; 4 | import mdx from "@astrojs/mdx"; 5 | import alpinejs from "@astrojs/alpinejs"; 6 | import { DEFAULT_LOCALE, LOCALES } from "./src/i18n/utils"; 7 | import vercel from "@astrojs/vercel/serverless"; 8 | 9 | // https://astro.build/config 10 | export default defineConfig({ 11 | site: "http://localhost:4321/", 12 | // todo - add site for sitemap 13 | build: { 14 | format: "directory", 15 | }, 16 | integrations: [ 17 | tailwind(), 18 | sitemap({ 19 | i18n: { 20 | defaultLocale: DEFAULT_LOCALE, 21 | locales: LOCALES, 22 | }, 23 | }), 24 | mdx(), 25 | alpinejs({ entrypoint: "/src/entrypoint" }), 26 | ], 27 | prefetch: true, 28 | output: "server", 29 | adapter: vercel(), 30 | }); 31 | -------------------------------------------------------------------------------- /src/pages/[lang]/blog/utils.ts: -------------------------------------------------------------------------------- 1 | export const calculateReadingTime = (content: string) => { 2 | // Remove MDX-specific syntax (JSX tags, imports, code blocks, etc.) 3 | const strippedContent = content 4 | .replace(/<.*?>/g, "") // Remove JSX tags 5 | .replace(/```[\s\S]*?```/g, "") // Remove code blocks 6 | .replace(/import .*?;/g, "") // Remove import statements 7 | .replace(/#/g, "") // Remove markdown headers 8 | .replace(/[*_`~[\]]/g, ""); // Remove other markdown symbols like *, _, ~, [], etc. 9 | 10 | // Split the remaining content by whitespace and count words 11 | const wordCount = strippedContent 12 | .split(/\s+/) // Split by whitespace 13 | .filter(Boolean).length; // Filter out empty strings 14 | 15 | // Calculate reading time (average 200 words per minute) 16 | const wordsPerMinute = 200; 17 | const readingTime = Math.ceil(wordCount / wordsPerMinute); 18 | 19 | return readingTime; 20 | }; 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "astro-template", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "scripts": { 6 | "dev": "astro dev", 7 | "start": "astro dev", 8 | "build": "astro check && astro build", 9 | "preview": "astro preview", 10 | "astro": "astro" 11 | }, 12 | "dependencies": { 13 | "@alpinejs/collapse": "^3.14.0", 14 | "@astrojs/alpinejs": "^0.4.0", 15 | "@astrojs/check": "^0.9.3", 16 | "@astrojs/mdx": "^3.1.6", 17 | "@astrojs/sitemap": "^3.1.6", 18 | "@astrojs/tailwind": "^5.1.0", 19 | "@astrojs/vercel": "^7.8.1", 20 | "@types/alpinejs": "^3.13.10", 21 | "alpinejs": "^3.14.0", 22 | "astro": "^4.15.6", 23 | "dotenv": "^16.4.5", 24 | "mailersend": "^2.2.0", 25 | "tailwindcss": "^3.4.4", 26 | "typescript": "^5.4.5" 27 | }, 28 | "devDependencies": { 29 | "@types/alpinejs__collapse": "^3.13.4", 30 | "prettier": "^3.3.1", 31 | "prettier-plugin-astro": "^0.14.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/pages/[lang]/blog/[...slug].astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { getCollection } from "astro:content"; 3 | import { LOCALES, getLangFromUrl } from "../../../i18n/utils"; 4 | import Layout from "../../../layouts/Layout.astro"; 5 | 6 | export const prerender = true; 7 | 8 | export async function getStaticPaths() { 9 | const blogEntries = await getCollection("blog"); 10 | 11 | const paths = []; 12 | 13 | for (const entry of blogEntries) { 14 | for (const lang of Object.keys(LOCALES)) { 15 | paths.push({ 16 | params: { 17 | lang, 18 | slug: entry.slug, 19 | }, 20 | props: { entry, lang }, 21 | }); 22 | } 23 | } 24 | 25 | return paths; 26 | } 27 | 28 | const { entry } = Astro.props; 29 | const { Content } = await entry.render(); 30 | 31 | const lang = getLangFromUrl(Astro.url); 32 | --- 33 | 34 | 35 |
    36 | 37 |
    38 |
    39 | -------------------------------------------------------------------------------- /src/pages/[lang]/blog/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { getCollection } from "astro:content"; 3 | import Layout from "../../../layouts/Layout.astro"; 4 | import { getLangFromUrl } from "../../../i18n/utils"; 5 | import { calculateReadingTime } from "./utils"; 6 | 7 | const lang = getLangFromUrl(Astro.url); 8 | const blogPosts = await getCollection("blog", ({ data }) => { 9 | return data.lang === lang; 10 | }); 11 | --- 12 | 13 | 14 |
    15 |

    Blog

    16 | 32 |
    33 |
    34 | -------------------------------------------------------------------------------- /src/components/Footer.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { getLangFromUrl, useTranslations, type UiType } from "../i18n/utils"; 3 | import LangSwitch from "./LangSwitch.astro"; 4 | 5 | const lang = getLangFromUrl(Astro.url); 6 | const t = useTranslations(lang as UiType); 7 | --- 8 | 9 |
    10 |
    11 |
    12 | spark logoSpark 24 | 25 |
    26 | 27 | © {t("footer.copyright", new Date().getFullYear())} 28 | {t("footer.createdBy")} 29 | Ruben Winant 34 | {t("footer.createdBy2")} 35 | 36 |
    37 |
    38 | -------------------------------------------------------------------------------- /public/icons/globe.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | -------------------------------------------------------------------------------- /src/pages/[lang]/faq.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import DropdownItem from "../../components/DropdownItem.astro"; 3 | import { getLangFromUrl, useTranslations, LOCALES } from "../../i18n/utils"; 4 | import Layout from "../../layouts/Layout.astro"; 5 | 6 | const lang = getLangFromUrl(Astro.url); 7 | const t = useTranslations(lang); 8 | 9 | export const prerender = true; 10 | export async function getStaticPaths() { 11 | return Object.keys(LOCALES).map((lang) => { 12 | return { params: { lang } }; 13 | }); 14 | } 15 | --- 16 | 17 | 18 |
    19 |
    20 |

    {t("faq.title")}

    21 |

    {t("faq.description")}

    22 |
    23 | 24 |
    25 | 26 | 27 | 28 | {t("faq.contact")} {t("faq.contactUs")} 34 |
    35 |
    36 |
    37 | -------------------------------------------------------------------------------- /src/components/IconCard.astro: -------------------------------------------------------------------------------- 1 | --- 2 | interface Props { 3 | title: string; 4 | imgHref: string; 5 | imgAlt: string; 6 | href: string; 7 | } 8 | 9 | const { href, title, imgHref, imgAlt } = Astro.props; 10 | --- 11 | 12 |
  • 32 |
    39 |
    40 |
    41 | {imgAlt} 46 |
    47 | 48 | {title} 49 | 50 |
    51 |
  • 52 | -------------------------------------------------------------------------------- /src/i18n/utils.ts: -------------------------------------------------------------------------------- 1 | import { ui } from "./ui"; 2 | 3 | export const DEFAULT_LOCALE = "nl"; 4 | 5 | export const LOCALES = { 6 | en: "en-US", 7 | nl: "be-nl", 8 | fr: "be-fr", 9 | }; 10 | 11 | export type UiType = keyof typeof ui; 12 | 13 | export function getLangFromUrl(url: URL): UiType { 14 | const [, lang] = url.pathname.split("/"); 15 | if (lang in ui) return lang as UiType; 16 | return DEFAULT_LOCALE as UiType; 17 | } 18 | 19 | export function useTranslations(lang?: UiType) { 20 | return function t( 21 | key: keyof (typeof ui)[typeof DEFAULT_LOCALE], 22 | ...args: any[] 23 | ) { 24 | let translation = 25 | ui[lang ?? DEFAULT_LOCALE][key] || ui[DEFAULT_LOCALE][key]; 26 | if (args.length > 0) { 27 | for (let i = 0; i < args.length; i++) { 28 | translation = translation.replace(`{${i}}`, args[i]); 29 | } 30 | } 31 | return translation; 32 | }; 33 | } 34 | 35 | export function pathNameIsInLanguage(pathname: string, lang: UiType): boolean { 36 | return ( 37 | pathname.startsWith(`/${lang}`) || 38 | (lang === DEFAULT_LOCALE && !pathNameStartsWithLanguage(pathname)) 39 | ); 40 | } 41 | 42 | function pathNameStartsWithLanguage(pathname: string): boolean { 43 | const languages = Object.keys(ui); 44 | for (let i = 0; i < languages.length; i++) { 45 | const lang = languages[i]; 46 | if (pathname.startsWith(`/${lang}`)) { 47 | return true; 48 | } 49 | } 50 | return false; 51 | } 52 | 53 | export function getLocalizedPathname(pathname: string, lang: string): string { 54 | if (pathNameStartsWithLanguage(pathname)) { 55 | const availableLanguages = Object.keys(ui).join("|"); 56 | const regex = new RegExp(`^\/(${availableLanguages})`); 57 | return pathname.replace(regex, `/${lang}`); 58 | } 59 | return `/${lang}${pathname}`; 60 | } 61 | -------------------------------------------------------------------------------- /tailwind.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"], 4 | theme: { 5 | container: { 6 | center: true, 7 | padding: { 8 | DEFAULT: "1.25rem", 9 | sm: "4rem", 10 | lg: "5rem", 11 | xl: "6rem", 12 | "2xl": "8rem", 13 | }, 14 | screens: { 15 | DEFAULT: "640px", 16 | sm: "768px", 17 | lg: "1024px", 18 | xl: "1280px", 19 | "2xl": "1280px", 20 | }, 21 | }, 22 | extend: { 23 | fontFamily: { 24 | raleway: ["Raleway", "sans-serif"], 25 | "raleway-italic": ["Raleway-Italic", "sans-serif"], 26 | "open-sans": ["OpenSans", "sans-serif"], 27 | "open-sans-italic": ["OpenSans-Italic", "sans-serif"], 28 | }, 29 | colors: { 30 | white: "#FFFFFF", 31 | accent: "#2CA02C", 32 | gray: "#13151a", 33 | elevated: "#fbf7f5", 34 | negative: "#E62323", 35 | positive: "#00d255", 36 | primary: "#563edc", 37 | secondary: "#cb29ef", 38 | }, 39 | keyframes: { 40 | slider: { 41 | from: { transform: "translate(0)" }, 42 | to: { transform: "translate(calc(-320px * 5 - 12rem))" }, 43 | }, 44 | "slider-md": { 45 | from: { transform: "translate(0)" }, 46 | to: { transform: "translate(calc(-432px * 5 - 12rem))" }, 47 | }, 48 | "slider-lg": { 49 | from: { transform: "translate(0)" }, 50 | to: { transform: "translate(calc(-544px * 5 - 12rem))" }, 51 | }, 52 | }, 53 | animation: { 54 | slider: "slider 20s linear infinite", 55 | "slider-md": "slider-md 20s linear infinite", 56 | "slider-lg": "slider-lg 20s linear infinite", 57 | }, 58 | }, 59 | }, 60 | plugins: [], 61 | }; 62 | -------------------------------------------------------------------------------- /public/icons/mailerSend.svg: -------------------------------------------------------------------------------- 1 | 3 | symbol 4 | 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Astro Spark Template 2 | 3 | Astro Spark is a modern starter template optimized for static sites, built using Astro. 4 | 5 | Demo available at https://www.spark.rubenwinant.be/en 6 | 7 | ## Features 8 | 9 | - Astro for blazing-fast static site generation. 10 | - Alpine.js: Lightweight JS for simple interactivity without overhead. 11 | - Tailwind CSS: Utility-first CSS framework for rapid UI development. 12 | - MDX Support: Write components directly within Markdown. 13 | - Sitemap Generator: Automatically generates a sitemap for SEO. 14 | - SEO Optimized: Meta tags, Open Graph, and structured data support. 15 | - Responsive Design: Out-of-the-box mobile responsiveness. 16 | - 17 | 18 | ## Installation 19 | 20 | First, clone the repository and navigate to the project folder: 21 | 22 | ``` 23 | git clone https://github.com/Ruben-Winant/astro_spark_template 24 | cd astro_spark_template 25 | ``` 26 | 27 | Next, install the necessary dependencies: 28 | 29 | ``` 30 | npm install 31 | ``` 32 | 33 | ## Usage 34 | 35 | To start the development server, run: 36 | 37 | ``` 38 | npm run dev 39 | ``` 40 | 41 | This will launch a local development server accessible at `http://localhost:3000`, allowing you to see changes in real-time. 42 | 43 | To build the project for production: 44 | 45 | ``` 46 | npm run build 47 | ``` 48 | 49 | This will create an optimized static site in the `dist/` folder. 50 | 51 | ## Scripts 52 | 53 | - `npm run dev`: Starts the development server. 54 | - `npm run build`: Builds the project for production. 55 | - `npm run preview`: Previews the production build locally. 56 | - `npm run astro ...`: Use this for custom Astro commands. 57 | 58 | ## Project Structure 59 | 60 | ``` 61 | ├── public/ # Static assets (e.g., images, fonts) 62 | ├── src/ 63 | │ ├── components/ # Reusable components (e.g., buttons, headers) 64 | │ ├── layouts/ # Layouts for different page types 65 | │ ├── pages/ # Your page content (in .astro or .mdx) 66 | │ ├── styles/ # Global CSS or Tailwind styles 67 | │ └── content/ # MDX content, easily extendable 68 | ├── package.json # Project scripts and dependencies 69 | └── astro.config.mjs # Astro configuration 70 | ``` 71 | 72 | - `components/`: Contains reusable components like navigation, buttons, etc. 73 | - `layouts/`: Defines page layouts (e.g., blog post, homepage). 74 | - `pages/`: All your main site pages (e.g., /index.astro, /about.astro). 75 | - `content/`: For MDX-based content management. 76 | - `styles/`: Custom global styles using Tailwind CSS. 77 | 78 | ## Customization 79 | 80 | - Styling: Modify src/styles and tweak Tailwind CSS as needed. 81 | - MDX Pages: Easily add interactive React components within your Markdown using MDX. 82 | - SEO Tags: Adjust meta tags in each page or globally in layouts/. 83 | 84 | ## Contributing 85 | 86 | Contributions, issues, and feature requests are welcome! Feel free to check the issues page. 87 | -------------------------------------------------------------------------------- /src/components/BaseHead.astro: -------------------------------------------------------------------------------- 1 | --- 2 | // Import the global.css file here so that it is included on 3 | // all pages through the use of the component. 4 | import { 5 | LOCALES, 6 | pathNameIsInLanguage, 7 | DEFAULT_LOCALE, 8 | getLocalizedPathname, 9 | } from "../i18n/utils"; 10 | 11 | interface Props { 12 | title: string; 13 | description: string; 14 | image?: string; 15 | } 16 | 17 | const canonicalPathname = pathNameIsInLanguage( 18 | Astro.url.pathname, 19 | DEFAULT_LOCALE, 20 | ) 21 | ? Astro.url.pathname 22 | : getLocalizedPathname(Astro.url.pathname, DEFAULT_LOCALE); 23 | const canonicalURL = new URL(canonicalPathname, Astro.site); 24 | 25 | const { title, description, image = "/favicon.svg" } = Astro.props; 26 | 27 | const alternateLang = Object.keys(LOCALES); 28 | const alternateLinks = alternateLang.map((lang: string) => { 29 | return { 30 | href: new URL(getLocalizedPathname(Astro.url.pathname, lang), Astro.site), 31 | hreflang: lang, 32 | }; 33 | }); 34 | --- 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 52 | 59 | 66 | 73 | 74 | 75 | 76 | 77 | 78 | { 79 | alternateLinks.map(({ href, hreflang }) => ( 80 | 81 | )) 82 | } 83 | 84 | 85 | {title} 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /src/content/blog/en/test.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Introduction to MDX and Markdown 3 | date: 21/09/2024 4 | lang: en 5 | slug: Introduction-to-MDX-and-Markdown 6 | author: Ruben Winant 7 | --- 8 | 9 | # {frontmatter.title} 10 | 11 | If you're new to web development or content creation, you might have come across two terms: **Markdown** and **MDX**. In this post, we’ll explain the basics of both and show you how to get started. 12 | 13 | ## What is Markdown? 14 | 15 | Markdown is a lightweight markup language that allows you to format text using plain text syntax. It's widely used for writing documentation, blog posts, and README files. 16 | 17 | Here's an example of Markdown syntax: 18 | 19 | - **Bold text**: `**Bold**` 20 | - *Italic text*: `*Italic*` 21 | - [Links](https://example.com): `[Link text](https://example.com)` 22 | - Headings: 23 | - `# Heading 1` 24 | - `## Heading 2` 25 | - `### Heading 3` 26 | 27 | ### Example 28 | 29 | Here’s a sample Markdown snippet: 30 | 31 | ```Md 32 | # My First Blog Post 33 | 34 | Welcome to my blog! This is an example of a Markdown file. Below is a list: 35 | 36 | - Item 1 37 | - Item 2 38 | - Item 3 39 | 40 | ``` 41 | 42 | When rendered, this will look like: 43 | 44 | # My First Blog Post 45 | 46 | Welcome to my blog! This is an example of a Markdown file. Below is a list: 47 | 48 | - Item 1 49 | - Item 2 50 | - Item 3 51 | 52 | ## What is MDX? 53 | 54 | MDX is an extension of Markdown that allows you to embed JSX (JavaScript XML) directly into your Markdown files. This makes it possible to mix standard Markdown content with dynamic components, making it a powerful tool for modern web development. 55 | 56 | However, if you're just starting out, you can use MDX exactly like Markdown without any JSX components. It works just fine with the basics. 57 | 58 | ### Example 59 | 60 | Here’s what a simple MDX file looks like (without custom components): 61 | 62 | ```mdx 63 | --- 64 | title: "My MDX Post" 65 | --- 66 | 67 | # Hello World 68 | 69 | This is an example MDX post. It behaves just like Markdown, but I can later add components if needed. 70 | 71 | Why Use MDX? 72 | 73 | The main advantage of MDX is that it gives you the flexibility to write simple content using Markdown but allows for dynamic content later on. It's commonly used in static site generators like Astro, Next.js, and Gatsby. 74 | Getting Started 75 | 76 | To create a basic MDX post, you just need to: 77 | 78 | Create a .mdx file. 79 | Write Markdown content as usual. 80 | Optionally, add JSX components later when you’re comfortable with the basics. 81 | 82 | That’s all you need to get started with MDX and Markdown. In future posts, we’ll dive deeper into how you can use MDX to create dynamic and interactive blog posts. 83 | 84 | Stay tuned for more tips and tutorials! 85 | 86 | markdown 87 | 88 | 89 | ### Key Elements: 90 | - **Basic Markdown features** like headings, bold, italics, lists, and links. 91 | - **MDX frontmatter** (`---`) for metadata (title, description, etc.). 92 | - No custom components or JSX, ensuring compatibility across different MDX parsers. 93 | 94 | This post covers the basics and will work smoothly without custom components or advanced features. You can use it as a foundation for learning or sharing MDX and Markdown tips. 95 | 96 | -------------------------------------------------------------------------------- /src/content/blog/nl/test.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Inleiding tot MDX en Markdown 3 | date: 21/09/2024 4 | lang: nl 5 | slug: Inleiding-tot-MDX-en-Markdown 6 | author: Ruben Winant 7 | --- 8 | 9 | # {frontmatter.title} 10 | 11 | Als je nieuw bent in webontwikkeling of contentcreatie, ben je misschien twee termen tegengekomen: **Markdown** en **MDX**. In deze post leggen we de basis van beide uit en laten we zien hoe je aan de slag kunt. 12 | 13 | ## Wat is Markdown? 14 | 15 | Markdown is een lichtgewicht opmaaktaal waarmee je tekst kunt opmaken met de syntaxis van platte tekst. Het wordt veel gebruikt voor het schrijven van documentatie, blog posts en README bestanden. 16 | 17 | Hier is een voorbeeld van Markdown syntaxis: 18 | 19 | - **Bold text**: `**Bold**` 20 | - *Italic text*: `*Italic*` 21 | - [Links](https://example.com): `[Link text](https://example.com)` 22 | - Headings: 23 | - `# Heading 1` 24 | - `## Heading 2` 25 | - `### Heading 3` 26 | 27 | ### Voorbeeld 28 | 29 | Hier is een voorbeeld van een Markdown fragment: 30 | 31 | ```Md 32 | # Mijn eerste blogpost 33 | 34 | Welkom op mijn blog! Dit is een voorbeeld van een Markdown-bestand. Hieronder staat een lijst: 35 | 36 | - Item 1 37 | - Item 2 38 | - Item 3 39 | 40 | ``` 41 | 42 | Bij het renderen ziet dit er als volgt uit: 43 | 44 | # Mijn eerste blogpost 45 | 46 | Welkom op mijn blog! Dit is een voorbeeld van een Markdown-bestand. Hieronder staat een lijst: 47 | 48 | - Item 1 49 | - Item 2 50 | - Item 3 51 | 52 | ## Wat is MDX? 53 | 54 | MDX is een uitbreiding van Markdown waarmee je JSX (JavaScript XML) rechtstreeks in je Markdown-bestanden kunt invoegen. Dit maakt het mogelijk om standaard Markdown-inhoud te mengen met dynamische componenten, waardoor het een krachtig hulpmiddel is voor moderne webontwikkeling. 55 | 56 | Als je echter net begint, kun je MDX net als Markdown gebruiken zonder JSX-componenten. Het werkt prima met de basis. 57 | 58 | ### Voorbeeld 59 | 60 | Zo ziet een eenvoudig MDX-bestand eruit (zonder aangepaste componenten): 61 | 62 | ```mdx 63 | --- 64 | title: "Mijn MDX post" 65 | --- 66 | 67 | # Hello World 68 | 69 | Dit is een voorbeeld van een MDX-post. Het gedraagt zich net als Markdown, maar ik kan later componenten toevoegen als dat nodig is. 70 | 71 | Waarom MDX gebruiken? 72 | 73 | Het belangrijkste voordeel van MDX is dat het je de flexibiliteit geeft om eenvoudige inhoud te schrijven met Markdown, maar later dynamische inhoud mogelijk maakt. Het wordt vaak gebruikt in statische site generators zoals Astro, Next.js en Gatsby. 74 | Aan de slag 75 | 76 | Om een MDX basispost te maken, hoef je alleen maar 77 | 78 | Een .mdx bestand aanmaken. 79 | Markdown inhoud schrijven zoals gewoonlijk. 80 | Eventueel kun je later JSX componenten toevoegen als je vertrouwd bent met de basis. 81 | 82 | Dat is alles wat je nodig hebt om aan de slag te gaan met MDX en Markdown. In toekomstige posts zullen we dieper ingaan op hoe je MDX kunt gebruiken om dynamische en interactieve blog posts te maken. 83 | 84 | Blijf kijken voor meer tips en tutorials! 85 | ``` 86 | 87 | ### Belangrijkste elementen: 88 | - **Basis Markdown-functies** zoals koppen, vet, cursief, lijsten en links. 89 | 90 | - **MDX frontmatter** (`--`) voor metadata (titel, beschrijving, enz.). 91 | 92 | - Geen aangepaste componenten of JSX, zodat compatibiliteit met verschillende MDX-parsers gegarandeerd is. 93 | 94 | Deze post behandelt de basis en werkt probleemloos zonder aangepaste componenten of geavanceerde functies. Je kunt het gebruiken als basis voor het leren of delen van MDX en Markdown tips. 95 | -------------------------------------------------------------------------------- /src/content/blog/fr/test.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title : Introduction à MDX et Markdown 3 | date : 21/09/2024 4 | lang : fr 5 | slug : Introduction-à-MDX-et-Markdown 6 | auteur : Ruben Winant 7 | --- 8 | 9 | # {frontmatter.title} 10 | 11 | Si vous êtes novice en matière de développement web ou de création de contenu, vous avez peut-être déjà rencontré deux termes : **Markdown** et **MDX**. Dans ce billet, nous allons vous expliquer les bases de ces deux termes et vous montrer comment commencer. 12 | 13 | ## Qu'est-ce que Markdown ? 14 | 15 | Markdown est un langage de balisage léger qui vous permet de formater du texte avec la syntaxe du texte brut. Il est largement utilisé pour rédiger de la documentation, des articles de blog et des fichiers README. 16 | 17 | Voici un exemple de syntaxe Markdown : 18 | 19 | - **Texte en gras** : `**Bold**` 20 | - *Texte en italique* : `*Italique*` 21 | - Liens](https://example.com) : `[Texte du lien](https://example.com)` 22 | - Rubriques : 23 | - `# Titre 1` 24 | - `## Titre 2` 25 | - `### Titre 3` 26 | 27 | ### Exemple 28 | 29 | Voici un exemple d'extrait Markdown : 30 | 31 | ```Md 32 | # Mon premier article de blog 33 | 34 | Bienvenue sur mon blog ! Voici un exemple de fichier Markdown. Voici une liste : 35 | 36 | - Point 1 37 | - Point 2 38 | - Point 3 39 | 40 | ``` 41 | 42 | Lors du rendu, cela ressemble à ceci : 43 | 44 | # Mon premier billet de blog 45 | 46 | Bienvenue sur mon blog ! Voici un exemple de fichier Markdown. Voici une liste : 47 | 48 | - Élément 1 49 | - Point 2 50 | - Élément 3 51 | 52 | ## Qu'est-ce que MDX ? 53 | 54 | MDX est une extension de Markdown qui vous permet d'insérer du JSX (JavaScript XML) directement dans vos fichiers Markdown. Cela vous permet de mélanger du contenu Markdown standard avec des composants dynamiques, ce qui en fait un outil puissant pour le développement web moderne. 55 | 56 | Toutefois, si vous débutez, vous pouvez utiliser MDX comme Markdown sans composants JSX. Cela fonctionne très bien avec les éléments de base. 57 | 58 | ### Exemple 59 | 60 | Voici à quoi ressemble un fichier MDX simple (sans composants personnalisés) : 61 | 62 | ```mdx 63 | --- 64 | title : « Mon billet MDX » 65 | --- 66 | 67 | # Hello World 68 | 69 | Voici un exemple de message MDX. Il se comporte comme du Markdown, mais je peux ajouter des composants plus tard si nécessaire. 70 | 71 | Pourquoi utiliser MDX ? 72 | 73 | Le principal avantage de MDX est qu'il vous donne la flexibilité d'écrire un contenu simple avec Markdown, tout en permettant un contenu dynamique plus tard. Il est souvent utilisé dans les générateurs de sites statiques tels que Astro, Next.js et Gatsby. 74 | Pour commencer 75 | 76 | Pour créer un billet de base MDX, il suffit de 77 | 78 | Créer un fichier .mdx. 79 | Écrire du contenu Markdown comme d'habitude. 80 | Optionnellement, vous pouvez ajouter des composants JSX plus tard si vous êtes familier avec les bases. 81 | 82 | C'est tout ce dont vous avez besoin pour commencer à utiliser MDX et Markdown. Dans les prochains articles, nous verrons plus en détail comment utiliser MDX pour créer des articles de blog dynamiques et interactifs. 83 | 84 | Restez à l'écoute pour d'autres conseils et tutoriels ! 85 | ``` 86 | 87 | ### Éléments clés : 88 | - **Fonctions de base de Markdown** telles que les titres, le gras, l'italique, les listes et les liens. 89 | 90 | - **Matière frontale MDX** (`--`) pour les métadonnées (titre, description, etc.). 91 | 92 | - Pas de composants personnalisés ni de JSX, ce qui garantit la compatibilité avec les différents analyseurs MDX. 93 | 94 | Ce billet couvre les bases et fonctionne sans problème sans composants personnalisés ni fonctionnalités avancées. Vous pouvez l'utiliser comme base pour apprendre ou partager des astuces MDX et Markdown. -------------------------------------------------------------------------------- /src/pages/api/contactFormSubmit.ts: -------------------------------------------------------------------------------- 1 | import type { APIRoute } from "astro"; 2 | import { MailerSend, Sender, Recipient, EmailParams } from "mailersend"; 3 | import { contactOptions } from "../[lang]/contact.astro"; 4 | import { DEFAULT_LOCALE, useTranslations, type UiType } from "../../i18n/utils"; 5 | 6 | export const POST: APIRoute = async ({ request }) => { 7 | const formData = await request.formData(); 8 | const name = formData.get("name"); 9 | const email = formData.get("email"); 10 | const topic = formData.get("topic"); 11 | const message = formData.get("message"); 12 | const lang = (formData.get("lang") as UiType) ?? (DEFAULT_LOCALE as UiType); 13 | 14 | const t = useTranslations(lang); 15 | 16 | const EMAIL: string = "example@hotmail.com"; 17 | const mailerSend = new MailerSend({ 18 | apiKey: import.meta.env.MAILERSEND_API_KEY ?? "", 19 | }); 20 | const sentFrom = new Sender( 21 | "sender@hotmail.com", 22 | "Spark", 23 | ); 24 | const recipients = [new Recipient(EMAIL, "Ruben Winant")]; 25 | const nameRegex = /^[a-zA-Z\s]+$/; 26 | const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 27 | 28 | // Validation 29 | let errors = { 30 | name: [] as string[], 31 | topic: [] as string[], 32 | email: [] as string[], 33 | message: [] as string[], 34 | general: [] as string[], 35 | }; 36 | 37 | if ( 38 | !name || 39 | typeof name !== "string" || 40 | name.length < 1 || 41 | !nameRegex.test(name) 42 | ) { 43 | errors.name.push(t("contact.error_name")); 44 | } 45 | 46 | if ( 47 | !email || 48 | typeof email !== "string" || 49 | email.length < 1 || 50 | !emailRegex.test(email) 51 | ) { 52 | errors.email.push(t("contact.error_email")); 53 | } 54 | 55 | if (!topic || typeof topic !== "string" || !contactOptions.includes(topic)) { 56 | errors.topic.push(t("contact.error_topic")); 57 | } 58 | 59 | if (!message || typeof message !== "string" || message.length < 1) { 60 | errors.message.push(t("contact.error_message")); 61 | } 62 | 63 | if ( 64 | errors.email.length > 0 || 65 | errors.name.length > 0 || 66 | errors.message.length > 0 || 67 | errors.topic.length > 0 68 | ) { 69 | return new Response(JSON.stringify(errors), { status: 400 }); 70 | } 71 | 72 | // Sanitize inputs 73 | const sanitizeInput = (input: FormDataEntryValue) => 74 | input.toString().replace(/<[^>]*>?/gm, ""); 75 | const sanitizedData = { 76 | name: sanitizeInput(name as FormDataEntryValue), 77 | email: sanitizeInput(email as FormDataEntryValue), 78 | message: sanitizeInput(message as FormDataEntryValue), 79 | topic: sanitizeInput(topic as FormDataEntryValue), 80 | }; 81 | 82 | const emailParams = new EmailParams() 83 | .setFrom(sentFrom) 84 | .setTo(recipients) 85 | .setSubject(sanitizedData.topic) 86 | .setTemplateId("insert template id") 87 | .setVariables([ 88 | { 89 | email: EMAIL, 90 | substitutions: [ 91 | { var: "from", value: sanitizedData.name }, 92 | { var: "fromEmail", value: sanitizedData.email }, 93 | { var: "message", value: sanitizedData.message }, 94 | ], 95 | }, 96 | ]); 97 | 98 | // send mail 99 | try { 100 | const result = await mailerSend.email.send(emailParams); 101 | 102 | if (result.statusCode >= 200 && result.statusCode < 300) { 103 | return new Response(JSON.stringify({ success: true }), { 104 | status: result.statusCode, 105 | }); 106 | } else { 107 | return new Response( 108 | JSON.stringify({ message: t("contact.error_general") }), 109 | { status: 400 }, 110 | ); 111 | } 112 | } catch (error) { 113 | return new Response( 114 | JSON.stringify({ message: t("contact.error_general") }), 115 | { status: 400 }, 116 | ); 117 | } 118 | }; 119 | -------------------------------------------------------------------------------- /src/components/Navigation.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { getLangFromUrl, useTranslations } from "../i18n/utils"; 3 | 4 | const lang = getLangFromUrl(Astro.url); 5 | const t = useTranslations(lang); 6 | --- 7 | 8 | 133 | -------------------------------------------------------------------------------- /src/pages/[lang]/contact.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { getLangFromUrl, useTranslations } from "../../i18n/utils"; 3 | import Layout from "../../layouts/Layout.astro"; 4 | 5 | const lang = getLangFromUrl(Astro.url); 6 | const t = useTranslations(lang); 7 | 8 | export const contactOptions = ["Inquiry", "Feedback", "Other"]; 9 | --- 10 | 11 | 12 |
    13 |
    14 |

    {t("contact.title")}

    15 | 16 |

    {t("contact.description")}

    17 | 18 |
    51 | 52 | 53 |
    54 | 55 |
    56 | 65 | 66 |
    67 | 68 | 69 |
    70 | 83 | 84 |
    85 |
    86 | 87 | 88 |
    89 | 98 | 99 |
    100 | 101 | 102 |
    103 | 111 | 112 |
    113 | 114 | 119 | 120 | 121 | {t("contact.success")} 122 | 123 | 124 |
    125 | 128 |
    129 |
    130 |
    131 |
    132 |
    133 | -------------------------------------------------------------------------------- /src/layouts/Layout.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import BaseHead from "../components/BaseHead.astro"; 3 | import Footer from "../components/Footer.astro"; 4 | import Navigation from "../components/Navigation.astro"; 5 | 6 | const { title, lang, description } = Astro.props; 7 | --- 8 | 9 | 10 | 11 | 12 | 13 | 14 |
    15 | 16 |
    17 | 18 | 19 | 20 |