Building apps
Routing and SEO
Hash routes, page titles, share previews, the SITE config every public string comes from, and crash and visit reporting.
Routing
Routes are hash routes (#/settings, #/items/42?tab=notes), so the app works on any static host with no server rules. src/lib/router.ts exports:
const HAS_LANDING: boolean; // SITE.landing && SITE.audience !== "internal"
const AUTH_PATHS: string[]; // ["/signin", "/signup", "/forgot"]; only exist when SITE.accounts is true
const APP_HOME: string; // "/app" when there are no accounts but there is a landing page; otherwise "/"
function appPath(path: string): string; // "/settings" → "/app/settings" when APP_HOME is "/app"
function navigate(to: string, opts?: { replace?: boolean }): void;
function useRoute(): { path: string; query: URLSearchParams; go: typeof navigate };
function match(pattern: string, path: string): Record<string, string> | null; // match("/items/:id", "/items/42") → { id: "42" }
function Link(props: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }): JSX.Element; // <a href="#/…">
function rememberReturn(path: string): void; // sessionStorage "auth.return"
function takeReturn(): string | null; // reads and clears it; only paths starting with "/"
function useHash(): [string, (to: string) => void]; // older screens
path is the part before ?; query is the part after. match compares segment by segment, so /items/:id does not match /items/42/edit; :id values are URL-decoded.
navigate with replace: true uses history.replaceState and falls back to setting the hash where that throws (the editor's sandboxed preview frame).
Screens are written app-relative in SCREENS and NAV; appPath is the one place that adds /app. When a signed-out visitor opens a signed-in screen, App.tsx remembers the path with rememberReturn before sending them to /signin, and the sign-in screen goes back there with takeReturn.
Page titles
function usePageTitle(title?: string): void;
usePageTitle("Settings") sets the tab to Settings · <SITE.name>. With no argument: <SITE.name> · <SITE.tagline>.
Share previews
When an app is published, Ripping writes its <head> from src/lib/site.ts without running it (plain string fields only) and from src/design.css:
| Tag | Source |
|---|---|
| Title | <name> · <tagline> (the project name when name is still App) |
| Description | SITE.description, else SITE.tagline |
| Image | GET /api/apps/<projectId>/og, a 1200×630 picture drawn from the app's own colour tokens, name, hero.eyebrow and hero.title (falls back to the tagline, then the project description). Cached for an hour. Only answers for published apps. ?thumb=1 gives the 640×400 project-card version. |
| Robots | noindex when SITE.indexable is false |
| Favicon | The first letter of the name on the primary colour |
The owner can override title, description, icon (an emoji or an uploaded image) and indexing on the project's Settings tab; those win over the file.
SITE
src/lib/site.ts holds every string the public side of the app says. The landing page, page titles, share previews, footer, Welcome screen and the privacy and terms pages read from it. Rewrite the strings; keep the shape.
type Site = {
name: string; tagline: string; description: string;
audience: "external" | "internal";
accounts: boolean; landing: boolean; indexable: boolean;
layout: LandingLayout;
company: string; contactEmail: string; legalUpdated: string;
hero: { eyebrow: string; title: string; body: string; primary: string; secondary: string; note?: string };
steps: { title: string; body: string }[];
features: Feature[];
pricing: { title: string; body: string; plans: Plan[] } | null;
faq: { q: string; a: string }[];
cta: { title: string; body: string; button: string };
};
| Field | Meaning |
|---|---|
audience | "internal": a tool for a team. No landing page; visitors go straight to sign-in. |
accounts | false: no sign up, no sign in, no per-account data. The landing page's single button opens the app at /app. hero.secondary is never shown. |
landing | false: no public page; / is the app (sign-in when there are accounts). |
indexable | false keeps the published app out of search engines. |
layout | How the landing page is arranged. Ripping's Design tab rewrites this one string; nothing else changes. |
company, contactEmail, legalUpdated | Used by the footer and the privacy and terms pages. |
hero.note | The small line under the buttons. Leave it out unless it is true of this app. |
pricing | null hides the section. Fill it in only when the app really charges, with the prices people are billed. |
type LandingLayout = "centered" | "split" | "showcase" | "story" | "minimal" | "feature-led";
type Feature = { icon: "zap" | "shield" | "sparkles" | "users" | "clock" | "globe" | "lock" | "heart" | "layers" | "bell" | "phone" | "star"; title: string; body: string };
type Plan = { name: string; price: string; period: string; description: string; features: string[]; cta: string; featured?: boolean };
const LANDING_LAYOUTS: { key: LandingLayout; label: string; description: string }[];
| Layout | Arrangement |
|---|---|
centered | Centred hero, how it works, feature grid, pricing, FAQ, closing call to action. |
split | Hero in two columns: the words left, a preview of the app right, then a narrow page. |
showcase | A full-bleed preview first, the headline under it, features two at a time. |
story | One idea per screenful, text and preview alternating sides, features as a plain list. |
minimal | One screen: headline, one line, one button, one small preview; the rest in the footer. |
feature-led | The feature grid at the top under a short headline, the preview after it. |
There is no field for testimonials, logos, review scores or user counts, and none may be added until the app really has them.
Crash reports and visits
src/lib/report.ts sends two things from a published app. Nothing is sent from the editor's preview (it reports errors itself).
function setScreen(path: string): void; // App.tsx calls it on every route change
function report(error: unknown): void; // the ErrorBoundary calls it; window "error" and "unhandledrejection" are also hooked
Visits
setScreen posts each screen once per page load (signed in and signed out counted separately):
POST /api/apps/<projectId>/views
Authorization: Bearer <token> (when signed in)
{ "screen": "/items/42", "visitor": "<random id kept in localStorage as ripping.visitor>" }
Ripping normalises the screen (id-like segments become :id, each segment cut to 40 characters, the whole to 120) and keeps one row per project, day, screen and visitor. Signed-in visits carry the app user, which is what the app's Fit measure counts. The route is public and rate limited to 120 a minute per IP per app; a screen not starting with /, longer than 200 characters, or a visitor id outside ^[A-Za-z0-9-]{6,64}$ is dropped with 400. The reply is { "ok": true | false }.
Errors
report sends at most 10 errors per page load and skips a message it has already sent:
POST /api/apps/<projectId>/errors
{ "message": "<first 1000 chars>", "stack": "<first 6000 chars>", "screen": "/items/42", "url": "https://myapp.example/" }
url is origin and pathname only, never the query or hash. The route is public, rate limited to 30 a minute per IP per app, and refuses a body over 16,000 characters. Repeats are counted on one row per distinct message (digits in the first line are treated as the same). The reply is always { "ok": true | false }. Errors show up on the project's Errors tab with the screen they happened on.