feat(website): add the Stalker portal setup guide with mock-backed screenshots

Publish "How to Connect a Stalker or Ministra Portal to IPTVnator": the
portal URL shapes discovery accepts, MAC normalization, the optional
serial/device-ID/signature fields and the pinning rules behind the
"generate device IDs" toggle, what endpoint discovery does on Add, the
sections a portal source gets, Account info, and a troubleshooting list
built from the app's own refusal messages, plus a seven-question FAQ.
The guide is cross-linked from the download pages and llms.txt.

Guide screenshots come from the capture script. Shots that walk into a
Stalker portal start the stalker-mock-server and seed its marketing-demo
portal for that run only, so release shots never gain a third source
card. The frame guard allowlists exactly that scenario's MAC and keeps
rejecting every other MAC-shaped string.

To keep the live-TV frame free of third-party images, the fictional live
channel list and the channel-logo SVG renderer move into
@iptvnator/shared/marketing-fixtures; both mocks now serve
/assets/marketing/logo/<slug>.svg, the Stalker marketing-demo scenario
builds its ITV categories, channels and schedule from those fixtures
instead of faker names with picsum logos, and the mock resolves asset
URLs on get_all_channels too, which is the response the app renders
the channel list from.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-04 07:20:56 +02:00
1 parent 9455e0db65
commit 6cfdaf5900
31 files changed
+938 -193

No files matched your search

