Skip to content

Commit 1e212b1

Browse files
committed
Extract page layout to common file for articles and deep-dive item views
1 parent f27ca74 commit 1e212b1

8 files changed

Lines changed: 324 additions & 225 deletions

File tree

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
---
2+
/**
3+
* Shared layout for article and deep-dive item views.
4+
*
5+
* Wraps MarkdownLayout and fills its named slots (hero-image,
6+
* after-content, related-content) so page files only need to
7+
* resolve data and forward props.
8+
*/
9+
import type { CollectionEntry } from 'astro:content'
10+
import { Picture } from 'astro:assets'
11+
import MarkdownLayout from '@layouts/MarkdownLayout.astro'
12+
import Shares from '@components/Social/Shares/index.astro'
13+
import Carousel from '@components/Carousel/index.astro'
14+
import WebMentions from '@components/WebMentions/index.astro'
15+
16+
export type Props = {
17+
/** Resolved author display name */
18+
author: string
19+
/** Content collection entry */
20+
article: CollectionEntry<'articles'> | CollectionEntry<'deepDives'>
21+
/** Canonical URL path, e.g. '/articles/my-slug' */
22+
path: string
23+
/** Estimated reading time label */
24+
readingTime?: string
25+
/** Breadcrumb / metadata section label */
26+
section: string
27+
}
28+
29+
const { author, article, path, readingTime, section } = Astro.props
30+
---
31+
32+
<MarkdownLayout
33+
author={author}
34+
collectionItem={article}
35+
contentType="article"
36+
description={article.data.description}
37+
hideBreadcrumbsOnMobile={true}
38+
pageTitle={article.data.title}
39+
path={path}
40+
publishDate={article.data.publishDate}
41+
{...article.data.modifiedDate && { modifiedDate: article.data.modifiedDate }}
42+
section={section}
43+
showProgressBar={true}
44+
showToc={article.data.showToc ?? true}
45+
tags={article.data.tags?.map(t => (typeof t === 'string' ? t : t.id)) ?? []}
46+
{...readingTime && { readingTime }}
47+
>
48+
{/** SLOT: hero-image */}
49+
{
50+
article.data.cover && (
51+
<div
52+
class="relative w-full overflow-hidden mb-8"
53+
style="aspect-ratio: 1564 / 670;"
54+
slot="hero-image"
55+
>
56+
<Picture
57+
src={article.data.cover}
58+
alt={article.data.coverAlt}
59+
widths={[480, 768, 1024, 1280, 1564]}
60+
sizes="100vw"
61+
formats={['avif', 'webp', 'jpeg']}
62+
layout="constrained"
63+
fit="cover"
64+
position="center"
65+
class="absolute inset-0 h-full w-full"
66+
/>
67+
</div>
68+
)
69+
}
70+
71+
{/** SLOT: after-content */}
72+
<section
73+
class="border-t border-trim pt-8 mt-12"
74+
aria-labelledby="article-share-title"
75+
slot="after-content"
76+
>
77+
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
78+
<div>
79+
<h2 class="text-lg font-semibold mb-2" id="article-share-title">
80+
Share this article
81+
</h2>
82+
<p class="text-content-active text-sm">
83+
Found this helpful? Share it with others who might benefit.
84+
</p>
85+
</div>
86+
<Shares
87+
url={`https://www.webstackbuilders.com${path}`}
88+
title={article.data.title}
89+
description={`Read "${article.data.title}" on Webstack Builders`}
90+
class="shrink-0"
91+
/>
92+
</div>
93+
<WebMentions url={`https://www.webstackbuilders.com${path}`} />
94+
</section>
95+
96+
{/** SLOT: related-content */}
97+
<section
98+
class="border-t border-trim pt-8 mt-12"
99+
aria-labelledby="article-related-title"
100+
slot="related-content"
101+
>
102+
<header class="mb-8">
103+
<h2 class="text-2xl font-bold" id="article-related-title">
104+
Other things I've written
105+
</h2>
106+
</header>
107+
<Carousel
108+
title=""
109+
variant="suggested"
110+
currentSlug={article.id}
111+
limit={5}
112+
type="articles"
113+
showReadMore={false}
114+
/>
115+
</section>
116+
</MarkdownLayout>
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// @vitest-environment node
2+
3+
import { beforeEach, describe, expect, test } from 'vitest'
4+
import { experimental_AstroContainer as AstroContainer } from 'astro/container'
5+
import SwitcherAstro from '@components/Content/Switcher/index.astro'
6+
import type { ContentSwitcherElement } from '@components/Content/Switcher/client'
7+
import type { WebComponentModule } from '@components/scripts/@types/webComponentModule'
8+
import { executeRender } from '@test/unit/helpers/litRuntime'
9+
10+
type SwitcherModule = WebComponentModule<ContentSwitcherElement>
11+
12+
describe('ContentSwitcherElement', () => {
13+
let container: AstroContainer
14+
15+
beforeEach(async () => {
16+
container = await AstroContainer.create()
17+
})
18+
19+
const runComponentRender = async (
20+
args: Parameters<AstroContainer['renderToString']>[1],
21+
assertion: (_context: { element: ContentSwitcherElement }) => Promise<void> | void,
22+
): Promise<void> => {
23+
await executeRender<SwitcherModule>({
24+
container,
25+
component: SwitcherAstro,
26+
moduleSpecifier: '@components/Content/Switcher/client/index',
27+
args,
28+
waitForReady: async (element: ContentSwitcherElement) => {
29+
await element.updateComplete
30+
},
31+
assert: async ({ element, module, renderResult }) => {
32+
expect(renderResult).toContain(`<${module.registeredName}`)
33+
await assertion({ element })
34+
},
35+
})
36+
}
37+
38+
test('renders overview state links with deep-dive toggle target', async () => {
39+
await runComponentRender(
40+
{
41+
props: {
42+
currentVariant: 'overview',
43+
slug: 'my-article',
44+
},
45+
},
46+
async ({ element }) => {
47+
const overviewLink = element.querySelector('#v1-overview-label') as HTMLAnchorElement | null
48+
const deepDiveLink = element.querySelector('#v1-deep-dive-label') as HTMLAnchorElement | null
49+
const switchLink = element.querySelector('[data-switcher-toggle]') as HTMLAnchorElement | null
50+
51+
expect(overviewLink?.getAttribute('href')).toBe('/articles/my-article')
52+
expect(deepDiveLink?.getAttribute('href')).toBe('/deep-dive/my-article')
53+
expect(switchLink?.getAttribute('href')).toBe('/deep-dive/my-article')
54+
expect(switchLink?.getAttribute('aria-checked')).toBe('false')
55+
},
56+
)
57+
})
58+
59+
test('renders deep-dive state with overview toggle target', async () => {
60+
await runComponentRender(
61+
{
62+
props: {
63+
currentVariant: 'deep-dive',
64+
slug: 'my-article',
65+
},
66+
},
67+
async ({ element }) => {
68+
const overviewLink = element.querySelector('#v1-overview-label') as HTMLAnchorElement | null
69+
const deepDiveLink = element.querySelector('#v1-deep-dive-label') as HTMLAnchorElement | null
70+
const switchLink = element.querySelector('[data-switcher-toggle]') as HTMLAnchorElement | null
71+
72+
expect(overviewLink?.getAttribute('href')).toBe('/articles/my-article')
73+
expect(deepDiveLink?.getAttribute('href')).toBe('/deep-dive/my-article')
74+
expect(switchLink?.getAttribute('href')).toBe('/articles/my-article')
75+
expect(switchLink?.getAttribute('aria-checked')).toBe('true')
76+
},
77+
)
78+
})
79+
80+
test('normalizes slugs with leading and trailing slashes', async () => {
81+
await runComponentRender(
82+
{
83+
props: {
84+
currentVariant: 'overview',
85+
slug: '/nested/path/',
86+
},
87+
},
88+
async ({ element }) => {
89+
const overviewLink = element.querySelector('#v1-overview-label') as HTMLAnchorElement | null
90+
const deepDiveLink = element.querySelector('#v1-deep-dive-label') as HTMLAnchorElement | null
91+
92+
expect(overviewLink?.getAttribute('href')).toBe('/articles/nested/path')
93+
expect(deepDiveLink?.getAttribute('href')).toBe('/deep-dive/nested/path')
94+
},
95+
)
96+
})
97+
})
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { LitElement, html } from 'lit'
2+
import { defineCustomElement } from '@components/scripts/utils'
3+
import type { WebComponentModule } from '@components/scripts/@types/webComponentModule'
4+
5+
export type ContentVariant = 'overview' | 'deep-dive'
6+
7+
const normalizeSlug = (slug: string): string => slug.replace(/^\/+|\/+$/g, '')
8+
9+
/**
10+
* Builds a route href for a content variant and slug.
11+
*/
12+
const buildVariantHref = (variant: ContentVariant, slug: string): string => {
13+
const normalizedSlug = normalizeSlug(slug)
14+
15+
if (variant === 'overview') {
16+
return normalizedSlug ? `/articles/${normalizedSlug}` : '/articles'
17+
}
18+
19+
return normalizedSlug ? `/deep-dive/${normalizedSlug}` : '/deep-dive'
20+
}
21+
22+
export class ContentSwitcherElement extends LitElement {
23+
static registeredName = 'content-switcher'
24+
25+
static override properties = {
26+
currentVariant: { type: String, attribute: 'current-variant' },
27+
slug: { type: String },
28+
}
29+
30+
declare currentVariant: ContentVariant
31+
declare slug: string
32+
33+
constructor() {
34+
super()
35+
this.currentVariant = 'overview'
36+
this.slug = ''
37+
}
38+
39+
protected override createRenderRoot() {
40+
return this
41+
}
42+
43+
protected override render() {
44+
const isDeepDive = this.currentVariant === 'deep-dive'
45+
const overviewHref = buildVariantHref('overview', this.slug)
46+
const deepDiveHref = buildVariantHref('deep-dive', this.slug)
47+
const switchHref = isDeepDive ? overviewHref : deepDiveHref
48+
49+
const overviewLabelClass = isDeepDive
50+
? 'text-sm text-content-offset content-switcher-label content-switcher-label--overview'
51+
: 'text-sm font-medium text-content-active content-switcher-label content-switcher-label--overview'
52+
53+
const deepDiveLabelClass = isDeepDive
54+
? 'text-sm font-medium text-content-active content-switcher-label content-switcher-label--deep-dive'
55+
: 'text-sm text-content-offset content-switcher-label content-switcher-label--deep-dive'
56+
57+
return html`
58+
<div class="flex items-center gap-3" role="radiogroup" aria-label="Content Variant">
59+
<a class="${overviewLabelClass}" id="v1-overview-label" href="${overviewHref}">Overview</a>
60+
61+
<a
62+
href="${switchHref}"
63+
role="switch"
64+
aria-checked="${isDeepDive ? 'true' : 'false'}"
65+
aria-label="Toggle between Overview and Deep Dive"
66+
class="content-switcher-track group relative inline-flex h-7 w-12 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-trim-offset transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-secondary"
67+
data-switcher-toggle
68+
>
69+
<span
70+
class="content-switcher-thumb pointer-events-none inline-block size-5 translate-x-0.5 rounded-full bg-page-base shadow-sm ring-0 transition-transform"
71+
></span>
72+
</a>
73+
74+
<a class="${deepDiveLabelClass}" id="v1-deep-dive-label" href="${deepDiveHref}">Deep Dive</a>
75+
</div>
76+
`
77+
}
78+
}
79+
80+
export const registerWebComponent = async (tagName = ContentSwitcherElement.registeredName) => {
81+
defineCustomElement(tagName, ContentSwitcherElement)
82+
}
83+
84+
export const registerContentSwitcherComponent = registerWebComponent
85+
86+
export const webComponentModule: WebComponentModule<ContentSwitcherElement> = {
87+
registeredName: ContentSwitcherElement.registeredName,
88+
componentCtor: ContentSwitcherElement,
89+
registerWebComponent,
90+
}

