diff --git a/apps/marketing/README.md b/apps/marketing/README.md index 8e4a4b303c47b..8ac631b7661f7 100644 --- a/apps/marketing/README.md +++ b/apps/marketing/README.md @@ -7,7 +7,8 @@ are upstream's. - `vp run dev:marketing` serves it on port 4173. - `vp run build:marketing` writes `dist/`. - Deployed by Vercel from this directory; `vercel.ts` carries the install and build commands, - the headers for `/install.sh`, and the rewrite that shows thread prompt links on `/new`. The Vercel project needs Root Directory `apps/marketing` with + the headers for `/install.sh`, and the rewrites that show thread prompt links on `/new` and + copied thread links on `/threads`. The Vercel project needs Root Directory `apps/marketing` with files outside the root included, because the build reads `FORK.md` and stages `scripts/install.sh` from the repository root. diff --git a/apps/marketing/src/components/ServerForm.astro b/apps/marketing/src/components/ServerForm.astro new file mode 100644 index 0000000000000..afe5f0f688bc9 --- /dev/null +++ b/apps/marketing/src/components/ServerForm.astro @@ -0,0 +1,26 @@ +--- +// The "Open on my server" form of a link page; `wireServerForm` in lib/serverForm.ts runs it. +interface Props { + label: string; +} + +const { label } = Astro.props; +--- + +
+ +
+ + +
+ +
diff --git a/apps/marketing/src/lib/serverForm.ts b/apps/marketing/src/lib/serverForm.ts new file mode 100644 index 0000000000000..a466f59bd3718 --- /dev/null +++ b/apps/marketing/src/lib/serverForm.ts @@ -0,0 +1,21 @@ +import { serverPromptLinkUrl } from "./promptLink"; + +/** Wires a link page's "Open on my server" form to open `appPath` on the address entered. */ +export function wireServerForm(appPath: string) { + const form = document.getElementById("server-form") as HTMLFormElement; + const address = document.getElementById("server-address") as HTMLInputElement; + const addressError = document.getElementById("server-error") as HTMLElement; + address.addEventListener("input", () => { + addressError.hidden = true; + }); + form.addEventListener("submit", (event) => { + event.preventDefault(); + const target = serverPromptLinkUrl(address.value, appPath); + if (target === null) { + addressError.hidden = false; + address.focus(); + return; + } + window.location.assign(target); + }); +} diff --git a/apps/marketing/src/lib/threadLink.test.ts b/apps/marketing/src/lib/threadLink.test.ts new file mode 100644 index 0000000000000..6acaaf40a3249 --- /dev/null +++ b/apps/marketing/src/lib/threadLink.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { serverPromptLinkUrl } from "./promptLink"; +import { readThreadLinkPage } from "./threadLink"; + +describe("readThreadLinkPage", () => { + it("opens the thread in the app and on a server", () => { + const page = readThreadLinkPage("https://j5.codes/threads/env-1/thread_2/"); + expect(page).toEqual({ + ok: true, + value: { appPath: "/env-1/thread_2", deepLink: "j5code://threads/env-1/thread_2" }, + }); + expect(page.ok && serverPromptLinkUrl("localhost:3773", page.value.appPath)).toBe( + "http://localhost:3773/env-1/thread_2", + ); + }); + + it("keeps escaped ids as the link wrote them", () => { + const page = readThreadLinkPage("https://j5.codes/threads/env%2E1/thread%20two"); + expect(page.ok && page.value.deepLink).toBe("j5code://threads/env%2E1/thread%20two"); + }); + + it("refuses a link that doesn't name a thread", () => { + for (const href of [ + "https://j5.codes/threads", + "https://j5.codes/threads/env-1", + "https://j5.codes/threads/env-1/thread-1/extra", + "https://j5.codes/threads/env-1/%E0%A4", + "https://j5.codes/threads/%20/thread-1", + "https://j5.codes/threads/env.1/thread-1", + ]) { + expect(readThreadLinkPage(href)).toEqual({ + ok: false, + error: "This link doesn't name a thread.", + }); + } + }); +}); diff --git a/apps/marketing/src/lib/threadLink.ts b/apps/marketing/src/lib/threadLink.ts new file mode 100644 index 0000000000000..2b17af647482f --- /dev/null +++ b/apps/marketing/src/lib/threadLink.ts @@ -0,0 +1,31 @@ +import { type PromptLinkResult, promptLinkDeepLink } from "@t3tools/shared/promptLinks"; + +export interface ThreadLinkPage { + /** The thread's path in a J5 web app, `//`. */ + readonly appPath: string; + readonly deepLink: string; +} + +// The characters a copied thread link escapes its ids into (upstream's `hostedThreadLink`). +const THREAD_LINK_PATH = /^\/threads\/([A-Za-z0-9_%-]+)\/([A-Za-z0-9_%-]+)$/; + +const isId = (segment: string) => { + try { + return decodeURIComponent(segment).trim().length > 0; + } catch { + return false; + } +}; + +/** Reads a copied thread link, `j5.codes/threads//`, as the page shows it. */ +export function readThreadLinkPage(href: string): PromptLinkResult { + // Static hosting serves the page with a trailing slash as well as without. + const match = THREAD_LINK_PATH.exec(new URL(href).pathname.replace(/\/+$/, "")); + if (!match || !isId(match[1]!) || !isId(match[2]!)) { + return { ok: false, error: "This link doesn't name a thread." }; + } + // The ids stay escaped as the link wrote them; the app decodes them. + // Every J5 server opens `//`, including those older than `/threads/…`. + const appPath = `/${match[1]}/${match[2]}`; + return { ok: true, value: { appPath, deepLink: promptLinkDeepLink(appPath) } }; +} diff --git a/apps/marketing/src/pages/new.astro b/apps/marketing/src/pages/new.astro index 0e3aa52e1c5c0..1c0da95697a91 100644 --- a/apps/marketing/src/pages/new.astro +++ b/apps/marketing/src/pages/new.astro @@ -1,10 +1,12 @@ --- import Layout from "../layouts/Layout.astro"; import Boat from "../components/Boat.astro"; +import ServerForm from "../components/ServerForm.astro"; +import "../styles/link-page.css"; --- -
+ -
- -
- - -
- -
+