`, what its components teleport to
+ `` (confirm host, toast host, drawer) first in ``, no
+ `modulepreload` links, and the module entry replaced by a small loader
+ (below);
+ - writes `dist/sitemap.xml` from the indexable routes, dated with the build
+ date, and the `.gz` / `.br` files of both;
+ - checks the result (`lib/seo/checkLanding.ts`): the h1, the positioning
+ line, the player count, every FAQ question and answer, the example plan
+ and GitHub links, one title, description and canonical, the Open Graph
+ tags, both JSON-LD blocks, robots and sitemap. Anything missing fails the
+ build.
+
+On load (`lib/seo/prerenderMount.ts`, used by `main.ts`):
+
+- The loader in `index.html` starts the app's entry only after the page's
+ first contentful paint (1 s fallback for a background tab), and only once
+ the page is parsed, so `/env.js` has run. Where `skip-prerender` hides
+ the page it starts at once: nothing paints. This keeps the text's paint
+ (the LCP) free of the scripts.
+- `main.ts` creates the app with `createSSRApp` over prerendered markup
+ (`createApp` otherwise), waits for the route and the `HomepageHeader`
+ chunk, and hydrates: the same elements stay, nothing repaints. The body
+ teleports hydrate from ``'s first child on, in render order;
+ `HomepageHeader` must stay a `defineAsyncComponent` in `App.vue` for that
+ order to match (a test checks the prerender's order).
+- Any route other than the landing page (another path, or a stored session
+ redirected to `/empire`), or a stored locale that isn't English: the
+ landing markup and its teleports are removed and the app mounts fresh
+ (nothing would match the prerender).
+- Whatever depends on the browser (the analytics consent banner, the
+ translation banner) shows only after mount, so the hydrated tree equals the
+ prerender.
+
+Don't use scoped CSS on the landing page: its chunk CSS isn't loaded before
+the app is.
+
+## Serving
+
+The Docker image serves `dist/` with spa-to-http, which answers every path
+that is no file with `index.html`, the prerendered landing page; Netlify
+rewrites the same way. The app mounts the right page on the client (see
+above). The cost: a crawler that doesn't run JavaScript gets the landing
+page's HTML, title and canonical on every URL, also on `/market` and
+`/imprint-tos`. Google renders JavaScript and gets each page's own title,
+description, canonical or `noindex`. A server that can serve an empty shell
+for the other paths would fix it. Every hard load of another path also
+downloads the hidden landing HTML and its eager hero image.
+
+## Updating
+
+- **Counts** (players, plans): `PLAYER_COUNT` and `PLAN_COUNT` in
+ `features/homepage/homepage.constants.ts`, and "6,000" in the description
+ of `index.html` and `homepage.seo.description`.
+- **Share image**: replace `public/images/og/prunplanner.jpg` (1200 × 630,
+ under 300 kB). Its alt text is `og:image:alt` in `index.html`.
+- **A new public page**: `meta: { indexable: true }` on its route, a title
+ and description through `useHead`.
diff --git a/index.html b/index.html
index 345efec25..9addf8450 100644
--- a/index.html
+++ b/index.html
@@ -1,18 +1,84 @@
-
+
-
PRUNplanner
+
PRUNplanner: Base and Empire Planner for Prosperous Universe
+ content="Free planning tool for Prosperous Universe (PrUn): plan bases and empires, find planets, compare ROI and track burn with FIO. Used by over 6,000 players." />
+
+
+
+
+
-
-
-
+ property="og:title"
+ content="PRUNplanner: Base and Empire Planner for Prosperous Universe" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/package.json b/package.json
index a56da3a11..55fb2b9a3 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,7 @@
"packageManager": "pnpm@10.30.3",
"scripts": {
"dev": "vite",
- "build": "vite build",
+ "build": "vite build && vite build --ssr src/prerender.ts --outDir .prerender && node scripts/prerender.mjs",
"tsc": "vue-tsc --project tsconfig.typecheck.json",
"tsc:watch": "vue-tsc --project tsconfig.typecheck.json --watch",
"preview": "vite preview",
diff --git a/public/images/homepage/plan-1440.webp b/public/images/homepage/plan-1440.webp
new file mode 100644
index 000000000..90a42d877
Binary files /dev/null and b/public/images/homepage/plan-1440.webp differ
diff --git a/public/images/og/prunplanner.jpg b/public/images/og/prunplanner.jpg
new file mode 100644
index 000000000..c53d7739f
Binary files /dev/null and b/public/images/og/prunplanner.jpg differ
diff --git a/public/robots.txt b/public/robots.txt
new file mode 100644
index 000000000..0797f8a0e
--- /dev/null
+++ b/public/robots.txt
@@ -0,0 +1,4 @@
+User-agent: *
+Allow: /
+
+Sitemap: https://prunplanner.org/sitemap.xml
diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs
new file mode 100644
index 000000000..22e420227
--- /dev/null
+++ b/scripts/prerender.mjs
@@ -0,0 +1,66 @@
+// Prerenders "/" into dist/index.html after `vite build` and the SSR build
+// of src/prerender.ts (see the build script and docs/seo.md). The logic is
+// in src/prerender.ts; this only provides a DOM, reads and writes files.
+import { readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { brotliCompressSync, gzipSync } from "node:zlib";
+import { JSDOM } from "jsdom";
+
+const DIST = "dist";
+const BUNDLE = ".prerender/assets";
+
+// modules that read browser globals at import time (storage, navigator)
+const dom = new JSDOM("", {
+ url: "https://prunplanner.org/",
+ pretendToBeVisual: true,
+});
+for (const key of [
+ "window",
+ "document",
+ "navigator",
+ "localStorage",
+ "sessionStorage",
+ "location",
+ "history",
+ "DOMParser",
+ "HTMLElement",
+ "Element",
+ "Node",
+ "getComputedStyle",
+ "requestAnimationFrame",
+ "cancelAnimationFrame",
+])
+ Object.defineProperty(globalThis, key, {
+ value: dom.window[key],
+ configurable: true,
+ writable: true,
+ });
+window.matchMedia = () => ({ matches: false, addEventListener() {} });
+
+const entry = readdirSync(BUNDLE).find((f) => /^prerender\..*\.js$/.test(f));
+const { renderLanding, renderSitemap, injectLanding, checkLanding } =
+ await import(`../${BUNDLE}/${entry}`);
+
+const shell = readFileSync(`${DIST}/index.html`, "utf8");
+const { html, teleports } = await renderLanding();
+const index = injectLanding(shell, html, teleports);
+const sitemap = renderSitemap(new Date());
+
+const files = { "index.html": index, "sitemap.xml": sitemap };
+for (const [name, content] of Object.entries(files)) {
+ writeFileSync(`${DIST}/${name}`, content);
+ // next to Vite's compressed files, which still hold the empty shell
+ writeFileSync(`${DIST}/${name}.gz`, gzipSync(content, { level: 9 }));
+ writeFileSync(`${DIST}/${name}.br`, brotliCompressSync(content));
+}
+rmSync(".prerender", { recursive: true, force: true });
+
+const errors = checkLanding(
+ index,
+ readFileSync(`${DIST}/robots.txt`, "utf8"),
+ sitemap
+);
+if (errors.length) {
+ console.error(`prerender: dist/index.html fails:\n- ${errors.join("\n- ")}`);
+ process.exit(1);
+}
+console.log(`prerender: / (${Math.round(index.length / 1024)} kB), sitemap.xml`);
diff --git a/src/App.vue b/src/App.vue
index e186c269a..5f7611d1d 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1,5 +1,5 @@
diff --git a/src/assets/css/style.css b/src/assets/css/style.css
index 7415fe6f6..672d0363d 100644
--- a/src/assets/css/style.css
+++ b/src/assets/css/style.css
@@ -39,6 +39,16 @@
/* popovers, menus, lists, tooltips, toasts, modals, tables */
--radius-panel: 6px;
--shadow-panel: 0 10px 15px -3px rgb(0 0 0 / 0.6);
+ /* the landing tours' step progress, its duration set inline */
+ --animate-tour-progress: tour-progress 5s linear forwards;
+ @keyframes tour-progress {
+ from {
+ width: 0;
+ }
+ to {
+ width: 100%;
+ }
+ }
}
/* chart colours, read by useChartTheme (src/ui/charts/chartTheme.ts);
diff --git a/src/features/account/components/LoginComponent.vue b/src/features/account/components/LoginComponent.vue
index d92261d8d..ce22f15bf 100644
--- a/src/features/account/components/LoginComponent.vue
+++ b/src/features/account/components/LoginComponent.vue
@@ -10,14 +10,13 @@
import router from "@/router";
// UI
+ import { PFormItem, PInput, PNotice, PSpin } from "@/ui";
import {
- PForm,
- PFormItem,
- PFormSeparator,
- PInput,
- PButton,
- PNotice,
- } from "@/ui";
+ landingField,
+ landingHeading,
+ landingInput,
+ landingPrimaryButton,
+ } from "@/features/homepage/homepage.constants";
const inputUsername: Ref
= ref(null);
const inputPassword: Ref = ref(null);
@@ -62,67 +61,58 @@
-
-
+
-
+
+
{{
loginError === "throttled"
? $t("account.components.login.throttled")
: $t("account.components.login.error")
}}
-
-
-
-
-
-
- {{ $t("account.components.login.tos_link") }}
-
-
-
-
-
+
+ :class="landingInput" />
+
+
+ :class="landingInput" />
-
-
-
-
-
- {{ $t("account.components.login.forgot_link") }}
-
-
-
-
-
-
-
- {{ $t("account.components.login.buttons.login") }}
-
-
-
-
+
+ {{ $t("account.components.login.forgot_link") }}
+
+
+
+
+ {{ $t("account.components.login.buttons.login") }}
+
+
+
+
+ {{ $t("account.components.login.tos_link") }}
+
+
+
+
diff --git a/src/features/account/components/RegistrationComponent.vue b/src/features/account/components/RegistrationComponent.vue
index d59841c8d..79b244ea1 100644
--- a/src/features/account/components/RegistrationComponent.vue
+++ b/src/features/account/components/RegistrationComponent.vue
@@ -13,18 +13,23 @@
import { useI18n } from "vue-i18n";
const { t } = useI18n({ useScope: "global" });
+ import { PFormItem, PIcon, PInput, PNotice, PSpin } from "@/ui";
+ import { CheckSharp } from "@vicons/material";
import {
- PForm,
- PFormItem,
- PFormSeparator,
- PButton,
- PInput,
- PNotice,
- } from "@/ui";
+ landingField,
+ landingHeading,
+ landingInput,
+ landingPrimaryButton,
+ } from "@/features/homepage/homepage.constants";
import type { UserRegistrationPayload } from "@/features/api/schemas/user.schemas";
import { useQuery } from "@/lib/query_cache/useQuery";
import { isEmailAddress } from "@/features/account/account.util";
+ // the welcome state's button: the panel switches to the login form
+ defineEmits<{
+ (e: "login"): void;
+ }>();
+
const isLoading = ref(false);
const registrationSuccess = ref(false);
const registrationUsername: Ref = ref(null);
@@ -178,208 +183,176 @@
-
-
-
-
- {{
- $t("account.components.registration.result.ok_header", {
- username: registrationUsername,
- })
- }}
-
-
- {{
- $t("account.components.registration.result.ok_message")
- }}
-
-
-
-
-
- {{ $t("account.components.registration.title") }}
-
-
-
-
-
- {{ $t("account.components.registration.tos_link") }}
-
+
+
+
+
+
+ {{
+ $t("account.components.registration.result.ok_header", {
+ username: registrationUsername,
+ })
+ }}
+
+
+ {{ $t("account.components.registration.result.ok_message") }}
+
+
+ {{ $t("account.components.login.buttons.login") }}
+
+
+
+
+
+
+
+
+ {{ fieldErrors.password.join(" ") }}
+
+
+ {{ $t("account.components.registration.form.password_info") }}
-
-
-
-
-
- {{ fieldErrors.email.join(" ") }}
-
-
- {{
- $t(
- "account.components.registration.form.email_invalid"
- )
- }}
-
-
- {{
- $t(
- "account.components.registration.form.email_info"
- )
- }}
-
+
+
+
+
+
+
+
+
+ {{ fieldErrors.email.join(" ") }}
+
+
+ {{ $t("account.components.registration.form.email_invalid") }}
+
+
+ {{ $t("account.components.registration.form.email_info") }}
-
-
-
- {{
- $t(
- "account.components.registration.form.security_question"
- )
- }}
-
-
-
-
- {{ activeSecurityOption }}
-
-
- {{ `PLI ${activeSecurityOption}` }}
-
-
-
- {{
- $t(
- "account.components.registration.form.another_planet"
- )
- }}
-
-
-
+
+
+
+
+
+ {{ $t("account.components.registration.form.security_question") }}
+
+
+
+ {{
+ activeSecurityOption
+ }}
+
+
+
+ >
+ {{
+ `PLI ${activeSecurityOption}`
+ }}
+
+
+ :label="t('account.components.registration.form.planet_name')">
+ autocomplete="off"
+ :placeholder="''"
+ :class="landingInput" />
{{ fieldErrors.planet_input.join(" ") }}
-
-
- {{
- $t(
- "account.components.registration.buttons.register"
- )
- }}
-
-
-
-
-
+
+
+ {{ $t("account.components.registration.form.another_planet") }}
+
+
+
+
+ {{ $t("account.components.registration.buttons.register") }}
+
+
+
+
+ {{ $t("account.components.registration.tos_link") }}
+
+
+
+
diff --git a/src/features/account/useAuthPanel.ts b/src/features/account/useAuthPanel.ts
index 97fcfb9dd..6cdec0296 100644
--- a/src/features/account/useAuthPanel.ts
+++ b/src/features/account/useAuthPanel.ts
@@ -1,39 +1,66 @@
import { ref, type Ref } from "vue";
-// Module state: the homepage header renders the panels, other components
-// (the shared plan banner) open them.
+// Composables
+import { trackEvent } from "@/lib/analytics/useAnalytics";
+
+// Types & Interfaces
+import type { IAnalyticsEventProperties } from "@/lib/analytics/useAnalytics.types";
+
+type AuthPanel = "login" | "registration";
+type AuthPanelSource = IAnalyticsEventProperties["landing:auth_open"]["source"];
+
+// Module state: the homepage header renders the panel, other components
+// (the landing page, the shared plan banner) open it.
const showLogin: Ref = ref(false);
const showRegistration: Ref = ref(false);
/**
- * Login and registration panels of the homepage header, at most one open.
+ * Login and registration panel of the homepage header, one form at a time.
* @author jplacht
*/
export function useAuthPanel() {
- function toggleLogin(): void {
- showRegistration.value = false;
- showLogin.value = !showLogin.value;
+ function show(panel: AuthPanel | null): void {
+ showLogin.value = panel === "login";
+ showRegistration.value = panel === "registration";
}
- function toggleRegistration(): void {
- showLogin.value = false;
- showRegistration.value = !showRegistration.value;
+ function toggle(panel: AuthPanel, isOpen: boolean): void {
+ if (isOpen) show(null);
+ else open(panel, "header");
}
+ const toggleLogin = (): void => toggle("login", showLogin.value);
+ const toggleRegistration = (): void =>
+ toggle("registration", showRegistration.value);
+
function close(): void {
- showLogin.value = false;
- showRegistration.value = false;
+ show(null);
+ }
+
+ /** The welcome state after a registration: on to the login form */
+ function showLoginForm(): void {
+ show("login");
}
/**
- * Opens a panel from further down the page and scrolls up to it.
+ * Opens the panel on a form, where the page is: it slides in over it.
*
- * @param {"login" | "registration"} panel Panel to open
+ * @param {AuthPanel} panel Form to show
+ * @param {AuthPanelSource} source Where the visitor opened it
*/
- function open(panel: "login" | "registration"): void {
- showLogin.value = panel === "login";
- showRegistration.value = panel === "registration";
- window.scrollTo({ top: 0, behavior: "smooth" });
+ function open(panel: AuthPanel, source: AuthPanelSource): void {
+ show(panel);
+ trackEvent("landing:auth_open", { panel, source });
+ }
+
+ /**
+ * The switch inside the open panel
+ *
+ * @param {AuthPanel} panel Form switched to
+ */
+ function switchTo(panel: AuthPanel): void {
+ show(panel);
+ trackEvent("landing:auth_switch", { panel });
}
return {
@@ -42,6 +69,8 @@ export function useAuthPanel() {
toggleLogin,
toggleRegistration,
open,
+ switchTo,
+ showLoginForm,
close,
};
}
diff --git a/src/features/analytics/components/AnalyticsFooterLink.vue b/src/features/analytics/components/AnalyticsFooterLink.vue
index 1a08e3ff7..63077d423 100644
--- a/src/features/analytics/components/AnalyticsFooterLink.vue
+++ b/src/features/analytics/components/AnalyticsFooterLink.vue
@@ -1,5 +1,5 @@
diff --git a/src/features/homepage/components/HomepageTour.vue b/src/features/homepage/components/HomepageTour.vue
new file mode 100644
index 000000000..c91a4fbd0
--- /dev/null
+++ b/src/features/homepage/components/HomepageTour.vue
@@ -0,0 +1,287 @@
+
+
+
+
+
+
+
+
+
+
+ {{ num(i) }}
+
+
+ {{ $t(stepText(step.key, "title")) }}
+
+
+
+
+
+
+ {{ $t(stepText(active.key, "text")) }}
+
+
+
+
+
+
+ {{ $t(playing ? "homepage.tour.pause" : "homepage.tour.play") }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ num(i) }}
+
+
+
+ {{ $t(stepText(step.key, "title")) }}
+
+
+ {{ $t(stepText(step.key, "text")) }}
+
+
+
+
+
+
+
+
+
+
+ {{
+ $t("homepage.tour.step_of", {
+ step: index + 1,
+ total: steps.length,
+ })
+ }}
+
+
+
+
+
+
+ {{ $t(playing ? "homepage.tour.pause" : "homepage.tour.play") }}
+
+
+
+
+
diff --git a/src/features/homepage/components/HomepageTourViewport.vue b/src/features/homepage/components/HomepageTourViewport.vue
new file mode 100644
index 000000000..c58f911cb
--- /dev/null
+++ b/src/features/homepage/components/HomepageTourViewport.vue
@@ -0,0 +1,37 @@
+
+
+
+
+
+
diff --git a/src/features/homepage/homepage.constants.ts b/src/features/homepage/homepage.constants.ts
new file mode 100644
index 000000000..056e3436c
--- /dev/null
+++ b/src/features/homepage/homepage.constants.ts
@@ -0,0 +1,52 @@
+import type { IHomepageTourStep } from "@/features/homepage/homepageTour.types";
+
+// the owner's figures (2026-10-12), updated by hand
+export const PLAYER_COUNT = 6000;
+export const PLAN_COUNT = 40000;
+
+// the owner keeps this plan shared for the landing page
+export const EXAMPLE_PLAN_UUID = "7864a361-cca5-4384-8477-ceec21a8ee65";
+export const EXAMPLE_PLAN_PATH = `/shared/${EXAMPLE_PLAN_UUID}`;
+
+export const GITHUB_URL = "https://github.com/PRUNplanner/frontend";
+export const CROWDIN_URL = "https://crowdin.com/project/prunplanner";
+
+// landing buttons only: lime primary, outlined secondary; the kit's
+// buttonConfig keeps the blue primary everywhere else
+const buttonBase =
+ "inline-flex items-center justify-center gap-2.5 min-h-13.5 px-6 rounded-lg text-[17px] cursor-pointer transition-[filter,translate] hover:brightness-110 hover:-translate-y-px";
+export const landingPrimaryButton = `${buttonBase} bg-prunplanner text-black font-bold disabled:cursor-auto disabled:opacity-50 disabled:hover:brightness-100 disabled:hover:translate-y-0`;
+export const landingOutlineButton = `${buttonBase} border border-white/28 text-white font-medium`;
+
+// the login and registration forms in the landing panel: label above,
+// 50 px inputs, hint below (PFormItem's label, field and info, stacked)
+export const landingField =
+ "flex flex-col gap-2 [&>label]:h-auto [&>label]:text-sm [&>label]:font-semibold [&>label]:text-white";
+export const landingInput =
+ "w-full *:h-12.5! *:rounded-lg! *:bg-[#030707]! *:border *:border-white/22 *:text-base! *:text-white! *:focus-within:border-prunplanner";
+export const landingHeading =
+ "m-0 text-4xl leading-[1.04] font-black tracking-[-0.03em] text-white";
+
+// Tour regions are measured on the current captures in
+// public/images/homepage: retaking a shot means re-measuring its boxes.
+export const PLAN_TOUR: IHomepageTourStep[] = [
+ { key: "whole", box: [0, 0, 1, 1], zoom: 1 },
+ { key: "buildings", box: [0.05, 0.538, 0.671, 0.997], zoom: 1.45 },
+ { key: "workforce", box: [0.066, 0.205, 0.363, 0.424], zoom: 2.3 },
+ { key: "money", box: [0.379, 0.205, 0.519, 0.416], zoom: 2.6 },
+ { key: "flow", box: [0.679, 0.205, 0.992, 0.749], zoom: 1.6 },
+ { key: "tabs", box: [0.05, 0.108, 0.53, 0.146], zoom: 1.8 },
+];
+export const PLANET_SEARCH_TOUR: IHomepageTourStep[] = [
+ { key: "materials", box: [0.052, 0.225, 0.31, 0.425], zoom: 2.5 },
+ { key: "conditions", box: [0.048, 0.44, 0.313, 0.84], zoom: 2.1 },
+ { key: "matches", box: [0.337, 0.228, 0.983, 0.815], zoom: 1.4 },
+ { key: "create_plan", box: [0.341, 0.279, 0.363, 0.313], zoom: 2.8 },
+ { key: "compare", box: [0.335, 0.955, 0.431, 0.993], zoom: 2.3 },
+];
+export const EMPIRE_TOUR: IHomepageTourStep[] = [
+ { key: "totals", box: [0.095, 0.152, 0.44, 0.22], zoom: 2.0 },
+ { key: "plans", box: [0.059, 0.246, 0.475, 0.432], zoom: 1.9 },
+ { key: "material_io", box: [0.493, 0.226, 0.983, 1.0], zoom: 1.29 },
+ { key: "tabs", box: [0.715, 0.014, 0.944, 0.045], zoom: 2.2 },
+];
diff --git a/src/features/homepage/homepageTour.types.ts b/src/features/homepage/homepageTour.types.ts
new file mode 100644
index 000000000..41ec4496a
--- /dev/null
+++ b/src/features/homepage/homepageTour.types.ts
@@ -0,0 +1,8 @@
+export type HomepageTourName = "plan" | "planet_search" | "empire";
+
+export interface IHomepageTourStep {
+ key: string;
+ /** x0, y0, x1, y1 in fractions of the image */
+ box: [number, number, number, number];
+ zoom: number;
+}
diff --git a/src/features/homepage/homepageTour.util.ts b/src/features/homepage/homepageTour.util.ts
new file mode 100644
index 000000000..9e01dd444
--- /dev/null
+++ b/src/features/homepage/homepageTour.util.ts
@@ -0,0 +1,34 @@
+import type { IHomepageTourStep } from "@/features/homepage/homepageTour.types";
+
+/**
+ * Zoom and pan of a tour viewport, origin top left: the step's box centred
+ * as far as the image allows, so no empty edge shows.
+ *
+ * @author jplacht
+ */
+export function tourTransform(step: IHomepageTourStep): string {
+ const [x0, y0, x1, y1] = step.box;
+ const z = step.zoom;
+ const clamp = (v: number) => Math.min(0, Math.max(1 - z, v));
+ const tx = clamp(0.5 - ((x0 + x1) / 2) * z) * 100;
+ const ty = clamp(0.5 - ((y0 + y1) / 2) * z) * 100;
+ // + 0 turns -0 into 0
+ return `translate(${+tx.toFixed(2) + 0}%, ${+ty.toFixed(2) + 0}%) scale(${z})`;
+}
+
+/**
+ * Position of the outlined region in the unzoomed image, outline and radius
+ * divided by the zoom so they look the same at every step
+ */
+export function tourHighlight(step: IHomepageTourStep): Record {
+ const [x0, y0, x1, y1] = step.box;
+ const pct = (v: number) => `${+(v * 100).toFixed(2)}%`;
+ return {
+ left: pct(x0),
+ top: pct(y0),
+ width: pct(x1 - x0),
+ height: pct(y1 - y0),
+ outlineWidth: `${+(2 / step.zoom).toFixed(2)}px`,
+ borderRadius: `${+(6 / step.zoom).toFixed(2)}px`,
+ };
+}
diff --git a/src/features/sharing/components/SharedPlanBanner.vue b/src/features/sharing/components/SharedPlanBanner.vue
index 0cf4b7ead..4f4859bb0 100644
--- a/src/features/sharing/components/SharedPlanBanner.vue
+++ b/src/features/sharing/components/SharedPlanBanner.vue
@@ -79,10 +79,10 @@
{{ $t("sharing.banner.save") }}
-
+
{{ $t("sharing.banner.signup") }}
-
+
{{ $t("homepage.navigation.login") }}
diff --git a/src/layout/components/HomepageHeader.vue b/src/layout/components/HomepageHeader.vue
index 45273d385..7b0332ea2 100644
--- a/src/layout/components/HomepageHeader.vue
+++ b/src/layout/components/HomepageHeader.vue
@@ -1,12 +1,6 @@
-
-
-
- PRUN planner
-
-
+
-
-
-
- PRUN planner
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+ {{ $t("homepage.auth_panel.label") }}
+
+
+ {{ $t("homepage.navigation.login") }}
+
+
+ {{ $t("homepage.navigation.registration") }}
+
-
-
+
+
+
+
diff --git a/src/lib/analytics/useAnalytics.types.ts b/src/lib/analytics/useAnalytics.types.ts
index 1d95da755..fc6308ff2 100644
--- a/src/lib/analytics/useAnalytics.types.ts
+++ b/src/lib/analytics/useAnalytics.types.ts
@@ -1,3 +1,4 @@
+import type { HomepageTourName } from "@/features/homepage/homepageTour.types";
import type {
NavArea,
NavSection,
@@ -100,6 +101,9 @@ export type XITTransferSource =
type Trigger = "button" | "shortcut";
+// the landing page's login / registration panel
+type AuthPanel = "login" | "registration";
+
// the save conflict dialog's options, "close" keeps the edits unsaved
type SaveConflictChoice = "save_as_new" | "overwrite" | "reload" | "close";
@@ -140,6 +144,15 @@ export interface IAnalyticsEventProperties {
"account:fio_link_failed": { reason: string };
"account:api_key_create": undefined;
"account:api_key_revoke": undefined;
+ "landing:auth_open": {
+ panel: AuthPanel;
+ source: "header" | "hero" | "closing" | "shared_plan";
+ };
+ "landing:auth_switch": { panel: AuthPanel };
+ "landing:example_open": { source: "hero" | "shared_plans" };
+ // clicks only, an automatic advance sends nothing
+ "landing:tour_step": { tour: HomepageTourName; step: string };
+ "landing:tour_toggle": { tour: HomepageTourName; is_playing: boolean };
"apikey:key_copy": undefined;
"apikey:builder_change": {
endpoint: string;
diff --git a/src/lib/seo/checkLanding.ts b/src/lib/seo/checkLanding.ts
new file mode 100644
index 000000000..5fe46358e
--- /dev/null
+++ b/src/lib/seo/checkLanding.ts
@@ -0,0 +1,117 @@
+import homepage from "@/locales/en_US/homepage.json";
+import {
+ EXAMPLE_PLAN_PATH,
+ GITHUB_URL,
+} from "@/features/homepage/homepage.constants";
+import { SITE_URL } from "@/lib/seo/site";
+
+const FAQ = ["what", "free", "fio", "try", "official", "languages"] as const;
+const OG = [
+ 'meta[property="og:type"]',
+ 'meta[property="og:site_name"]',
+ 'meta[property="og:title"]',
+ 'meta[property="og:description"]',
+ 'meta[property="og:url"]',
+ 'meta[property="og:image"]',
+ 'meta[property="og:image:width"]',
+ 'meta[property="og:image:height"]',
+ 'meta[property="og:image:alt"]',
+ 'meta[name="twitter:card"]',
+];
+
+/**
+ * What a crawler without JavaScript has to get from the build (landing
+ * redesign criteria 17 to 19). The prerender fails the build on any of it.
+ *
+ * @returns {string[]} what is missing, empty when all is there
+ */
+export function checkLanding(
+ indexHtml: string,
+ robots: string,
+ sitemap: string
+): string[] {
+ const errors: string[] = [];
+ const expect = (ok: boolean, message: string) => ok || errors.push(message);
+ const doc = new DOMParser().parseFromString(indexHtml, "text/html");
+ const text = doc.body.textContent!.replace(/\s+/g, " ");
+ const count = (selector: string) => doc.querySelectorAll(selector).length;
+
+ // the page text
+ const h1 = doc.querySelectorAll("#app h1");
+ expect(h1.length === 1, `${h1.length} h1, not 1`);
+ const title = homepage.hero.title.replace(
+ "{accent}",
+ homepage.hero.title_accent
+ );
+ expect(
+ !!h1[0]?.textContent?.includes(title) &&
+ h1[0].textContent.includes("Prosperous Universe"),
+ "h1 without the claim or the game"
+ );
+ for (const [label, value] of [
+ ["positioning line", homepage.hero.positioning],
+ ["player count", "6,000+"],
+ ...FAQ.flatMap((key) => [
+ [`FAQ question ${key}`, homepage.faq[key].question],
+ [`FAQ answer ${key}`, homepage.faq[key].answer.split("{")[0]],
+ ]),
+ ])
+ expect(text.includes(value), `no ${label}`);
+ expect(
+ count(`#app a[href="${EXAMPLE_PLAN_PATH}"]`) > 0,
+ "no link to the example plan"
+ );
+ expect(count(`#app a[href="${GITHUB_URL}"]`) > 0, "no link to GitHub");
+
+ // the head
+ expect(count("title") === 1, "not exactly one ");
+ expect(
+ doc.title === homepage.seo.title,
+ `title "${doc.title}", not the landing title`
+ );
+ expect(
+ count('meta[name="description"]') === 1,
+ "not exactly one description"
+ );
+ expect(
+ doc.querySelector('meta[name="description"]')?.getAttribute("content") ===
+ homepage.seo.description,
+ "description differs from homepage.seo.description"
+ );
+ expect(
+ count('link[rel="canonical"]') === 1 &&
+ doc
+ .querySelector('link[rel="canonical"]')
+ ?.getAttribute("href") === `${SITE_URL}/`,
+ "not exactly one canonical to /"
+ );
+ for (const selector of OG) expect(count(selector) === 1, `no ${selector}`);
+
+ const types = [
+ ...doc.querySelectorAll('script[type="application/ld+json"]'),
+ ].map((script) => {
+ try {
+ return JSON.parse(script.textContent!)["@type"];
+ } catch {
+ return "invalid JSON";
+ }
+ });
+ expect(
+ types.join() === "WebSite,WebApplication",
+ `JSON-LD blocks: ${types.join() || "none"}`
+ );
+
+ // robots and sitemap
+ expect(
+ robots.includes(`Sitemap: ${SITE_URL}/sitemap.xml`),
+ "robots.txt names no sitemap"
+ );
+ const xml = new DOMParser().parseFromString(sitemap, "application/xml");
+ expect(
+ !xml.querySelector("parsererror") &&
+ xml.querySelectorAll("url > loc").length > 0,
+ "sitemap.xml is no valid sitemap"
+ );
+
+ return errors;
+}
diff --git a/src/lib/seo/prerenderMount.ts b/src/lib/seo/prerenderMount.ts
new file mode 100644
index 000000000..8abba400b
--- /dev/null
+++ b/src/lib/seo/prerenderMount.ts
@@ -0,0 +1,52 @@
+import type { App } from "vue";
+import type { Router } from "vue-router";
+
+const root = () => document.getElementById("app")!;
+
+/** dist/index.html: #app holds the prerendered landing page */
+export const isPrerendered = (): boolean =>
+ root().hasAttribute("data-prerendered");
+
+/**
+ * Before the app mounts. The static description and canonical of
+ * index.html go, so the ones useHead sets are the only ones. Over the
+ * prerendered landing page, the route and the header are loaded first, so
+ * the app hydrates the markup as it is: the same elements stay, nothing
+ * repaints.
+ *
+ * @param {Router} router The app's router
+ * @param {string} locale The active locale; the prerender is in en_US
+ * @author jplacht
+ */
+export async function prepareMount(
+ router: Router,
+ locale: string
+): Promise {
+ document
+ .querySelectorAll(
+ 'head link[rel="canonical"], head meta[name="description"]'
+ )
+ .forEach((el) => el.remove());
+
+ if (!isPrerendered()) return;
+
+ await Promise.all([
+ router.isReady(),
+ import("@/layout/components/HomepageHeader.vue"),
+ ]);
+
+ // a stored session went on to /empire, or a stored locale isn't the
+ // prerender's English: nothing matches, so mount fresh (hydration would
+ // keep the classes of elements whose tag matches). The prerender's body
+ // teleports go with it.
+ if (router.currentRoute.value.name !== "homepage" || locale !== "en_US") {
+ while (root().previousSibling) root().previousSibling!.remove();
+ root().replaceChildren();
+ }
+}
+
+/** Mounts (or hydrates) the app; a stored session's hidden #app shows again */
+export function mountApp(app: App): void {
+ app.mount(root());
+ root().removeAttribute("data-prerendered");
+}
diff --git a/src/lib/seo/site.ts b/src/lib/seo/site.ts
new file mode 100644
index 000000000..a17a67870
--- /dev/null
+++ b/src/lib/seo/site.ts
@@ -0,0 +1,28 @@
+// the production origin: canonical links and the sitemap name it on every host
+export const SITE_URL = "https://prunplanner.org";
+
+/** "en_US" as a BCP 47 tag for ; key mode is English */
+export function htmlLang(locale: string): string {
+ return locale === "keymode" ? "en" : locale.replace("_", "-");
+}
+
+/**
+ * sitemap.xml of the indexable pages
+ *
+ * @param {string[]} paths Paths of the routes with meta.indexable
+ * @param {Date} date Build date, the lastmod of every page
+ */
+export function buildSitemap(paths: string[], date: Date): string {
+ const lastmod = date.toISOString().slice(0, 10);
+ const urls = paths
+ .map(
+ (path) =>
+ ` ${SITE_URL}${path} ${lastmod} `
+ )
+ .join("\n");
+ return `
+
+${urls}
+
+`;
+}
diff --git a/src/locales/en_US/account.json b/src/locales/en_US/account.json
index c7ec1e1fe..89687aa07 100644
--- a/src/locales/en_US/account.json
+++ b/src/locales/en_US/account.json
@@ -1,11 +1,11 @@
{
"components": {
"login": {
- "title": "Login",
+ "title": "Back to your empire.",
"error": "Error logging in. Please check your username and password.",
"throttled": "Too many login attempts. Please wait and try again later.",
"buttons": {
- "login": "Login"
+ "login": "Log in"
},
"form": {
"username": "Username",
@@ -13,36 +13,34 @@
},
"tos": "By using PRUNplanner you agree to the {tos_link}.",
"tos_link": "Terms of Service",
- "forgot": "Forgot your password? Request a {forgot_link}.",
- "forgot_link": "Password Reset"
+ "forgot_link": "Forgot your password?"
},
"registration": {
- "title": "Account Information",
+ "title": "Start planning, it's free.",
"tos": "PRUNplanner is free to use. By creating an account, you acknowledge and agree to the {tos_link}.",
"tos_link": "Terms of Service",
"result": {
"ok_header": "Welcome, {username}!",
- "ok_message": "Registration succesful. You can now login to PRUNplanner.",
+ "ok_message": "Registration successful. You can now log in to PRUNplanner.",
"error": "Error during registration.",
"fix_fields": "Please fix the fields marked below.",
"throttled": "Too many registration attempts. Please wait a minute and try again.",
"unknown": "Unknown error. Please try again later."
},
"buttons": {
- "register": "Create Account"
+ "register": "Create account"
},
"form": {
"username": "Username",
- "username_info": "Must be at least 3 characters long. Can't contain spaces.",
+ "username_info": "At least 3 characters, no spaces.",
"password": "Password",
"password_info": "At least 8 characters, not only numbers, and not a common password.",
"email": "Email (optional)",
- "email_placeholder": "Not mandatory, but recommended.",
- "email_info": "Only used to reset your password if you forget it. No newsletters, never shared. You can add it later in Profile.",
+ "email_info": "Not mandatory, but recommended. Increases your account security.",
"email_invalid": "This doesn't look like an email address. Leave it empty or fix it to create your account.",
- "security_question": "Security Question",
- "question": "Enter the name of planet {planet}. To find it, open a new Prosperous Universe buffer with the command {command}. You'll see the planets name listed under 'Name' in the planet information",
- "planet_name": "Name",
+ "security_question": "Security question",
+ "question": "Enter the name of planet {planet}. To find it, open a new Prosperous Universe buffer with this command and read the planet's name under \"Name\".",
+ "planet_name": "Planet name",
"another_planet": "Use a different planet"
}
},
diff --git a/src/locales/en_US/common.json b/src/locales/en_US/common.json
index 724c0d0e1..ae2dfb473 100644
--- a/src/locales/en_US/common.json
+++ b/src/locales/en_US/common.json
@@ -127,5 +127,10 @@
"chip": "Update ready",
"whats_new_title": "PRUNplanner {version} is here",
"whats_new_action": "What's new"
+ },
+ "not_found": {
+ "title": "Page not found",
+ "text": "There is no page at this address.",
+ "home": "Go to the start page"
}
}
diff --git a/src/locales/en_US/homepage.json b/src/locales/en_US/homepage.json
index abdcd33cc..52d1dfb06 100644
--- a/src/locales/en_US/homepage.json
+++ b/src/locales/en_US/homepage.json
@@ -1,74 +1,177 @@
{
"navigation": {
"registration": "Create account",
- "login": "Log in"
+ "login": "Log in",
+ "sections": "Sections",
+ "how": "How it works",
+ "features": "Features",
+ "tools": "Tools",
+ "github": "GitHub"
+ },
+ "auth_panel": {
+ "label": "Log in or create an account",
+ "switch": "Account"
},
"hero": {
"eyebrow": "Base and empire planner for Prosperous Universe",
- "title": "Plan a base before you build it.",
- "lead": "Pick a planet, add buildings and recipes, and watch daily profit, workforce, area and payback update as you go. Then group your bases into an empire and see what it makes, burns and has to ship.",
+ "title": "Plan a base {accent} you build it.",
+ "title_accent": "before",
+ "positioning": "The definitive Prosperous Universe companion for all who love to strategize and optimise.",
+ "lead": "Know what a base earns, what it needs and when it pays back, before you spend anything on it. Then group your bases into an empire and see what it makes, burns and has to ship.",
"cta": "Start planning, it's free",
"cta_logged_in": "Open your empire",
- "how": "How it works",
- "facts": "Free · Open source · Prices from FIO",
+ "example": "Open an example plan",
+ "example_note": "The example needs no account. Registering takes a minute with the game open.",
"image_alt": "The plan editor for an iron base on Montem: workforce, overview, production buildings and material I/O",
- "image_caption": "An iron base on Montem (OT-580b): 19,313 ȼ profit a day, pays back in 64 days."
+ "image_title": "prunplanner.org / plan / Montem iron"
},
- "setup": {
- "title": "Three things to set up",
- "lead": "Everything in PRUNplanner hangs off plans, empires and exchange preferences.",
+ "numbers": {
+ "label": "PRUNplanner in numbers",
+ "players": "players plan with it",
+ "plans": "plans created",
+ "free_value": "Free",
+ "free": "No ads, no paid tier.",
+ "languages_value": "{count} languages",
+ "languages": "Open source, translated by players."
+ },
+ "tour": {
+ "pause": "Pause",
+ "play": "Play",
+ "step_of": "Guided tour · step {step} of {total}",
"plan": {
- "label": "Plan",
- "text": "One base on one planet: buildings, recipes, habitation, experts and COGC. Start from Planet Search.",
- "alt": "Production buildings and recipes in the plan editor"
+ "label": "Plan tour steps",
+ "whole": {
+ "title": "The whole plan",
+ "text": "One base on one screen: workforce, costs, storage, production and material flow, updating as you change it."
+ },
+ "buildings": {
+ "title": "Add buildings",
+ "text": "Pick buildings and recipes from what the planet offers. Each one shows efficiency, revenue, area and construction cost."
+ },
+ "workforce": {
+ "title": "Staff it",
+ "text": "Workforce needed against housing supplied, per tier, with the luxuries you choose to provide."
+ },
+ "money": {
+ "title": "See the money",
+ "text": "This iron base on Montem makes 19,312.82 a day and pays back in 63.79 days."
+ },
+ "flow": {
+ "title": "Check the flow",
+ "text": "Material I/O lists what the base consumes and produces per day, and what each line is worth."
+ },
+ "tabs": {
+ "title": "Go deeper",
+ "text": "POPR, visitation frequency, construction cart, supply cart and repair analysis are one tab away."
+ }
},
- "empire": {
- "label": "Empire",
- "text": "Your plans together. See total profit, and which materials you're short of or have spare across all bases.",
- "alt": "Empire material I/O with surplus and deficit per material"
+ "planet_search": {
+ "label": "Planet Search tour steps",
+ "materials": {
+ "title": "Pick your materials",
+ "text": "H2O, at least 21 a day. Add more materials, in any-of or all-of groups."
+ },
+ "conditions": {
+ "title": "Set the conditions",
+ "text": "Surface, fertility, gravity, pressure, temperature and the active COGC program."
+ },
+ "matches": {
+ "title": "Read the matches",
+ "text": "9 of 4576 planets, with their other resources, COGC and infrastructure."
+ },
+ "create_plan": {
+ "title": "Start a plan",
+ "text": "The + button creates a plan on that planet."
+ },
+ "compare": {
+ "title": "Compare up to four",
+ "text": "Pin planets with the star and see them side by side."
+ }
},
- "exchange": {
- "label": "Exchange preference",
- "text": "Which CX prices to use, with your own prices per material or per planet. Every plan in the empire is priced with it.",
- "alt": "An exchange preference with ticker overrides"
+ "empire": {
+ "label": "Empire tour steps",
+ "totals": {
+ "title": "One bottom line",
+ "text": "Revenue, cost and profit of all bases together: this empire makes 186,973.33 a day."
+ },
+ "plans": {
+ "title": "Every plan side by side",
+ "text": "Each base with its planet, COGC and daily profit, and how many permits the empire uses."
+ },
+ "material_io": {
+ "title": "Short or spare",
+ "text": "Material I/O across all bases shows what the empire has to buy or ship in, and what it can sell."
+ },
+ "tabs": {
+ "title": "Look further",
+ "text": "Analysis and Production Opportunities are one tab away."
+ }
}
},
"most_used": {
- "title": "What players use most",
- "lead": "Where to settle, what pays back, and when a base runs dry.",
+ "eyebrow": "What players use most",
+ "title": "Where to settle, what pays back, and when a base runs dry.",
"needs_fio": "Needs FIO",
"planet_search": {
"label": "Planet Search",
"title": "Find the planet for your next base.",
"lead": "Filter every planet in the universe and start a plan from the result.",
- "point_1": "Materials you want to extract, with a minimum per day",
- "point_2": "Surface, gravity, pressure, temperature and fertility",
- "point_3": "COGC program and infrastructure",
- "point_4": "Compare up to four planets side by side",
"alt": "Planet Search: filters on the left, matching planets with their resources, COGC and infrastructure"
},
+ "empire": {
+ "label": "Empire",
+ "title": "Run every base as one empire.",
+ "lead": "Put your plans together and see what the whole operation earns, and which materials it is short of or has spare.",
+ "alt": "Empire view: revenue, cost and profit, the list of plans, and material I/O across all bases"
+ },
"resource_roi": {
"label": "Resource ROI",
"title": "See which planet pays back fastest.",
- "lead": "Pick a resource and your exchange preference. Every planet that has it gets a costed plan.",
- "point_1": "Plan cost, daily profit and ROI in days",
- "point_2": "Daily output and % of the best planet",
- "point_3": "POPR, COGC, environment and infrastructure next to it",
+ "lead": "Pick a resource and your exchange preference. Every planet that has it gets a costed plan: plan cost, daily profit and ROI in days, next to POPR, COGC, environment and infrastructure.",
"alt": "Resource ROI for ALO: planets ranked with daily output, plan cost, daily profit and ROI in days"
},
"fio_burn": {
"label": "FIO Burn",
"title": "Know when a base runs out.",
- "lead": "Link your FIO account and Burn compares your real stock with what each plan uses.",
- "point_1": "Days left per material and base",
- "point_2": "Red and yellow thresholds you set",
- "point_3": "A resupply list per base, exported as XIT actions",
+ "lead": "Link your FIO account and Burn compares your real stock with what each plan uses: days left per material, thresholds you set, and a resupply list per base exported as XIT actions.",
"alt": "FIO Burn: burn per plan, with one base expanded to show consumption, stock and days left per material"
+ },
+ "starter": {
+ "label": "New to the game",
+ "title": "Start from a typical setup, then make it yours.",
+ "text": "An empty plan offers what other players typically plan on that planet. It is a guideline, not a rule: change every building and recipe until you have found your own best setup."
+ },
+ "shared": {
+ "label": "Shared plans",
+ "title": "Send a plan as a link.",
+ "text": "Anyone with the link can see the recipes and try changes, without an account. Good for asking your corporation for feedback."
+ }
+ },
+ "setup": {
+ "eyebrow": "How it works",
+ "title": "From first base to empire.",
+ "lead": "Three things carry everything: plans, empires and exchange preferences.",
+ "label": "Set-up steps",
+ "plan": {
+ "label": "Plan",
+ "text": "One base on one planet: buildings, recipes, habitation, experts and COGC. Start from Planet Search.",
+ "alt": "Production buildings and recipes in the plan editor"
+ },
+ "empire": {
+ "label": "Empire",
+ "text": "Your plans together. See total profit, and which materials you're short of or have spare across all bases.",
+ "alt": "Empire material I/O with surplus and deficit per material"
+ },
+ "exchange": {
+ "label": "Exchange preference",
+ "text": "Which CX prices to use, with your own prices per material or per planet. Every plan in the empire is priced with it.",
+ "alt": "An exchange preference with ticker overrides"
}
},
"tools": {
- "title": "And the tools around it",
- "lead": "Market data and calculators, priced from FIO. Link your FIO account and Repair uses your real bases too.",
+ "eyebrow": "And the tools around it",
+ "title": "Market data and calculators, priced from FIO.",
+ "lead": "FIO is the community service that reads your in-game data. Link your account and Repair uses your real bases too.",
"with_fio": "with FIO",
"market_live": {
"label": "Market Live",
@@ -103,16 +206,54 @@
"text": "ȼ per need point for safety, health, comfort, culture and education."
}
},
+ "faq": {
+ "eyebrow": "Questions",
+ "title": "Before you sign up.",
+ "what": {
+ "question": "What is PRUNplanner?",
+ "answer": "PRUNplanner is a free, open source planning tool for the browser game Prosperous Universe (PrUn). You plan bases and whole empires in it, find planets, compare what pays back and see when a base runs out of materials."
+ },
+ "free": {
+ "question": "Is PRUNplanner free?",
+ "answer": "Yes. There are no ads and no paid tier. The code is on GitHub."
+ },
+ "fio": {
+ "question": "Do I need FIO to use it?",
+ "answer": "No. Plans, empires, Planet Search, Resource ROI and the calculators work without it. FIO Burn and Repair read your real in-game stock and buildings, so those two need a linked FIO account."
+ },
+ "try": {
+ "question": "Can I try it without an account?",
+ "answer": "Yes. Open the example plan: you can change buildings and recipes and see the numbers update. You need an account to save plans of your own."
+ },
+ "official": {
+ "question": "Is it an official Prosperous Universe tool?",
+ "answer": "No. It is a community project made by a player and is not made by the game's developer."
+ },
+ "languages": {
+ "question": "Which languages does it support?",
+ "answer": "English and {count} more, translated by players on Crowdin."
+ }
+ },
"closing": {
- "title": "Free, open source and translated by players.",
- "text": "No ads, no paid tier. Code on GitHub, translations on Crowdin.",
+ "title": "Plan your next base {accent}",
+ "title_accent": "before you pay for it.",
+ "text": "Over {players} players already do. Free, open source and translated by players. Registering takes a minute with the game open.",
+ "cta": "Start planning",
"github": "View on GitHub",
- "cta": "Start planning"
+ "github_text": "Read the code or contribute.",
+ "translate": "Translate on Crowdin",
+ "translate_text": "{count} languages so far."
},
"footer": {
"made_in": "Made in Munich, with contributions from around the world.",
"imprint": "Imprint and terms",
"translate": "Translate on Crowdin",
"language": "Language"
+ },
+ "seo": {
+ "title": "PRUNplanner: Base and Empire Planner for Prosperous Universe",
+ "description": "Free planning tool for Prosperous Universe (PrUn): plan bases and empires, find planets, compare ROI and track burn with FIO. Used by over 6,000 players.",
+ "imprint_title": "Imprint and Terms of Service",
+ "imprint_description": "Imprint, terms of service and privacy policy of PRUNplanner, the free planning tool for Prosperous Universe."
}
}
diff --git a/src/main.ts b/src/main.ts
index f3054fe34..8ae786356 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,4 +1,4 @@
-import { createApp } from "vue";
+import { createApp, createSSRApp } from "vue";
import "@fontsource/roboto/300.css";
import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css";
@@ -23,7 +23,9 @@ import router from "@/router";
// app + uses
import { trackVueError } from "@/lib/analytics/useAnalytics";
-const app = createApp(AppProvider);
+// the prerendered landing page is hydrated, any other page rendered
+import { isPrerendered, mountApp, prepareMount } from "@/lib/seo/prerenderMount";
+const app = (isPrerendered() ? createSSRApp : createApp)(AppProvider);
app.config.performance = import.meta.env.DEV;
app.config.errorHandler = trackVueError;
@@ -72,5 +74,8 @@ app.use(head);
import { useUserStore } from "./stores/userStore";
import type { Composer } from "vue-i18n";
-// mount
-app.mount("#app");
+// mount, over the prerendered landing page without a flash. Not awaited:
+// lazy chunks import from this module, which has to finish evaluating first
+prepareMount(router, i18n.global.locale.value)
+ .catch(console.error)
+ .then(() => mountApp(app));
diff --git a/src/prerender.ts b/src/prerender.ts
new file mode 100644
index 000000000..028c6a485
--- /dev/null
+++ b/src/prerender.ts
@@ -0,0 +1,81 @@
+import { createSSRApp } from "vue";
+import { renderToString } from "vue/server-renderer";
+import { createPinia } from "pinia";
+import { createHead } from "@unhead/vue/server";
+
+import AppProvider from "@/AppProvider.vue";
+import router from "@/router";
+import { i18n } from "@/lib/i18n";
+import { buildSitemap } from "@/lib/seo/site";
+
+// The build-time prerender of "/" (scripts/prerender.mjs runs this bundle):
+// English, logged out, tours on their first step, panel closed. No
+// onMounted runs, so it sends no events and makes no API calls.
+
+/**
+ * The landing page's markup for #app of dist/index.html, and what its
+ * components teleport to (the client hydrates both)
+ */
+export async function renderLanding(): Promise<{
+ html: string;
+ teleports: string;
+}> {
+ const app = createSSRApp(AppProvider);
+ app.use(router);
+ app.use(createPinia());
+ app.use(i18n);
+ app.use(createHead());
+ await router.push("/");
+ await router.isReady();
+ const ctx: { teleports?: Record } = {};
+ const html = await renderToString(app, ctx);
+ return { html, teleports: ctx.teleports?.body ?? "" };
+}
+
+/** sitemap.xml of every route with meta.indexable */
+export function renderSitemap(date: Date): string {
+ return buildSitemap(
+ router
+ .getRoutes()
+ .filter((r) => r.meta.indexable)
+ .map((r) => r.path),
+ date
+ );
+}
+
+// the app's entry starts once the prerendered page has painted (its
+// first-contentful-paint entry), or after a second where that never comes
+// (a background tab), so the text paints without waiting for the scripts.
+// Where index.html hides the page (skip-prerender) nothing paints: it starts
+// right away. Either way only once the page is parsed, after /env.js has run.
+const START_AFTER_PAINT = (src: string) =>
+ ``;
+
+/**
+ * dist/index.html with the landing page in #app, marked for main.ts (which
+ * hydrates it), the body teleports first in , and its scripts loaded
+ * after the first paint. The server answers every path with it; index.html
+ * hides the landing page where it doesn't belong.
+ */
+export function injectLanding(
+ shell: string,
+ markup: string,
+ teleports = ""
+): string {
+ const mount = '
';
+ if (!shell.includes(mount)) throw new Error("no empty #app in the shell");
+ // hydration looks for body teleports from 's first child on
+ const body = shell.match(/]*>\s*/);
+ if (!body) throw new Error("no in the shell");
+ const entry = shell.match(
+ /
+
+
`;
+
+describe("prerender", () => {
+ it("renders the logged-out landing page in English, without events or requests", async () => {
+ const get = vi.spyOn(apiService, "get");
+ const { html, teleports } = await renderLanding();
+ const doc = new DOMParser().parseFromString(html, "text/html");
+
+ expect(doc.querySelectorAll("h1")).toHaveLength(1);
+ expect(doc.querySelector("h1")!.textContent).toContain(
+ "Plan a base before you build it."
+ );
+ expect(doc.querySelectorAll("#faq details")).toHaveLength(6);
+ // the auth panel is closed, the tours on their first step
+ expect(doc.querySelector("[role=dialog]")).toBeNull();
+ expect(
+ doc.querySelector('[role=group] button[aria-pressed="true"]')!.textContent
+ ).toContain("01");
+ // confirm host, toast host, drawer: the order the client hydrates
+ // them in (HomepageHeader, async, comes last on both sides)
+ const parts = teleports.split("").slice(1);
+ expect(parts).toHaveLength(3);
+ expect(parts[1]).toContain("ptoast-host");
+ expect(trackEvent).not.toHaveBeenCalled();
+ expect(get).not.toHaveBeenCalled();
+ // the router records its pageview; PostHog never starts in the
+ // prerender, so capture() drops it
+ expect(vi.mocked(trackPageview).mock.calls.flat()).toEqual(["homepage"]);
+ });
+
+ it("puts the markup into #app and the teleports first in ", () => {
+ const index = injectLanding(
+ SHELL,
+ "landing ",
+ ""
+ );
+
+ expect(index).toContain(
+ '
landing '
+ );
+ expect(index).toContain('rel="canonical"');
+ });
+
+ it("starts the scripts after the first paint, without modulepreloads", () => {
+ const index = injectLanding(SHELL, "landing ");
+
+ expect(index).not.toContain("modulepreload");
+ expect(index).not.toContain('
-
+
-
-
-
- {{ $t("homepage.hero.eyebrow") }}
-
+
+
+
- {{ $t("homepage.hero.title") }}
+ class="m-0 max-w-[1100px] flex flex-col items-center gap-7 text-balance text-[clamp(44px,6.6vw,92px)] leading-[0.98] font-black tracking-[-0.035em] text-white">
+
+
+ {{ $t("homepage.hero.eyebrow") }}
+
+
+
+ {{
+ $t("homepage.hero.title_accent")
+ }}
+
+
-
+
+ {{ $t("homepage.hero.positioning") }}
+
+
{{ $t("homepage.hero.lead") }}
-
+
+ :class="landingPrimaryButton">
{{ $t("homepage.hero.cta_logged_in") }}
+
+ :class="landingPrimaryButton"
+ @click="open('registration', 'hero')">
{{ $t("homepage.hero.cta") }}
+
-
- {{ $t("homepage.hero.how") }}
- ↓
-
+
+ {{ $t("homepage.hero.example") }}
+
+
+
+ {{ $t("homepage.hero.example_note") }}
+
+
+
+
+
+
+
+
+ {{ $t("homepage.hero.image_title") }}
+
+
+
+
-
- {{ $t("homepage.hero.facts") }}
+
+
+
+
+
+
+
+
+ {{ cell.value }}
+
+
{{ cell.text }}
-
-
-
- {{ $t("homepage.hero.image_caption") }}
-
-
-
-
-
-
- {{ $t("homepage.setup.title") }}
-
-
- {{ $t("homepage.setup.lead") }}
-
+
+
+
+
{{ $t("homepage.most_used.eyebrow") }}
+
{{ $t("homepage.most_used.title") }}
-
-
-
- 0{{ index + 1 }}
+
+
+
+ {{ $t("homepage.most_used.planet_search.label") }}
-
- {{ $t(`homepage.setup.${item.key}.label`) }}
+
+ {{ $t("homepage.most_used.planet_search.title") }}
-
- {{ $t(`homepage.setup.${item.key}.text`) }}
+
+ {{ $t("homepage.most_used.planet_search.lead") }}
-
-
+
+
+
+ {{ $t("homepage.most_used.empire.label") }}
+
+ {{ $t("homepage.most_used.empire.title") }}
+
+
+ {{ $t("homepage.most_used.empire.lead") }}
+
+
+
+
+
+
+
+ {{ $t(`homepage.most_used.${tool.key}.label`) }}
+
+ {{ $t("homepage.most_used.needs_fio") }}
+
+
+
+ {{ $t(`homepage.most_used.${tool.key}.title`) }}
+
+
+ {{ $t(`homepage.most_used.${tool.key}.lead`) }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("homepage.most_used.starter.label") }}
+
+
+ {{ $t("homepage.most_used.starter.title") }}
+
+
+ {{ $t("homepage.most_used.starter.text") }}
+
+
+
+
+ {{ $t("homepage.most_used.shared.label") }}
+
+
+ {{ $t("homepage.most_used.shared.title") }}
+
+
+ {{ $t("homepage.most_used.shared.text") }}
+
+
+ {{ $t("homepage.hero.example") }}
+
+
+
-
-
-
-
- {{ $t("homepage.most_used.title") }}
-
-
- {{ $t("homepage.most_used.lead") }}
+
+
+
+
{{ $t("homepage.setup.eyebrow") }}
+
{{ $t("homepage.setup.title") }}
+
+ {{ $t("homepage.setup.lead") }}
-
+
-
- {{ $t(`homepage.most_used.${tool.key}.label`) }}
+ role="group"
+ :aria-label="$t('homepage.setup.label')"
+ class="flex-[1_1_340px] flex flex-col gap-3">
+
- {{ $t("homepage.most_used.needs_fio") }}
+ class="pt-1 font-mono text-sm font-bold text-prunplanner">
+ 0{{ index + 1 }}
-
-
- {{ $t(`homepage.most_used.${tool.key}.title`) }}
-
-
- {{ $t(`homepage.most_used.${tool.key}.lead`) }}
-
-
-
-
- {{ $t(`homepage.most_used.${tool.key}.point_${point}`) }}
-
-
+
+
+ {{ $t(`homepage.setup.${item.key}.label`) }}
+
+
+ {{ $t(`homepage.setup.${item.key}.text`) }}
+
+
+
+
+
+
-
-
-
-
-
- {{ $t("homepage.tools.title") }}
-
-
+
+