@@ -1 +1,2 @@
export * from './lib/shared-marketing-fixtures';
export * from './lib/marketing-live-fixtures';
@@ -0,0 +1,41 @@
import {
MARKETING_LIVE_CATEGORIES,
MARKETING_LIVE_CHANNELS,
marketingSlug,
marketingTitleFromSlug,
renderMarketingLogoSvg,
} from './marketing-live-fixtures';
describe('marketing live fixtures', () => {
it('places every channel in a declared category with a schedule', () => {
const keys = new Set(MARKETING_LIVE_CATEGORIES.map((c) => c.key));
for (const channel of MARKETING_LIVE_CHANNELS) {
expect(keys.has(channel.categoryKey)).toBe(true);
expect(channel.epgTitles.length).toBeGreaterThan(0);
}
expect(new Set(MARKETING_LIVE_CHANNELS.map((c) => c.name)).size).toBe(
MARKETING_LIVE_CHANNELS.length
);
});
it('round-trips a channel name through the slug used in asset URLs', () => {
expect(marketingSlug('Aurora News')).toBe('aurora-news');
expect(marketingTitleFromSlug('aurora-news.svg')).toBe('Aurora News');
});
it('renders a self-contained SVG logo with the channel initials', () => {
const svg = renderMarketingLogoSvg('aurora-news.svg', '128x128');
expect(svg.startsWith('<svg xmlns="http://www.w3.org/2000/svg"')).toBe(
true
);
expect(svg).toContain('width="128"');
expect(svg).toContain('>AN</text>');
expect(svg).not.toContain('http://localhost');
});
it('escapes markup that leaks into a slug', () => {
expect(renderMarketingLogoSvg('<b>x', undefined)).not.toContain('<b>');
});
});
@@ -0,0 +1,178 @@
/**
* Provider-neutral fictional live TV fixtures shared by the Xtream and
* Stalker marketing mocks, plus the generated logo artwork both serve from
* `/assets/marketing/logo/<slug>.svg`. Everything here is invented: no real
* broadcaster, programme, or logo is referenced, which is what makes the
* screenshots built on top of it publishable.
*/
export type MarketingLiveCategoryKey =
| 'newsroom'
| 'sports'
| 'family'
| 'culture';
export interface MarketingLiveCategoryFixture {
key: MarketingLiveCategoryKey;
name: string;
}
export interface MarketingLiveChannelFixture {
categoryKey: MarketingLiveCategoryKey;
name: string;
/** Cycled through the generated schedule, so the guide always has titles. */
epgTitles: readonly string[];
}
export const MARKETING_LIVE_CATEGORIES: readonly MarketingLiveCategoryFixture[] =
[
{ key: 'newsroom', name: 'Newsroom' },
{ key: 'sports', name: 'Sports & Motion' },
{ key: 'family', name: 'Family Channels' },
{ key: 'culture', name: 'Culture & Docs' },
];
export const MARKETING_LIVE_CHANNELS: readonly MarketingLiveChannelFixture[] = [
{
categoryKey: 'newsroom',
name: 'Aurora News',
epgTitles: ['Morning Briefing', 'City Desk', 'Global Window'],
},
{
categoryKey: 'newsroom',
name: 'Civic One',
epgTitles: ['Town Hall Live', 'Policy Today', 'Open Forum'],
},
{
categoryKey: 'sports',
name: 'Fieldside Sports',
epgTitles: ['Training Ground', 'Matchday Studio', 'Final Whistle'],
},
{
categoryKey: 'sports',
name: 'Motion Arena',
epgTitles: ['Court Vision', 'Trackside', 'Night Highlights'],
},
{
categoryKey: 'family',
name: 'Horizon Kids',
epgTitles: ['Rocket Workshop', 'Tiny Explorers', 'Story Lantern'],
},
{
categoryKey: 'family',
name: 'Kitchen Lab',
epgTitles: ['Breakfast Builders', 'Family Table', 'Sweet Science'],
},
{
categoryKey: 'culture',
name: 'Atlas Docs',
epgTitles: ['Ocean Notes', 'Museum Hour', 'Wide Angle'],
},
{
categoryKey: 'culture',
name: 'Night Music',
epgTitles: ['Studio Session', 'Late Set', 'Ambient City'],
},
];
export const MARKETING_LOGO_ASSET_PATH = '/assets/marketing/logo';
/** URL-safe slug shared by asset URLs and the renderer that answers them. */
export function marketingSlug(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
/** Inverse of `marketingSlug` for rendering initials back out of a URL. */
export function marketingTitleFromSlug(slug: string): string {
return slug
.replace(/\.svg$/i, '')
.split('-')
.filter(Boolean)
.map((part) => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`)
.join(' ');
}
export function marketingHash(value: string): number {
return value.split('').reduce((acc, char) => {
return (acc * 31 + char.charCodeAt(0)) >>> 0;
}, 0);
}
export function marketingPalette(seed: string): [string, string, string] {
const palettes: Array<[string, string, string]> = [
['#0b1026', '#1b6b77', '#f2a65a'],
['#15111f', '#6b3fa0', '#20c7b5'],
['#071b2c', '#2457a6', '#f05d5e'],
['#1b1b24', '#8d4f2a', '#e9c46a'],
['#10251d', '#2a9d8f', '#e76f51'],
['#18151f', '#b23a48', '#f4a261'],
];
return palettes[marketingHash(seed) % palettes.length];
}
export function marketingSvgDocument(
width: number,
height: number,
body: string
): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img">${body}</svg>`;
}
export function escapeMarketingSvg(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/** `WIDTHxHEIGHT` query value; falls back to a square 256 px logo. */
export function parseMarketingLogoSize(size: string | undefined): {
width: number;
height: number;
} {
const match = size?.match(/^(\d+)x(\d+)$/);
if (!match) {
return { width: 256, height: 256 };
}
return { width: Number(match[1]), height: Number(match[2]) };
}
/**
* Channel logo: gradient tile with the channel initials. Identical output for
* both mocks so a screenshot looks the same whichever portal type it shows.
*/
export function renderMarketingLogoSvg(
slug: string,
size: string | undefined
): string {
const { width, height } = parseMarketingLogoSize(size);
const title = marketingTitleFromSlug(slug);
const palette = marketingPalette(`logo:${slug}`);
const initials = title
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('');
return marketingSvgDocument(
width,
height,
`
<defs>
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stop-color="${palette[0]}" />
<stop offset="100%" stop-color="${palette[1]}" />
</linearGradient>
</defs>
<rect width="100%" height="100%" rx="${width * 0.22}" fill="url(#bg)" />
<circle cx="${width * 0.72}" cy="${height * 0.25}" r="${width * 0.2}" fill="${palette[2]}" opacity="0.35" />
<path d="M ${width * 0.16} ${height * 0.72} C ${width * 0.35} ${height * 0.48}, ${width * 0.58} ${height * 0.92}, ${width * 0.84} ${height * 0.58}" fill="none" stroke="#fff" stroke-width="${Math.max(8, width * 0.05)}" stroke-linecap="round" opacity="0.42" />
<text x="50%" y="54%" dominant-baseline="middle" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="${width * 0.3}" font-weight="800" fill="#fff">${escapeMarketingSvg(initials)}</text>
`
);
}