diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index c1aa825..7a98b11 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -12,12 +12,20 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + # The site checks that run after the build compare content dates + # against the commits that last touched that content, and a shallow + # clone has no commits to compare with. + fetch-depth: 0 - uses: actions/setup-node@v4 with: node-version: lts/* cache: npm - run: npm ci - run: npx tsc --noEmit + - run: npm run lint + # postbuild runs scripts/verify-site.mjs, so this step also fails on a + # page missing from the sitemap or a link that points at nothing. - run: npm run build audit: diff --git a/app/(legal)/privacy-policy/page.tsx b/app/(legal)/privacy-policy/page.tsx index 9e4fe0e..9336e02 100644 --- a/app/(legal)/privacy-policy/page.tsx +++ b/app/(legal)/privacy-policy/page.tsx @@ -6,8 +6,11 @@ import { LegalDocumentView } from "@/components/legal/legal-document"; export const metadata: Metadata = { title: "Privacy Policy", description: - "How Koki'o handles information — a privacy-first, data-minimising eSIM app that collects as little about you as possible.", - alternates: { canonical: "/privacy-policy" }, + "How Kokio handles information. A privacy-first, data-minimising eSIM app that collects as little about you as possible.", + alternates: { + canonical: "/privacy-policy", + types: { "text/markdown": "/privacy-policy.md" }, + }, }; export default function PrivacyPolicyPage() { diff --git a/app/(legal)/terms-of-service/page.tsx b/app/(legal)/terms-of-service/page.tsx index c0fee7c..77cfea3 100644 --- a/app/(legal)/terms-of-service/page.tsx +++ b/app/(legal)/terms-of-service/page.tsx @@ -5,8 +5,11 @@ import { LegalDocumentView } from "@/components/legal/legal-document"; export const metadata: Metadata = { title: "Terms of Service", - description: "The terms governing your use of the Koki'o app and services.", - alternates: { canonical: "/terms-of-service" }, + description: "The terms governing your use of the Kokio app and services.", + alternates: { + canonical: "/terms-of-service", + types: { "text/markdown": "/terms-of-service.md" }, + }, }; export default function TermsOfServicePage() { diff --git a/app/blog/[slug]/opengraph-image.tsx b/app/blogs/[slug]/opengraph-image.tsx similarity index 77% rename from app/blog/[slug]/opengraph-image.tsx rename to app/blogs/[slug]/opengraph-image.tsx index 3c40221..c898493 100644 --- a/app/blog/[slug]/opengraph-image.tsx +++ b/app/blogs/[slug]/opengraph-image.tsx @@ -5,7 +5,7 @@ import { renderOgImage, } from "@/lib/og-image"; -export const alt = "Koki'o blog post"; +export const alt = "Kokio blog post"; export const size = OG_IMAGE_SIZE; export const contentType = OG_IMAGE_CONTENT_TYPE; @@ -18,7 +18,7 @@ export default async function Image({ const post = getPost(slug); return renderOgImage({ - eyebrow: post?.tag ?? "Koki'o Blog", - title: post?.title ?? "Koki'o Blog", + eyebrow: post?.tag ?? "Kokio Blog", + title: post?.title ?? "Kokio Blog", }); } diff --git a/app/blog/[slug]/page.tsx b/app/blogs/[slug]/page.tsx similarity index 89% rename from app/blog/[slug]/page.tsx rename to app/blogs/[slug]/page.tsx index 718edb9..a844224 100644 --- a/app/blog/[slug]/page.tsx +++ b/app/blogs/[slug]/page.tsx @@ -17,7 +17,7 @@ import { WaveDivider } from "@/components/blog/wave-divider"; import { ReadingProgressBar } from "@/components/blog/reading-progress-bar"; import { BackToTopButton } from "@/components/blog/back-to-top-button"; import { getMdxComponents } from "@/components/blog/mdx-components"; -import Logo from "@/assets/logo.svg"; +import { ORGANIZATION_ID, WEBSITE_ID } from "@/lib/schema"; type Params = Promise<{ slug: string }>; @@ -34,12 +34,15 @@ export async function generateMetadata({ const post = getPost(slug); if (!post) return {}; - const url = `${siteConfig.url}/blog/${post.slug}`; + const url = `${siteConfig.url}/blogs/${post.slug}`; return { - title: `${post.title} | Kokio`, + title: post.title, description: post.description, - alternates: { canonical: `/blog/${post.slug}` }, + alternates: { + canonical: `/blogs/${post.slug}`, + types: { "text/markdown": `/blogs/${post.slug}.md` }, + }, openGraph: { type: "article", url, @@ -47,6 +50,7 @@ export async function generateMetadata({ title: post.title, description: post.description, publishedTime: post.date.toISOString(), + modifiedTime: post.updated.toISOString(), authors: [post.author.name], }, twitter: { @@ -80,31 +84,28 @@ export default async function BlogPostPage({ params }: { params: Params }) { const headings = frontmatter.headings ?? []; const showToc = headings.length >= 5; - const url = `${siteConfig.url}/blog/${post.slug}`; + const url = `${siteConfig.url}/blogs/${post.slug}`; const heroUrl = `${siteConfig.url}${post.ogImage ?? post.hero}`; - const logoUrl = `${siteConfig.url}${Logo.src}`; const jsonLd = { "@context": "https://schema.org", "@type": "Article", + "@id": `${url}#article`, headline: post.title, description: post.description, image: [heroUrl], datePublished: post.date.toISOString(), - dateModified: post.date.toISOString(), + dateModified: post.updated.toISOString(), + inLanguage: "en", author: { "@type": "Person", name: post.author.name, ...(post.author.twitter ? { sameAs: [post.author.twitter] } : {}), }, - publisher: { - "@type": "Organization", - name: siteConfig.name, - logo: { - "@type": "ImageObject", - url: logoUrl, - }, - }, + // Point at the Organization the layout already emits instead of describing + // it again here, so a post and the site cannot disagree about the publisher. + publisher: { "@id": ORGANIZATION_ID }, + isPartOf: { "@id": WEBSITE_ID }, mainEntityOfPage: { "@type": "WebPage", "@id": url, diff --git a/app/blog/opengraph-image.tsx b/app/blogs/opengraph-image.tsx similarity index 67% rename from app/blog/opengraph-image.tsx rename to app/blogs/opengraph-image.tsx index 23bd358..b7cafd0 100644 --- a/app/blog/opengraph-image.tsx +++ b/app/blogs/opengraph-image.tsx @@ -4,13 +4,13 @@ import { renderOgImage, } from "@/lib/og-image"; -export const alt = "Koki'o Blog"; +export const alt = "Kokio Blog"; export const size = OG_IMAGE_SIZE; export const contentType = OG_IMAGE_CONTENT_TYPE; export default function Image() { return renderOgImage({ - eyebrow: "Koki'o Blog", - title: "Guides, updates, and stories from Koki'o", + eyebrow: "Kokio Blog", + title: "Guides, updates, and stories from Kokio", }); } diff --git a/app/blog/page.tsx b/app/blogs/page.tsx similarity index 88% rename from app/blog/page.tsx rename to app/blogs/page.tsx index 73f552c..7fabfa8 100644 --- a/app/blog/page.tsx +++ b/app/blogs/page.tsx @@ -12,9 +12,10 @@ export const metadata: Metadata = { description: "Everything about eSIMs, travel connectivity, and what we're building behind the scenes.", alternates: { - canonical: "/blog", + canonical: "/blogs", types: { - "application/rss+xml": [{ url: "/blog/rss.xml", title: "Kokio Blog" }], + "application/rss+xml": [{ url: "/blogs/rss.xml", title: "Kokio Blog" }], + "text/markdown": "/blogs.md", }, }, }; @@ -34,10 +35,10 @@ export default function BlogIndexPage() { className="h-20 w-auto md:h-24" />

