diff --git a/packages/shared-content/README.md b/packages/shared-content/README.md
new file mode 100644
index 0000000..112d091
--- /dev/null
+++ b/packages/shared-content/README.md
@@ -0,0 +1,16 @@
+# @odal/shared-content
+
+Regulatory-fact content shared between the two Odal Node web properties (`odal-node.io` and `docs.odal-node.io`).
+
+This package has no runtime — it exports typed TypeScript constants. It exists because the same facts (the data-boundary guarantees, the signing pipeline) were previously authored independently on the landing page's `/trust` and in the docs' "What Odal can and cannot see" page, and had already drifted from each other. Both sites now import from here.
+
+Each fact carries two depths where the two sites genuinely need different detail:
+
+- `summary` — one line, for the landing page's compressed presentation.
+- `detail` — the full docs-depth wording.
+
+Marketing prose (hero copy, feature-card blurbs) is **not** part of this package and should not be — voice legitimately differs between a landing page and docs. Only facts that must stay identical across both sites (step counts, table rows, guarantees) belong here.
+
+## Sync rule
+
+If a fact changes (a new deployment guarantee, a corrected step), change it once here. Neither site should hand-retype these facts locally again.
diff --git a/packages/shared-content/package.json b/packages/shared-content/package.json
new file mode 100644
index 0000000..3b8a113
--- /dev/null
+++ b/packages/shared-content/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "@odal/shared-content",
+ "description": "Single-sourced regulatory-fact content (the data boundary, the signing pipeline) shared between odal-node.io and docs.odal-node.io.",
+ "version": "0.1.0",
+ "license": "Apache-2.0",
+ "private": true,
+ "type": "module",
+ "main": "./src/index.ts",
+ "exports": {
+ ".": "./src/index.ts",
+ "./data-boundary": "./src/data-boundary.ts",
+ "./mechanism": "./src/mechanism.ts"
+ },
+ "files": [
+ "src"
+ ]
+}
diff --git a/packages/shared-content/src/data-boundary.ts b/packages/shared-content/src/data-boundary.ts
new file mode 100644
index 0000000..c8b487e
--- /dev/null
+++ b/packages/shared-content/src/data-boundary.ts
@@ -0,0 +1,64 @@
+// The data-boundary facts (what Odal can see, cannot see, and could see but
+// does not) — single-sourced. Before this package existed, the same facts
+// were independently authored on odal-node.io/trust and on docs.odal-node.io's
+// "What Odal can and cannot see" page, and had already drifted (a table
+// header disagreed: "Managed" vs "Managed (Future)"). Both sites now render
+// from these constants; only the depth of what they show differs.
+
+export interface DataBoundaryRow {
+ property: string;
+ selfHosted: string;
+ managed: string;
+}
+
+export const dataBoundaryByDeployment: DataBoundaryRow[] = [
+ {
+ property: "Node discards raw import files; retains the signed passport (all tiers)",
+ selfHosted: "Yes — architectural invariant",
+ managed: "Yes — architectural invariant",
+ },
+ {
+ property: "Odal (the entity) can access stored data",
+ selfHosted: "No — not present in the deployment",
+ managed: "Constrained by access controls, audit logging, and contract",
+ },
+ {
+ property: "Odal can sign on the operator's behalf",
+ selfHosted: "No",
+ managed: "No — the operator holds the signing keys",
+ },
+];
+
+export interface DisclosureCategory {
+ /** One-line landing-depth statement. */
+ summary: string;
+ /** Docs-depth detail — one or more paragraphs/bullets, most-detailed first. */
+ detail: string[];
+}
+
+export const canSee: DisclosureCategory = {
+ summary:
+ "The signed passport you publish to a resolver we operate, and the metadata required to serve it.",
+ detail: [
+ "The GS1 Digital Link resolver cache",
+ "The DID document (public by definition)",
+ "The audit trail of signature and status transitions (managed deployments only)",
+ ],
+};
+
+export const cannotSee: DisclosureCategory = {
+ summary:
+ "Your private keys (held in-process on your infrastructure), your raw production data, your supply-chain detail.",
+ detail: [
+ "Your private signing keys — held in-process on your infrastructure, encrypted at rest via Argon2id-derived AES-256-GCM, never transmitted",
+ "Your raw production data, supply-chain detail beyond passport content, or import files",
+ "In a self-hosted deployment: nothing at all — we have no access to the instance, the database, or the keys",
+ ],
+};
+
+export const couldSeeButDoNot: DisclosureCategory = {
+ summary: "The contents of your import files, which the node discards after validation.",
+ detail: [
+ "The contents of your import files. The software reads them once, validates the data, signs the passport, and discards the input. There is no setting, configuration, or internal code path that retains the raw import after signing — it is not a choice made per customer; it is how the software works.",
+ ],
+};
diff --git a/packages/shared-content/src/index.ts b/packages/shared-content/src/index.ts
new file mode 100644
index 0000000..f581701
--- /dev/null
+++ b/packages/shared-content/src/index.ts
@@ -0,0 +1,8 @@
+/**
+ * @odal/shared-content — regulatory-fact content shared between the two web
+ * properties, so a fact (a step count, a table row) is authored once instead
+ * of independently reworded on each site.
+ */
+
+export * from "./data-boundary";
+export * from "./mechanism";
diff --git a/packages/shared-content/src/mechanism.ts b/packages/shared-content/src/mechanism.ts
new file mode 100644
index 0000000..9727c4b
--- /dev/null
+++ b/packages/shared-content/src/mechanism.ts
@@ -0,0 +1,46 @@
+// The signing pipeline (import → validate → sign → publish → verify) —
+// single-sourced. Previously reworded independently on the landing page (4
+// steps, no "Verify"), on odal-node.io/trust (5 steps), and in docs (5
+// steps) — the step count itself disagreed between landing and docs.
+
+export interface MechanismStep {
+ /** Short label — used by both the landing timeline and the docs list. */
+ label: string;
+ /** Landing-depth one-line description. */
+ summary: string;
+ /** Docs-depth description. */
+ detail: string;
+}
+
+export const mechanismSteps: MechanismStep[] = [
+ {
+ label: "Import",
+ summary: "Product data from CSV, Excel, or your ERP into your own node.",
+ detail:
+ "Product data arrives at your node — CSV, Excel, or ERP export. This happens on infrastructure you control.",
+ },
+ {
+ label: "Validate",
+ summary: "Against versioned sector schemas tracking the regulation, locally.",
+ detail:
+ "Locally against versioned sector schemas. Validation is a pure function — no network calls.",
+ },
+ {
+ label: "Sign",
+ summary: "With your own key, generated and held on your infrastructure.",
+ detail:
+ "Your Ed25519 private key, generated and held in-process, signs the validated passport into a JWS bound to your did:web identity.",
+ },
+ {
+ label: "Publish",
+ summary: "Only the signed passport becomes resolvable, via QR and GS1 Digital Link.",
+ detail:
+ "The signed passport becomes resolvable; the raw import files are discarded. Public fields are served to anyone, restricted tiers only against a verified credential.",
+ },
+ {
+ label: "Verify",
+ summary:
+ "Any consumer, authority, or recycler verifies the signature against your public DID document — without Odal in the loop.",
+ detail: "Anyone verifies against your public DID Document. Odal is not in the verify loop.",
+ },
+];
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0b248f7..b128e55 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -10,6 +10,8 @@ importers:
packages/brand-tokens: {}
+ packages/shared-content: {}
+
site/dpp-docs:
dependencies:
'@astrojs/starlight':
@@ -18,6 +20,9 @@ importers:
'@odal/brand-tokens':
specifier: workspace:*
version: link:../../packages/brand-tokens
+ '@odal/shared-content':
+ specifier: workspace:*
+ version: link:../../packages/shared-content
'@scalar/api-reference':
specifier: ^1.62.5
version: 1.62.5(tailwindcss@4.3.0)(typescript@5.9.3)(zod@4.4.3)
@@ -46,6 +51,9 @@ importers:
'@odal/brand-tokens':
specifier: workspace:*
version: link:../../packages/brand-tokens
+ '@odal/shared-content':
+ specifier: workspace:*
+ version: link:../../packages/shared-content
'@tailwindcss/vite':
specifier: ^4
version: 4.3.0(vite@6.4.2(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))
diff --git a/site/dpp-docs/astro.config.mjs b/site/dpp-docs/astro.config.mjs
index d3b0b25..7e631e8 100644
--- a/site/dpp-docs/astro.config.mjs
+++ b/site/dpp-docs/astro.config.mjs
@@ -34,6 +34,19 @@ export default defineConfig({
starlight({
title: 'Odal Node',
description: 'EU Digital Product Passport infrastructure — open-source core, sovereign by design, built for ESPR compliance.',
+ // English stays unprefixed (root), matching odal-node.io's URL shape.
+ // No content has been translated — Starlight ships built-in UI-string
+ // translations for all four (sidebar chrome, search, "on this page",
+ // etc.), and its own fallback shows the English page with a visible
+ // "this page has not been translated yet" notice rather than a 404 —
+ // no per-locale content directories needed for that to work.
+ locales: {
+ root: { label: 'English', lang: 'en' },
+ de: { label: 'Deutsch', lang: 'de' },
+ it: { label: 'Italiano', lang: 'it' },
+ fr: { label: 'Français', lang: 'fr' },
+ es: { label: 'Español', lang: 'es' },
+ },
logo: {
light: './src/assets/logo-light.svg',
dark: './src/assets/logo-dark.svg',
diff --git a/site/dpp-docs/package.json b/site/dpp-docs/package.json
index 685d367..9ffc26d 100644
--- a/site/dpp-docs/package.json
+++ b/site/dpp-docs/package.json
@@ -15,6 +15,7 @@
"dependencies": {
"@astrojs/starlight": "^0.30.6",
"@odal/brand-tokens": "workspace:*",
+ "@odal/shared-content": "workspace:*",
"@scalar/api-reference": "^1.62.5",
"astro": "^5",
"vue": "^3.5.39"
diff --git a/site/dpp-docs/src/content/docs/getting-started/what-odal-can-and-cannot-see.mdx b/site/dpp-docs/src/content/docs/getting-started/what-odal-can-and-cannot-see.mdx
index 1eca517..cda26a7 100644
--- a/site/dpp-docs/src/content/docs/getting-started/what-odal-can-and-cannot-see.mdx
+++ b/site/dpp-docs/src/content/docs/getting-started/what-odal-can-and-cannot-see.mdx
@@ -3,41 +3,54 @@ title: What Odal can and cannot see
description: A precise statement of data access by deployment model — the proof-bound architecture, stated as verifiable facts.
---
+import {
+ canSee,
+ cannotSee,
+ couldSeeButDoNot,
+ dataBoundaryByDeployment,
+ mechanismSteps,
+} from '@odal/shared-content';
+
The proof-bound architecture means the raw import files are read once on the operator's infrastructure — validated, used to sign the passport, then discarded. The signed passport itself, carrying the full product data across its access tiers, is what is stored and served. This page states precisely what Odal can see, cannot see, and could see but does not — by deployment model.
## By deployment
-| Property | Self-hosted | Managed (Future) |
-|---|---|---|
-| Node discards raw import files; retains the signed passport (all tiers) | Yes — architectural invariant | Yes — architectural invariant |
-| Odal (the entity) can access stored data | No — not present in the deployment | Constrained by access controls, audit logging, and contract |
-| Odal can sign on the operator's behalf | No | No — the operator holds the signing keys |
+{/* Row data is single-sourced from @odal/shared-content — this table
+ previously disagreed with the near-identical one on odal-node.io/trust
+ (a header read "Managed" on one, "Managed (Future)" here). */}
+
+
+
+
Property
Self-hosted
Managed (Future)
+
+
+ {dataBoundaryByDeployment.map((row) => (
+
{row.property}
{row.selfHosted}
{row.managed}
+ ))}
+
+
## What we can see
The signed passport published to a resolver we operate, and the metadata required to serve it:
-- The GS1 Digital Link resolver cache
-- The DID document (public by definition)
-- The audit trail of signature and status transitions (managed deployments only)
+
{canSee.detail.map((d) =>
{d}
)}
## What we cannot see
-- Your private signing keys — held in-process on your infrastructure, encrypted at rest via Argon2id-derived AES-256-GCM, never transmitted
-- Your raw production data, supply-chain detail beyond passport content, or import files
-- In a self-hosted deployment: nothing at all — we have no access to the instance, the database, or the keys
+
{cannotSee.detail.map((d) =>
{d}
)}
## What we could see but do not
-The contents of your import files. The software reads them once, validates the data, signs the passport, and discards the input. There is no setting, configuration, or internal code path that retains the raw import after signing — it is not a choice made per customer; it is how the software works.
+{couldSeeButDoNot.detail.map((d) =>
{d}
)}
## The mechanism
-1. **Import** — product data arrives at your node (CSV, Excel, ERP export) on infrastructure you control.
-2. **Validate** — locally against versioned sector schemas. Validation is a pure function — no network calls.
-3. **Sign** — your Ed25519 private key, generated and held in-process, signs the validated passport into a JWS bound to your `did:web` identity.
-4. **Publish** — the signed passport becomes resolvable; the raw import files are discarded. Public fields are served to anyone, restricted tiers only against a verified credential.
-5. **Verify** — anyone verifies against your public DID Document. Odal is not in the verify loop.
+
+ {mechanismSteps.map((step) => (
+
{step.label} — {step.detail}
+ ))}
+
## Read next
diff --git a/site/dpp-landing/astro.config.mjs b/site/dpp-landing/astro.config.mjs
index 23d247a..bf5a2f3 100644
--- a/site/dpp-landing/astro.config.mjs
+++ b/site/dpp-landing/astro.config.mjs
@@ -12,7 +12,40 @@ import { viteStaticCopy } from 'vite-plugin-static-copy';
export default defineConfig({
site: "https://odal-node.io",
- integrations: [sitemap()],
+ // i18n: chrome (nav/footer/badges — src/i18n/ui.ts) is translated for all
+ // five locales. Page prose (Hero copy, deadline citations, the
+ // data-boundary facts) is not yet — `fallback` + `fallbackType: "rewrite"`
+ // serves the English page content under a locale's URL when no
+ // locale-specific page exists, so /de/, /it/, /fr/, /es/ resolve instead
+ // of 404ing while prose translation is still pending native review.
+ i18n: {
+ defaultLocale: "en",
+ locales: ["en", "de", "it", "fr", "es"],
+ routing: {
+ prefixDefaultLocale: false,
+ fallbackType: "rewrite",
+ },
+ fallback: {
+ de: "en",
+ it: "en",
+ fr: "en",
+ es: "en",
+ },
+ },
+ integrations: [
+ sitemap({
+ i18n: {
+ defaultLocale: "en",
+ locales: {
+ en: "en-US",
+ de: "de-DE",
+ it: "it-IT",
+ fr: "fr-FR",
+ es: "es-ES",
+ },
+ },
+ }),
+ ],
vite: {
plugins: [
tailwindcss(),
diff --git a/site/dpp-landing/package.json b/site/dpp-landing/package.json
index 9a78a5e..f354d9b 100644
--- a/site/dpp-landing/package.json
+++ b/site/dpp-landing/package.json
@@ -14,6 +14,7 @@
"dependencies": {
"@astrojs/sitemap": "^3",
"@odal/brand-tokens": "workspace:*",
+ "@odal/shared-content": "workspace:*",
"@tailwindcss/vite": "^4",
"astro": "^5",
"tailwindcss": "^4"
diff --git a/site/dpp-landing/src/components/Footer.astro b/site/dpp-landing/src/components/Footer.astro
index 65fe893..6afd008 100644
--- a/site/dpp-landing/src/components/Footer.astro
+++ b/site/dpp-landing/src/components/Footer.astro
@@ -3,6 +3,11 @@
// Uses the shared brand mark (currentColor variant) tinted ice for the dark band,
// so it stays in sync with the asset instead of a hand-rolled inline copy.
import OdalMark from "../assets/favicon.svg";
+import { resolveLocale } from "../i18n/config";
+import { useTranslations } from "../i18n/ui";
+
+const lang = resolveLocale(Astro.currentLocale);
+const t = useTranslations(lang);
const year = new Date().getFullYear();
---
@@ -14,10 +19,10 @@ const year = new Date().getFullYear();
Odal Node
- Signed by you. Verified by anyone.
+ {t.footer.tagline}
- Sovereign Digital Product Passport infrastructure for EU ESPR.
+ {t.footer.subtagline}
diff --git a/site/dpp-landing/src/components/LanguageSwitcher.astro b/site/dpp-landing/src/components/LanguageSwitcher.astro
new file mode 100644
index 0000000..f429ced
--- /dev/null
+++ b/site/dpp-landing/src/components/LanguageSwitcher.astro
@@ -0,0 +1,33 @@
+---
+// LanguageSwitcher.astro — inline list of locale links, current page
+// preserved via getRelativeLocaleUrl. Five locales doesn't need a dropdown;
+// plain links keep this JS-free like the rest of the shared shell.
+import { getRelativeLocaleUrl } from "astro:i18n";
+import { locales, resolveLocale, stripLocalePrefix } from "../i18n/config";
+import { useTranslations } from "../i18n/ui";
+
+interface Props {
+ class?: string;
+}
+const { class: extra = "" } = Astro.props;
+
+const lang = resolveLocale(Astro.currentLocale);
+const t = useTranslations(lang);
+const unprefixedPath = stripLocalePrefix(Astro.url.pathname, lang);
+---
+
+
diff --git a/site/dpp-landing/src/components/Nav.astro b/site/dpp-landing/src/components/Nav.astro
index f0d8db3..b2bbc5f 100644
--- a/site/dpp-landing/src/components/Nav.astro
+++ b/site/dpp-landing/src/components/Nav.astro
@@ -3,19 +3,27 @@
// Lockup matches the docs (Starlight) site-title for cross-site parity:
// navy mark + accent wordmark at --sl-text-h4 scale, weight 600.
import StatusBadge from "./StatusBadge.astro";
+import LanguageSwitcher from "./LanguageSwitcher.astro";
// Use the real brand mark (currentColor variant of odal-mark) rather than a
// hand-rolled inline SVG, so the nav stays in sync with the brand asset and the
// mark inherits the wordmark's colour. Astro 5 imports .svg as a component.
import OdalMark from "../assets/favicon.svg";
+import { resolveLocale, stripLocalePrefix } from "../i18n/config";
+import { useTranslations } from "../i18n/ui";
+
+const lang = resolveLocale(Astro.currentLocale);
+const t = useTranslations(lang);
const links = [
- { href: "https://docs.odal-node.io", label: "Docs", external: true },
- // { href: "/trust", label: "Trust", external: false },
+ { href: "https://docs.odal-node.io", label: t.nav.docs, external: true },
+ { href: "/trust", label: t.nav.trust, external: false },
// { href: "/roadmap", label: "Roadmap", external: false },
// { href: "/about", label: "About", external: false },
- { href: "https://github.com/odal-node", label: "GitHub", external: true },
+ { href: "https://github.com/odal-node", label: t.nav.github, external: true },
];
-const current = Astro.url.pathname;
+// Compared against each link's unprefixed href, so "current page" still
+// highlights correctly once a locale prefix (/de/trust) is in the URL.
+const current = stripLocalePrefix(Astro.url.pathname, lang);
---
@@ -48,11 +56,12 @@ const current = Astro.url.pathname;
{l.label}
))}
+
- Join the waitlist
+ {t.nav.joinWaitlist}
@@ -63,7 +72,7 @@ const current = Astro.url.pathname;
class="inline-flex items-center justify-center rounded-md p-2 text-neutral-700 hover:bg-neutral-100 md:hidden"
aria-controls="nav-menu"
aria-expanded="false"
- aria-label="Open menu"
+ aria-label={t.nav.openMenu}
>
-
diff --git a/site/dpp-landing/src/i18n/config.ts b/site/dpp-landing/src/i18n/config.ts
new file mode 100644
index 0000000..fb32b03
--- /dev/null
+++ b/site/dpp-landing/src/i18n/config.ts
@@ -0,0 +1,46 @@
+// i18n foundation — locale registry.
+//
+// Adding a language is a two-step, type-checked change: add its code here,
+// then add its translations in `ui.ts`. TypeScript rejects a `ui.ts` missing
+// any locale listed in `locales`, so the two files can't drift.
+export const locales = ["en", "de", "it", "fr", "es"] as const;
+
+export type Locale = (typeof locales)[number];
+
+export const defaultLocale: Locale = "en";
+
+/** Per-locale metadata for markup that isn't a translated string — ``, `og:locale`. */
+export const localeMeta: Record = {
+ en: { label: "English", htmlLang: "en", ogLocale: "en_US" },
+ de: { label: "Deutsch", htmlLang: "de", ogLocale: "de_DE" },
+ it: { label: "Italiano", htmlLang: "it", ogLocale: "it_IT" },
+ fr: { label: "Français", htmlLang: "fr", ogLocale: "fr_FR" },
+ es: { label: "Español", htmlLang: "es", ogLocale: "es_ES" },
+};
+
+/**
+ * Narrow `Astro.currentLocale` (typed `string | undefined` by Astro, since it
+ * reflects arbitrary URL segments) down to a known `Locale`, falling back to
+ * the default. Prefer this over `Astro.currentLocale ?? defaultLocale` —
+ * that expression still type-checks as plain `string`, which defeats
+ * `localeMeta`/`ui`'s exhaustive `Record` lookups.
+ */
+export function resolveLocale(candidate: string | undefined): Locale {
+ return (locales as readonly string[]).includes(candidate ?? "")
+ ? (candidate as Locale)
+ : defaultLocale;
+}
+
+/**
+ * Strip a `/{locale}` prefix from a pathname, if present, for building a
+ * cross-locale link — `astro:i18n`'s `getRelativeLocaleUrl(locale, path)`
+ * expects an unprefixed `path` and will double-prefix otherwise (e.g.
+ * switching from `/de/trust` to `it` would produce `/it/de/trust`).
+ */
+export function stripLocalePrefix(pathname: string, from: Locale): string {
+ if (from === defaultLocale) return pathname;
+ const prefix = `/${from}`;
+ if (pathname === prefix) return "/";
+ if (pathname.startsWith(`${prefix}/`)) return pathname.slice(prefix.length);
+ return pathname;
+}
diff --git a/site/dpp-landing/src/i18n/plural.ts b/site/dpp-landing/src/i18n/plural.ts
new file mode 100644
index 0000000..3c2feb4
--- /dev/null
+++ b/site/dpp-landing/src/i18n/plural.ts
@@ -0,0 +1,25 @@
+// Locale-correct pluralization — native `Intl.PluralRules`, no dependency.
+//
+// Not consumed anywhere yet: no current UI string needs it. Added now as
+// foundation because the gap is real and easy to miss later — a flat
+// `Record` (as in ui.ts) cannot express "1 day left" vs
+// "3 days left" correctly across locales (EN/DE/MK all pluralize
+// differently), and the deadline-countdown style copy on the landing page
+// is the first place that will need it once it's translated.
+import type { Locale } from "./config";
+
+type PluralForms = Partial> & { other: string };
+
+/**
+ * Pick the correct plural form for `count` in `locale`, substituting `{n}`
+ * with the count.
+ *
+ * @example
+ * pluralize("en", 1, { one: "{n} day left", other: "{n} days left" });
+ * // => "1 day left"
+ */
+export function pluralize(locale: Locale, count: number, forms: PluralForms): string {
+ const rule = new Intl.PluralRules(locale).select(count);
+ const template = forms[rule] ?? forms.other;
+ return template.replace("{n}", String(count));
+}
diff --git a/site/dpp-landing/src/i18n/ui.ts b/site/dpp-landing/src/i18n/ui.ts
new file mode 100644
index 0000000..3e904f5
--- /dev/null
+++ b/site/dpp-landing/src/i18n/ui.ts
@@ -0,0 +1,168 @@
+// i18n foundation — the UI chrome string table (nav, footer, shared badges).
+//
+// Scope: shared-shell strings only (present on every page). Page-level prose
+// (index.astro's sections, privacy.astro, the roadmap/deadlines/standards
+// JSON) is a separate, larger migration — see docs/docs-web/WEB_CONTENT_STRATEGY.md
+// — and is deliberately not moved here yet. de/it/fr/es below are a first-pass
+// translation of this chrome only; not yet reviewed by a native speaker —
+// fine for these short, low-risk UI strings, but do not extend this practice
+// to regulatory prose (citations, guarantees) without that review.
+import type { Locale } from "./config";
+
+export interface UIStrings {
+ nav: {
+ docs: string;
+ trust: string;
+ github: string;
+ joinWaitlist: string;
+ openMenu: string;
+ closeMenu: string;
+ language: string;
+ };
+ footer: {
+ tagline: string;
+ subtagline: string;
+ projectHeading: string;
+ dppCoreLink: string;
+ dppEngineLink: string;
+ privacyHeading: string;
+ privacyPolicyLink: string;
+ securityPolicyLink: string;
+ };
+ status: {
+ alphaLabel: string;
+ };
+}
+
+// `satisfies Record` (not `: Record`)
+// so TypeScript still errors on a missing locale key, but keeps each locale's
+// literal type — useful once a second locale needs its own key subset checked.
+export const ui = {
+ en: {
+ nav: {
+ docs: "Docs",
+ trust: "Trust",
+ github: "GitHub",
+ joinWaitlist: "Join the waitlist",
+ openMenu: "Open menu",
+ closeMenu: "Close menu",
+ language: "Language",
+ },
+ footer: {
+ tagline: "Signed by you. Verified by anyone.",
+ subtagline: "Sovereign Digital Product Passport infrastructure for EU ESPR.",
+ projectHeading: "Project",
+ dppCoreLink: "dpp-core on GitHub",
+ dppEngineLink: "dpp-engine on GitHub",
+ privacyHeading: "Privacy & security",
+ privacyPolicyLink: "Privacy policy",
+ securityPolicyLink: "Security policy",
+ },
+ status: {
+ // Single source of truth for the alpha badge — previously duplicated
+ // as two slightly different strings ("Alpha · Active development" in
+ // Hero.astro vs "Alpha · in active development" in Nav.astro).
+ alphaLabel: "Alpha · in active development",
+ },
+ },
+ de: {
+ nav: {
+ docs: "Dokumentation",
+ trust: "Vertrauen",
+ github: "GitHub",
+ joinWaitlist: "Warteliste beitreten",
+ openMenu: "Menü öffnen",
+ closeMenu: "Menü schließen",
+ language: "Sprache",
+ },
+ footer: {
+ tagline: "Von Ihnen signiert. Von jedem verifizierbar.",
+ subtagline: "Souveräne Infrastruktur für digitale Produktpässe gemäß EU-ESPR.",
+ projectHeading: "Projekt",
+ dppCoreLink: "dpp-core auf GitHub",
+ dppEngineLink: "dpp-engine auf GitHub",
+ privacyHeading: "Datenschutz & Sicherheit",
+ privacyPolicyLink: "Datenschutzerklärung",
+ securityPolicyLink: "Sicherheitsrichtlinie",
+ },
+ status: {
+ alphaLabel: "Alpha · in aktiver Entwicklung",
+ },
+ },
+ it: {
+ nav: {
+ docs: "Documentazione",
+ trust: "Fiducia",
+ github: "GitHub",
+ joinWaitlist: "Iscriviti alla lista d'attesa",
+ openMenu: "Apri il menu",
+ closeMenu: "Chiudi il menu",
+ language: "Lingua",
+ },
+ footer: {
+ tagline: "Firmato da te. Verificabile da chiunque.",
+ subtagline: "Infrastruttura sovrana per i passaporti digitali di prodotto ai sensi dell'ESPR UE.",
+ projectHeading: "Progetto",
+ dppCoreLink: "dpp-core su GitHub",
+ dppEngineLink: "dpp-engine su GitHub",
+ privacyHeading: "Privacy e sicurezza",
+ privacyPolicyLink: "Informativa sulla privacy",
+ securityPolicyLink: "Politica di sicurezza",
+ },
+ status: {
+ alphaLabel: "Alpha · in sviluppo attivo",
+ },
+ },
+ fr: {
+ nav: {
+ docs: "Documentation",
+ trust: "Confiance",
+ github: "GitHub",
+ joinWaitlist: "Rejoindre la liste d'attente",
+ openMenu: "Ouvrir le menu",
+ closeMenu: "Fermer le menu",
+ language: "Langue",
+ },
+ footer: {
+ tagline: "Signé par vous. Vérifiable par tous.",
+ subtagline: "Infrastructure souveraine pour les passeports numériques de produits au titre de l'ESPR de l'UE.",
+ projectHeading: "Projet",
+ dppCoreLink: "dpp-core sur GitHub",
+ dppEngineLink: "dpp-engine sur GitHub",
+ privacyHeading: "Confidentialité et sécurité",
+ privacyPolicyLink: "Politique de confidentialité",
+ securityPolicyLink: "Politique de sécurité",
+ },
+ status: {
+ alphaLabel: "Alpha · en développement actif",
+ },
+ },
+ es: {
+ nav: {
+ docs: "Documentación",
+ trust: "Confianza",
+ github: "GitHub",
+ joinWaitlist: "Unirse a la lista de espera",
+ openMenu: "Abrir menú",
+ closeMenu: "Cerrar menú",
+ language: "Idioma",
+ },
+ footer: {
+ tagline: "Firmado por ti. Verificable por cualquiera.",
+ subtagline: "Infraestructura soberana para pasaportes digitales de producto bajo el ESPR de la UE.",
+ projectHeading: "Proyecto",
+ dppCoreLink: "dpp-core en GitHub",
+ dppEngineLink: "dpp-engine en GitHub",
+ privacyHeading: "Privacidad y seguridad",
+ privacyPolicyLink: "Política de privacidad",
+ securityPolicyLink: "Política de seguridad",
+ },
+ status: {
+ alphaLabel: "Alfa · en desarrollo activo",
+ },
+ },
+} satisfies Record;
+
+export function useTranslations(locale: Locale): UIStrings {
+ return ui[locale];
+}
diff --git a/site/dpp-landing/src/layouts/Base.astro b/site/dpp-landing/src/layouts/Base.astro
index 86abad2..fdc3820 100644
--- a/site/dpp-landing/src/layouts/Base.astro
+++ b/site/dpp-landing/src/layouts/Base.astro
@@ -4,6 +4,8 @@
import "../styles/global.css";
import Nav from "../components/Nav.astro";
import Footer from "../components/Footer.astro";
+import { getRelativeLocaleUrl } from "astro:i18n";
+import { defaultLocale, localeMeta, locales, resolveLocale, type Locale } from "../i18n/config";
interface Props {
title: string;
@@ -17,8 +19,21 @@ interface Props {
}
const { title, description, canonical, jsonLd = [], noindex = false } = Astro.props;
+const lang = resolveLocale(Astro.currentLocale);
const canonicalHref = canonical ?? new URL(Astro.url.pathname, Astro.site).toString();
const ogImage = new URL("/og-image.png", Astro.site).toString();
+// Self-referencing today (only "en" exists) — becomes real once a second
+// locale is registered in src/i18n/config.ts. Kept live now rather than
+// added later so the URL-building logic is exercised by every build.
+const hreflangAlternates = locales.map((l) => ({
+ hreflang: localeMeta[l].htmlLang,
+ href: new URL(getRelativeLocaleUrl(l, Astro.url.pathname), Astro.site).toString(),
+}));
+// Explicitly typed (rather than inferred from `.filter`) — with today's
+// single-locale tuple, TS narrows the filtered element type to `never`
+// since it can prove `l !== lang` is always false. This annotation is what
+// keeps that a today-only observation instead of a real constraint.
+const alternateLocales: Locale[] = locales.filter((l) => l !== lang);
// Base structured data — present on every page.
const organizationLd = {
@@ -41,7 +56,7 @@ const websiteLd = {
---
-
+
@@ -49,6 +64,14 @@ const websiteLd = {
{noindex && }
+ {hreflangAlternates.map((alt) => (
+
+ ))}
+
@@ -62,7 +85,10 @@ const websiteLd = {
-
+
+ {alternateLocales.map((l) => (
+
+ ))}
diff --git a/site/dpp-landing/src/pages/_trust.astro b/site/dpp-landing/src/pages/_trust.astro
deleted file mode 100644
index eb738e0..0000000
--- a/site/dpp-landing/src/pages/_trust.astro
+++ /dev/null
@@ -1,113 +0,0 @@
----
-import Base from "../layouts/Base.astro";
-import Section from "../components/Section.astro";
-
-const title = "Trust — Odal Node";
-const description =
- "What Odal can see, cannot see, and could see but does not. The proof-bound architecture, stated precisely.";
----
-
-
-
-
- Odal Node is built on a proof-bound architecture: the manufacturer's
- raw production data is validated and signed locally, on infrastructure
- the manufacturer controls, then discarded. Only the signed proof is
- stored and served. This is a property of the software, not a policy
- promise — it holds regardless of who operates the node.
-
-
-
-
-
-
-
-
-
-
Property
-
Self-hosted
-
Managed
-
-
-
-
-
Node discards raw inputs; stores only the signed proof
-
Yes — architectural invariant
-
Yes — architectural invariant
-
-
-
Odal can access stored data
-
No — not present in the deployment
-
Constrained by access controls, audit logging, and contract
-
-
-
Odal can sign on the operator's behalf
-
No
-
No — the operator holds the signing keys
-
-
-
-
-
-
-
-
- Import. Product data arrives at your node — CSV, Excel,
- or an ERP export. This happens on infrastructure you control.
-
-
- Validate. The node validates locally against versioned
- sector schemas. Validation is a pure function — no network calls, no
- third-party API.
-
-
- Sign. Your private signing key — generated and held
- in-process on your infrastructure — signs the validated passport.
- The result is a signature cryptographically bound to your published
- did:web identity.
-
-
- Publish. Only the signed passport becomes publicly
- resolvable via QR and GS1 Digital Link. The raw input is discarded.
-
-
- Verify. Any consumer, authority, or recycler verifies
- the signature against your public DID Document. Verification does not
- require Odal to be online or to exist.
-
-
-
-
-
diff --git a/site/dpp-landing/src/pages/index.astro b/site/dpp-landing/src/pages/index.astro
index c86fa53..27f068e 100644
--- a/site/dpp-landing/src/pages/index.astro
+++ b/site/dpp-landing/src/pages/index.astro
@@ -8,6 +8,7 @@ import Hero from "../components/Hero.astro";
import Section from "../components/Section.astro";
import StandardsRow from "../components/StandardsRow.astro";
import Base from "../layouts/Base.astro";
+import { canSee, cannotSee, couldSeeButDoNot, mechanismSteps } from "@odal/shared-content";
const title = "Odal Node — EU Digital Product Passport Infrastructure | ESPR Compliance";
const description =
@@ -36,12 +37,9 @@ const softwareAppLd = {
// The end-to-end process as one timeline. On the landing the underlying
// services (integrator, validation, vault + in-process signing, resolver) are
// deliberately merged into a single flow; the docs keep them as separate pages.
-const steps = [
- { label: "Import", body: "Product data from CSV, Excel, or your ERP into your own node." },
- { label: "Validate", body: "Against versioned sector schemas tracking the regulation, locally." },
- { label: "Sign", body: "With your own key, generated and held on your infrastructure." },
- { label: "Publish", body: "Only the signed passport becomes resolvable, via QR and GS1 Digital Link." },
-];
+// Sourced from @odal/shared-content — the "Verify" step is consumer-side, not
+// part of this producer-flow timeline, so only the first four render here.
+const steps = mechanismSteps.slice(0, 4);
---
@@ -93,7 +91,7 @@ const steps = [
)}
{s.label}
-
{s.body}
+
{s.summary}
))}
@@ -156,27 +154,24 @@ const steps = [
We can see
- The signed passport you publish to a resolver we operate, and the
- metadata required to serve it.
+ {canSee.summary}
We cannot see
- Your private keys (held in-process on your infrastructure), your raw
- production data, your supply-chain detail.
+ {cannotSee.summary}
We could see but do not
- The contents of your import files, which the node discards after
- validation.
+ {couldSeeButDoNot.summary}
diff --git a/site/dpp-landing/src/pages/trust.astro b/site/dpp-landing/src/pages/trust.astro
new file mode 100644
index 0000000..fdf1a35
--- /dev/null
+++ b/site/dpp-landing/src/pages/trust.astro
@@ -0,0 +1,34 @@
+---
+// trust.astro — deliberately a short landing-styled statement, not a mirror
+// of the docs page. The full data-boundary table and signing mechanism used
+// to be independently retyped here (and had already drifted from the docs
+// wording — a table header disagreed: "Managed" vs "Managed (Future)"); this
+// page now states the guarantee in brief and links to the single canonical,
+// full-depth version instead of maintaining a second copy of it.
+import Base from "../layouts/Base.astro";
+import Section from "../components/Section.astro";
+
+const title = "Trust — Odal Node";
+const description =
+ "What Odal can see, cannot see, and could see but does not. The proof-bound architecture, stated precisely.";
+
+const DOCS_BREAKDOWN_URL =
+ "https://docs.odal-node.io/getting-started/what-odal-can-and-cannot-see";
+---
+
+
+
+
+ Odal Node is built on a proof-bound architecture: the manufacturer's
+ raw production data is validated and signed locally, on infrastructure
+ the manufacturer controls, then discarded. Only the signed proof is
+ stored and served. This is a property of the software, not a policy
+ promise — it holds regardless of who operates the node.
+