diff --git a/docs/usage/deploy-vercel.md b/docs/usage/deploy-vercel.md index a1650e6..75fb1c4 100644 --- a/docs/usage/deploy-vercel.md +++ b/docs/usage/deploy-vercel.md @@ -101,10 +101,18 @@ of the private Hosted Studio project: | Deployment | Vercel project | Purpose | | --- | --- | --- | +| Public Demo | `atelier-kit-public-demo` | Separate Demo runtime; bounded guest authoring and read-only consumer showcase | | Public Visitor demo | `atelier-kit-visitor-demo` | Read-only Atelier-Kit default demo content | | Private Hosted Studio | `atelier-kit-hosted-studio` | Explicitly configured private authoring only | -The public production URL is +The Demo project uses `ATELIER_STUDIO_MODE=demo` and the separate server-side +Demo configuration documented in [Public Demo deployment](public-demo.md). +Its homepage displays the fixed bilingual “Built with Atelier-Kit” section, +even when Demo authoring is unavailable. The section is excluded from Visitor, +Local, Hosted and invalid runtimes. Do not enable Demo mode on +`atelier-kit-visitor-demo` to obtain this presentation. + +The public Visitor production URL is . It deploys this canonical Atelier-Kit checkout in default Visitor runtime, using the default content in `config/`, `content/`, and `static/`. Do not substitute client content merely diff --git a/docs/usage/public-demo.md b/docs/usage/public-demo.md index ed85ea3..882faf8 100644 --- a/docs/usage/public-demo.md +++ b/docs/usage/public-demo.md @@ -5,6 +5,28 @@ bounded Visitor → Try Studio → Social edit → Visitor experience. It is neither ordinary Visitor production nor private Hosted Studio. +## Deployment identity and homepage showcase + +`atelier-kit-public-demo` is the dedicated Demo runtime deployment. It is +separate from `atelier-kit-visitor-demo`, the read-only default-content Visitor +deployment, and from private Hosted Studio. + +Only Demo runtime displays the fixed, read-only “Built with Atelier-Kit” +homepage section, outside the hero/sidebar grid and catalog. It presents exactly +three consumers with Italian and English Visitor translations: + +- [Ombre Quotidiane](https://ombre-quotidiane-giadaware.vercel.app/) +- Universi Condivisi — no link; its public URL has not been verified. +- [Club dell'Assurdo](https://club-dell-assurdo.vercel.app/) + +These are ordinary links without embeds or external fetches. The section is +application presentation, not editable sandbox or starter content. It needs +only `ATELIER_STUDIO_MODE=demo`: no public-authoring flag, session, Redis or +sandbox credentials. Missing or invalid authoring configuration keeps the +showcase visible while the existing Try Studio CTA remains unavailable. +Visitor, Local, Hosted and invalid runtimes never show it; browser query +parameters and cookies cannot enable it. It grants no authoring authority. + ## Runtime ```text diff --git a/src/lib/i18n/messages/en.js b/src/lib/i18n/messages/en.js index 119426c..9a4cab5 100644 --- a/src/lib/i18n/messages/en.js +++ b/src/lib/i18n/messages/en.js @@ -1503,6 +1503,12 @@ export default { allNews: 'All news' }, home: { + showcase: { + title: 'Built with Atelier-Kit', + ombreDescription: 'The narrative universe of the Chronicles of the Gentleman of Shadows.', + universiDescription: 'An editorial project dedicated to stories and narrative universes.', + clubDescription: 'A showcase of satirical fiction and tales of the absurd.' + }, collectionsEyebrow: 'Collections', collectionsTitle: 'Collections', catalogEyebrow: 'Catalog', diff --git a/src/lib/i18n/messages/it.js b/src/lib/i18n/messages/it.js index 6dc0206..40a289e 100644 --- a/src/lib/i18n/messages/it.js +++ b/src/lib/i18n/messages/it.js @@ -1514,6 +1514,12 @@ export default { allNews: 'Tutte le notizie' }, home: { + showcase: { + title: 'Built with Atelier-Kit', + ombreDescription: 'Universo narrativo delle Cronache del Gentiluomo delle Ombre.', + universiDescription: 'Progetto editoriale dedicato a storie e universi narrativi.', + clubDescription: "Vetrina di narrativa satirica e racconti dell'assurdo." + }, collectionsEyebrow: 'Collezioni', collectionsTitle: 'Collezioni', catalogEyebrow: 'Catalogo', diff --git a/src/routes/+page.server.js b/src/routes/+page.server.js index d9a16f9..00ec827 100644 --- a/src/routes/+page.server.js +++ b/src/routes/+page.server.js @@ -24,12 +24,13 @@ export function load() { const collectionsEditorial = getCollectionsEditorialConfig(); + const runtime = getStudioRuntimeMode(); let demoAvailable = false; try { demoAvailable = resolveDemoPublicConfig( - getStudioRuntimeMode(), + runtime, process.env ) !== null; } catch { @@ -43,6 +44,7 @@ export function load() { return { site, demoAvailable, + demoShowcaseVisible: runtime === 'demo', catalog: getCatalogConfig(), collections: getCollections(), items: getItems(), diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 1f775ce..06ea5aa 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -140,6 +140,26 @@ {/if} + {#if data.demoShowcaseVisible} +
+

