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
8 changes: 7 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,12 @@
"dependencies": {
"@gonext/blocks-core": "workspace:*",
"@gonext/blocks-sdk": "workspace:*",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"tailwind-merge": "^2.5.5"
},
"devDependencies": {
"@gonext/test-config": "workspace:*",
Expand All @@ -29,9 +32,12 @@
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitest/coverage-v8": "^1.6.0",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.0",
"eslint-config-next": "^15.0.0",
"jsdom": "^24.0.0",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.6.0",
"vitest": "^1.6.0"
}
Expand Down
16 changes: 16 additions & 0 deletions apps/web/postcss.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* PostCSS config — required for Next.js to pick up Tailwind v3.
*
* Next.js auto-detects `postcss.config.{js,mjs,cjs}` at the app root
* and runs the listed plugins over every imported `.css` file (we
* import `./globals.css` from the root layout). Without this file
* Next would fall through to its default postcss preset and silently
* skip the Tailwind processing pass, so the `@tailwind base/components/utilities`
* directives at the top of globals.css would never expand.
*/
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
40 changes: 40 additions & 0 deletions apps/web/src/app/PublicShell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ describe('PublicShell', () => {
bodyHtml="<header>H</header><main>M</main><footer>F</footer>"
cssCustomProperties=":root{--x:1}"
templateBasename="single.html"
withChrome={false}
/>,
);
expect(container.querySelector('header')).not.toBeNull();
Expand All @@ -29,6 +30,7 @@ describe('PublicShell', () => {
bodyHtml=""
cssCustomProperties=":root{--y:2}"
templateBasename="index.html"
withChrome={false}
/>,
);
const style = container.querySelector('style[data-gn-theme]');
Expand All @@ -41,9 +43,47 @@ describe('PublicShell', () => {
bodyHtml="<p>hi</p>"
cssCustomProperties=""
templateBasename="archive-book.tsx"
withChrome={false}
/>,
);
const site = container.querySelector('.gn-site');
expect(site?.getAttribute('data-gn-template')).toBe('archive-book.tsx');
});

it('renders the brand chrome (nav + footer) by default', () => {
const { container } = render(
<PublicShell
bodyHtml="<p>themed body</p>"
cssCustomProperties=""
templateBasename="single.html"
/>,
);
// The marketing nav uses a sticky pill on the forest surface;
// checking for the aria-label keeps the assertion forward-
// compatible with class-name changes.
expect(
container.querySelector('nav[aria-label="Primary"]'),
).not.toBeNull();
// The footer is a real <footer> with the brand wordmark.
expect(container.querySelector('footer')).not.toBeNull();
// The themed body is still injected verbatim within the chrome.
expect(container.querySelector('.gn-site')?.textContent).toContain(
'themed body',
);
});

it('renders children inside the chrome, after the themed body', () => {
const { container } = render(
<PublicShell
bodyHtml="<p>post</p>"
cssCustomProperties=""
templateBasename="single.html"
>
<aside data-testid="comments">comments</aside>
</PublicShell>,
);
const slot = container.querySelector('[data-testid=comments]');
expect(slot).not.toBeNull();
expect(slot?.textContent).toBe('comments');
});
});
89 changes: 67 additions & 22 deletions apps/web/src/app/PublicShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,42 @@
* PublicShell — the React envelope that turns a `RenderResult` into a
* paint-ready node.
*
* Why a component (vs. emitting raw HTML from the route): React's
* `dangerouslySetInnerHTML` is the one supported escape hatch for
* server-rendered HTML in App Router. Wrapping it in a component
* keeps the route handlers small and lets us snapshot the structure
* from tests.
* Two responsibilities:
*
* The two `dangerouslySetInnerHTML` calls are intentional:
* 1. Inject the trusted `bodyHtml` (theme header + main + footer)
* verbatim via `dangerouslySetInnerHTML`. The Go-side block walker
* escapes user input on the way in, so the strings reaching this
* boundary are safe by construction. Wrapping the injection in a
* React component keeps the route handlers small and lets us
* snapshot the structure from tests.
* 2. Wrap the themed body in the brand site chrome (sticky nav at top,
* forest footer at bottom) when `withChrome` is set. The chrome
* uses the Living-Systems wordmark and the same nav links as the
* marketing landing, so single-post pages and category archives
* keep the brand surface around the theme's content rather than
* dropping the visitor into bare-theme chrome.
*
* - `bodyHtml` is the assembled header + main + footer string. The
* main region was produced by our block walker (which HTML-escapes
* user input via @gonext/blocks-core's `escapeHtml`). The header /
* footer parts came from the Go-side walker over template-part
* HTML the theme ships — trusted at install time.
* The "wrap with chrome" mode is opt-in because not every consumer
* wants it — e.g. a future preview iframe will paint a barebones
* shell without the marketing chrome.
*
* - `cssCustomProperties` is the `:root { ... }` block emitted by
* the Go-side `EmitCSSCustomProperties`. The function whitelists
* token slugs and values during parse, so the output is safe to
* drop into a `<style>` element.
* The two `dangerouslySetInnerHTML` calls are intentional:
*
* - `bodyHtml` is the assembled header + main + footer string. The
* main region was produced by our block walker (which HTML-escapes
* user input via @gonext/blocks-core's `escapeHtml`). The header /
* footer parts came from the Go-side walker over template-part
* HTML the theme ships — trusted at install time.
*
* - `cssCustomProperties` is the `:root { ... }` block emitted by
* the Go-side `EmitCSSCustomProperties`. The function whitelists
* token slugs and values during parse, so the output is safe to
* drop into a `<style>` element.
*/
import type { ReactElement } from 'react';
import type { ReactElement, ReactNode } from 'react';

import { MarketingFooter } from '@/components/marketing/Footer';
import { MarketingNav } from '@/components/marketing/Nav';

interface PublicShellProps {
/** Already-assembled HTML body. Trusted — see file header. */
Expand All @@ -30,13 +46,39 @@ interface PublicShellProps {
cssCustomProperties: string;
/** Template basename — surfaced for e2e assertions. */
templateBasename: string;
/**
* Wrap the themed body in the brand site chrome (sticky nav, forest
* footer). Defaults to true on routes that render through the public
* shell — the legacy "bare" mode is reserved for preview surfaces.
*/
withChrome?: boolean;
/**
* Optional slot rendered between the themed body and the brand
* footer. The catch-all route uses this to drop the comments
* thread directly under the post content but inside the chrome.
*/
children?: ReactNode;
}

export function PublicShell({
bodyHtml,
cssCustomProperties,
templateBasename,
withChrome = true,
children,
}: PublicShellProps): ReactElement {
const site = (
<>
<div
className="gn-site"
data-gn-template={templateBasename}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: bodyHtml }}
/>
{children}
</>
);

return (
<>
{/* Theme tokens. The Go side already brace-validated the CSS. */}
Expand All @@ -52,12 +94,15 @@ export function PublicShell({
content={templateBasename}
data-gn-template={templateBasename}
/>
<div
className="gn-site"
data-gn-template={templateBasename}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: bodyHtml }}
/>
{withChrome ? (
<div className="min-h-screen bg-paper text-ink">
<MarketingNav />
<main>{site}</main>
<MarketingFooter />
</div>
) : (
site
)}
</>
);
}
13 changes: 6 additions & 7 deletions apps/web/src/app/[...slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,13 +197,12 @@ export default async function CatchAllSlugPage(
}

return (
<>
<PublicShell
bodyHtml={result.html}
cssCustomProperties={result.css}
templateBasename={result.templateBasename}
/>
<PublicShell
bodyHtml={result.html}
cssCustomProperties={result.css}
templateBasename={result.templateBasename}
>
{commentsBlock}
</>
</PublicShell>
);
}
Loading
Loading