‎src/components/Content/Switcher/index.astro‎

Lines changed: 8 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,33 +4,16 @@ import './index.css'
44
export type Props = {
55
/** The currently active variant, used to determine the position of the switcher thumb */
66
currentVariant: 'overview' | 'deep-dive'
7+
/** Shared slug used to navigate between overview and deep-dive routes */
8+
slug: string
79
}
810
9-
const { currentVariant } = Astro.props
11+
const { currentVariant, slug } = Astro.props
1012
---
1113

12-
<div class="flex items-center gap-3" role="radiogroup" aria-label="Content Variant">
13-
<span
14-
class="text-sm font-medium text-content-active content-switcher-label content-switcher-label--overview"
15-
id="v1-overview-label"
16-
>
17-
Overview
18-
</span>
14+
<content-switcher current-variant={currentVariant} slug={slug}></content-switcher>
1915

20-
<button
21-
type="button"
22-
role="switch"
23-
aria-checked={currentVariant === 'deep-dive' ? 'true' : 'false'}
24-
aria-label="Toggle between Overview and Deep Dive"
25-
class="content-switcher-track group relative inline-flex h-7 w-12 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-trim-offset transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-secondary"
26-
>
27-
<span class="content-switcher-thumb pointer-events-none inline-block size-5 translate-x-0.5 rounded-full bg-page-base shadow-sm ring-0 transition-transform" />
28-
</button>
29-
30-
<span
31-
class="text-sm text-content-offset content-switcher-label content-switcher-label--deep-dive"
32-
id="v1-deep-dive-label"
33-
>
34-
Deep Dive
35-
</span>
36-
</div>
16+
<script>
17+
import { registerContentSwitcherComponent } from '@components/Content/Switcher/client'
18+
registerContentSwitcherComponent()
19+
</script>

‎src/components/Content/Switcher/index.css‎

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,3 @@
99
.content-switcher-track[aria-checked='true'] .content-switcher-thumb {
1010
transform: translateX(1.25rem);
1111
}
12-
13-
.content-switcher-track[aria-checked='false'] ~ .content-switcher-label--overview {
14-
color: var(--color-content-active);
15-
font-weight: 600;
16-
}
17-
18-
.content-switcher-track[aria-checked='false'] ~ .content-switcher-label--deep-dive {
19-
color: var(--color-content-offset);
20-
}
21-
22-
.content-switcher-track[aria-checked='true'] ~ .content-switcher-label--deep-dive {
23-
color: var(--color-content-active);
24-
font-weight: 600;
25-
}

‎src/layouts/MarkdownLayout.astro‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,9 +165,6 @@ const shouldRenderToc = showToc !== false && tocHeadings.length > 0
165165
{/** SLOT: hero-image, conditional on image specified */}
166166
<slot name="hero-image" />
167167

168-
{/** SLOT: content-prefix, renders prior to markdown body (e.g., metadata panels) */}
169-
<slot name="content-prefix" />
170-
171168
{/** SLOT: default (unnamed) */}
172169
<div
173170
id="content"

0 commit comments

Comments
 (0)