diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..9b23136
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,26 @@
+# Example environment for indopensource.org
+#
+# Copy this file to `.env` and fill in real values:
+#
+# cp .env.example .env
+#
+# `.env` and every `.env.*` file are gitignored except this template
+# (`!.env.example`), so secrets never get committed. Keep this file free of
+# real tokens — it documents which variables exist, not their values.
+
+# GitHub API token used by the content sync scripts:
+# npm run sync:projects (scripts/sync-projects.mjs)
+# npm run sync:blog (scripts/sync-blog-posts.mjs)
+#
+# Optional, but strongly recommended: unauthenticated GitHub API calls are
+# rate-limited to ~60 requests/hour, which is not enough to sync the full
+# project directory. A read-only, fine-grained "public repositories" token is
+# sufficient — no write scopes are required. `GH_TOKEN` is accepted as a
+# fallback if `GITHUB_TOKEN` is not set.
+GITHUB_TOKEN=
+# GH_TOKEN=
+
+# Astro base path for the build (`astro.config.mjs` reads `process.env.ASTRO_BASE`).
+# Production and the deploy workflow use `/` (the site is served from the apex
+# domain). Override only when previewing under a sub-path, e.g. `/my-fork/`.
+ASTRO_BASE=/
diff --git a/astro.config.mjs b/astro.config.mjs
index 09007eb..4cefeda 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -2,9 +2,32 @@ import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
+ // Canonical production origin. Must stay the upstream domain so generated
+ // canonical URLs, the sitemap, robots.txt, and Open Graph tags all point at
+ // the real site regardless of where a fork is hosted.
site: 'https://indopensource.org',
base: process.env.ASTRO_BASE || '/',
+ // Emit a single, consistent URL shape for every route. Without this, Astro's
+ // default ('ignore') lets both `/blog` and `/blog/` resolve, which splits
+ // canonical signals and PageRank across two URLs. Forcing trailing slashes
+ // matches the directory-style output of the static build and the URLs the
+ // sitemap advertises, avoiding duplicate-content and 301 hops. (SEO-5)
+ trailingSlash: 'always',
+ build: {
+ // Never inline the scroll-reveal `` inside synced data (repo description, post
+// title) can't terminate the inline block. Escaped before hashing, so the CSP
+// SHA-256 still matches the emitted bytes.
+const jsonLdString = JSON.stringify(structuredData).replace(/` tag.
+ * Inline `