Public: anyone can read this; the crew can change it.
Copy the approved MLS description in one tap from the listing, as plain text ready for the MLS #114
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/107-copy-the-approved-mls-description-in-one → main 2 commits fork 3 threads · 3 unresolved
4 files
changed +69
−9
README.md +6
| @@ -87,6 +87,12 @@ to make a listing of your own. | ||
| 87 | 87 | listing's property page link (`/homes/:id`, absolute), ready to paste into |
| 88 | 88 | Instagram or Facebook. A draft has none. Without JavaScript the same text |
| 89 | 89 | sits in a selectable read-only box under "Copy post". |
| 90 | ||
| 91 | Copy for MLS works the same way on the MLS tab: once the MLS description is | |
| 92 | approved (the sample *412 Alder Lane* MLS description already is), a **Copy | |
| 93 | for MLS** button copies the approved words as plain text, with no link, | |
| 94 | markup or emoji (© ® ™ stay). Without JavaScript the text sits in a | |
| 95 | read-only box under "Copy for MLS". | |
| 90 | 96 | 9. **Mark it sold.** On your listing, pick **Sold** in the status box beside |
| 91 | 97 | the tone, add the price it sold at if you want it said, and press **Set |
| 92 | 98 | status**. Two more assets appear, a status post under Social and a status |
src/copy.test.ts +38 −2
| @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; | ||
| 5 | 5 | import { join } from "node:path"; |
| 6 | 6 | import { approveAsset, editAsset, getListing, launchListing } from "./actions.ts"; |
| 7 | 7 | import { openDb } from "./db.ts"; |
| 8 | import { listingPage } from "./pages/listing.ts"; | |
| 8 | import { listingPage, mlsCopy } from "./pages/listing.ts"; | |
| 9 | 9 | import { SAMPLE } from "./sample.ts"; |
| 10 | 10 | import type { Who } from "./who.ts"; |
| 11 | 11 | |
| @@ -45,7 +45,7 @@ test("an approved post renders its words and the absolute property page link", ( | ||
| 45 | 45 | |
| 46 | 46 | test("hostile words stay escaped in the copy text", () => { |
| 47 | 47 | const { db, id } = setup(); |
| 48 | const edited = editAsset(db, me, { id, kind: "social_short", text: `<script>alert(1)</script> "hi"` } as never); | |
| 48 | const edited = editAsset(db, me, { id, kind: "social_short", text: `<script>alert(1)</script> "hi"` }); | |
| 49 | 49 | if (!edited.ok) throw new Error(edited.message); |
| 50 | 50 | approve(db, id); |
| 51 | 51 | const html = page(db, id); |
| @@ -61,3 +61,39 @@ test("the static script copies from the box; the page adds no inline script", () | ||
| 61 | 61 | approve(db, id); |
| 62 | 62 | expect(page(db, id)).not.toMatch(/<script(?![^>]*\bsrc=)/); |
| 63 | 63 | }); |
| 64 | ||
| 65 | const approveMls = (db: ReturnType<typeof openDb>, id: number) => { | |
| 66 | const a = load(db, id).assets.find((x) => x.kind === "mls")!; | |
| 67 | expect(approveAsset(db, me, { id, kind: "mls", door: "page", seen: a.updated_at }).ok).toBe(true); | |
| 68 | }; | |
| 69 | ||
| 70 | test("a draft MLS description offers no copy control", () => { | |
| 71 | const { db, id } = setup(); | |
| 72 | expect(page(db, id)).not.toContain("Copy for MLS"); | |
| 73 | }); | |
| 74 | ||
| 75 | test("an approved MLS description offers plain text only, in the box and the button", () => { | |
| 76 | const { db, id } = setup(); | |
| 77 | const edited = editAsset(db, me, { id, kind: "mls", text: `Sunny home 🏡 with <b>bonus</b> room.\r\n\r\n\r\nSee https://x.example/homes/1 now.` }); | |
| 78 | if (!edited.ok) throw new Error(edited.message); | |
| 79 | approveMls(db, id); | |
| 80 | const html = page(db, id); | |
| 81 | expect(html).toContain(`>Copy for MLS</button>`); | |
| 82 | expect(html).toMatch(/<button[^>]*data-name="Copy for MLS"[^>]*\bhidden\b/); | |
| 83 | expect(html).toContain("<summary>Copy for MLS</summary>"); | |
| 84 | expect(box(html)).toBe("Sunny home with bonus room.\n\nSee now."); | |
| 85 | }); | |
| 86 | ||
| 87 | test("MLS plain text keeps prose angle brackets and ©®™ but drops tags and emoji", () => { | |
| 88 | expect(mlsCopy("Priced under <$500k, 3 bd > near <i>town</i> 🏡 Acme® home™ ©2026")).toBe( | |
| 89 | "Priced under <$500k, 3 bd > near town Acme® home™ ©2026", | |
| 90 | ); | |
| 91 | }); | |
| 92 | ||
| 93 | test("the social Copy post keeps data-name=\"Copy post\" for the script's reset", () => { | |
| 94 | const { db, id } = setup(); | |
| 95 | approve(db, id); | |
| 96 | const html = page(db, id); | |
| 97 | expect(html).toMatch(/<button[^>]*data-name="Copy post"/); | |
| 98 | expect(html).toContain("<summary>Copy post</summary>"); | |
| 99 | }); |
src/pages/listing.ts +23 −5
| @@ -175,13 +175,31 @@ export const postCopy = (l: Launched, a: Asset, origin: string): string => { | ||
| 175 | 175 | return a.text.includes(url) ? a.text : `${a.text.replace(/\s+$/, "")}\n\n${url}`; |
| 176 | 176 | }; |
| 177 | 177 | |
| 178 | /** Copy post: only for an approved social post (only a person-approved message leaves). A button with JavaScript; without it, the same text in a selectable read-only box. */ | |
| 178 | /** The approved MLS words as plain text: no markup, no emoji, no link; line breaks kept as paragraphs. */ | |
| 179 | export const mlsCopy = (text: string): string => | |
| 180 | text | |
Only strip real tags ( |
||
| 181 | .replace(/<\/?[a-zA-Z][^>]*>/g, "") | |
| 182 | .replace(/https?:\/\/\S+/g, "") | |
Extended_Pictographic includes ©®™; keep those. |
||
| 183 | .replace(/(?![©®™])[\p{Extended_Pictographic}\u200d\ufe0f\u20e3]/gu, "") | |
| 184 | .replace(/\r\n?/g, "\n") | |
| 185 | .split("\n") | |
| 186 | .map((line) => line.replace(/[ \t]+/g, " ").trim()) | |
| 187 | .join("\n") | |
| 188 | .replace(/\n{3,}/g, "\n\n") | |
| 189 | .trim(); | |
| 190 | ||
| 191 | /** Copy post / Copy for MLS: only for an approved social post or MLS description (only a person-approved message leaves). A button with JavaScript; without it, the same text in a selectable read-only box. */ | |
| 179 | 192 | function copyPost(l: Launched, a: Asset, origin: string): string { |
| 180 | if (!a.kind.startsWith("social_") || a.approval === null) return ""; | |
| 193 | if (a.approval === null) return ""; | |
| 194 | const mls = isMls(a.kind); | |
| 195 | if (!mls && !a.kind.startsWith("social_")) return ""; | |
| 196 | const name = mls ? "Copy for MLS" : "Copy post"; | |
| 197 | const what = mls ? `the ${escape(a.label)} as plain text` : `the ${escape(a.label)} with its property page link`; | |
| 198 | const boxLabel = mls ? `${escape(a.label)} as plain text, ready to copy` : `${escape(a.label)} with its property page link, ready to copy`; | |
| 181 | 199 | return `<div data-copy-post> |
| 182 | <button type="button" class="button" data-copy-text data-asset="${escape(a.label)}" hidden aria-label="Copy post: the ${escape(a.label)} with its property page link">Copy post</button> | |
| 183 | <details data-copy-fallback><summary>Copy post</summary> | |
| 184 | <textarea class="input textarea" rows="5" readonly aria-label="The ${escape(a.label)} with its property page link, ready to copy" data-copy-source>${escape(postCopy(l, a, origin))}</textarea> | |
| 200 | <button type="button" class="button" data-copy-text data-name="${name}" data-asset="${escape(a.label)}" hidden aria-label="${name}: ${what}">${name}</button> | |
| 201 | <details data-copy-fallback><summary>${name}</summary> | |
| 202 | <textarea class="input textarea" rows="${mls ? 8 : 5}" readonly aria-label="${boxLabel}" data-copy-source>${escape(mls ? mlsCopy(a.text) : postCopy(l, a, origin))}</textarea> | |
| 185 | 203 | </details> |
| 186 | 204 | </div>`; |
| 187 | 205 | } |
static/listing.js +2 −2
| @@ -64,8 +64,8 @@ for (const box of document.querySelectorAll("[data-copy-post]")) { | ||
| 64 | 64 | button.addEventListener("click", async () => { |
| 65 | 65 | await navigator.clipboard.writeText(box.querySelector("[data-copy-source]").value); |
| 66 | 66 | button.textContent = "Copied"; |
| 67 | announce(`Copied the ${button.dataset.asset} with its link`); | |
| 68 | setTimeout(() => (button.textContent = "Copy post"), 1500); | |
| 67 | announce(`Copied the ${button.dataset.asset}`); | |
| 68 | setTimeout(() => (button.textContent = button.dataset.name), 1500); | |
| 69 | 69 | }); |
| 70 | 70 | } |
| 71 | 71 |
Drop the
as nevercast; type the call. Also strengthen thehiddenassertion (check data-name and the summary).