feat(embeds): detailed Steam store / news / app-widget embeds
Recognize store.steampowered.com content URLs and render each richly, within
the existing privacy-first facade. 2-agent reviewed (both SHIP).
- getSteamTarget / steamWidgetEmbedUrl (videoEmbed.ts, +tests): classify
/app/{id}, /news/app/{id}/view/{gid}, /(bundle|sub|dlc)/{id}; non-content
pages (home/search/wishlist) and other hosts fall through to the generic card.
- SteamCard now dispatches:
- app → OG capsule header + click-to-play facade → Steam's OFFICIAL store
widget iframe (store.steampowered.com/widget/{id}): live region-aware price,
discount %, Buy on Steam. Nothing loads from Steam until "Show price &
store" is pressed; gated by the inlineMediaEmbeds setting. App pages use the
wide card so the ~646px widget has room.
- news → rich announcement card (banner + headline + body preview + link) —
your example URL previously fell through to the plain generic card.
- bundle/sub/dlc → the OG store card.
Grounded in our CSP: the widget works via frame-src https: (no infra change),
images route through the homeserver (img-src excludes Steam), and there is NO
client-side Steam API call (connect-src + Steam CORS both block it) — which is
also the honest ceiling: no review scores/genres client-side, price/buy come
from the official widget.
Runtime QA still needed: the live widget iframe rendering (height/fit) can't be
verified headlessly.
Gates: tsc 0, eslint 0, prettier clean, 912 tests, build ok.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,8 @@ import {
|
||||
getBlueskyEmbed,
|
||||
getLoomId,
|
||||
getKickChannel,
|
||||
getSteamTarget,
|
||||
steamWidgetEmbedUrl,
|
||||
buildVideoEmbedUrl,
|
||||
spotifyEmbedHeight,
|
||||
parseMediaEmbed,
|
||||
@@ -208,6 +210,36 @@ test('Apple Music: album vs single song height, embed host swap', () => {
|
||||
assert.equal(getAppleMusicEmbed('https://example.com/album/x/1'), null);
|
||||
});
|
||||
|
||||
test('getSteamTarget: app / news / bundle / non-content', () => {
|
||||
assert.deepEqual(getSteamTarget('https://store.steampowered.com/app/739630/Phasmophobia/'), {
|
||||
kind: 'app',
|
||||
appId: '739630',
|
||||
});
|
||||
assert.deepEqual(
|
||||
getSteamTarget(
|
||||
'https://store.steampowered.com/news/app/739630/view/668371152183232404?l=english',
|
||||
),
|
||||
{ kind: 'news', appId: '739630', gid: '668371152183232404' },
|
||||
);
|
||||
assert.deepEqual(getSteamTarget('https://store.steampowered.com/bundle/232/'), {
|
||||
kind: 'store',
|
||||
label: 'bundle',
|
||||
});
|
||||
assert.deepEqual(getSteamTarget('https://store.steampowered.com/sub/12345/'), {
|
||||
kind: 'store',
|
||||
label: 'sub',
|
||||
});
|
||||
// non-content store pages and other hosts are not embedded
|
||||
assert.equal(getSteamTarget('https://store.steampowered.com/'), null);
|
||||
assert.equal(getSteamTarget('https://store.steampowered.com/search/?term=horror'), null);
|
||||
assert.equal(getSteamTarget('https://steamcommunity.com/app/739630'), null);
|
||||
assert.equal(getSteamTarget('not a url'), null);
|
||||
});
|
||||
|
||||
test('steamWidgetEmbedUrl', () => {
|
||||
assert.equal(steamWidgetEmbedUrl('739630'), 'https://store.steampowered.com/widget/739630/');
|
||||
});
|
||||
|
||||
test('getTweetId', () => {
|
||||
assert.equal(getTweetId('https://x.com/user/status/1799999999999999999'), '1799999999999999999');
|
||||
assert.equal(getTweetId('https://twitter.com/user/status/12345'), '12345');
|
||||
|
||||
@@ -219,6 +219,42 @@ export function getStreamableId(url: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Steam ----------------------------------------------------------------
|
||||
|
||||
export type SteamTarget =
|
||||
// a game/app store page → gets the official click-to-play store widget
|
||||
| { kind: 'app'; appId: string }
|
||||
// a news/announcement post → a rich card (no official widget for these)
|
||||
| { kind: 'news'; appId: string; gid: string }
|
||||
// bundle / sub / dlc pages → an OG store card (no per-app widget)
|
||||
| { kind: 'store'; label: string };
|
||||
|
||||
/**
|
||||
* Classify a store.steampowered.com content URL. Only content pages (app / news /
|
||||
* bundle / sub / dlc) match; the homepage, search, wishlist, cart etc. return
|
||||
* null and fall through to the generic preview card.
|
||||
*/
|
||||
export function getSteamTarget(url: string): SteamTarget | null {
|
||||
try {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
if (hostname.replace(/^www\./, '') !== 'store.steampowered.com') return null;
|
||||
let m = pathname.match(/^\/news\/app\/(\d+)\/view\/(\d+)/);
|
||||
if (m) return { kind: 'news', appId: m[1], gid: m[2] };
|
||||
m = pathname.match(/^\/app\/(\d+)/);
|
||||
if (m) return { kind: 'app', appId: m[1] };
|
||||
m = pathname.match(/^\/(bundle|sub|dlc)\/\d+/);
|
||||
if (m) return { kind: 'store', label: m[1] };
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Steam's official embeddable store widget (live price / discount / Buy). */
|
||||
export function steamWidgetEmbedUrl(appId: string): string {
|
||||
return `https://store.steampowered.com/widget/${encodeURIComponent(appId)}/`;
|
||||
}
|
||||
|
||||
// --- Twitch ---------------------------------------------------------------
|
||||
|
||||
export type TwitchTarget =
|
||||
|
||||
Reference in New Issue
Block a user