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{" "}
{" "}
- for release updates.
+ for updates.