From ec51e38312e516d0d81b5018d8955a638af86a71 Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 18 Sep 2026 09:57:57 -0400 Subject: [PATCH] Refresh script library with batch uploads and beta handoff --- src/lib/script-library.ts | 55 ++ .../ui/components/ScriptUploadDrawer.svelte | 210 ++++++++ src/routes/api/scripts/+server.ts | 5 + .../api/scripts/[id]/versions/+server.ts | 12 + src/routes/api/scripts/inspect/+server.ts | 17 + src/routes/betas/+page.svelte | 45 +- src/routes/scripts/+page.svelte | 504 +++++------------- tests/unit/script-library.test.ts | 43 ++ tests/unit/script-upload.test.ts | 67 +++ 9 files changed, 576 insertions(+), 382 deletions(-) create mode 100644 src/lib/script-library.ts create mode 100644 src/lib/ui/components/ScriptUploadDrawer.svelte create mode 100644 src/routes/api/scripts/inspect/+server.ts create mode 100644 tests/unit/script-library.test.ts create mode 100644 tests/unit/script-upload.test.ts diff --git a/src/lib/script-library.ts b/src/lib/script-library.ts new file mode 100644 index 0000000..b62999d --- /dev/null +++ b/src/lib/script-library.ts @@ -0,0 +1,55 @@ +import { T5_ZOMBIE_MAPS, T6_ZOMBIE_MAPS } from './map-aliases.js'; + +export interface LibraryVersion { + id: number; version: number; sha256: string; size: number; + state: 'untested' | 'tested' | 'test-failed'; uploaded_at: string; +} +export interface LibraryScript { + id: number; name: string; target_rel_path: string; + scope_type: 'game' | 'map' | null; scope_value: string | null; + current_version_id: number | null; + currentVersion: LibraryVersion | null; latestVersion: LibraryVersion | null; + versionCount: number; servers: string[]; runningBetaId: number | null; +} +export type ScriptStatus = 'untested' | 'testing' | 'tested' | 'failed' | 'promoted' | 'empty'; +export const statusLabels: Record = { + untested: 'Needs testing', testing: 'Beta active', tested: 'Test passed', + failed: 'Test failed', promoted: 'Promoted', empty: 'No versions' +}; +export function scriptStatus(s: LibraryScript): ScriptStatus { + if (s.runningBetaId !== null) return 'testing'; + if (!s.latestVersion) return 'empty'; + if (s.latestVersion.id === s.current_version_id) return 'promoted'; + return s.latestVersion.state === 'test-failed' ? 'failed' : s.latestVersion.state; +} +export function canTest(s: LibraryScript): boolean { + return s.runningBetaId === null && s.latestVersion !== null && s.latestVersion.id !== s.current_version_id; +} +export function mapLabel(canonical: string): string { + const matches = [...T5_ZOMBIE_MAPS, ...T6_ZOMBIE_MAPS].filter(m => m.canonical === canonical); + return matches.length > 1 ? `${matches[0].friendly} + ${matches.length - 1} variants` : matches[0]?.friendly ?? canonical; +} +export function scopeLabel(s: Pick): string { + return s.scope_type === 'game' ? (s.scope_value ?? '').toUpperCase() + : s.scope_type === 'map' ? mapLabel(s.scope_value ?? '') : 'Unscoped'; +} +export interface BetaSelection { scriptId: number; versionId: number; } +export function betaSelectionHref(selections: BetaSelection[]): string { + const query = new URLSearchParams(); + for (const s of selections) query.append('artifact', `${s.scriptId}:${s.versionId}`); + return `/betas?${query}`; +} +export function parseBetaSelection(query: URLSearchParams): BetaSelection[] { + const values = query.getAll('artifact'); + if (!values.length && query.has('pluginId')) values.push(`${query.get('pluginId')}:${query.get('versionId')}`); + const seen = new Set(); + return values.map(value => { + if (!/^[1-9]\d*:[1-9]\d*$/.test(value)) throw new Error('The beta selection link is invalid. Choose artifacts below.'); + const [scriptId, versionId] = value.split(':').map(Number); + if (!Number.isSafeInteger(scriptId) || !Number.isSafeInteger(versionId) || seen.has(scriptId)) { + throw new Error('The beta selection link contains invalid or repeated artifacts. Choose artifacts below.'); + } + seen.add(scriptId); + return { scriptId, versionId }; + }); +} diff --git a/src/lib/ui/components/ScriptUploadDrawer.svelte b/src/lib/ui/components/ScriptUploadDrawer.svelte new file mode 100644 index 0000000..cd5e9a2 --- /dev/null +++ b/src/lib/ui/components/ScriptUploadDrawer.svelte @@ -0,0 +1,210 @@ + + + +
+
Prepare your next release

