Public: anyone can read this; the crew can change it.
A property page link pasted into a text or a post previews the home: its address, price, facts and hero photo #91
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/84-a-property-page-link-pasted-into-a-text → main 1 commit fork
5 files
changed +126
−10
README.md +6
| @@ -341,6 +341,12 @@ bun run contract:emit # rewrite contract/opena | ||
| 341 | 341 | it: the preview comes up the moment the app answers, and says `live`, or |
| 342 | 342 | `serving · tests failed` the moment the suite disagrees. |
| 343 | 343 | |
| 344 | A property page's link preview (Open Graph and Twitter tags) carries the | |
| 345 | address and price, the facts line, the first sentence of the approved words | |
| 346 | and the hero photo's absolute URL. The sample photos are SVG, which some | |
| 347 | networks ignore when drawing a card; images are not converted, so those | |
| 348 | previews show text only there. Uploaded JPEG and PNG photos are unaffected. | |
| 349 | ||
| 344 | 350 | ## The shape |
| 345 | 351 | |
| 346 | 352 | One implementation, many doors. `src/actions.ts` is every operation as a |
src/hostile.test.ts +10 −1
| @@ -108,7 +108,7 @@ for (const path of ["/", "/listings/:id", "/listings/:id/flyer", "/listings/:id/ | ||
| 108 | 108 | } |
| 109 | 109 | |
| 110 | 110 | test("share and Open Graph tags escape the listing's words", async () => { |
| 111 | for (const path of [`/listings/${id}`, `/listings/${id}/email`, `/listings/${id}/flyer`]) { | |
| 111 | for (const path of [`/listings/${id}`, `/listings/${id}/email`, `/listings/${id}/flyer`, `/homes/${id}`]) { | |
| 112 | 112 | const html = await (await call(path)).text(); |
| 113 | 113 | const metas = [...html.matchAll(/<meta\b[^>]*>/g)].map((m) => m[0]); |
| 114 | 114 | expect(metas.length).toBeGreaterThan(0); |
| @@ -119,6 +119,15 @@ test("share and Open Graph tags escape the listing's words", async () => { | ||
| 119 | 119 | } |
| 120 | 120 | }); |
| 121 | 121 | |
| 122 | test("the property page's preview tags hold the address as an inert attribute value", async () => { | |
| 123 | const html = await (await call(`/homes/${id}`)).text(); | |
| 124 | const title = /<meta property="og:title" content="([^"]*)">/.exec(html)![1]!; | |
| 125 | expect(title).toContain("<script>alert(1)</script>"); | |
| 126 | expect(title).not.toMatch(/[<>"]/); | |
| 127 | const desc = /<meta property="og:description" content="([^"]*)">/.exec(html)![1]!; | |
| 128 | expect(desc).not.toMatch(/[<>"]/); | |
| 129 | }); | |
| 130 | ||
| 122 | 131 | test("the email's copy source is the escaped article", async () => { |
| 123 | 132 | const html = await (await call(`/listings/${id}/email`)).text(); |
| 124 | 133 | const article = /<article class="email[^]*?<\/article>/.exec(html)![0]; |
src/pages/layout.ts +18 −6
| @@ -26,6 +26,12 @@ export interface Frame { | ||
| 26 | 26 | readonly heading?: string; |
| 27 | 27 | /** A class on `<body>`: `print` for a page that is meant for paper. */ |
| 28 | 28 | readonly kind?: string; |
| 29 | /** What a shared link says, in place of the app's description. */ | |
| 30 | readonly description?: string; | |
| 31 | /** Its preview image: an absolute URL and what it shows. */ | |
| 32 | readonly image?: { readonly url: string; readonly alt: string }; | |
| 33 | /** The share title, when it is not the page's own title. */ | |
| 34 | readonly shareTitle?: string; | |
| 29 | 35 | } |
| 30 | 36 | |
| 31 | 37 | /** "Listing Launch", from the package's "listing-launch". */ |
| @@ -42,6 +48,9 @@ export const appName = title | ||
| 42 | 48 | export function layout(body: string, who: Who, frame: Frame = {}): string { |
| 43 | 49 | const path = frame.path ?? "/"; |
| 44 | 50 | const name = frame.heading === undefined ? appName : `${frame.heading} · ${appName}`; |
| 51 | const said = frame.description ?? description; | |
| 52 | const shared = frame.shareTitle ?? name; | |
| 53 | const image = frame.image; | |
| 45 | 54 | const door = |
| 46 | 55 | who.role === "anon" |
| 47 | 56 | ? `<a class="button button-quiet" href="/__forge/signin?to=${escape(path)}">Sign in</a>` |
| @@ -56,15 +65,18 @@ export function layout(body: string, who: Who, frame: Frame = {}): string { | ||
| 56 | 65 | <meta name="theme-color" content="${token("color-bg", "dark")}" media="(prefers-color-scheme: dark)"> |
| 57 | 66 | <link rel="icon" href="data:,"> |
| 58 | 67 | <title>${escape(name)}</title> |
| 59 | <meta name="description" content="${escape(description)}"> | |
| 68 | <meta name="description" content="${escape(said)}"> | |
| 60 | 69 | <meta property="og:type" content="website"> |
| 61 | 70 | <meta property="og:site_name" content="${escape(appName)}"> |
| 62 | <meta property="og:title" content="${escape(name)}"> | |
| 63 | <meta property="og:description" content="${escape(description)}"> | |
| 71 | <meta property="og:title" content="${escape(shared)}"> | |
| 72 | <meta property="og:description" content="${escape(said)}"> | |
| 64 | 73 | <meta property="og:url" content="${escape(publicOrigin + path)}"> |
| 65 | <meta name="twitter:card" content="summary"> | |
| 66 | <meta name="twitter:title" content="${escape(name)}"> | |
| 67 | <meta name="twitter:description" content="${escape(description)}"> | |
| 74 | ${image === undefined ? "" : `<meta property="og:image" content="${escape(image.url)}"> | |
| 75 | <meta property="og:image:alt" content="${escape(image.alt)}"> | |
| 76 | <meta name="twitter:image" content="${escape(image.url)}"> | |
| 77 | `}<meta name="twitter:card" content="${image === undefined ? "summary" : "summary_large_image"}"> | |
| 78 | <meta name="twitter:title" content="${escape(shared)}"> | |
| 79 | <meta name="twitter:description" content="${escape(said)}"> | |
| 68 | 80 | <link rel="stylesheet" href="${tokensHref}"> |
| 69 | 81 | <link rel="stylesheet" href="${asset("app.css")}"> |
| 70 | 82 | <script type="module" src="${asset("live.js")}"></script> |
src/pages/property.ts +23 −3
| @@ -5,7 +5,8 @@ | ||
| 5 | 5 | * approval, sees a notice that it is not public yet. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { factItems, isOffMarket, money, statusLine, whenOf } from "../generate.ts"; | |
| 8 | import { factItems, factsLine, isOffMarket, money, statusLine, whenOf } from "../generate.ts"; | |
| 9 | import { publicOrigin } from "../env.ts"; | |
| 9 | 10 | import { escape } from "../http.ts"; |
| 10 | 11 | import { altOf, propertyPath, publicDescription, type Launched } from "../listing.ts"; |
| 11 | 12 | import type { Who } from "../who.ts"; |
| @@ -24,10 +25,21 @@ function offMarketPage(who: Who, l: Launched): string { | ||
| 24 | 25 | ${agentFooter(l)} |
| 25 | 26 | </article>`, |
| 26 | 27 | who, |
| 27 | { path: propertyPath(l), heading: l.address }, | |
| 28 | { | |
| 29 | path: propertyPath(l), | |
| 30 | heading: l.address, | |
| 31 | shareTitle: l.address, | |
| 32 | description: "This home is no longer available.", | |
| 33 | }, | |
| 28 | 34 | ); |
| 29 | 35 | } |
| 30 | 36 | |
| 37 | /** The first sentence of the approved words, for a link preview. */ | |
| 38 | const firstSentence = (words: string): string => { | |
| 39 | const flat = words.replace(/\s+/g, " ").trim(); | |
| 40 | return /^.*?[.!?](?=\s|$)/.exec(flat)?.[0] ?? flat; | |
| 41 | }; | |
| 42 | ||
| 31 | 43 | const agentFooter = (l: Launched): string => `<footer class="home-agent card"> |
| 32 | 44 | ${l.headshot === null ? "" : `<img class="home-face" src="${escape(photoUrl(l, l.headshot))}" alt="Photo of ${escape(l.agent_name)}" width="72" height="72">`} |
| 33 | 45 | <div> |
| @@ -63,6 +75,14 @@ export function propertyPage(input: { who: Who; listing: Launched }): string { | ||
| 63 | 75 | <p class="muted"><small>Equal Housing Opportunity.</small></p> |
| 64 | 76 | </article>`, |
| 65 | 77 | who, |
| 66 | { path: propertyPath(l), heading: l.address }, | |
| 78 | { | |
| 79 | path: propertyPath(l), | |
| 80 | heading: l.address, | |
| 81 | shareTitle: `${l.address} · ${money(l.price)}`, | |
| 82 | description: [factsLine(l), words === null ? "" : firstSentence(words)].filter(Boolean).join(". ").replace(/\.\.$/, "."), | |
| 83 | ...(hero === undefined | |
| 84 | ? {} | |
| 85 | : { image: { url: publicOrigin + photoUrl(l, hero), alt: altOf(l, hero, l.address) } }), | |
| 86 | }, | |
| 67 | 87 | ); |
| 68 | 88 | } |
src/property.test.ts +69
| @@ -124,3 +124,72 @@ test("a spoofed Host never reaches the flyer or the email link", async () => { | ||
| 124 | 124 | test("an unknown home is 404", async () => { |
| 125 | 125 | expect((await call("/homes/99999")).status).toBe(404); |
| 126 | 126 | }); |
| 127 | ||
| 128 | // --- link previews --------------------------------------------------------- | |
| 129 | import { approveAsset, editAsset, getListing, launchListing, setStatus } from "./actions.ts"; | |
| 130 | import { openDb } from "./db.ts"; | |
| 131 | import { propertyPage } from "./pages/property.ts"; | |
| 132 | import { SAMPLE } from "./sample.ts"; | |
| 133 | import type { Who } from "./who.ts"; | |
| 134 | ||
| 135 | const me: Who = { role: "user", id: "u1", name: "me" }; | |
| 136 | const tag = (html: string, key: string): string | undefined => | |
| 137 | new RegExp(`<meta (?:property|name)="${key}" content="([^"]*)">`).exec(html)?.[1]; | |
| 138 | ||
| 139 | const preview = (text = `${DRAFT} here. Second.`) => { | |
| 140 | const db = openDb(mkdtempSync(join(tmpdir(), "property-og-"))); | |
| 141 | const made = launchListing(db, me, { fields: SAMPLE }); | |
| 142 | if (!made.ok) throw new Error(made.message); | |
| 143 | const id = made.value.id; | |
| 144 | const mls = made.value.assets.find((a) => a.kind === "mls")!; | |
| 145 | const draft = editAsset(db, me, { id, kind: "mls", text }); | |
| 146 | if (!draft.ok) throw new Error(draft.message); | |
| 147 | const get = () => { | |
| 148 | const f = getListing(db, me, { id }); | |
| 149 | if (!f.ok) throw new Error(f.message); | |
| 150 | return f.value; | |
| 151 | }; | |
| 152 | return { db, id, get, mls }; | |
| 153 | }; | |
| 154 | ||
| 155 | test("a property page's preview names the address, price and facts, with the hero's absolute URL", () => { | |
| 156 | const { db, id, get } = preview("Bright corner home near the park. Second sentence."); | |
| 157 | for (const a of get().assets) approveAsset(db, me, { id, kind: a.kind, door: "page", seen: a.updated_at }); | |
| 158 | const approved = get(); | |
| 159 | const first = approved.assets.find((a) => a.kind === "mls")!.text.replace(/\s+/g, " ").split(/(?<=[.!?])\s/)[0]!; | |
| 160 | const html = propertyPage({ who: { role: "anon" }, listing: { ...approved, photos: ["sample-front.svg"], photo_alt: { "sample-front.svg": "The front porch" } } }); | |
| 161 | for (const k of ["og:title", "twitter:title"]) expect(tag(html, k)).toBe(`${approved.address} · $689,000`.replace(/&/g, "&")); | |
| 162 | for (const k of ["og:description", "twitter:description", "description"]) { | |
| 163 | expect(tag(html, k)).toContain(" bd · "); | |
| 164 | expect(tag(html, k)).toContain("sq ft"); | |
| 165 | expect(tag(html, k)).toContain(first.replace(/&/g, "&").replace(/'/g, "'").replace(/"/g, """)); | |
| 166 | } | |
| 167 | expect(tag(html, "og:type")).toBe("website"); | |
| 168 | expect(tag(html, "og:image")).toMatch(new RegExp(`^${publicOrigin}/.*sample-front`)); | |
| 169 | expect(tag(html, "twitter:image")).toBe(tag(html, "og:image")); | |
| 170 | expect(tag(html, "og:image:alt")).toBe("The front porch"); | |
| 171 | expect(tag(html, "twitter:card")).toBe("summary_large_image"); | |
| 172 | expect(tag(html, "og:description")).not.toContain("Second sentence"); | |
| 173 | }); | |
| 174 | ||
| 175 | test("draft words never reach the preview, and without a photo there are no image tags", () => { | |
| 176 | const { get } = preview(); | |
| 177 | const html = propertyPage({ who: me, listing: { ...get(), photos: [] } }); | |
| 178 | const head = html.slice(0, html.indexOf("<body")); | |
| 179 | expect(head).not.toContain(DRAFT); | |
| 180 | expect(head).not.toContain("og:image"); | |
| 181 | expect(head).not.toContain("twitter:image"); | |
| 182 | expect(tag(html, "twitter:card")).toBe("summary"); | |
| 183 | }); | |
| 184 | ||
| 185 | test("an off-market listing's preview says it is no longer available and carries no price", () => { | |
| 186 | const { db, id, get } = preview(); | |
| 187 | for (const a of get().assets) approveAsset(db, me, { id, kind: a.kind, door: "page", seen: a.updated_at }); | |
| 188 | expect(setStatus(db, me, { id, status: "withdrawn" }).ok).toBe(true); | |
| 189 | const html = propertyPage({ who: { role: "anon" }, listing: get() }); | |
| 190 | const head = html.slice(0, html.indexOf("<body")); | |
| 191 | expect(tag(html, "og:description")).toBe("This home is no longer available."); | |
| 192 | expect(tag(html, "og:title")).toBe(get().address); | |
| 193 | expect(head).not.toContain("$"); | |
| 194 | expect(head).not.toContain(" bd "); | |
| 195 | }); |