From 49a70c7ffbeb13b3f0cdb4b41ebcd108c0b2daa8 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 6 Oct 2026 13:08:47 +0000 Subject: [PATCH] Publisher page: Share button next to RSS copies one of ~50 canned posts Each click copies the next post (from a random start), filled with the publisher's name, app count, bio and link, so people sharing the same page do not all post the same words. Posts that need a bio are skipped when the publisher has none; the link always goes last. Co-Authored-By: Claude Opus 5.5 --- apps/web/public/store.js | 28 ++++++++++++++ apps/web/public/sw.js | 2 +- apps/web/src/pages.js | 3 +- apps/web/src/share-posts.js | 75 +++++++++++++++++++++++++++++++++++++ test/api.test.js | 2 + test/unit.test.js | 18 +++++++++ 6 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/share-posts.js diff --git a/apps/web/public/store.js b/apps/web/public/store.js index 369f148..9f0edf7 100644 --- a/apps/web/public/store.js +++ b/apps/web/public/store.js @@ -263,6 +263,34 @@ if (share) { } } +// Share (publisher pages): each click copies the next canned post, from a random start, +// so people sharing the same page do not all post the same words. +for (const btn of document.querySelectorAll('[data-share-posts]')) { + let posts = []; + try { + posts = JSON.parse(btn.dataset.sharePosts); + } catch {} + if (!posts.length) continue; + let i = Math.floor(Math.random() * posts.length); + btn.addEventListener('click', async () => { + const text = posts[i]; + i = (i + 1) % posts.length; + try { + await navigator.clipboard.writeText(text); + } catch { + const t = document.createElement('textarea'); + t.value = text; + document.body.append(t); + t.select(); + document.execCommand('copy'); + t.remove(); + } + btn.textContent = 'Copied! Paste anywhere'; + clearTimeout(btn._reset); + btn._reset = setTimeout(() => (btn.textContent = 'Share'), 1800); + }); +} + // "Get notified": follow an app or publisher by account, email or browser push. for (const box of document.querySelectorAll('[data-follow-kind]')) { const kind = box.dataset.followKind; diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js index 29cf3e2..8cc9fec 100644 --- a/apps/web/public/sw.js +++ b/apps/web/public/sw.js @@ -1,7 +1,7 @@ // pwamart service worker. Static assets: cache first, refreshed in the background. // Pages and the API: network first, so a listing is never stale; the last copy of a // page is the offline fallback. Bump VERSION when an asset changes shape. -const VERSION = 'pwamart-v3'; +const VERSION = 'pwamart-v4'; // Push: "Get notified" releases (handlers from @profullstack/notifications). importScripts('/assets/push-sw.js'); diff --git a/apps/web/src/pages.js b/apps/web/src/pages.js index 5153544..5637892 100644 --- a/apps/web/src/pages.js +++ b/apps/web/src/pages.js @@ -1,4 +1,5 @@ import { CATEGORIES, CATEGORY_NAMES, PLANS, config } from './config.js'; +import { publisherPosts } from './share-posts.js'; /** * Server-rendered store pages. Plain template strings: every value from the @@ -536,7 +537,7 @@ export function publisherPage({ publisher, list, stats }) { ${publisher.verified ? `

Verified publisher${publisher.verified_domain ? `: proved it runs ${e(publisher.verified_domain)}` : publisher.verified_by === 'staff' ? ': checked by pwamart staff' : ''}.

` : ''} ${publisher.claimable ? claimNote(publisher) : publisher.bio ? `

${e(publisher.bio)}

` : ''} -
${followButton('publisher', publisher.slug, publisher.name)}RSS
+
${followButton('publisher', publisher.slug, publisher.name)}RSS
${safeUrl(publisher.website) ? `

${e(publisher.website)}

` : ''} ${publisher.claimable ? '' : `

Yours? Edit the logo and profile, or verify, in the console →

