From 82fab0ff2ade505bde53a94880ed3db84bdc2f62 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 04:43:07 +0000 Subject: [PATCH] Add Privacy and Terms pages on xo.run The public site footer About group only linked to the company site. Add short compiler/OSS Privacy and Terms pages, link them from the footer, keep project mail on @modoterra.xyz, and hide the Discord placeholder until there is a real invite. Fixes #16 Co-authored-by: Christoffer Hallas --- www/SITE.md | 34 ++++++-- www/scripts/verify-docs-pages.mjs | 60 +++++++++++++ www/scripts/verify-prose.mjs | 11 ++- www/src/docs/site.ts | 134 +++++++++++++++++++++++++++++- www/src/docs/static-html.ts | 41 ++++++++- www/src/legal.tsx | 74 +++++++++++++++++ www/src/router.tsx | 21 ++++- 7 files changed, 358 insertions(+), 17 deletions(-) create mode 100644 www/src/legal.tsx diff --git a/www/SITE.md b/www/SITE.md index 6960bc8e..cc7f1312 100644 --- a/www/SITE.md +++ b/www/SITE.md @@ -162,9 +162,9 @@ The home page is a language-docs front door. Copy and links live in 3. First-class links: Documents (`/docs`), Packages (`/docs/std`), Spec (`/e26`) 4. Footer -Each of those links, plus Install, First program, Book, and Try, is a real -page (`path/index.html`) with that page title and body. A destination -without a document is removed from the catalog or footer. +Each of those links, plus Install, First program, Book, Try, Privacy, and +Terms, is a real page (`path/index.html`) with that page title and body. A +destination without a document is removed from the catalog or footer. Homepage trust stays factual. Rust, LLVM, the public edition, and the machine-checked suite are implementation facts. @@ -191,10 +191,11 @@ Cross-links: Reference ↔ Spec ↔ suite pages keep the triangle explicit. ## Static pages `npm run build` writes `index.html` for every content route: the homepage, -`/install`, `/try`, and each page in `docsPages` (Documents, Packages, Spec, -Book, First program, and the rest of the Reference / std / suite pages). -Each file keeps the SPA shell and a noscript body from the same modules the -React app renders. Unknown paths still use the `404.html` bounce. +`/install`, `/try`, `/privacy`, `/terms`, and each page in `docsPages` +(Documents, Packages, Spec, Book, First program, and the rest of the +Reference / std / suite pages). Each file keeps the SPA shell and a noscript +body from the same modules the React app renders. Unknown paths still use the +`404.html` bounce. Wasm bindings stay in `www/public/echo-wasm/`. @@ -206,6 +207,22 @@ playground run then executes the checked MIR and captures `io.print`. Filesystem, net, process, and tasks fail with a playground-host error. Compile and native run stay on `xo` (LLVM). +## Footer + +Learn, Community, and About. Copy lives in `src/docs/site.ts` (`footerLinkGroups`). + +| Group | Links | +| --------- | --------------------------------------------------------------------------- | +| Learn | Install, Try Echo, First program, Documents, Book, Echo 2026 | +| Community | GitHub. Omit Discord until a public invite URL exists. | +| About | Modoterra (`https://modoterra.xyz`), Privacy (`/privacy`), Terms (`/terms`) | + +Public project mail on the site is `@modoterra.xyz` only (`hello@`, `security@`, +`oss@`). Do not publish `@modoterra.com`. + +Privacy and Terms are short compiler/OSS pages for this documentation site. +They are not a consumer-app policy. + ## Discovery `/sitemap.xml` lists the public catalog on `https://xo.run`: home, Install, @@ -216,4 +233,5 @@ Pages host. ## Out of scope (later) -Richer download tabs, `/e26` URL rename to `/echo-2026`. +Richer download tabs, `/e26` URL rename to `/echo-2026`. Discord footer link +when a real invite exists. diff --git a/www/scripts/verify-docs-pages.mjs b/www/scripts/verify-docs-pages.mjs index d62e77ca..2d07395d 100644 --- a/www/scripts/verify-docs-pages.mjs +++ b/www/scripts/verify-docs-pages.mjs @@ -2,6 +2,8 @@ * Verifies the shipped docs-first site model: * - homepage definition, leader-bearing sample, docs/std/spec links * - primary nav labels and paths + * - footer About links Privacy and Terms; Discord stays hidden + * - legal pages use @modoterra.xyz mail only * - Documents hub catalog groups * - every language-feature catalog entry is a real page with a summary * and at least one Echo code block @@ -32,13 +34,18 @@ try { const { docsHubCatalog, + footerLinkGroups, homePage, installCta, languageFeatureEntries, + legalPages, primaryNav, primaryNavItemIsActive, + privacyPage, publicChromePaths, + publicMailAddresses, renderStaticHomeAndHub, + termsPage, } = site; const { docsPageByPath } = content; const staticHtml = await server.ssrLoadModule("/src/docs/static-html.ts"); @@ -80,6 +87,54 @@ try { fail("installCta must be Install → /install"); } + const footerLinks = footerLinkGroups.flatMap((group) => group.links); + const footerByLabel = new Map(footerLinks.map((link) => [link.label, link.href])); + if (footerByLabel.get("Privacy") !== "/privacy") { + fail(`footer Privacy should be /privacy, got ${footerByLabel.get("Privacy")}`); + } + if (footerByLabel.get("Terms") !== "/terms") { + fail(`footer Terms should be /terms, got ${footerByLabel.get("Terms")}`); + } + if (footerByLabel.get("Modoterra") !== "https://modoterra.xyz") { + fail("footer About must keep the Modoterra company link"); + } + if (footerByLabel.has("Discord") || footerLinks.some((link) => /discord/i.test(link.label))) { + fail("footer must hide Discord until there is a public invite"); + } + + const expectedMail = ["hello@modoterra.xyz", "security@modoterra.xyz", "oss@modoterra.xyz"]; + if (JSON.stringify([...publicMailAddresses]) !== JSON.stringify(expectedMail)) { + fail(`publicMailAddresses must be ${expectedMail.join(", ")}`); + } + + if (privacyPage.path !== "/privacy" || termsPage.path !== "/terms") { + fail("legal pages must live at /privacy and /terms"); + } + if (legalPages.length !== 2) { + fail("legalPages should be Privacy and Terms only"); + } + + const legalText = legalPages + .flatMap((page) => [page.summary, ...page.sections.flatMap((section) => section.paragraphs)]) + .join("\n"); + for (const address of expectedMail) { + if (!legalText.includes(address)) { + fail(`legal pages missing ${address}`); + } + } + if (/@modoterra\.com\b/.test(legalText)) { + fail("legal pages must not publish @modoterra.com"); + } + if (legalText.includes("github.io")) { + fail("legal pages must not list github.io"); + } + const publishedMail = legalText.match(/[a-z0-9._%+-]+@[a-z0-9-]+(?:\.[a-z0-9-]+)+/gi) ?? []; + for (const address of publishedMail) { + if (!address.endsWith("@modoterra.xyz")) { + fail(`legal pages published non-xyz mail: ${address}`); + } + } + if (!primaryNavItemIsActive("/docs", "/docs/leaders")) { fail("Documents nav should be active on /docs/leaders"); } @@ -241,6 +296,8 @@ try { ["/install", "Install Echo"], ["/docs/first-program", "First program"], ["/book", "Introduction"], + ["/privacy", "Privacy"], + ["/terms", "Terms"], ]; for (const [path, heading] of requiredChrome) { const page = pages.get(path); @@ -280,6 +337,9 @@ try { ok: true, definition: homePage.definition, nav: primaryNav.map((item) => item.label), + footerAbout: footerLinkGroups + .find((group) => group.title === "About") + ?.links.map((link) => link.label), languagePages: languageFeatureEntries.map((entry) => entry.to), hubGroups: docsHubCatalog.map((group) => group.title), chromePaths: publicChromePaths(), diff --git a/www/scripts/verify-prose.mjs b/www/scripts/verify-prose.mjs index 716077b7..72569ad8 100644 --- a/www/scripts/verify-prose.mjs +++ b/www/scripts/verify-prose.mjs @@ -128,7 +128,7 @@ try { const { docsPages, docsPageByPath } = content; const { stdModules } = ref; - const { homePage, docsHubCatalog, tryPage } = site; + const { homePage, docsHubCatalog, legalPages, tryPage } = site; const install = await server.ssrLoadModule("/src/docs/install-content.ts"); const { installPage, inlineText } = install; @@ -168,6 +168,14 @@ try { prose.push({ where: `hub ${group.title} ${entry.title}`, text: entry.description }); } } + for (const page of legalPages) { + prose.push({ where: `${page.path} summary`, text: page.summary }); + for (const section of page.sections) { + for (const paragraph of section.paragraphs) { + prose.push({ where: `${page.path}#${section.title}`, text: paragraph }); + } + } + } for (const item of prose) { checkPatterns(item.where, item.text, BAN_PATTERNS, failures); @@ -201,6 +209,7 @@ try { const tsxFiles = [ "src/app.tsx", "src/install.tsx", + "src/legal.tsx", "src/router.tsx", "src/docs/site.ts", "src/docs/install-content.ts", diff --git a/www/src/docs/site.ts b/www/src/docs/site.ts index 2dc0340c..27a8c01a 100644 --- a/www/src/docs/site.ts +++ b/www/src/docs/site.ts @@ -1,7 +1,7 @@ /** - * Public site chrome: homepage, primary nav, Documents hub catalog, and the - * discovery files (`/sitemap.xml`, `/robots.txt`). Pages and tests load this - * module; do not duplicate the lists in UI or fixtures. + * Public site chrome: homepage, primary nav, footer, legal pages, Documents + * hub catalog, and the discovery files (`/sitemap.xml`, `/robots.txt`). Pages + * and tests load this module; do not duplicate the lists in UI or fixtures. */ /** Live public host. Do not emit github.io URLs in discovery files. */ @@ -62,6 +62,10 @@ export type FooterLinkGroup = { links: FooterLink[]; }; +/** + * Footer chrome. Discord stays omitted until there is a public invite URL. + * Public project mail on linked pages is @modoterra.xyz only. + */ export const footerLinkGroups: FooterLinkGroup[] = [ { title: "Learn", @@ -81,7 +85,6 @@ export const footerLinkGroups: FooterLinkGroup[] = [ label: "GitHub", href: "https://github.com/modoterra/echo", }, - { label: "Discord", href: "#", disabled: true }, ], }, { @@ -91,6 +94,8 @@ export const footerLinkGroups: FooterLinkGroup[] = [ label: "Modoterra", href: "https://modoterra.xyz", }, + { label: "Privacy", href: "/privacy" }, + { label: "Terms", href: "/terms" }, ], }, ]; @@ -130,6 +135,127 @@ export function publicChromePaths(): string[] { return [...paths].sort(); } +/** Public addresses already used in-repo. Keep the site on this domain. */ +export const publicMailAddresses = [ + "hello@modoterra.xyz", + "security@modoterra.xyz", + "oss@modoterra.xyz", +] as const; + +export type LegalSection = { + title: string; + paragraphs: string[]; +}; + +export type LegalPageContent = { + path: string; + title: string; + summary: string; + sections: LegalSection[]; +}; + +export const privacyPage: LegalPageContent = { + path: "/privacy", + title: "Privacy", + summary: + "This page describes how the Echo project site and related project mail handle information. Echo is an open-source compiled language. xo.run is its public documentation site.", + sections: [ + { + title: "The site", + paragraphs: [ + "xo.run publishes language documentation, the Echo 2026 spec, install instructions, and an in-browser checker. Modoterra Corporation maintains the site. These pages have no sign-in.", + ], + }, + { + title: "Hosting logs", + paragraphs: [ + "The site is a static site served from a CDN. The host may record ordinary request data such as IP address, user agent, requested URL, and time. This site does not run a first-party analytics product.", + ], + }, + { + title: "Mail", + paragraphs: [ + "If you write to hello@modoterra.xyz, security@modoterra.xyz, or oss@modoterra.xyz, we receive the address and message you send so we can reply.", + ], + }, + { + title: "GitHub", + paragraphs: [ + "Issues, pull requests, and other activity on the public Echo repository are public on GitHub and follow GitHub's terms.", + ], + }, + { + title: "Try Echo", + paragraphs: [ + "The Try page runs the compiler frontend in your browser. Source you type there stays in that browser session. Playground source is not uploaded to a Modoterra server.", + ], + }, + { + title: "The compiler", + paragraphs: [ + "xo is a local toolchain. It reads source on the machine where you run it. This site is not a hosted compile service.", + ], + }, + { + title: "Contact", + paragraphs: [ + "Questions about this page go to hello@modoterra.xyz. Report security issues to security@modoterra.xyz.", + ], + }, + ], +}; + +export const termsPage: LegalPageContent = { + path: "/terms", + title: "Terms", + summary: + "These terms cover the xo.run website and the Echo software published by Modoterra Corporation.", + sections: [ + { + title: "Software", + paragraphs: [ + "Echo is released under the MIT License. The license text lives in the project LICENSE file. You may use, copy, modify, and distribute the software under that license.", + ], + }, + { + title: "Website", + paragraphs: [ + "The pages on xo.run describe Echo and how to use xo. We may change or remove pages as the language changes. The site is provided as is.", + ], + }, + { + title: "Warranty", + paragraphs: [ + "The software and this site come with no warranty. The MIT License states the full disclaimer.", + ], + }, + { + title: "Contributions", + paragraphs: [ + "Contributions to the public repository are governed by the Contributor License Agreement in CLA.md. Submitting a contribution accepts that agreement.", + ], + }, + { + title: "Conduct", + paragraphs: [ + "Project participation follows CODE_OF_CONDUCT.md. Report conduct issues to oss@modoterra.xyz.", + ], + }, + { + title: "Security", + paragraphs: [ + "Report vulnerabilities to security@modoterra.xyz. Use a private report for security issues.", + ], + }, + { + title: "Contact", + paragraphs: ["Other questions go to hello@modoterra.xyz."], + }, + ], +}; + +export const legalPages: readonly LegalPageContent[] = [privacyPage, termsPage]; + /** * Documents stays active on language and guide pages. Packages owns /docs/std. */ diff --git a/www/src/docs/static-html.ts b/www/src/docs/static-html.ts index f8cea7ed..5c5664ec 100644 --- a/www/src/docs/static-html.ts +++ b/www/src/docs/static-html.ts @@ -5,7 +5,13 @@ import { docsPages, type DocsBlock, type DocsPage, type DocsTextPart } from "./content"; import { installPage, inlineText, isLinkPart, type InlinePart } from "./install-content"; import { currentPrereleaseAssets } from "../lib/current-release"; -import { homePage, renderStaticHomeAndHub, tryPage } from "./site"; +import { + homePage, + legalPages, + renderStaticHomeAndHub, + tryPage, + type LegalPageContent, +} from "./site"; export type StaticPage = { path: string; @@ -107,6 +113,30 @@ export function renderStaticInstall(): string { ].join(""); } +function linkLegalMail(html: string): string { + return html.replace(/([a-z.]+@modoterra\.xyz)/g, '$1'); +} + +export function renderStaticLegal(page: LegalPageContent): string { + const sections = page.sections + .map((section) => { + const paragraphs = section.paragraphs + .map((text) => `