- KOKI'O Blog + Kokio Blogs

- Guides, updates, and stories from Koki'o + Guides, updates, and stories from Kokio

Everything about eSIMs, travel connectivity, and what we're diff --git a/app/blog/rss.xml/route.ts b/app/blogs/rss.xml/route.ts similarity index 86% rename from app/blog/rss.xml/route.ts rename to app/blogs/rss.xml/route.ts index b5a892d..47dc733 100644 --- a/app/blog/rss.xml/route.ts +++ b/app/blogs/rss.xml/route.ts @@ -16,7 +16,7 @@ export function GET() { const items = posts .map((post) => { - const url = `${siteConfig.url}/blog/${post.slug}`; + const url = `${siteConfig.url}/blogs/${post.slug}`; return ` ${escapeXml(post.title)} @@ -32,8 +32,8 @@ export function GET() { Kokio Blog - ${siteConfig.url}/blog - + ${siteConfig.url}/blogs + ${escapeXml(siteConfig.description)} en-us ${lastBuildDate}${items} diff --git a/app/glossary/page.tsx b/app/glossary/page.tsx new file mode 100644 index 0000000..9569682 --- /dev/null +++ b/app/glossary/page.tsx @@ -0,0 +1,66 @@ +import type { Metadata } from "next"; + +import { glossaryData } from "@/content/glossary"; +import { siteConfig } from "@/config/site"; +import { glossaryGraph } from "@/lib/schema"; +import { PRODUCT_NAME } from "@/lib/site-copy"; +import { JsonLd } from "@/components/json-ld"; + +const DESCRIPTION = `Plain definitions of the terms ${PRODUCT_NAME} uses: eSIM, eUICC, SM-DP+, LPA, passkey, smart wallet, no-KYC and more.`; + +export const metadata: Metadata = { + title: "Glossary", + description: DESCRIPTION, + alternates: { + canonical: "/glossary", + types: { "text/markdown": "/glossary.md" }, + }, + openGraph: { + type: "article", + url: `${siteConfig.url}/glossary`, + siteName: PRODUCT_NAME, + title: `${PRODUCT_NAME} glossary`, + description: DESCRIPTION, + }, +}; + +export default function GlossaryPage() { + return ( +