{t('home.showcase.title')}

+ +
+ {/if} + {#if showAboutMain && data.main?.about} {@const about = data.main.about}
@@ -435,6 +455,52 @@ line-height: 1.5; } + .demo-showcase { + padding: 2rem 0; + } + + .demo-showcase h2 { + margin: 0 0 1rem; + } + + .demo-showcase-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr)); + gap: 1.2rem; + list-style: none; + margin: 0; + padding: 0; + } + + .demo-showcase-list li { + min-width: 0; + padding: 1.25rem; + border: 1px solid var(--site-border-color, #e4d8c7); + border-radius: 1rem; + background: var(--site-card-color, #fffaf2); + overflow-wrap: anywhere; + } + + .demo-showcase-list h3 { + margin: 0; + font-size: 1.25rem; + } + + .demo-showcase-list p { + margin: 0.75rem 0 0; + line-height: 1.6; + } + + .demo-showcase-list a { + color: inherit; + text-underline-offset: 0.2em; + } + + .demo-showcase-list a:focus-visible { + outline: 3px solid currentColor; + outline-offset: 4px; + } + .hero-banner { position: relative; display: block; diff --git a/test/demo-consumer-showcase.test.js b/test/demo-consumer-showcase.test.js new file mode 100644 index 0000000..23e27e9 --- /dev/null +++ b/test/demo-consumer-showcase.test.js @@ -0,0 +1,196 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { createServer } from 'vite'; +import { Window } from 'happy-dom'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'atelier-demo-showcase-')); +const environmentKeys = [ + 'ATELIER_STUDIO', 'ATELIER_STUDIO_MODE', 'ATELIER_DEMO_PUBLIC', + 'ATELIER_DEMO_CANONICAL_ORIGIN', 'ATELIER_DEMO_STATE_REDIS_REST_URL', + 'ATELIER_DEMO_STATE_REDIS_REST_TOKEN', 'ATELIER_DEMO_STATE_NAMESPACE', + 'ATELIER_DEMO_ISSUANCE_SECRET' +]; +/** @type {Array<[string, string | undefined]>} */ +const originalEnvironment = environmentKeys.map((key) => [key, process.env[key]]); +/** @type {import('vite').ViteDevServer} */ +let server; + +/** @param {string} relativePath */ +function read(relativePath) { + return fs.readFileSync(path.join(root, relativePath), 'utf8'); +} + +/** @param {Record} environment */ +function setRuntime(environment) { + for (const key of environmentKeys) delete process.env[key]; + Object.assign(process.env, environment); +} + +test.before(async () => { + server = await createServer({ + configFile: false, + root, + cacheDir, + logLevel: 'error', + appType: 'custom', + plugins: [ + { + name: 'demo-showcase-test', + resolveId(id) { + if (id === '$app/environment') return '\0demo-showcase-environment'; + if (id === '/__demo-showcase-harness.svelte') return id; + }, + load(id) { + if (id === '\0demo-showcase-environment') return 'export const dev = false;'; + if (id === '/__demo-showcase-harness.svelte') { + return ` + `; + } + } + }, + svelte({ compilerOptions: { css: 'injected' } }) + ], + resolve: { alias: { $lib: path.join(root, 'src/lib') } }, + optimizeDeps: { noDiscovery: true, include: [] }, + server: { middlewareMode: true, hmr: false } + }); +}); + +test.after(async () => { + await server?.close(); + fs.rmSync(cacheDir, { recursive: true, force: true }); + for (const [key, value] of originalEnvironment) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +}); + +const readyEnvironment = { + ATELIER_STUDIO_MODE: 'demo', + ATELIER_DEMO_PUBLIC: '1', + ATELIER_DEMO_CANONICAL_ORIGIN: 'https://demo.example.test', + ATELIER_DEMO_STATE_REDIS_REST_URL: 'https://redis.example.test', + ATELIER_DEMO_STATE_REDIS_REST_TOKEN: 'test-only-token', + ATELIER_DEMO_STATE_NAMESPACE: 'demo-showcase-test', + ATELIER_DEMO_ISSUANCE_SECRET: Buffer.alloc(32, 91).toString('base64url') +}; + +/** @param {Record} data */ +async function documentFor(data) { + const harness = await server.ssrLoadModule('/__demo-showcase-harness.svelte'); + const { render } = await server.ssrLoadModule('svelte/server'); + const window = new Window(); + window.document.body.innerHTML = render(harness.default, { props: { data } }).body; + return window.document; +} + +test('only server Demo runtime enables showcase; browser query and cookies cannot enable it', async () => { + const page = await server.ssrLoadModule('/src/routes/+page.server.js'); + /** @type {Array>} */ + const cases = [ + {}, + { ATELIER_STUDIO: '1' }, + { ATELIER_STUDIO_MODE: 'local' }, + { ATELIER_STUDIO_MODE: 'hosted' }, + { ATELIER_STUDIO_MODE: '' }, + { ATELIER_STUDIO_MODE: 'unknown' }, + { ATELIER_STUDIO_MODE: 'demo', ATELIER_STUDIO: '1' }, + { ATELIER_STUDIO_MODE: 'demo' } + ]; + for (const environment of cases) { + setRuntime(environment); + const expected = environment.ATELIER_STUDIO_MODE === 'demo' && !environment.ATELIER_STUDIO; + for (const query of ['', '?runtime=demo&ATELIER_STUDIO_MODE=demo&demoShowcaseVisible=true&demoAvailable=1']) { + const url = new URL(`https://example.test/${query}`); + const data = page.load({ + url, + request: new Request(url, { headers: { cookie: 'runtime=demo; demoShowcaseVisible=true; __Host-atelier_demo_session=forged' } }), + cookies: { get() { throw new Error('Showcase must not read cookies'); } } + }); + assert.equal(data.demoShowcaseVisible, expected); + assert.equal(data.demoAvailable, false); + const document = await documentFor(data); + assert.equal(document.querySelectorAll('.demo-showcase').length, expected ? 1 : 0); + } + } +}); + +test('Demo presentation stays visible with disabled, missing, malformed or ready authoring configuration', async () => { + const page = await server.ssrLoadModule('/src/routes/+page.server.js'); + /** @type {Array<[Record, boolean]>} */ + const cases = [ + [{ ATELIER_STUDIO_MODE: 'demo' }, false], + [{ ATELIER_STUDIO_MODE: 'demo', ATELIER_DEMO_PUBLIC: '0' }, false], + [{ ATELIER_STUDIO_MODE: 'demo', ATELIER_DEMO_PUBLIC: '1' }, false], + [{ ...readyEnvironment, ATELIER_DEMO_STATE_REDIS_REST_URL: 'invalid' }, false], + [readyEnvironment, true] + ]; + for (const [environment, available] of cases) { + setRuntime(environment); + const data = page.load(); + assert.equal(data.demoShowcaseVisible, true); + assert.equal(data.demoAvailable, available); + const document = await documentFor(data); + assert.ok(document.querySelector('.demo-showcase')); + assert.equal(document.querySelectorAll('form[action="/demo/start"]').length, available ? 1 : 0); + } +}); + +test('SSR renders exactly three bilingual consumers and two correct ordinary links outside hero and catalog', async () => { + setRuntime({ ATELIER_STUDIO_MODE: 'demo' }); + const page = await server.ssrLoadModule('/src/routes/+page.server.js'); + /** @type {Record} */ + const descriptions = { + it: [ + 'Universo narrativo delle Cronache del Gentiluomo delle Ombre.', + 'Progetto editoriale dedicato a storie e universi narrativi.', + "Vetrina di narrativa satirica e racconti dell'assurdo." + ], + en: [ + 'The narrative universe of the Chronicles of the Gentleman of Shadows.', + 'An editorial project dedicated to stories and narrative universes.', + 'A showcase of satirical fiction and tales of the absurd.' + ] + }; + for (const language of ['it', 'en']) { + const data = page.load(); + data.site = { ...data.site, language }; + const document = await documentFor(data); + const section = document.querySelector('.demo-showcase'); + assert.ok(section); + assert.equal(section.parentElement?.tagName, 'MAIN'); + assert.equal(section.closest('.home-intro, .home-sidebar, .catalog'), null); + assert.equal(section.querySelector('h2')?.id, section.getAttribute('aria-labelledby')); + assert.equal(section.querySelector('h2')?.textContent, 'Built with Atelier-Kit'); + const entries = [...section.querySelectorAll('li')]; + assert.equal(entries.length, 3); + assert.deepEqual(entries.map((entry) => entry.querySelector('h3')?.textContent), + ['Ombre Quotidiane', 'Universi Condivisi', "Club dell'Assurdo"]); + assert.deepEqual(entries.map((entry) => entry.querySelector('p')?.textContent), descriptions[language]); + assert.equal(entries[1].querySelector('a'), null); + assert.deepEqual([...section.querySelectorAll('a')].map((a) => [a.textContent, a.getAttribute('href')]), [ + ['Ombre Quotidiane', 'https://ombre-quotidiane-giadaware.vercel.app/'], + ["Club dell'Assurdo", 'https://club-dell-assurdo.vercel.app/'] + ]); + assert.equal(section.querySelector('form, button, iframe, img, script'), null); + const repeated = await documentFor(data); + assert.equal(repeated.querySelector('.demo-showcase')?.outerHTML, section.outerHTML); + } +}); + +test('homepage resolves runtime once and keeps showcase separate from authoring configuration', () => { + const loader = read('src/routes/+page.server.js'); + assert.equal([...loader.matchAll(/getStudioRuntimeMode\(\)/g)].length, 1); + assert.match(loader, /demoShowcaseVisible: runtime === 'demo'/); +});