(feat) Discord build share

This commit is contained in:
jb
2026-08-02 12:00:30 -04:00
parent d09b6220ee
commit d9924a95b9
19 changed files with 1193 additions and 96 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ ENV NODE_ENV=production \
TDR_GAME_ICON_STORAGE_PATH=/app/data/game-icons TDR_GAME_ICON_STORAGE_PATH=/app/data/game-icons
WORKDIR /app WORKDIR /app
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl gosu \ && apt-get install -y --no-install-recommends ca-certificates curl fonts-dejavu-core gosu \
&& useradd --uid 10001 --gid node --no-create-home --shell /usr/sbin/nologin shd-runtime \ && useradd --uid 10001 --gid node --no-create-home --shell /usr/sbin/nologin shd-runtime \
&& case "$TARGETARCH" in \ && case "$TARGETARCH" in \
amd64) infisical_sha256=53abc96a3bc872ade81efe156275bd30c53f9ee8b52965b0c9cb320a9c814fbc ;; \ amd64) infisical_sha256=53abc96a3bc872ade81efe156275bd30c53f9ee8b52965b0c9cb320a9c814fbc ;; \
+27 -4
View File
@@ -1920,10 +1920,33 @@ selector:
- `PUBLIC` is accessible by direct link and included in the public archive. - `PUBLIC` is accessible by direct link and included in the public archive.
Keep visibility adjacent to the account-save action and explain its selected Keep visibility adjacent to the account-save action and explain its selected
state in plain language. Show a share-page action only after a non-private build state in plain language. Show Build Uplink after an Agent saves a Build. Public
has been saved. Public and unlisted build pages are read-only tactical and unlisted Builds expose its active controls; private Builds show the locked
summaries; owner editing returns to the builder. On narrow layouts, persistence state. Public and unlisted Build pages are read-only tactical summaries; owner
controls and shared-loadout columns stack vertically. editing returns to the builder.
Build Uplink uses a compact tactical control surface. Use one bordered dark
panel with a raised telemetry header, a textual `PUBLIC LINK READY` or
`UNLISTED LINK READY` state, and a small orange signal marker. The primary
`SHARE BUILD` action opens the native Web Share sheet when the browser supports
it. Keep `COPY LINK` visible as a separate explicit action for every eligible
Build. When Web Share is unavailable, promote Copy Link to the primary action
instead of presenting a control that cannot run. Supporting copy may state that
Discord renders the Build preview after an Agent posts the link. It must not
imply that SHD.build posts to Discord or operates a bot.
Private and unsaved Builds use the locked Uplink state. Show `PRIVATE BUILD` or
`UNSAVED BUILD`, `PUBLIC LINK UNAVAILABLE`, and a short instruction to change
visibility or save. Render no active Share or Copy Link control in that state.
Do not expose a crawler-preview action or suggest that owner access makes a
private Build shareable.
Uplink actions retain textual pending, success, cancellation, unsupported, and
failure feedback. They use at least 44-pixel targets, orange focus-visible
treatment, pressed feedback, and disabled duplicate-action protection. Stack
Share above Copy Link at phone widths and remove pressed motion under
`prefers-reduced-motion`. On narrow layouts, persistence controls and
shared-loadout columns also stack vertically.
### Tiered compatibility editors ### Tiered compatibility editors
+34
View File
@@ -187,6 +187,40 @@ boundary. An owned Build page links to `/guides/new?build=<id>`, where the
server verifies ownership and initializes a removable Build block in a new server verifies ownership and initializes a removable Build block in a new
private Guide. private Guide.
### Discord-compatible Build sharing experiment
Persisted public and unlisted Builds expose absolute Open Graph metadata for
their canonical `/builds/[slug]` URL. Their `og:image` and Twitter card metadata
reference the same-origin 1200 × 630 PNG at `/builds/[slug]/preview.png` so
Discord and other link unfurlers can render a Build summary. Private Builds set
`noindex,nofollow`, expose no crawler image, and provide no usable sharing
control.
The preview endpoint derives its image from canonical Build facts and the
current known-stat summary in the shared Build projection. It includes the Build
title, author, visibility, direct URL, equipped weapon names, core totals, and
up to three supporting Attributes. The renderer performs no external fetches. It
normalizes and bounds every text field by grapheme count, escapes text before
inserting it into XML, builds a text-only SVG, and rasterizes that SVG to PNG
with Sharp. Production installs DejaVu Sans from the Debian package and the
renderer names that fixed family explicitly.
The endpoint hashes a versioned serialization of the image input for its bounded
in-process render cache and derives the HTTP ETag from the resulting PNG bytes.
A bounded request queue limits concurrent database projections and Sharp work.
Responses require cache revalidation so a visibility change to Private blocks
the next request instead of leaving a stale response window. Matching
conditional requests receive `304 Not Modified`. Private, missing, and
inaccessible Builds receive no preview. Unlisted images send the
`X-Robots-Tag: noindex` response header.
For eligible Builds, the Build Uplink invokes the native Web Share API as the
primary action when the browser supports it. An explicit Copy Link action
remains available for every public or unlisted persisted Build. This experiment
does not install a Discord bot, post to Discord, extend Discord OAuth scopes, or
store Discord tokens or webhooks. Discord receives only a link that an Agent
chooses to share and may fetch the public metadata and same-origin preview.
## Public data corrections and discussion ## Public data corrections and discussion
Every public database card links to its canonical ISAC record with an exact Every public database card links to its canonical ISAC record with an exact
+84
View File
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from 'vitest';
import { buildShareUrl, copyBuildLink, shareBuild } from './build-sharing';
describe('Build sharing', () => {
it('constructs Build URLs from slugs and preserves absolute URLs', () => {
expect(buildShareUrl('field-medic', 'https://shd.build')).toBe(
'https://shd.build/builds/field-medic',
);
expect(buildShareUrl('build name', 'https://shd.build/ignored')).toBe(
'https://shd.build/builds/build%20name',
);
expect(buildShareUrl('https://stage.shd.build/builds/test')).toBe(
'https://stage.shd.build/builds/test',
);
});
it('uses the native share API', async () => {
const share = vi.fn().mockResolvedValue(undefined);
await expect(
shareBuild(
{ share },
{ title: 'Field Medic', url: 'https://shd.build/builds/field-medic' },
),
).resolves.toEqual({ state: 'shared' });
expect(share).toHaveBeenCalledWith({
title: 'Field Medic',
text: 'View Field Medic on SHD.build.',
url: 'https://shd.build/builds/field-medic',
});
});
it('treats AbortError as cancellation', async () => {
const share = vi
.fn()
.mockRejectedValue(new DOMException('Cancelled', 'AbortError'));
await expect(
shareBuild(
{ share },
{ title: 'Build', url: 'https://shd.build/builds/a' },
),
).resolves.toEqual({ state: 'cancelled' });
});
it('reports unsupported and failed native sharing', async () => {
await expect(
shareBuild({}, { title: 'Build', url: 'https://shd.build/builds/a' }),
).resolves.toEqual({ state: 'unsupported' });
const error = new Error('Share failed');
await expect(
shareBuild(
{ share: vi.fn().mockRejectedValue(error) },
{ title: 'Build', url: 'https://shd.build/builds/a' },
),
).resolves.toEqual({ state: 'failed', error });
});
it('copies the Build URL to the clipboard', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
await expect(
copyBuildLink({ clipboard: { writeText } }, 'https://shd.build/builds/a'),
).resolves.toEqual({ state: 'copied' });
expect(writeText).toHaveBeenCalledWith('https://shd.build/builds/a');
});
it('reports unsupported and failed clipboard access', async () => {
await expect(
copyBuildLink({}, 'https://shd.build/builds/a'),
).resolves.toEqual({
state: 'unsupported',
});
const error = new Error('Clipboard denied');
await expect(
copyBuildLink(
{ clipboard: { writeText: vi.fn().mockRejectedValue(error) } },
'https://shd.build/builds/a',
),
).resolves.toEqual({ state: 'failed', error });
});
});
+72
View File
@@ -0,0 +1,72 @@
export type BuildShareNavigator = {
share?: (data: ShareData) => Promise<void>;
clipboard?: {
writeText: (text: string) => Promise<void>;
};
};
export type BuildShareResult =
| { state: 'shared' }
| { state: 'cancelled' }
| { state: 'unsupported' }
| { state: 'failed'; error: unknown };
export type BuildCopyResult =
| { state: 'copied' }
| { state: 'unsupported' }
| { state: 'failed'; error: unknown };
export function buildShareUrl(slugOrUrl: string, origin?: string): string {
const value = slugOrUrl.trim();
if (!value) throw new Error('A Build slug or URL is required.');
if (/^https?:\/\//i.test(value)) return new URL(value).toString();
const base = origin ?? globalThis.location?.origin;
if (!base) throw new Error('An origin is required to construct a Build URL.');
const slug = value.replace(/^\/+|\/+$/g, '');
return new URL(`/builds/${encodeURIComponent(slug)}`, base).toString();
}
export async function shareBuild(
navigatorApi: BuildShareNavigator,
input: { title: string; url: string },
): Promise<BuildShareResult> {
if (typeof navigatorApi.share !== 'function') return { state: 'unsupported' };
try {
await navigatorApi.share({
title: input.title,
text: `View ${input.title} on SHD.build.`,
url: input.url,
});
return { state: 'shared' };
} catch (error) {
if (
typeof error === 'object' &&
error !== null &&
'name' in error &&
error.name === 'AbortError'
) {
return { state: 'cancelled' };
}
return { state: 'failed', error };
}
}
export async function copyBuildLink(
navigatorApi: BuildShareNavigator,
url: string,
): Promise<BuildCopyResult> {
if (typeof navigatorApi.clipboard?.writeText !== 'function') {
return { state: 'unsupported' };
}
try {
await navigatorApi.clipboard.writeText(url);
return { state: 'copied' };
} catch (error) {
return { state: 'failed', error };
}
}
+108
View File
@@ -0,0 +1,108 @@
<script lang="ts">
import { onMount } from 'svelte';
import { buildShareUrl, copyBuildLink, shareBuild } from '$lib/build-sharing';
import { toast } from '$lib/toast';
let {
title,
slugOrUrl,
visibility
}: {
title: string;
slugOrUrl?: string | null;
visibility: 'public' | 'unlisted' | 'private';
} = $props();
let pending = $state<'share' | 'copy' | null>(null);
let status = $state('');
let nativeShareAvailable = $state(false);
const canShare = $derived((visibility === 'public' || visibility === 'unlisted') && Boolean(slugOrUrl?.trim()));
onMount(() => {
nativeShareAvailable = typeof navigator.share === 'function';
});
function shareUrl() {
return buildShareUrl(slugOrUrl ?? '', window.location.origin);
}
async function handleShare() {
if (pending) return;
pending = 'share';
const result = await shareBuild(navigator, { title, url: shareUrl() });
pending = null;
if (result.state === 'shared') {
status = 'Build shared.';
toast.success('BUILD SHARED', 'The system share sheet accepted the Build link.');
} else if (result.state === 'cancelled') {
status = 'Sharing cancelled.';
toast.info('SHARING CANCELLED', 'No link was shared.');
} else if (result.state === 'unsupported') {
status = 'Native sharing is not supported on this device. Use Copy Link instead.';
toast.info('SHARE NOT SUPPORTED', 'Use COPY LINK to copy the Build URL.');
} else {
status = 'The Build could not be shared. Try Copy Link instead.';
toast.error('BUILD NOT SHARED', 'Try COPY LINK or try again.');
}
}
async function handleCopy() {
if (pending) return;
pending = 'copy';
const result = await copyBuildLink(navigator, shareUrl());
pending = null;
if (result.state === 'copied') {
status = 'Build link copied to the clipboard.';
toast.success('LINK COPIED', 'Paste the Build link wherever you want to share it.');
} else if (result.state === 'unsupported') {
status = 'Clipboard copying is not supported in this browser.';
toast.info('COPY NOT SUPPORTED', 'This browser cannot copy the Build link automatically.');
} else {
status = 'The Build link could not be copied.';
toast.error('LINK NOT COPIED', 'Check clipboard permissions and try again.');
}
}
</script>
<section class="build-share-control" aria-label="Share Build">
{#if canShare}
<div class="build-share-heading">
<div>
<span>BUILD UPLINK</span>
<strong>{visibility === 'unlisted' ? 'UNLISTED LINK READY' : 'PUBLIC LINK READY'}</strong>
</div>
<span class="build-share-signal" aria-hidden="true"></span>
</div>
<div class:single={!nativeShareAvailable} class="build-share-actions">
{#if nativeShareAvailable}
<button class="build-share-primary" type="button" onclick={handleShare} disabled={pending !== null} aria-busy={pending === 'share'}>
{pending === 'share' ? 'OPENING SHARE…' : 'SHARE BUILD →'}
</button>
{/if}
<button class:build-share-primary={!nativeShareAvailable} class:build-share-copy={nativeShareAvailable} type="button" onclick={handleCopy} disabled={pending !== null} aria-busy={pending === 'copy'}>
{pending === 'copy' ? 'COPYING…' : 'COPY LINK'}
</button>
</div>
<p class="build-share-discord"><img src="/discord-symbol.svg" alt="" aria-hidden="true" />Discord will render the Build preview when you post the copied link.</p>
{:else}
<div class="build-share-locked">
<span>{slugOrUrl?.trim() ? 'PRIVATE BUILD' : 'UNSAVED BUILD'}</span>
<strong>PUBLIC LINK UNAVAILABLE</strong>
<p>{slugOrUrl?.trim() ? 'Change visibility to Public or Unlisted before sharing this Build.' : 'Save this Build before creating a public or unlisted sharing link.'}</p>
</div>
{/if}
<p class="build-share-status" aria-live="polite" aria-atomic="true">{status}</p>
</section>
<style>
.build-share-control{display:grid;border:1px solid var(--shd-border-strong);background:#0d1011;font-family:var(--shd-font-sans)}
.build-share-heading{min-height:48px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 11px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}
.build-share-heading>div,.build-share-locked{display:grid;gap:3px}.build-share-heading span,.build-share-locked>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.9px}.build-share-heading strong,.build-share-locked strong{font-size:var(--shd-type-supporting);letter-spacing:.45px}.build-share-signal{width:8px;height:8px;border:1px solid var(--shd-orange);background:var(--shd-orange);box-shadow:0 0 0 3px #f6a8211c}
.build-share-actions{display:grid;grid-template-columns:minmax(0,1fr) minmax(120px,.45fr);gap:8px;padding:10px}.build-share-actions button{min-height:44px;padding:0 13px;border-radius:0;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px;cursor:pointer;transition:var(--shd-transition)}.build-share-primary{border:1px solid var(--shd-orange);background:var(--shd-orange);color:#111415}.build-share-copy{border:1px solid var(--shd-border-strong);background:var(--shd-surface);color:var(--shd-text)}.build-share-primary:hover{background:var(--shd-orange-hover);border-color:var(--shd-orange-hover)}.build-share-copy:hover{border-color:var(--shd-text-muted);background:var(--shd-surface-active)}.build-share-actions button:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:2px}.build-share-actions button:active:not(:disabled){transform:translateY(1px);filter:brightness(.86)}.build-share-actions button:disabled{cursor:not-allowed;opacity:.48}
.build-share-actions.single{grid-template-columns:1fr}
.build-share-discord{display:flex;align-items:center;gap:7px;margin:0;padding:0 11px 10px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.35}.build-share-discord img{width:16px;height:13px;object-fit:contain;opacity:.72}.build-share-locked{padding:14px}.build-share-locked>span{color:var(--shd-danger)}.build-share-locked p{margin:2px 0 0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}.build-share-status{min-height:0;margin:0;padding:0 11px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.build-share-status:not(:empty){min-height:32px;padding-top:7px;padding-bottom:7px;border-top:1px solid var(--shd-border-soft)}
@media(max-width:560px){.build-share-actions{grid-template-columns:1fr}.build-share-actions button{width:100%;min-height:44px}}
@media(prefers-reduced-motion:reduce){.build-share-actions button{transition:none}.build-share-actions button:active:not(:disabled){transform:none}}
</style>
+7 -1
View File
@@ -75,7 +75,11 @@
} }
function paste(event: ClipboardEvent) { function paste(event: ClipboardEvent) {
const file = Array.from(event.clipboardData?.files ?? []).find((item) => item.type.startsWith('image/')); const file =
Array.from(event.clipboardData?.items ?? [])
.find((item) => item.kind === 'file' && item.type.startsWith('image/'))
?.getAsFile() ??
Array.from(event.clipboardData?.files ?? []).find((item) => item.type.startsWith('image/'));
if (file) { if (file) {
event.preventDefault(); event.preventDefault();
setFile(file); setFile(file);
@@ -107,10 +111,12 @@
}); });
</script> </script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -- focus is required as a scoped native paste target -->
<div <div
class="image-drop-input" class="image-drop-input"
class:dragging class:dragging
role="group" role="group"
tabindex="0"
aria-label="Drop an image here or paste one from the clipboard" aria-label="Drop an image here or paste one from the clipboard"
ondragenter={(event) => { event.preventDefault(); dragging = true; }} ondragenter={(event) => { event.preventDefault(); dragging = true; }}
ondragover={(event) => event.preventDefault()} ondragover={(event) => event.preventDefault()}
+105
View File
@@ -0,0 +1,105 @@
import sharp from 'sharp';
import { describe, expect, it } from 'vitest';
import {
BUILD_SOCIAL_CARD_HEIGHT,
BUILD_SOCIAL_CARD_WIDTH,
buildBuildSocialCardSvg,
renderBuildSocialCardPng,
type BuildSocialCardInput,
} from './build-social-card';
const input: BuildSocialCardInput = {
title: 'Vanguard Counter-Offensive',
author: 'Agent Kelso',
primaryWeapon: 'Police M4',
secondaryWeapon: 'Super 90',
firepower: 1280,
toughness: 940,
engineering: 615,
supportingAttributes: [
{ label: 'Critical Hit Chance', value: '18.5%' },
{ label: 'Headshot Damage', value: '42%' },
{ label: 'Armor', value: '12,400' },
],
visibility: 'public',
directUrl: 'shd.build/builds/vanguard-counter-offensive',
};
describe('build social card', () => {
it('escapes XML injection and keeps inputs out of markup and URLs', () => {
const attack = `</text><script href="https://attacker.invalid">alert('x') & more</script>`;
const svg = buildBuildSocialCardSvg({
...input,
title: attack,
author: `A\u0000gent <&> "one" 'two'`,
directUrl: `javascript:alert(1)\n${attack}`,
supportingAttributes: [{ label: attack, value: attack }],
});
expect(svg).not.toContain('<script');
expect(svg).not.toContain('href="https://attacker.invalid"');
expect(svg).not.toContain('\u0000');
expect(svg).toContain(
'&lt;/text&gt;&lt;script href=&quot;https://attacker.invalid&quot;&gt;',
);
expect(svg).toContain(
'A gent &lt;&amp;&gt; &quot;one&quot; &apos;two&apos;',
);
expect(svg).not.toMatch(/<[^>]+\s(?:href|xlink:href)\s*=/gu);
});
it('wraps and truncates long Unicode text without splitting graphemes', () => {
const family = '👨‍👩‍👧‍👦';
const svg = buildBuildSocialCardSvg({
...input,
title: `${family.repeat(31)} ${family.repeat(31)} ${family.repeat(31)}`,
author: `Zoë ${'e\u0301'.repeat(60)}`,
directUrl: `shd.build/builds/${'路'.repeat(130)}`,
});
const titleBlock =
svg.match(/<text class="title">(.*?)<\/text>/u)?.[1] ?? '';
expect(titleBlock.match(/<tspan/gu)).toHaveLength(2);
expect(titleBlock).toContain('…');
expect(titleBlock).not.toMatch(
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/gu,
);
expect(svg).toContain('é');
expect(svg).not.toContain('é');
expect(svg).toContain(`${'路'.repeat(20)}`);
expect(svg).toContain('…');
});
it('builds deterministic SVG with bounded supporting facts', () => {
const withExtraFacts = {
...input,
supportingAttributes: [
...input.supportingAttributes!,
{ label: 'Must not render', value: '999' },
],
};
const first = buildBuildSocialCardSvg(withExtraFacts);
const second = buildBuildSocialCardSvg(withExtraFacts);
expect(first).toBe(second);
expect(first).toContain('width="1200" height="630"');
expect(first).toContain('SHD</text><text');
expect(first).toContain('.build');
expect(first).toContain('font-family:"DejaVu Sans Condensed",sans-serif');
expect(first).toContain('SUPPORTING ATTRIBUTES / 03');
expect(first).not.toContain('Must not render');
});
it('renders a valid 1200x630 PNG through Sharp', async () => {
const png = await renderBuildSocialCardPng(input);
const metadata = await sharp(png).metadata();
expect(png.subarray(0, 8)).toEqual(
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
);
expect(metadata.format).toBe('png');
expect(metadata.width).toBe(BUILD_SOCIAL_CARD_WIDTH);
expect(metadata.height).toBe(BUILD_SOCIAL_CARD_HEIGHT);
expect(png.byteLength).toBeLessThan(1_000_000);
});
});
+170
View File
@@ -0,0 +1,170 @@
import sharp from 'sharp';
export const BUILD_SOCIAL_CARD_WIDTH = 1200;
export const BUILD_SOCIAL_CARD_HEIGHT = 630;
export type BuildVisibility = 'public' | 'unlisted' | 'private';
export interface BuildSocialCardFact {
label: string;
value: string;
}
export interface BuildSocialCardInput {
title: string;
author: string;
primaryWeapon: string;
secondaryWeapon: string;
firepower: string | number;
toughness: string | number;
engineering: string | number;
supportingAttributes?: readonly BuildSocialCardFact[];
visibility: BuildVisibility;
directUrl: string;
}
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
function graphemes(value: string): string[] {
return Array.from(segmenter.segment(value), ({ segment }) => segment);
}
function normalizeText(value: string | number, maximum: number): string {
const normalized = String(value)
.normalize('NFC')
.replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' ')
.replace(/\s+/gu, ' ')
.trim();
const characters = graphemes(normalized);
if (characters.length <= maximum) return normalized;
return `${characters
.slice(0, Math.max(0, maximum - 1))
.join('')
.trimEnd()}…`;
}
function escapeXml(value: string): string {
return value
.replace(/&/gu, '&amp;')
.replace(/</gu, '&lt;')
.replace(/>/gu, '&gt;')
.replace(/"/gu, '&quot;')
.replace(/'/gu, '&apos;');
}
function wrapText(
value: string,
width: number,
maximumLines: number,
): string[] {
const characters = graphemes(value);
const lines: string[] = [];
let offset = 0;
while (offset < characters.length && lines.length < maximumLines) {
const remaining = characters.slice(offset);
if (remaining.length <= width) {
lines.push(remaining.join('').trim());
offset = characters.length;
break;
}
const candidate = remaining.slice(0, width + 1);
let splitAt = -1;
for (
let index = Math.min(width, candidate.length - 1);
index > 0;
index--
) {
if (/\s/u.test(candidate[index])) {
splitAt = index;
break;
}
}
if (splitAt < Math.floor(width * 0.55)) splitAt = width;
lines.push(remaining.slice(0, splitAt).join('').trim());
offset += splitAt;
while (offset < characters.length && /\s/u.test(characters[offset]))
offset++;
}
if (offset < characters.length && lines.length > 0) {
const last = graphemes(lines.at(-1) ?? '');
lines[lines.length - 1] = `${last
.slice(0, Math.max(0, width - 1))
.join('')
.trimEnd()}…`;
}
return lines.length > 0 ? lines : ['UNTITLED BUILD'];
}
function textLines(
lines: readonly string[],
x: number,
y: number,
lineHeight: number,
): string {
return lines
.map(
(line, index) =>
`<tspan x="${x}" y="${y + index * lineHeight}">${escapeXml(line)}</tspan>`,
)
.join('');
}
function visibilityLabel(visibility: BuildVisibility): string {
if (visibility === 'public') return 'PUBLIC';
if (visibility === 'unlisted') return 'UNLISTED';
return 'PRIVATE';
}
/** Builds the complete, text-only SVG supplied to Sharp. */
export function buildBuildSocialCardSvg(input: BuildSocialCardInput): string {
const title = wrapText(normalizeText(input.title, 112), 31, 2);
const author = normalizeText(input.author, 42) || 'UNKNOWN AGENT';
const primaryWeapon = normalizeText(input.primaryWeapon, 52) || 'UNSPECIFIED';
const secondaryWeapon =
normalizeText(input.secondaryWeapon, 52) || 'UNSPECIFIED';
const firepower = normalizeText(input.firepower, 18) || '—';
const toughness = normalizeText(input.toughness, 18) || '—';
const engineering = normalizeText(input.engineering, 18) || '—';
const directUrl = normalizeText(input.directUrl, 92) || 'shd.build';
const facts = (input.supportingAttributes ?? []).slice(0, 3).map((fact) => ({
label: normalizeText(fact.label.toUpperCase(), 28) || 'ATTRIBUTE',
value: normalizeText(fact.value, 30) || '—',
}));
const factMarkup = facts
.map((fact, index) => {
const y = 421 + index * 48;
return `<g><line x1="746" y1="${y + 13}" x2="764" y2="${y + 13}" class="signal"/><text x="778" y="${y}" class="fact-label">${escapeXml(fact.label)}</text><text x="778" y="${y + 22}" class="fact-value">${escapeXml(fact.value)}</text></g>`;
})
.join('');
return `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-label="SHD.build build card">
<style>
text{font-family:"DejaVu Sans Condensed",sans-serif}.telemetry{font-size:13px;font-weight:700;letter-spacing:2px;fill:#858b8d}.title{font-size:54px;font-weight:900;letter-spacing:-1px;fill:#ecebe5}.weapon{font-size:25px;font-weight:800;fill:#ecebe5}.stat-label,.fact-label{font-size:12px;font-weight:800;letter-spacing:1.5px;fill:#858b8d}.stat-value{font-size:31px;font-weight:900;fill:#ecebe5}.fact-value{font-size:19px;font-weight:700;fill:#ecebe5}.signal{stroke:#f6a821;stroke-width:2}.grid{stroke:#272c2d;stroke-width:1}.panel{fill:#111415;stroke:#303536;stroke-width:1}
</style>
<rect width="1200" height="630" fill="#0a0c0d"/><path d="M0 96H1200M0 594H1200M710 96V594" class="grid"/><path d="M28 28H76M28 28V76M1172 28H1124M1172 28V76M28 602H76M28 602V554M1172 602H1124M1172 602V554" class="signal"/>
<g transform="translate(52 45)"><text x="0" y="0" font-size="34" font-weight="900" fill="#ecebe5">SHD</text><text x="67" y="0" font-size="34" font-weight="900" fill="#f6a821">.build</text><text x="191" y="-2" class="telemetry">BUILD DOSSIER / SOCIAL TRANSMISSION</text></g>
<text x="52" y="139" class="telemetry">AGENT LOADOUT</text><text class="title">${textLines(title, 52, 201, 61)}</text><text x="52" y="294" class="telemetry">AUTHORED BY</text><text x="164" y="294" font-size="18" font-weight="800" fill="#ecebe5">${escapeXml(author)}</text>
<g><rect x="52" y="328" width="622" height="88" class="panel"/><rect x="52" y="328" width="4" height="88" fill="#f6a821"/><text x="76" y="356" class="stat-label">PRIMARY WEAPON</text><text x="76" y="391" class="weapon">${escapeXml(primaryWeapon)}</text></g>
<g><rect x="52" y="430" width="622" height="88" class="panel"/><text x="76" y="458" class="stat-label">SECONDARY WEAPON</text><text x="76" y="493" class="weapon">${escapeXml(secondaryWeapon)}</text></g>
<g transform="translate(746 132)"><text x="0" y="0" class="telemetry">CORE ATTRIBUTES</text><rect x="0" y="24" width="402" height="174" class="panel"/><line x1="134" y1="24" x2="134" y2="198" class="grid"/><line x1="268" y1="24" x2="268" y2="198" class="grid"/><text x="18" y="66" class="stat-label">FIREPOWER</text><text x="18" y="112" class="stat-value">${escapeXml(firepower)}</text><text x="152" y="66" class="stat-label">TOUGHNESS</text><text x="152" y="112" class="stat-value">${escapeXml(toughness)}</text><text x="286" y="66" class="stat-label">ENGINEERING</text><text x="286" y="112" class="stat-value">${escapeXml(engineering)}</text><path d="M18 166H116M152 166H250M286 166H384" class="signal"/></g>
<text x="746" y="374" class="telemetry">SUPPORTING ATTRIBUTES / ${String(facts.length).padStart(2, '0')}</text>${factMarkup}
<g><text x="52" y="566" class="telemetry">DIRECT LINK</text><text x="157" y="566" font-size="15" font-weight="700" fill="#ecebe5">${escapeXml(directUrl)}</text><rect x="972" y="541" width="176" height="36" fill="#15191a" stroke="#f6a821"/><text x="1060" y="564" text-anchor="middle" font-size="13" font-weight="900" letter-spacing="2" fill="#f6a821">${visibilityLabel(input.visibility)}</text></g>
</svg>`;
}
/** Renders a deterministic 1200x630 PNG without loading external resources. */
export async function renderBuildSocialCardPng(
input: BuildSocialCardInput,
): Promise<Buffer> {
return sharp(Buffer.from(buildBuildSocialCardSvg(input)), {
density: 72,
failOn: 'error',
unlimited: false,
})
.png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
.toBuffer();
}
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { publicOrigin } from './public-origin';
describe('publicOrigin', () => {
it('uses the configured public origin instead of a request host', () => {
expect(
publicOrigin('https://host-header.invalid', 'https://shd.build/app/path'),
).toBe('https://shd.build');
});
it('uses the request origin when APP_ORIGIN is not configured', () => {
expect(publicOrigin('http://localhost:5173', '')).toBe(
'http://localhost:5173',
);
});
it('rejects non-HTTP origins', () => {
expect(() =>
publicOrigin('https://shd.build', 'javascript:alert(1)'),
).toThrow('APP_ORIGIN must use HTTP or HTTPS.');
});
});
+13
View File
@@ -0,0 +1,13 @@
import { env } from '$env/dynamic/private';
export function publicOrigin(
fallback: string,
configuredOrigin: string | undefined = env.APP_ORIGIN,
): string {
const value = configuredOrigin?.trim() || fallback;
const url = new URL(value);
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
throw new Error('APP_ORIGIN must use HTTP or HTTPS.');
}
return url.origin;
}
+100
View File
@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createDraft } from '$lib/builder/loadout';
const mocks = vi.hoisted(() => ({
row: null as Record<string, unknown> | null,
loadCatalog: vi.fn(),
}));
vi.mock('$lib/server/database', () => ({
getDatabase: vi.fn(async () => ({
prepare: vi.fn(async (sql: string) => ({
get: vi.fn(async () => (sql.includes('FROM builds') ? mocks.row : null)),
all: vi.fn(async () => []),
})),
})),
}));
vi.mock('$lib/server/shared-build-summary', () => ({
loadSharedBuildSummaryCatalog: mocks.loadCatalog,
}));
import { loadSharedBuildProjection } from './shared-build';
function buildRow(visibility: 'private' | 'unlisted' | 'public') {
return {
id: 'build-id',
owner_id: 'owner-id',
title: 'Field Medic',
slug: 'field-medic-12345678',
visibility,
author: 'Aewyn',
profile_is_public: 1,
loadout_json: JSON.stringify(createDraft()),
updated_at: '2026-08-02T00:00:00.000Z',
};
}
describe('shared Build access policy', () => {
beforeEach(() => {
mocks.row = null;
mocks.loadCatalog.mockReset().mockResolvedValue({
attributes: [],
gear: [],
gearAugments: [],
brands: [],
weapons: [],
talents: [],
chipSets: [],
protocols: [],
specializations: [],
});
});
it.each(['public', 'unlisted'] as const)(
'permits %s Builds in public previews',
async (visibility) => {
mocks.row = buildRow(visibility);
const result = await loadSharedBuildProjection('field-medic-12345678', {
kind: 'public-preview',
});
expect(result?.build.visibility).toBe(visibility);
expect(result?.build.owner).toBe(false);
},
);
it('does not expose private Builds to public previews or other viewers', async () => {
mocks.row = buildRow('private');
await expect(
loadSharedBuildProjection('field-medic-12345678', {
kind: 'public-preview',
}),
).resolves.toBeNull();
await expect(
loadSharedBuildProjection('field-medic-12345678', {
kind: 'viewer-page',
viewerId: 'another-agent',
}),
).resolves.toBeNull();
expect(mocks.loadCatalog).not.toHaveBeenCalled();
});
it('permits the owner to view a private Build page', async () => {
mocks.row = buildRow('private');
const result = await loadSharedBuildProjection('field-medic-12345678', {
kind: 'viewer-page',
viewerId: 'owner-id',
});
expect(result?.build.owner).toBe(true);
expect(result?.build.visibility).toBe('private');
});
it('returns null for malformed loadout snapshots', async () => {
mocks.row = { ...buildRow('public'), loadout_json: '{invalid' };
await expect(
loadSharedBuildProjection('field-medic-12345678', {
kind: 'public-preview',
}),
).resolves.toBeNull();
expect(mocks.loadCatalog).not.toHaveBeenCalled();
});
});
+104
View File
@@ -0,0 +1,104 @@
import { avatarUrl } from '$lib/avatar';
import { parseLoadoutDraft } from '$lib/builder/loadout';
import { summarizeLoadout } from '$lib/builder/loadout-summary';
import { getDatabase } from '$lib/server/database';
import { loadSharedBuildSummaryCatalog } from '$lib/server/shared-build-summary';
export type SharedBuildAccessPolicy =
{ kind: 'viewer-page'; viewerId: string | null } | { kind: 'public-preview' };
type BuildVisibility = 'public' | 'unlisted' | 'private';
export async function loadSharedBuildProjection(
slug: string,
access: SharedBuildAccessPolicy,
) {
const database = await getDatabase();
const build = await (
await database.prepare(`
SELECT builds.*,COALESCE(user_profiles.display_name,users.username,'Deleted Agent') AS author,
user_profiles.public_slug,
COALESCE(user_profiles.is_public,1) AS profile_is_public,deleted_accounts.user_id AS deleted_user_id,
user_avatars.source AS avatar_source,user_avatars.value AS avatar_value,
user_avatars.updated_at AS avatar_updated_at
FROM builds
JOIN users ON users.id=builds.owner_id
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
LEFT JOIN user_avatars ON user_avatars.user_id=users.id
WHERE builds.slug=?
`)
).get(slug);
if (!build) return null;
const ownerId = String(build.owner_id);
const visibility = String(build.visibility) as BuildVisibility;
const viewerId = access.kind === 'viewer-page' ? access.viewerId : null;
const accessible =
access.kind === 'public-preview'
? visibility === 'public' || visibility === 'unlisted'
: visibility === 'public' ||
visibility === 'unlisted' ||
(visibility === 'private' && ownerId === viewerId);
if (!accessible) return null;
let loadout;
try {
loadout = parseLoadoutDraft(JSON.parse(String(build.loadout_json)));
} catch {
return null;
}
if (!loadout) return null;
const [gear, weapons, chips, protocols, brands, summaryCatalog] =
await Promise.all([
(
await database.prepare(
'SELECT id,name,slot,tier,base_armor_min FROM gear',
)
).all(),
(
await database.prepare(
'SELECT id,name,category,is_exotic,rpm,magazine,reload_speed,damage_type FROM weapons',
)
).all(),
(await database.prepare('SELECT id,name FROM skill_chip_sets')).all(),
(
await database.prepare('SELECT id,name,quality FROM os_protocols')
).all(),
(await database.prepare('SELECT id,name FROM brand_sets')).all(),
loadSharedBuildSummaryCatalog(database),
]);
const summary = summarizeLoadout(loadout, summaryCatalog);
return {
build: {
id: String(build.id),
title: String(build.title),
slug: String(build.slug),
visibility,
author: String(build.author),
ownerId,
authorPublicSlug: build.public_slug ? String(build.public_slug) : null,
profileAvailable:
Boolean(build.profile_is_public) && !build.deleted_user_id,
authorAvatarUrl: avatarUrl(
build.avatar_source,
build.avatar_value,
ownerId,
build.avatar_updated_at,
),
owner: ownerId === viewerId,
updatedAt: String(build.updated_at),
},
loadout,
gear,
weapons,
chips,
protocols,
brands,
summary,
attributes: summaryCatalog.attributes,
talents: summaryCatalog.talents,
};
}
+2
View File
@@ -4,6 +4,7 @@ import { getSessionUser } from '$lib/server/auth/session';
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { parseLoadoutDraft } from '$lib/builder/loadout'; import { parseLoadoutDraft } from '$lib/builder/loadout';
import { publicOrigin } from '$lib/server/public-origin';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses'; import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
@@ -284,6 +285,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
})); }));
return { return {
viewer: viewer ? { id: viewer.id, displayName: viewer.displayName } : null, viewer: viewer ? { id: viewer.id, displayName: viewer.displayName } : null,
publicOrigin: publicOrigin(url.origin),
initialBuild, initialBuild,
attributes, attributes,
coreRanges, coreRanges,
+11 -4
View File
@@ -8,6 +8,7 @@
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte'; import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import PendingForm from '$lib/components/PendingForm.svelte'; import PendingForm from '$lib/components/PendingForm.svelte';
import ItemIcon from '$lib/components/ItemIcon.svelte'; import ItemIcon from '$lib/components/ItemIcon.svelte';
import BuildShareControl from '$lib/components/BuildShareControl.svelte';
import LoadoutMatrix, { type MatrixCell } from '$lib/components/builder/LoadoutMatrix.svelte'; import LoadoutMatrix, { type MatrixCell } from '$lib/components/builder/LoadoutMatrix.svelte';
import LoadoutStatsSummary from '$lib/components/builder/LoadoutStatsSummary.svelte'; import LoadoutStatsSummary from '$lib/components/builder/LoadoutStatsSummary.svelte';
import PlannedCombatModel from '$lib/components/builder/PlannedCombatModel.svelte'; import PlannedCombatModel from '$lib/components/builder/PlannedCombatModel.svelte';
@@ -25,7 +26,10 @@
let message = $state(''); let message = $state('');
let persistedBuildId = $state(untrack(() => data.initialBuild?.id ?? '')); let persistedBuildId = $state(untrack(() => data.initialBuild?.id ?? ''));
let persistedSlug = $state(untrack(() => data.initialBuild?.slug ?? '')); let persistedSlug = $state(untrack(() => data.initialBuild?.slug ?? ''));
let persistedVisibility = $state(untrack(() => data.initialBuild?.visibility ?? 'private')); type BuildVisibility = 'private' | 'unlisted' | 'public';
let persistedVisibility = $state<BuildVisibility>(untrack(() => (data.initialBuild?.visibility ?? 'private') as BuildVisibility));
let savedBuildVisibility = $state<BuildVisibility>(untrack(() => (data.initialBuild?.visibility ?? 'private') as BuildVisibility));
let savedBuildTitle = $state(untrack(() => draft.buildName));
let handledBuildToken = ''; let handledBuildToken = '';
const weaponRangeKeys = ['damage','firepower','toughness','engineering'] as const; const weaponRangeKeys = ['damage','firepower','toughness','engineering'] as const;
const coreKeys = ['firepower','toughness','engineering'] as const; const coreKeys = ['firepower','toughness','engineering'] as const;
@@ -378,7 +382,9 @@
handledBuildToken = form.buildToken; handledBuildToken = form.buildToken;
persistedBuildId = form.savedBuild.id; persistedBuildId = form.savedBuild.id;
persistedSlug = form.savedBuild.slug; persistedSlug = form.savedBuild.slug;
persistedVisibility = form.savedBuild.visibility; persistedVisibility = form.savedBuild.visibility as BuildVisibility;
savedBuildVisibility = form.savedBuild.visibility as BuildVisibility;
savedBuildTitle = form.savedBuild.title;
message = form.savedBuild.visibility === 'private' message = form.savedBuild.visibility === 'private'
? 'Private build saved to your account.' ? 'Private build saved to your account.'
: form.savedBuild.visibility === 'unlisted' : form.savedBuild.visibility === 'unlisted'
@@ -419,15 +425,16 @@
{ value:'private', label:'PRIVATE' }, { value:'private', label:'PRIVATE' },
{ value:'unlisted', label:'UNLISTED' }, { value:'unlisted', label:'UNLISTED' },
{ value:'public', label:'PUBLIC' } { value:'public', label:'PUBLIC' }
]} value={persistedVisibility} onchange={(values) => persistedVisibility = values[0]} required/> ]} value={persistedVisibility} onchange={(values) => persistedVisibility = values[0] as BuildVisibility} required/>
<p>{persistedVisibility === 'private' ? 'Only you can open this build.' : persistedVisibility === 'unlisted' ? 'Anyone with its direct link can open this build.' : 'Listed publicly and accessible by direct link.'}</p> <p>{persistedVisibility === 'private' ? 'Only you can open this build.' : persistedVisibility === 'unlisted' ? 'Anyone with its direct link can open this build.' : 'Listed publicly and accessible by direct link.'}</p>
{#if persistedSlug && persistedVisibility !== 'private'}<a class="builder-share-link" href={`/builds/${persistedSlug}`}>VIEW SHARE PAGE →</a>{/if} {#if persistedSlug && savedBuildVisibility !== 'private'}<a class="builder-share-link" href={`/builds/${persistedSlug}`}>VIEW SHARE PAGE →</a>{/if}
</div> </div>
{/if} {/if}
<div class="builder-status"><span><i></i> KNOWN STATS ONLINE</span><span>ADVANCED MODEL · COMING SOON</span><span>12 LOADOUT CELLS + 9 CHIPS</span><span>{data.gear.length + data.weapons.length + data.chipSets.length + data.protocols.length} CATALOG ITEMS</span></div> <div class="builder-status"><span><i></i> KNOWN STATS ONLINE</span><span>ADVANCED MODEL · COMING SOON</span><span>12 LOADOUT CELLS + 9 CHIPS</span><span>{data.gear.length + data.weapons.length + data.chipSets.length + data.protocols.length} CATALOG ITEMS</span></div>
</div> </div>
{#if message}<p class="success" role="status">{message}</p>{/if} {#if message}<p class="success" role="status">{message}</p>{/if}
{#if form?.buildError}<p class="error" role="alert">{form.buildError}</p>{/if} {#if form?.buildError}<p class="error" role="alert">{form.buildError}</p>{/if}
{#if data.viewer && persistedSlug}<BuildShareControl title={savedBuildTitle} slugOrUrl={new URL(`/builds/${encodeURIComponent(persistedSlug)}`, data.publicOrigin).toString()} visibility={savedBuildVisibility}/>{/if}
{#if !data.gear.length && !data.weapons.length && !data.chipSets.length && !data.protocols.length}<div class="builder-empty-data"><strong>ISAC DATA LINK EMPTY</strong><span>Add active equipment definitions in ISAC to populate this workbench.</span></div>{/if} {#if !data.gear.length && !data.weapons.length && !data.chipSets.length && !data.protocols.length}<div class="builder-empty-data"><strong>ISAC DATA LINK EMPTY</strong><span>Add active equipment definitions in ISAC to populate this workbench.</span></div>{/if}
<div class="loadout-workspace"> <div class="loadout-workspace">
<LoadoutMatrix <LoadoutMatrix
+9 -85
View File
@@ -1,91 +1,15 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { getDatabase } from '$lib/server/database';
import { getSessionUser } from '$lib/server/auth/session'; import { getSessionUser } from '$lib/server/auth/session';
import { parseLoadoutDraft, type LoadoutDraft } from '$lib/builder/loadout'; import { publicOrigin } from '$lib/server/public-origin';
import { summarizeLoadout } from '$lib/builder/loadout-summary'; import { loadSharedBuildProjection } from '$lib/server/shared-build';
import { loadSharedBuildSummaryCatalog } from '$lib/server/shared-build-summary';
import { avatarUrl } from '$lib/avatar';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, cookies }) => { export const load: PageServerLoad = async ({ params, cookies, url }) => {
const database = await getDatabase();
const viewer = await getSessionUser(cookies); const viewer = await getSessionUser(cookies);
const build = await ( const projection = await loadSharedBuildProjection(params.slug, {
await database.prepare(` kind: 'viewer-page',
SELECT builds.*,COALESCE(user_profiles.display_name,users.username,'Deleted Agent') AS author, viewerId: viewer?.id ?? null,
user_profiles.public_slug, });
COALESCE(user_profiles.is_public,1) AS profile_is_public,deleted_accounts.user_id AS deleted_user_id, if (!projection) error(404, 'Build not found');
user_avatars.source AS avatar_source,user_avatars.value AS avatar_value, return { ...projection, publicOrigin: publicOrigin(url.origin) };
user_avatars.updated_at AS avatar_updated_at
FROM builds
JOIN users ON users.id=builds.owner_id
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
LEFT JOIN user_avatars ON user_avatars.user_id=users.id
WHERE builds.slug=?
`)
).get(params.slug);
if (
!build ||
(build.visibility === 'private' && build.owner_id !== viewer?.id)
)
error(404, 'Build not found');
let loadout: LoadoutDraft;
try {
const parsed = parseLoadoutDraft(JSON.parse(String(build.loadout_json)));
if (!parsed) error(404, 'Build data is unavailable');
loadout = parsed;
} catch {
error(404, 'Build data is unavailable');
}
const [gear, weapons, chips, protocols, brands, summaryCatalog] =
await Promise.all([
(
await database.prepare(
'SELECT id,name,slot,tier,base_armor_min FROM gear',
)
).all(),
(
await database.prepare(
'SELECT id,name,category,is_exotic,rpm,magazine,reload_speed,damage_type FROM weapons',
)
).all(),
(await database.prepare('SELECT id,name FROM skill_chip_sets')).all(),
(
await database.prepare('SELECT id,name,quality FROM os_protocols')
).all(),
(await database.prepare('SELECT id,name FROM brand_sets')).all(),
loadSharedBuildSummaryCatalog(database),
]);
const summary = summarizeLoadout(loadout, summaryCatalog);
return {
build: {
id: String(build.id),
title: String(build.title),
slug: String(build.slug),
visibility: String(build.visibility),
author: String(build.author),
ownerId: String(build.owner_id),
authorPublicSlug: build.public_slug ? String(build.public_slug) : null,
profileAvailable:
Boolean(build.profile_is_public) && !build.deleted_user_id,
authorAvatarUrl: avatarUrl(
build.avatar_source,
build.avatar_value,
String(build.owner_id),
build.avatar_updated_at,
),
owner: build.owner_id === viewer?.id,
updatedAt: String(build.updated_at),
},
loadout,
gear,
weapons,
chips,
protocols,
brands,
summary,
attributes: summaryCatalog.attributes,
talents: summaryCatalog.talents,
};
}; };
+25 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { gearSlots } from '$lib/builder/loadout'; import { gearSlots } from '$lib/builder/loadout';
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte'; import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
import BuildShareControl from '$lib/components/BuildShareControl.svelte';
import LoadoutStatsSummary from '$lib/components/builder/LoadoutStatsSummary.svelte'; import LoadoutStatsSummary from '$lib/components/builder/LoadoutStatsSummary.svelte';
import PlannedCombatModel from '$lib/components/builder/PlannedCombatModel.svelte'; import PlannedCombatModel from '$lib/components/builder/PlannedCombatModel.svelte';
let { data } = $props(); let { data } = $props();
@@ -17,14 +18,37 @@
.join(' · '); .join(' · ');
const coreLine = (selection: { firepower: number; toughness: number; engineering: number }) => const coreLine = (selection: { firepower: number; toughness: number; engineering: number }) =>
`FP ${number.format(selection.firepower)} · TGH ${number.format(selection.toughness)} · ENG ${number.format(selection.engineering)}`; `FP ${number.format(selection.firepower)} · TGH ${number.format(selection.toughness)} · ENG ${number.format(selection.engineering)}`;
const description = $derived(`${data.build.title}, a Division Resurgence Build by ${data.build.author}. View its equipped weapons, gear, Skill Chips, and known Agent stats on SHD.build.`);
const canonicalUrl = $derived(new URL(`/builds/${encodeURIComponent(data.build.slug)}`, data.publicOrigin).toString());
const previewUrl = $derived(new URL(`${canonicalUrl}/preview.png`).toString());
</script> </script>
<svelte:head><title>{data.build.title} — SHD.build</title></svelte:head> <svelte:head>
<title>{data.build.title} — SHD.build</title>
<meta name="description" content={description}/>
<link rel="canonical" href={canonicalUrl}/>
<meta property="og:type" content="website"/>
<meta property="og:site_name" content="SHD.build"/>
<meta property="og:title" content={`${data.build.title} — SHD.build`}/>
<meta property="og:description" content={description}/>
<meta property="og:url" content={canonicalUrl}/>
{#if data.build.visibility !== 'private'}
<meta property="og:image" content={previewUrl}/>
<meta property="og:image:width" content="1200"/>
<meta property="og:image:height" content="630"/>
<meta property="og:image:type" content="image/png"/>
<meta property="og:image:alt" content={`Build preview for ${data.build.title}`}/>
<meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:image" content={previewUrl}/>
{/if}
{#if data.build.visibility !== 'public'}<meta name="robots" content="noindex,nofollow"/>{/if}
</svelte:head>
<main class="app-page shared-build-page"> <main class="app-page shared-build-page">
<header class="shared-build-command"> <header class="shared-build-command">
<div><p class="eyebrow"><span></span> SHD LOADOUT ARCHIVE / {data.build.visibility.toUpperCase()}</p><h1>{data.build.title}</h1><AgentIdentityLink size="compact" linked={data.build.profileAvailable} agent={{ id: data.build.ownerId, displayName: data.build.author, publicSlug: data.build.authorPublicSlug, avatarUrl: data.build.authorAvatarUrl }} meta={`UPDATED ${new Date(data.build.updatedAt).toLocaleDateString()}`}/></div> <div><p class="eyebrow"><span></span> SHD LOADOUT ARCHIVE / {data.build.visibility.toUpperCase()}</p><h1>{data.build.title}</h1><AgentIdentityLink size="compact" linked={data.build.profileAvailable} agent={{ id: data.build.ownerId, displayName: data.build.author, publicSlug: data.build.authorPublicSlug, avatarUrl: data.build.authorAvatarUrl }} meta={`UPDATED ${new Date(data.build.updatedAt).toLocaleDateString()}`}/></div>
<div>{#if data.build.owner}<a class="secondary" href={`/guides/new?build=${encodeURIComponent(data.build.id)}`}>CREATE GUIDE FOR BUILD</a><a class="secondary" href={`/builder?build=${data.build.slug}`}>EDIT LOADOUT</a>{/if}<a class="primary" href="/builder">NEW BUILD +</a></div> <div>{#if data.build.owner}<a class="secondary" href={`/guides/new?build=${encodeURIComponent(data.build.id)}`}>CREATE GUIDE FOR BUILD</a><a class="secondary" href={`/builder?build=${data.build.slug}`}>EDIT LOADOUT</a>{/if}<a class="primary" href="/builder">NEW BUILD +</a></div>
</header> </header>
<BuildShareControl title={data.build.title} slugOrUrl={canonicalUrl} visibility={data.build.visibility}/>
<div class="shared-build-intelligence"> <div class="shared-build-intelligence">
<LoadoutStatsSummary summary={data.summary} attributes={data.attributes} talents={data.talents} headingId="shared-known-stats-heading" layout="page" /> <LoadoutStatsSummary summary={data.summary} attributes={data.attributes} talents={data.talents} headingId="shared-known-stats-heading" layout="page" />
<PlannedCombatModel headingId="shared-planned-model-heading" headingLevel="h2" variant="compact" /> <PlannedCombatModel headingId="shared-planned-model-heading" headingLevel="h2" variant="compact" />
@@ -0,0 +1,180 @@
import { createHash } from 'node:crypto';
import { error } from '@sveltejs/kit';
import {
renderBuildSocialCardPng,
type BuildSocialCardFact,
type BuildSocialCardInput,
} from '$lib/server/build-social-card';
import { publicOrigin } from '$lib/server/public-origin';
import { loadSharedBuildProjection } from '$lib/server/shared-build';
import type { RequestHandler } from './$types';
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 });
const MAX_CACHE_BYTES = 16 * 1024 * 1024;
const MAX_ACTIVE_REQUESTS = 4;
const MAX_QUEUED_REQUESTS = 32;
type CachedImage = { body: Buffer; etag: string };
const imageCache = new Map<string, CachedImage>();
const pendingImages = new Map<string, Promise<CachedImage>>();
const requestQueue: Array<(release: () => void) => void> = [];
let cacheBytes = 0;
let activeRequests = 0;
function unitValue(value: number, unit: string | null | undefined) {
const rendered = number.format(value);
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit ?? ''}`;
}
export function _socialCardInput(
projection: NonNullable<
Awaited<ReturnType<typeof loadSharedBuildProjection>>
>,
directUrl: string,
): BuildSocialCardInput {
const attribute = (id: string) =>
projection.attributes.find((item) => String(item.id) === id);
const statByName = (name: string) =>
projection.summary.stats.find(
(stat) => String(attribute(stat.id)?.name) === name,
);
const statValue = (name: string) => {
const stat = statByName(name);
return stat ? unitValue(Number(stat.value), attribute(stat.id)?.unit) : '0';
};
const weaponName = (index: number) => {
const selected = projection.loadout.weapons[index];
return (
projection.weapons.find((item) => String(item.id) === selected?.itemId)
?.name ?? 'Unassigned'
);
};
const coreNames = new Set(['Firepower', 'Toughness', 'Engineering']);
const supportingAttributes: BuildSocialCardFact[] = projection.summary.stats
.filter((stat) => !coreNames.has(String(attribute(stat.id)?.name)))
.slice(0, 3)
.map((stat) => ({
label: String(attribute(stat.id)?.name ?? 'Attribute'),
value: unitValue(Number(stat.value), attribute(stat.id)?.unit),
}));
return {
title: projection.build.title,
author: projection.build.author,
primaryWeapon: String(weaponName(0)),
secondaryWeapon: String(weaponName(1)),
firepower: statValue('Firepower'),
toughness: statValue('Toughness'),
engineering: statValue('Engineering'),
supportingAttributes,
visibility: projection.build.visibility as 'public' | 'unlisted',
directUrl,
};
}
function matchesEtag(request: Request, etag: string) {
return request.headers
.get('if-none-match')
?.split(',')
.some((value) => value.trim() === etag);
}
function releaseRequest() {
const next = requestQueue.shift();
if (next) {
next(releaseRequest);
return;
}
activeRequests -= 1;
}
function acquireRequest(): Promise<(() => void) | null> {
if (activeRequests < MAX_ACTIVE_REQUESTS) {
activeRequests += 1;
return Promise.resolve(releaseRequest);
}
if (requestQueue.length >= MAX_QUEUED_REQUESTS) return Promise.resolve(null);
return new Promise((resolve) => requestQueue.push(resolve));
}
function storeImage(key: string, image: CachedImage) {
imageCache.set(key, image);
cacheBytes += image.body.byteLength;
while (cacheBytes > MAX_CACHE_BYTES && imageCache.size > 1) {
const oldestKey = imageCache.keys().next().value;
if (!oldestKey) break;
const oldest = imageCache.get(oldestKey);
imageCache.delete(oldestKey);
cacheBytes -= oldest?.body.byteLength ?? 0;
}
}
async function loadImage(input: BuildSocialCardInput, inputKey: string) {
const cached = imageCache.get(inputKey);
if (cached) {
imageCache.delete(inputKey);
imageCache.set(inputKey, cached);
return cached;
}
const pending = pendingImages.get(inputKey);
if (pending) return pending;
const request = renderBuildSocialCardPng(input).then((body) => {
const image = {
body,
etag: `"${createHash('sha256').update(body).digest('base64url')}"`,
};
storeImage(inputKey, image);
return image;
});
pendingImages.set(inputKey, request);
try {
return await request;
} finally {
pendingImages.delete(inputKey);
}
}
export const GET: RequestHandler = async ({ params, request, url }) => {
const release = await acquireRequest();
if (!release) error(503, 'Build preview capacity exceeded');
try {
const projection = await loadSharedBuildProjection(params.slug, {
kind: 'public-preview',
});
if (!projection) error(404, 'Build preview not found');
const origin = publicOrigin(url.origin);
const directUrl = new URL(
`/builds/${encodeURIComponent(projection.build.slug)}`,
origin,
).toString();
const input = _socialCardInput(projection, directUrl);
const inputKey = createHash('sha256')
.update(JSON.stringify({ version: 1, input }))
.digest('base64url');
const image = await loadImage(input, inputKey);
const cacheControl = 'public, max-age=0, must-revalidate';
if (matchesEtag(request, image.etag)) {
return new Response(null, {
status: 304,
headers: { 'cache-control': cacheControl, etag: image.etag },
});
}
return new Response(new Uint8Array(image.body), {
headers: {
'cache-control': cacheControl,
'content-length': String(image.body.byteLength),
'content-type': 'image/png',
'cross-origin-resource-policy': 'same-origin',
etag: image.etag,
'x-content-type-options': 'nosniff',
...(projection.build.visibility === 'unlisted'
? { 'x-robots-tag': 'noindex' }
: {}),
},
});
} finally {
release();
}
};
@@ -0,0 +1,119 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
loadProjection: vi.fn(),
render: vi.fn(),
}));
vi.mock('$lib/server/shared-build', () => ({
loadSharedBuildProjection: mocks.loadProjection,
}));
vi.mock('$lib/server/build-social-card', () => ({
renderBuildSocialCardPng: mocks.render,
}));
import { GET, _socialCardInput } from './+server';
const projection = {
build: {
title: 'Field Medic',
slug: 'field-medic-12345678',
author: 'Aewyn',
visibility: 'unlisted',
},
loadout: {
weapons: [{ itemId: 'weapon-1' }, { itemId: 'weapon-2' }],
},
weapons: [
{ id: 'weapon-1', name: 'ACR' },
{ id: 'weapon-2', name: 'Super 90' },
],
attributes: [
{ id: 'fp', name: 'Firepower', unit: '' },
{ id: 'tgh', name: 'Toughness', unit: '' },
{ id: 'eng', name: 'Engineering', unit: '' },
{ id: 'crit', name: 'Critical Hit Chance', unit: '%' },
],
summary: {
stats: [
{ id: 'fp', value: 1200 },
{ id: 'tgh', value: 950 },
{ id: 'eng', value: 610 },
{ id: 'crit', value: 18.5 },
],
},
};
describe('Build social preview endpoint', () => {
beforeEach(() => {
mocks.loadProjection.mockReset().mockResolvedValue(projection);
mocks.render.mockReset().mockResolvedValue(Buffer.from([1, 2, 3]));
});
it('projects canonical Build facts into the card input', () => {
expect(
_socialCardInput(
projection as never,
'https://shd.build/builds/field-medic-12345678',
),
).toEqual({
title: 'Field Medic',
author: 'Aewyn',
primaryWeapon: 'ACR',
secondaryWeapon: 'Super 90',
firepower: '1,200',
toughness: '950',
engineering: '610',
supportingAttributes: [{ label: 'Critical Hit Chance', value: '18.5%' }],
visibility: 'unlisted',
directUrl: 'https://shd.build/builds/field-medic-12345678',
});
});
it('serves a cacheable PNG and honors its ETag', async () => {
const first = await GET({
params: { slug: 'field-medic-12345678' },
request: new Request(
'https://shd.build/builds/field-medic-12345678/preview.png',
),
url: new URL('https://shd.build/builds/field-medic-12345678/preview.png'),
} as never);
expect(first.status).toBe(200);
expect(first.headers.get('content-type')).toBe('image/png');
expect(first.headers.get('x-content-type-options')).toBe('nosniff');
expect(first.headers.get('cache-control')).toBe(
'public, max-age=0, must-revalidate',
);
expect(first.headers.get('x-robots-tag')).toBe('noindex');
expect(mocks.loadProjection).toHaveBeenCalledWith('field-medic-12345678', {
kind: 'public-preview',
});
const etag = first.headers.get('etag');
expect(etag).toBeTruthy();
const second = await GET({
params: { slug: 'field-medic-12345678' },
request: new Request(
'https://shd.build/builds/field-medic-12345678/preview.png',
{ headers: { 'if-none-match': String(etag) } },
),
url: new URL('https://shd.build/builds/field-medic-12345678/preview.png'),
} as never);
expect(second.status).toBe(304);
expect(mocks.render).toHaveBeenCalledTimes(1);
});
it('returns a generic 404 when the public projection is unavailable', async () => {
mocks.loadProjection.mockResolvedValue(null);
await expect(
GET({
params: { slug: 'private-build' },
request: new Request(
'https://shd.build/builds/private-build/preview.png',
),
url: new URL('https://shd.build/builds/private-build/preview.png'),
} as never),
).rejects.toMatchObject({ status: 404 });
expect(mocks.render).not.toHaveBeenCalled();
});
});