`} diff --git a/apps/web/src/share-posts.js b/apps/web/src/share-posts.js new file mode 100644 index 0000000..3c4f63e --- /dev/null +++ b/apps/web/src/share-posts.js @@ -0,0 +1,75 @@ +/** + * Canned posts for the Share button on a publisher page. Each click copies the + * next one (from a random start), so a dozen people sharing the same publisher + * do not all post the same sentence. {name}, {n} (app count), {apps} ("app" or + * "apps"), {bio} and {url} are filled per publisher; a post that needs {bio} is + * left out when the publisher has none. The URL always goes last, where every + * network turns it into a card. + */ +export const PUBLISHER_POSTS = [ + '{name} has {n} web {apps} on pwamart. Install any of them on your phone or desktop, no app store needed.\n\n{url}', + 'Every web app from {name}, in one place, installable on any device:\n\n{url}', + '{name}: {bio}\n\nAll their apps, one tap to install:\n{url}', + 'Found {name} on pwamart. {n} {apps}, all installable straight from the browser.\n\n{url}', + 'No App Store, no Play Store, no gatekeeper. Just {name}\'s apps, installed from the web:\n\n{url}', + 'If you like {name}, their whole catalog is on pwamart:\n\n{url}', + '{bio}\n\nThat\'s {name}. Their apps install on any device from here:\n{url}', + '{n} {apps} from {name}, each one a PWA you can put on your home screen:\n\n{url}', + 'Bookmarking this: every app {name} ships, installable on phone, tablet and desktop.\n\n{url}', + 'Web apps that install like native ones. {name} has {n} of them:\n\n{url}', + 'Follow {name} on pwamart and hear about new apps the day they launch:\n\n{url}', + '{name} builds for the open web. Their apps, no store required:\n\n{url}', + 'Here\'s {name}\'s shelf on pwamart. Pick an app, hit Install, done.\n\n{url}', + '{name}: {bio}\n\n{url}', + 'Install {name}\'s apps on iPhone, Android, Mac, Windows or Linux from one page:\n\n{url}', + 'One link, every {name} app:\n\n{url}', + 'Skipping the app store today. {name} ships {n} web {apps} you can install right from the browser:\n\n{url}', + 'Worth a look: {name} on pwamart.\n\n{url}', + 'Every one of {name}\'s apps installs in a tap. Have a look:\n\n{url}', + '{name} on pwamart: {n} installable web {apps}, release notes and an RSS feed.\n\n{url}', + 'Want everything {name} makes on your home screen? Start here:\n\n{url}', + 'Small web, big apps. {name}\'s catalog:\n\n{url}', + 'Tip: {name}\'s apps are on pwamart, so you can install them without any store account.\n\n{url}', + '{bio}\n\nSee all {n} {apps} from {name}:\n{url}', + 'Apps from {name}, straight from the source, no store cut:\n\n{url}', + 'Get notified when {name} ships something new (email, browser push or RSS):\n\n{url}', + '{name} keeps shipping. Here\'s everything so far:\n\n{url}', + 'The {name} collection on pwamart. Every app installs from the web in seconds.\n\n{url}', + 'Not another store download. {name}\'s apps install straight from the web:\n\n{url}', + 'Sharing {name}\'s page because their apps deserve more eyes:\n\n{url}', + '{n} reasons to check out {name}:\n\n{url}', + 'From {name}: {bio}\n\nInstall their apps here:\n{url}', + 'PWAs done right. {name} has {n} on pwamart:\n\n{url}', + 'Web apps you can actually install. {name}\'s are here:\n\n{url}', + 'Every {name} app with one Install button each:\n\n{url}', + 'Looking for good web apps? {name} has a whole shelf of them:\n\n{url}', + 'Desktop, phone, tablet: {name}\'s apps install on all of them from one page.\n\n{url}', + '{name} on pwamart. Browse, install, follow for updates:\n\n{url}', + 'If you build for the web, look at how {name} ships:\n\n{url}', + 'Install once, get updates automatically. That\'s how {name}\'s web apps work:\n\n{url}', + 'No sign-up, no store, no waiting for review. {name}\'s apps:\n\n{url}', + '{name} ({n} {apps}) is on pwamart:\n\n{url}', + 'Discovered {name} today. {bio}\n\n{url}', + 'Open-web apps from {name}, installable everywhere:\n\n{url}', + 'Here\'s a publisher worth following: {name}.\n\n{url}', + 'Every app {name} makes, with release notes and one-tap install:\n\n{url}', + 'Skip the store, keep the apps. {name} on pwamart:\n\n{url}', + '{name}\'s web apps, all in one place. Install the ones you like:\n\n{url}', + 'Your next favorite app might be from {name}:\n\n{url}', + 'A whole catalog of installable web apps from {name}:\n\n{url}', +]; + +/** The posts for one publisher, filled in. */ +export function publisherPosts({ name, bio, apps, url }) { + const short = bio ? String(bio).replace(/\s+/g, ' ').trim().slice(0, 160).replace(/[,;:\s]+$/, '') : ''; + const fill = (t) => + t + .replaceAll('{name}', name) + .replaceAll('{n}', String(apps)) + .replaceAll('{apps}', apps === 1 ? 'app' : 'apps') + .replaceAll('{bio}', short) + .replaceAll('{url}', url); + return PUBLISHER_POSTS.filter((t) => short || !t.includes('{bio}')) + .filter((t) => (apps > 1 || !t.includes('{n} reasons')) && (apps > 0 || !t.includes('{n}'))) + .map(fill); +} diff --git a/test/api.test.js b/test/api.test.js index 3d254c6..63a5552 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -184,6 +184,8 @@ d('store end to end', () => { const page = await (await app.request('/publishers/alice-apps')).text(); expect(page).toContain('https://cdn.example/logo.png'); expect(page).toContain('/console/publishers/alice-apps'); + expect(page).toContain('data-share-posts='); + expect(page).toContain('We make notes.'); expect((await call('PATCH', '/publishers/alice-apps', { avatar_url: '' })).body.publisher.avatar_url).toBe(null); }); diff --git a/test/unit.test.js b/test/unit.test.js index 0f8c029..6ec7648 100644 --- a/test/unit.test.js +++ b/test/unit.test.js @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test'; import { listingName, manifestToken, pickIcon, readHead } from '../apps/web/src/inspect.js'; import { sharedHost } from '../apps/web/src/shared-hosts.js'; import { aiBio, pageText, pickLogo } from '../apps/web/src/profile.js'; +import { PUBLISHER_POSTS, publisherPosts } from '../apps/web/src/share-posts.js'; import { summarizeNetwork } from '../apps/web/src/crawlproof.js'; import { advertisePage, compact, e, reachTiles, safeUrl } from '../apps/web/src/pages.js'; import { TOOLS, installInstructions } from '../packages/mcp/src/core.js'; @@ -54,6 +55,23 @@ describe('inspector parsing', () => { if (key) process.env.ANTHROPIC_API_KEY = key; }); + test('publisher share posts: about 50, all filled, URL last, no em dashes', () => { + const url = 'https://pwamart.com/publishers/acme'; + const withBio = publisherPosts({ name: 'Acme', bio: 'Acme makes tools for writers.', apps: 7, url }); + expect(withBio.length).toBe(PUBLISHER_POSTS.length); + expect(withBio.length).toBeGreaterThanOrEqual(45); + expect(new Set(withBio).size).toBe(withBio.length); + for (const p of withBio) { + expect(p).not.toMatch(/\{\w+\}/); + expect(p.endsWith(url)).toBe(true); + expect(p).not.toContain('—'); + } + const bare = publisherPosts({ name: 'Acme', bio: null, apps: 1, url }); + expect(bare.length).toBeLessThan(withBio.length); + expect(bare.some((p) => p.includes('reasons'))).toBe(false); + expect(bare.some((p) => p.includes('1 apps'))).toBe(false); + }); + test('listingName trims page-title style names', () => { expect(listingName('HQTUI — High Quality Terminal UI for TypeScript', 'HQTUI')).toBe('HQTUI'); expect(listingName('QryptChat - Quantum-Resistant Messaging')).toBe('QryptChat');