${linkLegalMail(escapeHtml(text))}

`) + .join(""); + return `

${escapeHtml(section.title)}

${paragraphs}
`; + }) + .join(""); + + return [ + `
`, + `

${escapeHtml(page.title)}

`, + `

${escapeHtml(page.summary)}

`, + sections, + `

Privacy Terms

`, + `
`, + ].join(""); +} + export function renderStaticTry(): string { return [ `
`, @@ -144,6 +174,15 @@ export function staticPages(): StaticPage[] { }, ]; + for (const page of legalPages) { + pages.push({ + path: page.path, + title: documentTitle(page.title), + description: page.summary, + body: renderStaticLegal(page), + }); + } + for (const page of docsPages) { pages.push({ path: page.path, diff --git a/www/src/legal.tsx b/www/src/legal.tsx new file mode 100644 index 00000000..fb7d3119 --- /dev/null +++ b/www/src/legal.tsx @@ -0,0 +1,74 @@ +import { useEffect } from "react"; +import { Link } from "@tanstack/react-router"; +import { type LegalPageContent } from "./docs/site"; + +const MAIL_RE = /([a-z.]+@modoterra\.xyz)/g; + +function renderLegalText(text: string) { + return text.split(MAIL_RE).map((part, index) => { + if (part.endsWith("@modoterra.xyz")) { + return ( + + {part} + + ); + } + + return {part}; + }); +} + +export function LegalPage({ page }: { page: LegalPageContent }) { + useEffect(() => { + document.title = `${page.title} · Echo`; + return () => { + document.title = "Echo Programming Language"; + }; + }, [page.title]); + + return ( +
+
+

+ {page.title} +

+

{page.summary}

+ + {page.sections.map((section) => ( +
+

+ {section.title} +

+ {section.paragraphs.map((paragraph) => ( +

+ {renderLegalText(paragraph)} +

+ ))} +
+ ))} + + +
+
+ ); +} diff --git a/www/src/router.tsx b/www/src/router.tsx index af4517b5..b830273a 100644 --- a/www/src/router.tsx +++ b/www/src/router.tsx @@ -25,6 +25,7 @@ import { DocsSearch } from "./components/docs-search"; import { EchoCode } from "./components/echo-code"; import { Logo } from "./components/logo"; import { InstallPage } from "./install"; +import { LegalPage } from "./legal"; import { TryPage } from "./try"; import { docsPageByPath, @@ -43,6 +44,8 @@ import { installCta, primaryNav, primaryNavItemIsActive, + privacyPage, + termsPage, type SiteLink, } from "./docs/site"; @@ -335,9 +338,7 @@ function SiteFooter() {