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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/listing-launch:mason/84-a-property-page-link-pasted-into-a-text → main 1 commit fork

  • Merged. mason/84-a-property-page-link-pasted-into-a-text was merged into main as caae6ad.

Review

Sign in to review this change.
5 files changed +126 −10
README.md +6
@@ -341,6 +341,12 @@ bun run contract:emit # rewrite contract/opena
341341 it: the preview comes up the moment the app answers, and says `live`, or
342342 `serving · tests failed` the moment the suite disagrees.
343343
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
344350 ## The shape
345351
346352 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/
108108 }
109109
110110 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}`]) {
112112 const html = await (await call(path)).text();
113113 const metas = [...html.matchAll(/<meta\b[^>]*>/g)].map((m) => m[0]);
114114 expect(metas.length).toBeGreaterThan(0);
@@ -119,6 +119,15 @@ test("share and Open Graph tags escape the listing's words", async () => {
119119 }
120120 });
121121
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("&lt;script&gt;alert(1)&lt;/script&gt;");
126 expect(title).not.toMatch(/[<>"]/);
127 const desc = /<meta property="og:description" content="([^"]*)">/.exec(html)![1]!;
128 expect(desc).not.toMatch(/[<>"]/);
129 });
130
122131 test("the email's copy source is the escaped article", async () => {
123132 const html = await (await call(`/listings/${id}/email`)).text();
124133 const article = /<article class="email[^]*?<\/article>/.exec(html)![0];
src/pages/layout.ts +18 −6
@@ -26,6 +26,12 @@ export interface Frame {
2626 readonly heading?: string;
2727 /** A class on `<body>`: `print` for a page that is meant for paper. */
2828 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;
2935 }
3036
3137 /** "Listing Launch", from the package's "listing-launch". */
@@ -42,6 +48,9 @@ export const appName = title
4248 export function layout(body: string, who: Who, frame: Frame = {}): string {
4349 const path = frame.path ?? "/";
4450 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;
4554 const door =
4655 who.role === "anon"
4756 ? `<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 {
5665 <meta name="theme-color" content="${token("color-bg", "dark")}" media="(prefers-color-scheme: dark)">
5766 <link rel="icon" href="data:,">
5867 <title>${escape(name)}</title>
59 <meta name="description" content="${escape(description)}">
68 <meta name="description" content="${escape(said)}">
6069 <meta property="og:type" content="website">
6170 <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)}">
6473 <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)}">
6880 <link rel="stylesheet" href="${tokensHref}">
6981 <link rel="stylesheet" href="${asset("app.css")}">
7082 <script type="module" src="${asset("live.js")}"></script>
src/pages/property.ts +23 −3
@@ -5,7 +5,8 @@
55 * approval, sees a notice that it is not public yet.
66 */
77
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";
910 import { escape } from "../http.ts";
1011 import { altOf, propertyPath, publicDescription, type Launched } from "../listing.ts";
1112 import type { Who } from "../who.ts";
@@ -24,10 +25,21 @@ function offMarketPage(who: Who, l: Launched): string {
2425 ${agentFooter(l)}
2526 </article>`,
2627 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 },
2834 );
2935 }
3036
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
3143 const agentFooter = (l: Launched): string => `<footer class="home-agent card">
3244 ${l.headshot === null ? "" : `<img class="home-face" src="${escape(photoUrl(l, l.headshot))}" alt="Photo of ${escape(l.agent_name)}" width="72" height="72">`}
3345 <div>
@@ -63,6 +75,14 @@ export function propertyPage(input: { who: Who; listing: Launched }): string {
6375 <p class="muted"><small>Equal Housing Opportunity.</small></p>
6476 </article>`,
6577 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 },
6787 );
6888 }
src/property.test.ts +69
@@ -124,3 +124,72 @@ test("a spoofed Host never reaches the flyer or the email link", async () => {
124124 test("an unknown home is 404", async () => {
125125 expect((await call("/homes/99999")).status).toBe(404);
126126 });
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, "&amp;"));
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, "&amp;").replace(/'/g, "&#39;").replace(/"/g, "&quot;"));
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs