feat(www): landing page (#25)

* design: landing page spec + app icon asset

* chore(www): add anime.js, fonts, and landing page screenshot assets

* feat(www): add design tokens, fonts, shared layout

* feat(www): add landing page sections and styling

* feat(www): add theme toggle and anime.js viewport animations

* feat(www): feedback updates — no hero card, os-aware download, footer email, lowercase colored heading

* feat(www): remove screenshot borders, move macos hint below ctas, mobile github fallback

* feat(www): keep hero CTAs side by side

* feat(www): set initial nav border to transparent

* feat(www): add Rybbit prod-only, sitemap, SEO head, OG image, robots.txt, 404 page

* docs(AGENTS): include www landing page version in release sync checklist

* chore(www): add railpack.json for Dokploy deploy (force node provider, TZ)

* chore(www): use bun for Railpack deploy (packageManager + standalone www/bun.lock)

* docs(AGENTS): note www/bun.lock sync for Railpack deploys

* chore(www): add vitest test tooling

* feat(www): add FAQ data source

* feat(www): add SEO JSON-LD builders

* feat(www): add Seo JSON-LD component

* feat(www): wire Seo component and social/meta tags into Layout

* feat(www): add homepage JSON-LD, keyword H1, data-driven FAQ

* fix(www): point Roadmap footer link to GitHub

* feat(www): add lastmod to sitemap

* perf(www): self-host anime.js

* refactor(www): move nav and footer into shared Layout

* feat(www): add changelog page

* fix(www): make shared component styles global in Layout

* perf(www): optimize screenshots with Astro Image (webp/avif + srcset)

* feat(www): add web app manifest

* feat(www): add helpful links to 404 page

* fix(www): shorten homepage H1 copy

* fix(www): OS-aware navbar download + footer layout

* feat(www): build full changelog from v0.7.0 to v0.7.10
This commit is contained in:
2026-08-17 09:16:58 +08:00
committed by GitHub
parent 62eed15844
commit 501fed962c
33 changed files with 2361 additions and 16 deletions
+73
View File
@@ -0,0 +1,73 @@
import { describe, it, expect } from "vitest";
import {
buildSoftwareApplicationJsonLd,
buildFaqJsonLd,
buildOrganizationJsonLd,
buildWebSiteJsonLd,
buildBreadcrumbJsonLd,
buildTitle,
} from "./seo";
describe("seo builders", () => {
it("buildSoftwareApplicationJsonLd returns valid schema", () => {
const ld = buildSoftwareApplicationJsonLd({
name: "Gridline",
url: "https://getgridline.app/",
description: "Open-source database GUI",
image: "https://getgridline.app/og.png",
operatingSystem: "macOS, Windows, Linux",
applicationCategory: "DeveloperApplication",
offersPrice: 0,
offersCurrency: "USD",
downloadUrl: "https://github.com/AdrianBonpin/gridline/releases",
});
expect(ld["@type"]).toBe("SoftwareApplication");
expect(ld.name).toBe("Gridline");
expect((ld.offers as { price: number }).price).toBe(0);
});
it("buildFaqJsonLd maps questions to mainEntity", () => {
const ld = buildFaqJsonLd([
{ question: "Q1", answer: "A1" },
{ question: "Q2", answer: "A2" },
]);
expect(ld["@type"]).toBe("FAQPage");
const main = ld.mainEntity as { name: string }[];
expect(main).toHaveLength(2);
expect(main[0].name).toBe("Q1");
});
it("buildOrganizationJsonLd includes sameAs", () => {
const ld = buildOrganizationJsonLd({
name: "Gridline",
url: "https://getgridline.app/",
logo: "https://getgridline.app/gridline-icon.svg",
sameAs: ["https://github.com/AdrianBonpin/gridline"],
});
expect(ld["@type"]).toBe("Organization");
expect(ld.sameAs).toContain("https://github.com/AdrianBonpin/gridline");
});
it("buildWebSiteJsonLd includes SearchAction", () => {
const ld = buildWebSiteJsonLd({
name: "Gridline",
url: "https://getgridline.app/",
searchUrl: "https://getgridline.app/?q={search_term_string}",
});
expect(ld["@type"]).toBe("WebSite");
expect(ld.potentialAction).toBeDefined();
});
it("buildBreadcrumbJsonLd builds itemListElement", () => {
const ld = buildBreadcrumbJsonLd([
{ name: "Home", url: "https://getgridline.app/" },
{ name: "Changelog", url: "https://getgridline.app/changelog" },
]);
expect(ld["@type"]).toBe("BreadcrumbList");
expect((ld.itemListElement as unknown[]).length).toBe(2);
});
it("buildTitle appends the brand", () => {
expect(buildTitle("PostgreSQL GUI")).toBe("PostgreSQL GUI | Gridline");
});
});
+110
View File
@@ -0,0 +1,110 @@
export interface JsonLd {
"@context": "https://schema.org";
"@type": string;
[key: string]: unknown;
}
export function buildSoftwareApplicationJsonLd(opts: {
name: string;
url: string;
description: string;
image: string;
operatingSystem: string;
applicationCategory: string;
offersPrice: number;
offersCurrency: string;
downloadUrl: string;
}): JsonLd {
return {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: opts.name,
url: opts.url,
description: opts.description,
image: opts.image,
operatingSystem: opts.operatingSystem,
applicationCategory: opts.applicationCategory,
offers: {
"@type": "Offer",
price: opts.offersPrice,
priceCurrency: opts.offersCurrency,
},
downloadUrl: opts.downloadUrl,
};
}
export function buildFaqJsonLd(
questions: { question: string; answer: string }[],
): JsonLd {
return {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: questions.map((q) => ({
"@type": "Question",
name: q.question,
acceptedAnswer: {
"@type": "Answer",
text: q.answer,
},
})),
};
}
export function buildOrganizationJsonLd(opts: {
name: string;
url: string;
logo: string;
sameAs: string[];
}): JsonLd {
return {
"@context": "https://schema.org",
"@type": "Organization",
name: opts.name,
url: opts.url,
logo: opts.logo,
sameAs: opts.sameAs,
};
}
export function buildWebSiteJsonLd(opts: {
name: string;
url: string;
searchUrl?: string;
}): JsonLd {
const ld: JsonLd = {
"@context": "https://schema.org",
"@type": "WebSite",
name: opts.name,
url: opts.url,
};
if (opts.searchUrl) {
ld.potentialAction = {
"@type": "SearchAction",
target: {
"@type": "EntryPoint",
urlTemplate: opts.searchUrl,
},
"query-input": "required name=search_term_string",
};
}
return ld;
}
export function buildBreadcrumbJsonLd(
items: { name: string; url: string }[],
): JsonLd {
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: items.map((item, i) => ({
"@type": "ListItem",
position: i + 1,
name: item.name,
item: item.url,
})),
};
}
export function buildTitle(primary: string, brand = "Gridline"): string {
return `${primary} | ${brand}`;
}