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
Original file line number Diff line number Diff line change
Expand Up @@ -353,9 +353,7 @@ <h3 class="mb-2 font-mono text-[10px] uppercase tracking-[0.06em] text-fg-faint"

@if (showPreview() && content(); as c) {
<app-content-preview-overlay
[slug]="c.slug"
[type]="c.type"
[live]="c.status === 'published'"
[content]="c"
(closed)="showPreview.set(false)"
/>
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,11 @@ describe('ContentEditorPageComponent', () => {
).toBeNull();
});

it('should open the preview overlay with the /preview/:slug iframe via the topbar action', async () => {
it('should preview the persisted snapshot instead of unsaved form edits', async () => {
setValue('[data-testid="editor-title"]', 'Unsaved replacement title');
setValue('[data-testid="editor-body"]', 'Unsaved replacement body');
await settle();

const topbar = TestBed.inject(AdminTopbarService);
const previewAction = topbar
.context()
Expand All @@ -303,13 +307,17 @@ describe('ContentEditorPageComponent', () => {
previewAction!.run();
await settle();

const iframe = el().querySelector<HTMLIFrameElement>(
'[data-testid="preview-iframe"]',
const preview = el().querySelector<HTMLElement>(
'[data-testid="preview-frame"]',
);
expect(iframe?.getAttribute('src')).toBe('/preview/value-semantics');
expect(preview).toBeTruthy();
expect(
el().querySelector('[data-testid="preview-note"]')?.textContent,
).toContain('draft preview');
preview?.querySelector('[data-testid="preview-iframe"]'),
).toBeNull();
expect(preview?.textContent).toContain('Value semantics in Go');
expect(preview?.textContent).toContain('Some body text here.');
expect(preview?.textContent).not.toContain('Unsaved replacement title');
expect(preview?.textContent).not.toContain('Unsaved replacement body');
});

it('should close the preview overlay on scrim mousedown', async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,39 +1,49 @@
import { TestBed, type ComponentFixture } from '@angular/core/testing';
import { ContentPreviewOverlayComponent } from './preview-overlay.component';
import type { ApiContent } from '../../../../core/models/api.model';

function contentPayload(overrides: Partial<ApiContent> = {}): ApiContent {
return {
id: 'abc-1',
slug: 'value-semantics',
title: 'Value semantics in Go',
body: '# Heading\n\nPersisted body.',
excerpt: 'A persisted excerpt.',
type: 'article',
status: 'draft',
topics: [],
cover_image: null,
series_id: null,
series_order: null,
is_public: false,
reading_time_min: 3,
published_at: null,
created_at: '2026-06-01T00:00:00Z',
updated_at: '2026-06-02T00:00:00Z',
...overrides,
};
}

describe('ContentPreviewOverlayComponent', () => {
let fixture: ComponentFixture<ContentPreviewOverlayComponent>;

function create(
inputs: { slug?: string; type?: string; live?: boolean } = {},
): void {
function create(overrides: Partial<ApiContent> = {}): void {
fixture = TestBed.createComponent(ContentPreviewOverlayComponent);
fixture.componentRef.setInput('slug', inputs.slug ?? 'value-semantics');
fixture.componentRef.setInput('type', inputs.type ?? 'article');
fixture.componentRef.setInput('live', inputs.live ?? false);
fixture.componentRef.setInput('content', contentPayload(overrides));
fixture.detectChanges();
}

function el(): HTMLElement {
return fixture.nativeElement as HTMLElement;
}

it('should point the iframe at the /preview/:slug route', () => {
create({ slug: 'value-semantics' });

const iframe = el().querySelector<HTMLIFrameElement>(
'[data-testid="preview-iframe"]',
);
expect(iframe?.getAttribute('src')).toBe('/preview/value-semantics');
});

it('should URI-encode the slug in the iframe src', () => {
create({ slug: 'a b' });
it('should render the persisted content inline without an iframe', () => {
create();

const iframe = el().querySelector<HTMLIFrameElement>(
'[data-testid="preview-iframe"]',
);
expect(iframe?.getAttribute('src')).toBe('/preview/a%20b');
expect(el().querySelector('[data-testid="preview-iframe"]')).toBeNull();
expect(el().querySelector('[data-testid="preview-content"]')).toBeTruthy();
expect(el().textContent).toContain('Value semantics in Go');
expect(el().textContent).toContain('Persisted body.');
});

it('should display the canonical /articles public URL regardless of type', () => {
Expand All @@ -44,16 +54,16 @@ describe('ContentPreviewOverlayComponent', () => {
).toContain('koopa0.dev/articles/my-post');
});

it('should label the preview draft when not live and live when published', () => {
create({ live: false });
it('should distinguish saved non-public content from a live snapshot', () => {
create();
expect(
el().querySelector('[data-testid="preview-note"]')?.textContent,
).toContain('draft preview');
).toContain('not public');

create({ live: true });
create({ status: 'published', is_public: true });
expect(
el().querySelector('[data-testid="preview-note"]')?.textContent,
).toContain('live preview');
).toContain('live on the public site');
});

it('should emit closed on scrim mousedown but not on frame mousedown', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,26 @@ import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
output,
} from '@angular/core';
import { DomSanitizer, type SafeResourceUrl } from '@angular/platform-browser';
import { A11yModule } from '@angular/cdk/a11y';
import type { ContentType } from '../../../../core/models/api.model';
import type { ApiContent } from '../../../../core/models/api.model';
import { contentTypeRoute } from '../../../../core/models/content-type.config';
import { ArticleDetailComponent } from '../../../../pages/article-detail/article-detail';

/**
* Publish-preview overlay: a scrim-centered frame whose iframe renders
* the chrome-less public reading surface at `/preview/{slug}`.
* Publish-preview overlay for the content snapshot already loaded by the
* editor. It renders the shared reading component inline, so private drafts do
* not cross the public API boundary and unsaved form edits cannot masquerade
* as the persisted snapshot.
*
* Closes on Escape, on scrim mousedown, and via the Close button.
* The bar shows the public URL form and whether the preview reflects
* the live article (published) or a pre-publish state.
* The bar shows the public URL form and whether that saved snapshot is live.
*/
@Component({
selector: 'app-content-preview-overlay',
imports: [A11yModule],
imports: [A11yModule, ArticleDetailComponent],
template: `
<div
class="fixed inset-0 z-[100] grid place-items-center bg-black/60"
Expand Down Expand Up @@ -57,8 +57,8 @@ import { contentTypeRoute } from '../../../../core/models/content-type.config';
class="hidden font-mono text-[10px] text-fg-faint sm:inline"
data-testid="preview-note"
>
renders the live public article component ·
{{ live() ? 'live preview' : 'draft preview' }}
saved snapshot ·
{{ isLive() ? 'live on the public site' : 'not public' }}
</span>
<span class="flex-1"></span>
<button
Expand All @@ -70,12 +70,17 @@ import { contentTypeRoute } from '../../../../core/models/content-type.config';
Close
</button>
</div>
<iframe
[src]="iframeSrc()"
title="Publish preview"
class="block w-full flex-1 border-0 bg-bg"
data-testid="preview-iframe"
></iframe>
<div
class="ed flex-1 overflow-y-auto bg-bg"
data-tone="b"
data-testid="preview-content"
>
<app-article-detail
class="block min-h-full"
[article]="content()"
[preview]="true"
/>
</div>
</div>
</div>
`,
Expand All @@ -85,27 +90,13 @@ import { contentTypeRoute } from '../../../../core/models/content-type.config';
},
})
export class ContentPreviewOverlayComponent {
readonly slug = input.required<string>();
readonly type = input.required<ContentType>();
/** True when the content is published — the iframe shows the live article. */
readonly live = input(false);
/** Persisted API snapshot; never constructed from the editor form. */
readonly content = input.required<ApiContent>();

readonly closed = output();

private readonly sanitizer = inject(DomSanitizer);

/**
* Same-origin preview route for the iframe.
*
* SECURITY_REVIEW: bypassSecurityTrustResourceUrl is safe here — the
* URL is built from the constant `/preview/` prefix plus a
* URI-encoded path segment, so no caller-controlled scheme, host, or
* path traversal can reach the iframe src.
*/
protected readonly iframeSrc = computed<SafeResourceUrl>(() =>
this.sanitizer.bypassSecurityTrustResourceUrl(
`/preview/${encodeURIComponent(this.slug())}`,
),
protected readonly isLive = computed(
() => this.content().status === 'published' && this.content().is_public,
);

/**
Expand All @@ -115,6 +106,7 @@ export class ContentPreviewOverlayComponent {
* URL is sourced from contentTypeRoute rather than the bare type slug.
*/
protected readonly displayUrl = computed(
() => `koopa0.dev${contentTypeRoute(this.type())}/${this.slug()}`,
() =>
`koopa0.dev${contentTypeRoute(this.content().type)}/${this.content().slug}`,
);
}
2 changes: 1 addition & 1 deletion frontend/src/app/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
<router-outlet />
</main>
} @else {
<!-- Chrome-less preview column for the admin publish-preview iframe. -->
<!-- Standalone full-screen route (currently login). -->
<main
id="main-content"
class="flex-1"
Expand Down
5 changes: 0 additions & 5 deletions frontend/src/app/app.routes.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,6 @@ export const serverRoutes: ServerRoute[] = [
path: 'topics/:slug',
renderMode: RenderMode.Server,
},
{
// Admin publish-preview iframe target — browser-only render.
path: 'preview/:slug',
renderMode: RenderMode.Client,
},
{
path: 'about',
renderMode: RenderMode.Prerender,
Expand Down
17 changes: 16 additions & 1 deletion frontend/src/app/app.routes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,26 @@ describe('public route retirement', () => {
expect(routes.some((route) => route.path === 'articles')).toBe(true);
expect(serverRoutes.some((route) => route.path === 'articles')).toBe(true);
});

it('does not expose the retired iframe preview route', () => {
expect(routes.some((route) => route.path === 'preview/:slug')).toBe(false);
expect(serverRoutes.some((route) => route.path === 'preview/:slug')).toBe(
false,
);

// The real public reading route remains available.
expect(routes.some((route) => route.path === 'articles/:slug')).toBe(true);
expect(serverRoutes.some((route) => route.path === 'articles/:slug')).toBe(
true,
);
});
});

describe('admin route retirement', () => {
it('does not expose the retired dedicated content-search page', () => {
const adminRoutes = routes.find((route) => route.path === 'admin')?.children;
const adminRoutes = routes.find(
(route) => route.path === 'admin',
)?.children;

expect(
adminRoutes?.some((route) => route.path === 'knowledge/search'),
Expand Down
11 changes: 0 additions & 11 deletions frontend/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,6 @@ export const routes: Routes = [
),
resolve: { article: articleResolver },
},
// Chrome-less render of the same reading surface for the admin
// publish-preview iframe (no header / footer / TOC / nav).
{
path: 'preview/:slug',
loadComponent: () =>
import('./pages/article-detail/article-detail').then(
(m) => m.ArticleDetailComponent,
),
data: { preview: true },
resolve: { article: articleResolver },
},
{
path: 'topics',
loadComponent: () =>
Expand Down
13 changes: 5 additions & 8 deletions frontend/src/app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { ThemeService } from './core/services/theme.service';
/**
* The application shell — picks the chrome for the current route. The public
* reading site wears the editorial Tone B frame (masthead + footer); the
* admin area carries its own shell; the publish-preview iframe is bare.
* admin area carries its own shell; login is a standalone full-screen page.
*/
@Component({
selector: 'app-root',
Expand Down Expand Up @@ -59,13 +59,10 @@ export class AppComponent {
);

/**
* Routes that render bare — no editorial masthead or footer: the admin
* preview iframe column, and the standalone full-screen login.
* Routes that render bare — no editorial masthead or footer.
*/
protected readonly isChromeless = computed(
() =>
this.currentPath().startsWith('/preview') ||
this.currentPath() === '/login',
() => this.currentPath() === '/login',
);

/** The admin area carries its own shell (sidebar + topbar). */
Expand All @@ -74,8 +71,8 @@ export class AppComponent {
);

/**
* Public reading site: everything that is neither the chrome-less preview
* nor the admin area. This is the surface that wears the editorial frame.
* Public reading site: everything that is neither login nor the admin area.
* This is the surface that wears the editorial frame.
*/
protected readonly isPublicSite = computed(
() => !this.isChromeless() && !this.isAdminArea(),
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/pages/article-detail/article-detail.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
@if (article(); as content) {
@if (preview()) {
<!-- Chrome-less preview column for the admin publish-preview iframe. -->
<!-- Chrome-less reading column embedded in the admin preview dialog. -->
<article class="mx-auto max-w-[640px] px-6 pb-16 pt-8 sm:px-10">
<h1 class="ed-article-title">{{ content.title }}</h1>
@if (content.excerpt) {
Expand Down
Loading
Loading