Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion docs/usage/deploy-vercel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<https://atelier-kit-visitor-demo.vercel.app>. 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
Expand Down
22 changes: 22 additions & 0 deletions docs/usage/public-demo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/lib/i18n/messages/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 6 additions & 0 deletions src/lib/i18n/messages/it.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 3 additions & 1 deletion src/routes/+page.server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -43,6 +44,7 @@ export function load() {
return {
site,
demoAvailable,
demoShowcaseVisible: runtime === 'demo',
catalog: getCatalogConfig(),
collections: getCollections(),
items: getItems(),
Expand Down
66 changes: 66 additions & 0 deletions src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,26 @@
{/if}
</div>

{#if data.demoShowcaseVisible}
<section class="demo-showcase" aria-labelledby="demo-showcase-title">
<h2 id="demo-showcase-title">{t('home.showcase.title')}</h2>
<ul class="demo-showcase-list">
<li>
<h3><a href="https://ombre-quotidiane-giadaware.vercel.app/">Ombre Quotidiane</a></h3>
<p>{t('home.showcase.ombreDescription')}</p>
</li>
<li>
<h3>Universi Condivisi</h3>
<p>{t('home.showcase.universiDescription')}</p>
</li>
<li>
<h3><a href="https://club-dell-assurdo.vercel.app/">Club dell'Assurdo</a></h3>
<p>{t('home.showcase.clubDescription')}</p>
</li>
</ul>
</section>
{/if}

{#if showAboutMain && data.main?.about}
{@const about = data.main.about}
<section class="home-about" aria-labelledby="home-about-title">
Expand Down Expand Up @@ -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;
Expand Down
196 changes: 196 additions & 0 deletions test/demo-consumer-showcase.test.js
Original file line number Diff line number Diff line change
@@ -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<string, string>} 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 `<script>
import Page from '/src/routes/+page.svelte';
import { setVisitorI18nContext } from '$lib/i18n/visitor-context.js';
let { data } = $props();
setVisitorI18nContext(() => data.site.language);
</script>
<Page {data} />`;
}
}
},
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<string, any>} 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<Record<string, string>>} */
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<string, string>, 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<string, string[]>} */
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'/);
});
Loading