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

Merged opened this

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

Merged

mason/listing-launch:mason/107-copy-the-approved-mls-description-in-one → main 2 commits fork 3 threads · 3 unresolved

  • Merged. mason/107-copy-the-approved-mls-description-in-one was merged into main as 1e35d90.

Review

Sign in to review this change.
4 files changed +69 −9
README.md +6
@@ -87,6 +87,12 @@ to make a listing of your own.
8787 listing's property page link (`/homes/:id`, absolute), ready to paste into
8888 Instagram or Facebook. A draft has none. Without JavaScript the same text
8989 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".
9096 9. **Mark it sold.** On your listing, pick **Sold** in the status box beside
9197 the tone, add the price it sold at if you want it said, and press **Set
9298 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";
55 import { join } from "node:path";
66 import { approveAsset, editAsset, getListing, launchListing } from "./actions.ts";
77 import { openDb } from "./db.ts";
8 import { listingPage } from "./pages/listing.ts";
8 import { listingPage, mlsCopy } from "./pages/listing.ts";
99 import { SAMPLE } from "./sample.ts";
1010 import type { Who } from "./who.ts";
1111
@@ -45,7 +45,7 @@ test("an approved post renders its words and the absolute property page link", (
4545
4646 test("hostile words stay escaped in the copy text", () => {
4747 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"` });
4949 if (!edited.ok) throw new Error(edited.message);
5050 approve(db, id);
5151 const html = page(db, id);
@@ -61,3 +61,39 @@ test("the static script copies from the box; the page adds no inline script", ()
6161 approve(db, id);
6262 expect(page(db, id)).not.toMatch(/<script(?![^>]*\bsrc=)/);
6363 });
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 });

Drop the as never cast; type the call. Also strengthen the hidden assertion (check data-name and the summary).

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 => {
175175 return a.text.includes(url) ? a.text : `${a.text.replace(/\s+$/, "")}\n\n${url}`;
176176 };
177177
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 (/<\/?[a-zA-Z][^>]*>/g); the current pattern deletes text between a plain < and >.

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. */
179192 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`;
181199 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>
185203 </details>
186204 </div>`;
187205 }
static/listing.js +2 −2
@@ -64,8 +64,8 @@ for (const box of document.querySelectorAll("[data-copy-post]")) {
6464 button.addEventListener("click", async () => {
6565 await navigator.clipboard.writeText(box.querySelector("[data-copy-source]").value);
6666 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);
6969 });
7070 }
7171

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs