Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions apps/web/public/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion apps/web/public/sw.js
Original file line number Diff line number Diff line change
@@ -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');
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/pages.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -536,7 +537,7 @@ export function publisherPage({ publisher, list, stats }) {
</div>
${publisher.verified ? `<p class="muted" style="margin:0 0 10px">Verified publisher${publisher.verified_domain ? `: proved it runs <b>${e(publisher.verified_domain)}</b>` : publisher.verified_by === 'staff' ? ': checked by pwamart staff' : ''}.</p>` : ''}
${publisher.claimable ? claimNote(publisher) : publisher.bio ? `<p class="lede">${e(publisher.bio)}</p>` : ''}
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:14px">${followButton('publisher', publisher.slug, publisher.name)}<a class="btn sm" href="/publishers/${e(publisher.slug)}/feed.xml">RSS</a></div>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:14px">${followButton('publisher', publisher.slug, publisher.name)}<a class="btn sm" href="/publishers/${e(publisher.slug)}/feed.xml">RSS</a><button class="btn sm" type="button" data-share-posts="${e(JSON.stringify(publisherPosts({ name: publisher.name, bio: publisher.bio, apps: list.total, url: `${config.siteUrl}/publishers/${publisher.slug}` })))}" title="Copies a ready-made post with the link">Share</button></div>
${safeUrl(publisher.website) ? `<p><a href="${e(safeUrl(publisher.website))}" rel="noopener" target="_blank">${e(publisher.website)}</a></p>` : ''}
${publisher.claimable ? '' : `<p class="muted" style="font-size:13px">Yours? <a href="/console/publishers/${e(publisher.slug)}">Edit the logo and profile, or verify, in the console →</a></p>`}
</section>
Expand Down
75 changes: 75 additions & 0 deletions apps/web/src/share-posts.js
Original file line number Diff line number Diff line change
@@ -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);
}
2 changes: 2 additions & 0 deletions test/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
18 changes: 18 additions & 0 deletions test/unit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand Down
Loading