+ + +
+
+

+ Glossary +

+

{DESCRIPTION}

+
+ + {/* Each id is the target of the matching DefinedTerm @id, so a citation + can link to the one definition it used. */} +
+ {glossaryData.map((entry) => ( +
+
+ {entry.term} + {entry.aliases ? ( + + also {entry.aliases.join(", ")} + + ) : null} +
+
+ {entry.definition} +
+
+ ))} +
+
+
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx index c9f8119..effc39c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,7 +3,10 @@ import type { Metadata } from "next"; import { Anybody, Lexend } from "next/font/google"; import { cn } from "@/lib/utils"; -import { siteConfig } from "@/config/site"; +import { siteConfig, TWITTER_HANDLE } from "@/config/site"; +import { META_DESCRIPTION, PRODUCT_NAME } from "@/lib/site-copy"; +import { siteGraph } from "@/lib/schema"; +import { JsonLd } from "@/components/json-ld"; import { NavBar } from "@/components/global/nav-bar"; import { Footer } from "@/components/global/footer"; import { Toaster } from "@/components/ui/toaster"; @@ -24,8 +27,29 @@ const lexend = Lexend({ export const metadata: Metadata = { metadataBase: new URL(siteConfig.url), - title: "Kokio", - description: "Blockchain powered eSIM", + title: { + default: PRODUCT_NAME, + template: `%s | ${PRODUCT_NAME}`, + }, + description: META_DESCRIPTION, + applicationName: PRODUCT_NAME, + alternates: { + canonical: "/", + types: { "text/markdown": "/index.md" }, + }, + openGraph: { + type: "website", + url: siteConfig.url, + siteName: PRODUCT_NAME, + title: PRODUCT_NAME, + description: META_DESCRIPTION, + }, + twitter: { + card: "summary_large_image", + site: TWITTER_HANDLE, + title: PRODUCT_NAME, + description: META_DESCRIPTION, + }, }; export default function RootLayout({ @@ -44,6 +68,7 @@ export default function RootLayout({ "flex min-h-screen flex-col bg-beach-sky font-sans antialiased" )} > +
{children}
diff --git a/app/llms-full.txt/route.ts b/app/llms-full.txt/route.ts new file mode 100644 index 0000000..f8a07ff --- /dev/null +++ b/app/llms-full.txt/route.ts @@ -0,0 +1,40 @@ +import sitemap from "@/app/sitemap"; +import { siteConfig } from "@/config/site"; +import { PRODUCT_NAME } from "@/lib/site-copy"; +import { pageSections } from "@/lib/site-text"; + +export const dynamic = "force-static"; + +/** + * Every page of the site as one file, so an agent gets a complete picture from + * a single request instead of giving up part way through crawling eight routes. + * + * Sections are driven by sitemap.xml rather than listed by hand: a page that + * reaches the sitemap without text here fails the build instead of quietly + * going missing. + */ +export function GET() { + const sections = pageSections(); + + const body = sitemap().map(({ url }) => { + const { pathname } = new URL(url); + const render = sections[pathname]; + if (!render) { + throw new Error( + `llms-full.txt has no section for ${pathname}. Add one in lib/site-text.ts or drop the page from the sitemap.` + ); + } + return `# ${pathname}\n\n${render()}`; + }); + + const header = [ + `# ${PRODUCT_NAME}: full site text`, + `> Every page of ${siteConfig.url} as one markdown file. Each section below is headed by the path it was rendered from. Any single page is also available on its own by adding .md to its URL.`, + ].join("\n\n"); + + return new Response(`${header}\n\n---\n\n${body.join("\n\n---\n\n")}\n`, { + headers: { + "Content-Type": "text/plain; charset=utf-8", + }, + }); +} diff --git a/app/llms.txt/route.ts b/app/llms.txt/route.ts new file mode 100644 index 0000000..1992a6d --- /dev/null +++ b/app/llms.txt/route.ts @@ -0,0 +1,70 @@ +import { siteConfig, TWITTER_URL } from "@/config/site"; +import { legalConfig } from "@/config/legal"; +import { getAllPosts } from "@/lib/blog"; +import { + CANONICAL_DESCRIPTION, + PRODUCT_NAME, + PRODUCT_NAME_SPELLINGS, +} from "@/lib/site-copy"; + +export const dynamic = "force-static"; + +const DOCS_URL = "https://docs.kokio.app"; +const CONTRACTS_URL = + "https://github.com/Blockchain-Powered-eSIM/smart-contract-suite"; + +function absolute(path: string): string { + return new URL(path, siteConfig.url).href; +} + +function link(label: string, path: string, summary: string): string { + return `- [${label}](${absolute(path)}): ${summary}`; +} + +/** + * Plain-text summary of the site for agents, following the llms.txt convention: + * one H1, one blockquote, then H2 sections of annotated links. + * + * Built from the same modules the pages render from, so it cannot fall behind + * them. Only list URLs that exist; a 404 here costs more than a missing entry. + */ +export function GET() { + const posts = getAllPosts(); + + const body = `# ${PRODUCT_NAME} + +> ${CANONICAL_DESCRIPTION} + +${PRODUCT_NAME} is also written ${PRODUCT_NAME_SPELLINGS.join(", ")}. Every one of those spellings refers to this product, this website and this app. ${PRODUCT_NAME} is built by ${legalConfig.companyLegalName} (Singapore, UEN ${legalConfig.uen}). Technical documentation and the smart contract reference are at ${DOCS_URL}. + +## Product +${link("Home", "/", "what Kokio is, how setup works, features, and the FAQ")} +${link("Manifesto", "/manifesto", "why Kokio treats connectivity as a right and privacy as its guardian")} +${link("Glossary", "/glossary", "eSIM, eUICC, SM-DP+, LPA, passkey, smart wallet and no-KYC defined")} + +## Guides +${posts.map((post) => link(post.title, `/blogs/${post.slug}`, post.description)).join("\n")} + +## Reference +- [Documentation](${DOCS_URL}): architecture, mobile app flow, smart contract suite +- [Smart contracts](${CONTRACTS_URL}): open source wallet contract suite +- [X](${TWITTER_URL}): announcements +${link("Full text", "/llms-full.txt", "every page of this site as one markdown file")} + +Any page on this site is also readable as markdown by adding .md to its URL, for example ${absolute("/blogs/where-your-sim-data-goes.md")}. The home page is ${absolute("/index.md")}. + +## Legal +${link("Terms of Service", "/terms-of-service", "terms governing use of the app")} +${link("Privacy Policy", "/privacy-policy", "what Kokio does and does not collect")} + +## Optional +${link("Blog index", "/blogs", "all posts")} +${link("RSS", "/blogs/rss.xml", "post feed")} +`; + + return new Response(body, { + headers: { + "Content-Type": "text/plain; charset=utf-8", + }, + }); +} diff --git a/app/manifesto/opengraph-image.tsx b/app/manifesto/opengraph-image.tsx index 9dd5e8e..18198d8 100644 --- a/app/manifesto/opengraph-image.tsx +++ b/app/manifesto/opengraph-image.tsx @@ -9,7 +9,7 @@ import { OG_IMAGE_SIZE, } from "@/lib/og-image"; -export const alt = "Koki'o Manifesto"; +export const alt = "Kokio Manifesto"; export const size = OG_IMAGE_SIZE; export const contentType = OG_IMAGE_CONTENT_TYPE; diff --git a/app/manifesto/page.tsx b/app/manifesto/page.tsx index ec2c15d..92e9302 100644 --- a/app/manifesto/page.tsx +++ b/app/manifesto/page.tsx @@ -14,9 +14,12 @@ export function generateMetadata(): Metadata { const manifesto = getManifesto(); return { - title: `${manifesto.title} | Kokio`, + title: manifesto.title, description: manifesto.description, - alternates: { canonical: "/manifesto" }, + alternates: { + canonical: "/manifesto", + types: { "text/markdown": "/manifesto.md" }, + }, openGraph: { type: "article", url: `${siteConfig.url}/manifesto`, diff --git a/app/moonpay-return/page.tsx b/app/moonpay-return/page.tsx index 5b6a6ff..ec35cdb 100644 --- a/app/moonpay-return/page.tsx +++ b/app/moonpay-return/page.tsx @@ -1,10 +1,19 @@ +import type { Metadata } from "next"; + +// Reached only after a payment. It says nothing useful on its own, and a search +// result pointing here would look like a transaction that never happened. +export const metadata: Metadata = { + title: "Payment complete", + robots: { index: false, follow: false }, +}; + export default function MoonpayReturnPage() { return (

Your payment was successful. You can close this window and return to - the KOKI'O app. Kindly wait for your order to process. + the Kokio app. Kindly wait for your order to process.

diff --git a/app/page.tsx b/app/page.tsx index 46588ac..4400e50 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -4,12 +4,15 @@ import { Features } from "@/components/features"; import { Hero } from "@/components/hero"; import { RoadMap } from "@/components/road-map"; import { Setup } from "@/components/setup"; +import { JsonLd } from "@/components/json-ld"; +import { homeGraph } from "@/lib/schema"; import PyramidTransition from "@/assets/transition2.svg"; export default function Home() { return (
+
diff --git a/app/raw/[...path]/route.ts b/app/raw/[...path]/route.ts new file mode 100644 index 0000000..9ab94fe --- /dev/null +++ b/app/raw/[...path]/route.ts @@ -0,0 +1,48 @@ +import { siteConfig } from "@/config/site"; +import { pageSections } from "@/lib/site-text"; + +export const dynamic = "force-static"; +export const dynamicParams = false; + +/** `/index.md` is the home page, since `/.md` is not a URL anyone can request. */ +const HOME_SEGMENT = "index"; + +function toPathname(segments: string[]): string { + const joined = segments.join("/"); + return joined === HOME_SEGMENT ? "/" : `/${joined}`; +} + +export function generateStaticParams() { + return Object.keys(pageSections()).map((pathname) => ({ + path: pathname === "/" ? [HOME_SEGMENT] : pathname.slice(1).split("/"), + })); +} + +/** + * Serves one page as markdown. Reached through the `/:path*.md` rewrite in + * next.config.ts, so `/blog/.md` lands here rather than at a real file. + * + * Roughly 4 KB of prose against 90 KB of markup and Tailwind classes for the + * same page. An agent working to a context budget gets more of the site. + */ +export async function GET( + _request: Request, + { params }: { params: Promise<{ path: string[] }> } +) { + const { path } = await params; + const pathname = toPathname(path); + const render = pageSections()[pathname]; + + if (!render) { + return new Response("Not found\n", { status: 404 }); + } + + const source = new URL(pathname, siteConfig.url).href; + const body = `> Markdown rendering of ${source}\n\n${render()}\n`; + + return new Response(body, { + headers: { + "Content-Type": "text/markdown; charset=utf-8", + }, + }); +} diff --git a/app/robots.ts b/app/robots.ts index 5393e47..c4b9bcc 100644 --- a/app/robots.ts +++ b/app/robots.ts @@ -2,12 +2,88 @@ import type { MetadataRoute } from "next"; import { siteConfig } from "@/config/site"; +/** + * AI crawlers named individually. + * + * The wildcard group below already allows every crawler, named or not, so this + * list grants no access that would otherwise be missing. It exists so the + * stance is auditable, and so blocking any single agent later is a one-line + * edit rather than a research task. + * + * One rule for adding an entry: the operator publishes a crawler page naming + * the token. That keeps the list checkable and finite. + * + * It is not meant to be exhaustive and cannot be. Most labs publish nothing: + * xAI documents no user agent and Grok's fetches arrive as ordinary browser + * traffic from rotating addresses, DeepSeek crawls with no user agent at all, + * and the tokens circulating for Qwen, GLM and Bytespider come from + * third-party trackers rather than their operators. Chasing those names would + * mean maintaining a list that is unverifiable, permanently incomplete, and + * worth nothing while the answer is yes to everyone. The wildcard covers them. + */ +const AI_CRAWLERS = [ + // OpenAI, developers.openai.com/api/docs/bots + "GPTBot", + "OAI-SearchBot", + "ChatGPT-User", + "OAI-AdsBot", + + // Anthropic, support.claude.com/en/articles/8896518 + "ClaudeBot", + "Claude-SearchBot", + "Claude-User", + + // Perplexity, docs.perplexity.ai/docs/resources/perplexity-crawlers + // Perplexity-User is listed for completeness. Perplexity says it generally + // ignores robots.txt for that agent because the fetch is user-initiated. + "PerplexityBot", + "Perplexity-User", + + // Google and Apple. These two are control tokens for AI training rather than + // crawlers, and they have no user agent of their own. Allowing them is a + // no-op because permitted is already the default; the lines record the + // opt-in decision where someone auditing the file will look for it. + "Google-Extended", + "Applebot-Extended", + + // Meta, Amazon, Mistral. + "meta-externalagent", + "Meta-ExternalFetcher", + "Amazonbot", + "MistralAI-User", + + // Allen Institute, allenai.org/crawler. Feeds the Dolma corpus and the OLMo + // open models. + "AI2Bot", + + // Common Crawl. The most consequential entry in this file for open-weight + // models: most open training sets, C4, RefinedWeb, FineWeb and Dolma among + // them, are built from Common Crawl rather than from their own crawls. + // Allowing it is close to irreversible, because a page that lands in the + // archive is copied into derived datasets that have no recall mechanism. + "CCBot", +]; + +/** Payment callback. Meaningless without the transaction that led to it. */ +const DISALLOWED = ["/moonpay-return"]; + export default function robots(): MetadataRoute.Robots { - return { - rules: { - userAgent: "*", + // The wildcard group is what actually grants access, including to every + // crawler not named above. The named groups only make the stance explicit. + // + // Groups in robots.txt are independent: a crawler that matches a named group + // reads only that group and never sees the wildcard rules. So the disallow + // has to be repeated per agent rather than stated once. + const rules = [{ userAgent: "*", allow: "/", disallow: DISALLOWED }].concat( + AI_CRAWLERS.map((userAgent) => ({ + userAgent, allow: "/", - }, + disallow: DISALLOWED, + })) + ); + + return { + rules, sitemap: new URL("/sitemap.xml", siteConfig.url).href, host: siteConfig.url, }; diff --git a/app/sitemap.ts b/app/sitemap.ts index 95bd1d5..4de337d 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,46 +1,72 @@ import type { MetadataRoute } from "next"; import { siteConfig } from "@/config/site"; +import { legalConfig } from "@/config/legal"; +import { GLOSSARY_UPDATED } from "@/content/glossary"; import { getAllPosts } from "@/lib/blog"; +import { getManifesto } from "@/lib/manifesto"; +import { HOME_UPDATED } from "@/lib/site-copy"; +/** + * Every lastModified here comes from the content itself, never from build time. + * + * A date that moves on every deploy tells a crawler the whole site changed when + * nothing did, and once it learns the signal is noise it stops re-fetching on it. + */ export default function sitemap(): MetadataRoute.Sitemap { - const lastModified = new Date(); const posts = getAllPosts(); + const manifesto = getManifesto(); + const legalUpdated = new Date(legalConfig.lastUpdatedIso); + + // The blog index changes when its newest post does. + const blogUpdated = posts.length + ? new Date(Math.max(...posts.map((post) => post.updated.getTime()))) + : HOME_UPDATED; return [ { url: new URL("/", siteConfig.url).href, - lastModified, + lastModified: HOME_UPDATED, changeFrequency: "monthly", priority: 1, }, { - url: new URL("/blog", siteConfig.url).href, - lastModified, - changeFrequency: "daily", + // Posts are aimed at roughly every two weeks. The sitemap spec has no + // value for that, and weekly is the closer of the two neighbours, so a + // crawler checks a little too often rather than missing a post. Revisit + // once the real cadence is known: monthly if it slips, daily never. + url: new URL("/blogs", siteConfig.url).href, + lastModified: blogUpdated, + changeFrequency: "weekly", priority: 0.8, }, { url: new URL("/manifesto", siteConfig.url).href, - lastModified, + lastModified: manifesto.updated, + changeFrequency: "yearly", + priority: 0.7, + }, + { + url: new URL("/glossary", siteConfig.url).href, + lastModified: GLOSSARY_UPDATED, changeFrequency: "yearly", priority: 0.7, }, ...posts.map((post) => ({ - url: new URL(`/blog/${post.slug}`, siteConfig.url).href, - lastModified: post.date, + url: new URL(`/blogs/${post.slug}`, siteConfig.url).href, + lastModified: post.updated, changeFrequency: "monthly" as const, priority: 0.6, })), { url: new URL("/terms-of-service", siteConfig.url).href, - lastModified, + lastModified: legalUpdated, changeFrequency: "yearly", priority: 0.5, }, { url: new URL("/privacy-policy", siteConfig.url).href, - lastModified, + lastModified: legalUpdated, changeFrequency: "yearly", priority: 0.5, }, diff --git a/components/blog/cta-content.ts b/components/blog/cta-content.ts index 190bbe4..7f7a5b8 100644 --- a/components/blog/cta-content.ts +++ b/components/blog/cta-content.ts @@ -3,18 +3,18 @@ import { APP_URL, TWITTER_URL } from "@/config/site"; export const CTA_CONTENT = { prelaunch: { heading: "Follow along on X", - body: "Stay updated about Koki'o and learn about digital well-being and wellness :)", + body: "Stay updated about Kokio and learn about digital well-being and wellness :)", ctaLabel: "Follow on X", href: TWITTER_URL, inlineText: "Want to learn more?", inlineLabel: "Follow us on X", }, live: { - heading: "Try Koki'o", + heading: "Try Kokio", body: "Tap. Authenticate. Connect.", ctaLabel: "Get the app", href: APP_URL, inlineText: "Ready to travel smarter?", - inlineLabel: "Try Koki'o", + inlineLabel: "Try Kokio", }, } as const; diff --git a/components/blog/post-card.tsx b/components/blog/post-card.tsx index 943c6d6..cbd8dbd 100644 --- a/components/blog/post-card.tsx +++ b/components/blog/post-card.tsx @@ -7,7 +7,7 @@ import { formatPostDate } from "@/lib/utils"; export function PostCard({ post }: { post: Post }) { return (
diff --git a/components/faqs.tsx b/components/faqs.tsx index 3693b2c..c5598dd 100644 --- a/components/faqs.tsx +++ b/components/faqs.tsx @@ -1,59 +1,30 @@ import React from "react"; -const faqsData = [ - { - question: - "What is Kokio and how does it differ from traditional eSIM providers?", - answer: - "Kokio utilizes distributed ledger technology to offer decentralized mobile connectivity, enhancing privacy, security, and ease of use compared to traditional eSIM providers.", - }, - { - question: "How do I sign up for Kokio’s beta program?", - answer: - "To join Kokio’s beta program, click on the “Sign up for beta” button on the landing page and follow the instructions provided.", - }, - { - question: "How can I set up my Kokio eSIM?", - answer: - "Download the Kokio app, choose your desired plan from over 200 destinations, and activate your eSIM instantly to start enjoying mobile connectivity.", - }, - { - question: "What payment options does Kokio accept?", - answer: - "Kokio supports a variety of payment methods including credit and debit cards, Apple Pay, Google Pay, PayPal and other digital wallets.", - }, - /*{ - question: "How does the eSIM wallet feature work?", - answer: - "When you activate your Kokio eSIM, an optional secure digital wallet is also created. This wallet can function as both a crypto wallet and a digital ID.", - },*/ - { - question: "What security measures does Kokio employ for its services?", - answer: - "Kokio’s app ensures top-notch security and privacy using biometrics, secure enclave technology, and the inherent security features of distributed ledger technology.", - }, - { - question: "Is personal information required to use Kokio’s services?", - answer: - "No, Kokio values privacy and does not require personal information for setting up or using its services, ensuring your data remains private.", - }, - { - question: "What is the roadmap for Kokio’s service launch?", - answer: - "Get your eSIM, KOKI'O alpha is now live in over 200 countries. Key milestones include the Dev and Ops work for beta features in Q1 2026 followed by a beta launch in Q2 alongside ongoing bug fixes and continuous improvements based on user feedback.", - }, -]; +import { faqsData } from "@/content/faqs"; const Faqs = () => { return ( -
+
-

+

Frequently Asked Questions

+ {/* Each id is the target of the matching Question @id in the page + schema, so a citation can link to the single answer it used. */}
{faqsData.map((faq) => ( -
+
{faq.question}
diff --git a/components/features.tsx b/components/features.tsx index b2f6497..20f07f9 100644 --- a/components/features.tsx +++ b/components/features.tsx @@ -6,7 +6,7 @@ import Meditate from "@/assets/icons/meditate.svg"; import Shield from "@/assets/icons/shield.svg"; import ThumbsUp from "@/assets/icons/thumbs-up.svg"; -const featuresData = [ +export const featuresData = [ { icon: Relax, title: "Simple", @@ -41,7 +41,12 @@ const featuresData = [ const Features = () => { return ( -
+ // No heading of its own, so the anchor needs an accessible name spelled out. +
{featuresData.map((feature) => (
{ return (

- KOKI'O is launching soon!{" "} - Follow us on{" "} + {LAUNCH_NOTICE} Follow us on{" "} Twitter {" "} - for release updates. + for updates.

diff --git a/components/global/footer.tsx b/components/global/footer.tsx index 007e641..0d8ef37 100644 --- a/components/global/footer.tsx +++ b/components/global/footer.tsx @@ -10,13 +10,15 @@ import Blog from "@/assets/icons/blog.svg"; import Twitter from "@/assets/icons/twitter.svg"; import GitHub from "@/assets/icons/github.svg"; import Docs from "@/assets/icons/docs.svg"; +import { TWITTER_URL } from "@/config/site"; +import { LAUNCH_NOTICE } from "@/lib/site-copy"; import SignUpButton from "@/components/sign-up-button"; export const socialLinks = [ { icon: Twitter, title: "Twitter", - href: "https://x.com/kokiodotapp", + href: TWITTER_URL, }, { icon: GitHub, @@ -31,12 +33,13 @@ export const socialLinks = [ { icon: Blog, title: "Blogs", - href: "/blog", + href: "/blogs", }, ]; -const legalLinks = [ +const siteLinks = [ { title: "Manifesto", href: "/manifesto" }, + { title: "Glossary", href: "/glossary" }, { title: "Privacy Policy", href: "/privacy-policy" }, { title: "Terms of Service", href: "/terms-of-service" }, ]; @@ -74,23 +77,23 @@ const Footer = () => {

- KOKI'O launches soon —{" "} + {LAUNCH_NOTICE} Follow us on{" "} - Follow us on X + X {" "} for updates.