Upload scripts

+

Drop files anywhere or choose them below. Review each destination before uploading.

+
+ {preferredTarget ? `New version for ${scripts.find(s => s.id === preferredTarget)?.name ?? 'script'}` : 'One file or a whole batch'} + Existing filenames are recognized automatically. + + { void addFiles([...event.currentTarget.files ?? []]); event.currentTarget.value = ''; }} /> +
+
+ {#each queue as item (item.key)} +
+
{item.file.name}{(item.file.size / 1024).toFixed(1)} KB + +
+ {#if item.result} +

{item.result.reused ? 'Already uploaded' : 'Uploaded'} as v{item.result.version} · View script →

+ {:else} + + {#if item.target === 'new'} + {:else if item.target !== ''}{@const s = scripts.find(s => s.id === item.target)}

Keeps scope: {s ? scopeLabel(s) : '—'}. Target: {s?.target_rel_path}

{/if} + {#if item.inspecting}

Checking file contents…

+ {:else if duplicate(item)}

Already uploaded as v{duplicate(item)!.version}. We’ll reuse that version.

+ {:else if item.target !== 'new' && item.target !== ''}

New version for {scripts.find(s => s.id === item.target)?.name}

{/if} + {#if validation(item)}

{validation(item)}

{/if} + {#if item.error}{#if !item.inspected}{/if}{/if} + {/if} +
+ {/each} +
+ {#if pending.length} +
+

Shared upload details

+ + {#if hasNew} + + {#if scopeType !== 'none'} + + {#if scopeType === 'map'}{/if} + {#if scopeValue}

{matches.length} matching servers: {matches.map(s => s.name).join(', ') || 'None'}

{/if} + {#if scopeType === 'map' && scopeGame === 't6'}

Map scope includes every server using this engine map, including its Survival, Grief, or other variants.

{/if} + {:else}

Unscoped scripts deploy only to explicitly selected beta targets.

{/if} +

Existing scripts keep their current scope.

+ {/if} +
+ {/if} + {#if message}

{message}

{/if} +
+ {#if pending.length} + {:else if testable.length}{/if} +
+
+
+ + diff --git a/src/routes/api/scripts/+server.ts b/src/routes/api/scripts/+server.ts index f0e3e30..1c4eb0b 100644 --- a/src/routes/api/scripts/+server.ts +++ b/src/routes/api/scripts/+server.ts @@ -36,8 +36,13 @@ export async function GET({ url }: { url: URL }) { ? serversMatchingScope(s.scope_type as ScopeType, s.scope_value) : []; const runningBeta = findRunningBetaForScript(s.id); + const current = versions.find(v => v.id === s.current_version_id); return { ...s, + currentVersion: current ? { + id: current.id, version: current.version, sha256: current.sha256, + size: current.size, state: current.state, uploaded_at: current.uploaded_at + } : null, latestVersion: versions[0] ? { id: versions[0].id, diff --git a/src/routes/api/scripts/[id]/versions/+server.ts b/src/routes/api/scripts/[id]/versions/+server.ts index ee62492..c58b99f 100644 --- a/src/routes/api/scripts/[id]/versions/+server.ts +++ b/src/routes/api/scripts/[id]/versions/+server.ts @@ -1,5 +1,8 @@ import { json } from '@sveltejs/kit'; import { uploadNewVersion, getScript, ScriptError } from '$lib/server/fleet/scripts.js'; +import { createHash } from 'node:crypto'; +import { db } from '$lib/server/db/index.js'; +import { findRunningBetaForScript } from '$lib/server/fleet/beta.js'; function parseId(idRaw: string): number | null { const n = Number.parseInt(idRaw, 10); @@ -21,6 +24,15 @@ export async function POST({ params, request }: { params: { id: string }; reques if (!(file instanceof File)) return json({ ok: false, error: 'content file is required' }, { status: 400 }); const content = Buffer.from(await file.arrayBuffer()); + if (script.kind === 'script' && form.get('reuseExisting') === 'true') { + if (!content.length) return json({ ok: false, error: 'Choose a non-empty script file.' }, { status: 400 }); + if (findRunningBetaForScript(scriptId)) { + return json({ ok: false, error: 'Finish the active beta before uploading a version.' }, { status: 409 }); + } + const sha = createHash('sha256').update(content).digest('hex'); + const existing = db().prepare('SELECT id, version, sha256, size FROM script_versions WHERE script_id = ? AND sha256 = ? ORDER BY version DESC LIMIT 1').get(scriptId, sha); + if (existing) return json({ ok: true, version: existing, reused: true }); + } try { const releaseVersion = form.has('releaseVersion') ? String(form.get('releaseVersion')) : undefined; const version = uploadNewVersion(scriptId, content, notes, releaseVersion); diff --git a/src/routes/api/scripts/inspect/+server.ts b/src/routes/api/scripts/inspect/+server.ts new file mode 100644 index 0000000..201dd74 --- /dev/null +++ b/src/routes/api/scripts/inspect/+server.ts @@ -0,0 +1,17 @@ +import { json } from '@sveltejs/kit'; +import { createHash } from 'node:crypto'; +import { db } from '$lib/server/db/index.js'; + +// Read-only inspection works on HTTP fleet consoles as well as secure origins. +export async function POST({ request }: { request: Request }) { + const form = await request.formData(); + const file = form.get('content'); + if (!(file instanceof File) || file.size === 0) { + return json({ error: 'Choose a non-empty script file.' }, { status: 400 }); + } + const sha256 = createHash('sha256').update(Buffer.from(await file.arrayBuffer())).digest('hex'); + const duplicates = db().prepare(`SELECT v.script_id AS scriptId, v.id AS versionId, v.version + FROM script_versions v JOIN scripts s ON s.id = v.script_id + WHERE s.kind = 'script' AND v.sha256 = ? ORDER BY v.version DESC`).all(sha256); + return json({ sha256, duplicates }); +} diff --git a/src/routes/betas/+page.svelte b/src/routes/betas/+page.svelte index 9c29dab..638c01c 100644 --- a/src/routes/betas/+page.svelte +++ b/src/routes/betas/+page.svelte @@ -4,6 +4,7 @@ import { onMount, onDestroy } from 'svelte'; import { artifactVersionLabel } from '$lib/artifact-version.js'; import { goto } from '$app/navigation'; + import { parseBetaSelection } from '$lib/script-library.js'; import Card from '$ui/components/primitives/Card.svelte'; import Badge from '$ui/components/primitives/Badge.svelte'; import Button from '$ui/components/primitives/Button.svelte'; @@ -61,6 +62,7 @@ fetch('/api/scripts'), fetch('/api/servers') ]); + if (!betasRes.ok || !scriptsRes.ok || !serversRes.ok) throw new Error('Could not load beta setup. Refresh to try again.'); const bBody = (await betasRes.json()) as { betas: BetaListRow[] }; betas = bBody.betas; if (scriptsRes.ok) { @@ -71,7 +73,6 @@ const svBody = (await serversRes.json()) as { servers: ServerRow[] }; fleetServers = svBody.servers; } - loading = false; } async function refreshBetas(): Promise { const res = await fetch('/api/betas'); @@ -82,7 +83,7 @@ let poll: ReturnType | null = null; onMount(() => { - void load().then(seedImportedPlugin).catch(error => { flash = (error as Error).message; }); + void load().then(seedSelection).catch(error => { flash = (error as Error).message; }).finally(() => { loading = false; }); poll = setInterval(refreshBetas, 5000); }); onDestroy(() => { @@ -91,7 +92,8 @@ const eligibleScripts = $derived( scripts.filter( - (s) => s.runningBetaId === null && s.latestVersion !== null && s.latestVersion.id !== s.current_version_id + (s) => s.runningBetaId === null && s.latestVersion !== null && (s.latestVersion.id !== s.current_version_id + || picks.some(p => p.scriptId === s.id && p.versionId !== null && p.versionId !== s.current_version_id)) ) ); @@ -102,24 +104,26 @@ return eligibleScripts.filter((s) => !takenIds.has(s.id)); } - async function seedImportedPlugin(): Promise { + async function seedSelection(): Promise { const query = new URLSearchParams(window.location.search); - if (!query.has('pluginId')) return; - const pluginId = Number(query.get('pluginId')); - const versionId = Number(query.get('versionId')); - if (!eligibleScripts.some(s => s.id === pluginId && s.kind === 'plugin')) { - flash = 'This plugin is already promoted or has a running beta. Open its detail page to review its status.'; - return; + const selections = parseBetaSelection(query); + if (!selections.length) return; + // Validate all selections before populating; never substitute a newer upload. + const details = await Promise.all(selections.map(async ({ scriptId, versionId }) => { + const res = await fetch(`/api/scripts/${scriptId}`); + if (!res.ok) throw new Error('A selected artifact is no longer available. Choose artifacts below.'); + const detail = await res.json() as ScriptDetail & { runningBetaId: number | null }; + if (detail.runningBetaId !== null || detail.script.current_version_id === versionId + || !detail.versions.some(v => v.id === versionId)) { + throw new Error(`${detail.script.name}: the selected version is already promoted, unavailable, or in an active beta. Review your selection below.`); + } + return detail; + })); + for (const detail of details) { + detailById[detail.script.id] = detail; } - picks = [{ key: nextKey++, scriptId: pluginId, versionId: null }]; - await onPickScript(0); - if (!versionsForRow(picks[0]).some(v => v.id === versionId)) { - picks[0].versionId = null; - flash = 'The imported version is no longer available for testing. Choose a version to continue.'; - return; - } - picks[0].versionId = versionId; - picks = [...picks]; + picks = selections.map(s => ({ key: nextKey++, ...s })); + pickedServers = new Set(); } async function onPickScript(rowIdx: number): Promise { @@ -246,6 +250,8 @@ subtitle="Test unpromoted versions on selected servers before rolling them out." /> + {#if flash}{/if} + {#if loading}
Loading…
{:else} @@ -336,7 +342,6 @@ > Open beta - {#if flash}{flash}{/if} {/if} diff --git a/src/routes/scripts/+page.svelte b/src/routes/scripts/+page.svelte index ad3cd4d..1f23c04 100644 --- a/src/routes/scripts/+page.svelte +++ b/src/routes/scripts/+page.svelte @@ -1,387 +1,167 @@ + { if (event.dataTransfer?.types.includes('Files')) { event.preventDefault(); dragDepth++; } }} + ondragleave={() => dragDepth = Math.max(0, dragDepth - 1)} ondragend={() => dragDepth = 0} /> +
- - - - -

Upload a script

- - {#if successBanner} - - {/if} - -
- - - - - - - - - -
- - {/if} - {#if newScopeType === 'map'} - - - -
- - {#if flash}{flash}{/if} -
- - - - -
-
-

All scripts

- {scripts.length} + + {#snippet actions()}{/snippet} + +
+ {#each tabs as tab} + + {/each} +
+
+
+ + + +
- - {#if loading} -
Loading…
- {:else if scripts.length === 0} - +
+ + {visible.length} of {scripts.length} scripts + {#if search || status !== 'all' || game || map}{/if} +
+ {#if error}{/if} + {#if loading}

Loading your library…

+ {:else if !scripts.length && !error} + +
+ {:else if !visible.length && !error} + {:else}
- {#each scripts as s (s.id)} - -
- {s.name} - - {scopeLabel(s)} - - {#if stateVariantFor(s)} - {@const v = stateVariantFor(s)!} - {stateLabel(s)} - {/if} - {#if s.runningBetaId} - β{s.runningBetaId} running - {/if} + {#each visible as s (s.id)} + {@const state = scriptStatus(s)} +
+ toggle(s.id)} /> +
+
{s.name}{statusLabels[state]}
+
{s.target_rel_path}
+
{scopeLabel(s)}{s.scope_type ? `${s.servers.length} scope matches` : 'Explicit beta targets'}{s.versionCount} version{s.versionCount === 1 ? '' : 's'}
-
- {s.target_rel_path} - v{s.latestVersion?.version ?? '—'} · {s.versionCount} version{s.versionCount === 1 ? '' : 's'} - {#if s.servers.length} - {s.servers.length} matching server{s.servers.length === 1 ? '' : 's'} - {/if} +
+ {s.currentVersion ? `Promoted v${s.currentVersion.version}` : 'Never promoted'} + {#if s.latestVersion && s.latestVersion.id !== s.current_version_id}Latest v{s.latestVersion.version} · {s.latestVersion.state === 'untested' ? 'awaiting test' : s.latestVersion.state.replaceAll('-', ' ')}{/if}
- +
+ {#if s.runningBetaId !== null} + {:else if state === 'failed'} + {:else if canTest(s)} + {:else}{/if} +
+
{/each}
{/if} +

Drop files anywhere to add scripts or recognize new versions. Scope matches describe eligible servers, not installed versions.

+ {#if selectedScripts.length} +
+ {selectedScripts.length} selectedLatest versions will be carried into beta setup. + + +
+ {/if}
+{#if dragDepth > 0}
Drop your scripts hereWe’ll recognize existing scripts and check for duplicate versions.
{/if} + diff --git a/tests/unit/script-library.test.ts b/tests/unit/script-library.test.ts new file mode 100644 index 0000000..e51bee7 --- /dev/null +++ b/tests/unit/script-library.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { betaSelectionHref, canTest, mapLabel, parseBetaSelection, scriptStatus, type LibraryScript } from '../../src/lib/script-library.js'; + +const script: LibraryScript = { + id: 1, name: 'HUD', target_rel_path: 'hud.gsc', scope_type: null, scope_value: null, + current_version_id: null, currentVersion: null, versionCount: 1, servers: [], runningBetaId: null, + latestVersion: { id: 10, version: 1, sha256: 'abc', size: 12, state: 'untested', uploaded_at: '2026-09-18' } +}; + +describe('script library state', () => { + it.each([['untested', 'untested'], ['tested', 'tested'], ['test-failed', 'failed']] as const)( + 'preserves %s for a never-promoted version', (state, expected) => { + expect(scriptStatus({ ...script, latestVersion: { ...script.latestVersion!, state } })).toBe(expected); + }); + it('distinguishes an active beta, a promoted release, and a newer upload', () => { + expect(scriptStatus({ ...script, runningBetaId: 5 })).toBe('testing'); + expect(canTest({ ...script, runningBetaId: 5 })).toBe(false); + expect(scriptStatus({ ...script, current_version_id: 10 })).toBe('promoted'); + expect(canTest({ ...script, current_version_id: 10 })).toBe(false); + expect(scriptStatus({ ...script, current_version_id: 9 })).toBe('untested'); + expect(canTest({ ...script, current_version_id: 9 })).toBe(true); + expect(scriptStatus({ ...script, latestVersion: null })).toBe('empty'); + }); + it('does not describe shared T6 engine maps as a single variant', () => { + expect(mapLabel('zombie_moon')).toBe('Moon'); + expect(mapLabel('zm_transit')).toMatch(/variants/); + expect(mapLabel('custom_map')).toBe('custom_map'); + }); +}); + +describe('beta selection links', () => { + it('round-trips exact versions in a multi-script selection', () => { + const picks = [{ scriptId: 4, versionId: 20 }, { scriptId: 7, versionId: 25 }]; + expect(parseBetaSelection(new URL(betaSelectionHref(picks), 'http://localhost').searchParams)).toEqual(picks); + }); + it('preserves existing plugin import links', () => { + expect(parseBetaSelection(new URLSearchParams('pluginId=8&versionId=32'))).toEqual([{ scriptId: 8, versionId: 32 }]); + }); + it.each(['artifact=0:1', 'artifact=1:NaN', 'artifact=1:2junk', 'artifact=1:2&artifact=1:3', 'artifact=9007199254740992:1', 'pluginId=8'])( + 'rejects malformed or ambiguous selections: %s', query => { + expect(() => parseBetaSelection(new URLSearchParams(query))).toThrow(); + }); +}); diff --git a/tests/unit/script-upload.test.ts b/tests/unit/script-upload.test.ts new file mode 100644 index 0000000..e328930 --- /dev/null +++ b/tests/unit/script-upload.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { newFixture, type Fixture } from '../helpers/fixture.js'; + +let fixture: Fixture; +beforeEach(async () => { + vi.resetModules(); fixture = await newFixture(); + delete process.env.KOMODO_ADDRESS; delete process.env.RCON_PASSWORD; +}); +afterEach(async () => { await fixture.cleanup(); }); +function request(content: string, reuse = false) { + const form = new FormData(); form.append('content', new File([content], 'hud.gsc')); + if (reuse) form.append('reuseExisting', 'true'); + return new Request('http://localhost/api/scripts/inspect', { method: 'POST', body: form }); +} +async function seed() { + const mod = await import('../../src/lib/server/fleet/scripts.js'); + return { ...mod, ...mod.createScript({ name: 'HUD', targetRelPath: 'hud.gsc', scopeType: 'game', scopeValue: 't5', content: Buffer.from('main(){}') }) }; +} + +describe('script upload review', () => { + it('inspects content against all versions without creating records or including plugins', async () => { + const { script, version, uploadNewVersion, createScript, listVersions } = await seed(); + uploadNewVersion(script.id, Buffer.from('changed(){}')); + createScript({ name: 'Plugin', targetRelPath: 'plugin.dll', kind: 'plugin', content: Buffer.from('main(){}') }); + const { POST } = await import('../../src/routes/api/scripts/inspect/+server.js'); + const res = await POST({ request: request('main(){}') }); + expect(await res.json()).toEqual({ sha256: version.sha256, duplicates: [{ scriptId: script.id, versionId: version.id, version: 1 }] }); + expect(listVersions(script.id)).toHaveLength(2); + expect((await POST({ request: request('') })).status).toBe(400); + }); + it('reuses identical historical content and creates changed versions while preserving scope', async () => { + const { script, version, uploadNewVersion, listVersions, getScript } = await seed(); + uploadNewVersion(script.id, Buffer.from('changed(){}')); + const { POST } = await import('../../src/routes/api/scripts/[id]/versions/+server.js'); + const invoke = (content: string) => POST({ params: { id: String(script.id) }, request: request(content, true) }); + const res = await invoke('main(){}'); + expect(await res.json()).toMatchObject({ ok: true, reused: true, version: { id: version.id, version: 1 } }); + expect(listVersions(script.id)).toHaveLength(2); + const added = await invoke('third(){}'); + expect(await added.json()).toMatchObject({ ok: true, version: { version: 3 } }); + expect(listVersions(script.id)).toHaveLength(3); + expect(getScript(script.id)).toMatchObject({ scope_type: 'game', scope_value: 't5' }); + expect((await invoke('')).status).toBe(400); + expect(listVersions(script.id)).toHaveLength(3); + }); + it('rejects new versions when a beta becomes active after the upload preview', async () => { + const { script, version, listVersions } = await seed(); + const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); + await seedServerRegistry(); + const { openBeta } = await import('../../src/lib/server/fleet/beta.js'); + await openBeta({ scripts: [{ scriptId: script.id, scriptVersionId: version.id }], servers: ['t5_zombie_theater_1'], waitForHealth: false, waitForMatch: false }); + const { POST } = await import('../../src/routes/api/scripts/[id]/versions/+server.js'); + const res = await POST({ params: { id: String(script.id) }, request: request('changed(){}', true) }); + expect(res.status).toBe(409); + expect(listVersions(script.id)).toHaveLength(1); + }); + it('lists promoted and latest version metadata separately without file contents', async () => { + const { script, version, uploadNewVersion } = await seed(); + const latest = uploadNewVersion(script.id, Buffer.from('changed(){}')); + const { db } = await import('../../src/lib/server/db/index.js'); + db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(version.id, script.id); + const { GET } = await import('../../src/routes/api/scripts/+server.js'); + const body = await (await GET({ url: new URL('http://localhost/api/scripts?kind=script') })).json(); + expect(body.scripts[0]).toMatchObject({ currentVersion: { id: version.id, version: 1 }, latestVersion: { id: latest.id, version: 2 } }); + expect(body.scripts[0].currentVersion).not.toHaveProperty('content'); + }); +});