astro-theme-popular 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/index.mjs +81 -0
- package/package.json +47 -0
- package/schemas.ts +137 -0
- package/scripts/blog-filter.js +24 -0
- package/scripts/checklist.js +42 -0
- package/scripts/copy-code.js +25 -0
- package/scripts/nav.js +12 -0
- package/scripts/toc.js +25 -0
- package/src/components/AuthorBox.astro +51 -0
- package/src/components/Callout.astro +22 -0
- package/src/components/Checklist.astro +29 -0
- package/src/components/DemoBar.astro +50 -0
- package/src/components/EventRow.astro +59 -0
- package/src/components/Footer.astro +42 -0
- package/src/components/Header.astro +49 -0
- package/src/components/OrganizerCard.astro +21 -0
- package/src/components/PageHero.astro +19 -0
- package/src/components/Pagination.astro +35 -0
- package/src/components/PostCard.astro +36 -0
- package/src/layouts/BaseLayout.astro +102 -0
- package/src/lib/slugs.ts +10 -0
- package/src/pages/[slug].astro +62 -0
- package/src/pages/authors/[slug].astro +39 -0
- package/src/pages/blog/[...page].astro +52 -0
- package/src/pages/blog/[slug].astro +122 -0
- package/src/pages/events/[...page].astro +86 -0
- package/src/pages/events/[slug].astro +81 -0
- package/src/pages/index.astro +178 -0
- package/src/pages/organizers/[...page].astro +38 -0
- package/src/pages/rss.xml.js +33 -0
- package/src/pages/speakers/[slug].astro +39 -0
- package/src/pages/tags/[tag]/[...page].astro +47 -0
- package/src/pages/venues/[slug].astro +50 -0
- package/src/styles/base.css +113 -0
- package/src/styles/components.css +310 -0
- package/src/styles/tokens/colors.css +82 -0
- package/src/styles/tokens/elevation.css +27 -0
- package/src/styles/tokens/fonts.css +9 -0
- package/src/styles/tokens/spacing.css +39 -0
- package/src/styles/tokens/typography.css +43 -0
- package/tsconfig.json +6 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SITE, STRINGS } from 'popular:config';
|
|
3
|
+
import type { CollectionEntry } from 'astro:content';
|
|
4
|
+
import { getCollection } from 'astro:content';
|
|
5
|
+
|
|
6
|
+
interface Props { event: CollectionEntry<'events'> }
|
|
7
|
+
const { event } = Astro.props;
|
|
8
|
+
const d = event.data;
|
|
9
|
+
|
|
10
|
+
/* Venue: a venueRef to the venues collection wins over the flat fields. */
|
|
11
|
+
const venuePage = d.venueRef ? (await getCollection('venues')).find((v) => v.id === d.venueRef) : undefined;
|
|
12
|
+
if (d.venueRef && !venuePage) console.warn(`events/${event.id}: venueRef "${d.venueRef}" matches no venue page`);
|
|
13
|
+
const venueName = venuePage?.data.title ?? d.venue;
|
|
14
|
+
const venueAddress = venuePage?.data.address ?? (venuePage ? undefined : d.address);
|
|
15
|
+
const tbd = d.venueWanted || !venueName;
|
|
16
|
+
|
|
17
|
+
/* Speaker line: resolved speaker-profile names, falling back to the string. */
|
|
18
|
+
const allSpeakers = d.speakers.length ? await getCollection('speakers') : [];
|
|
19
|
+
const names = d.speakers.map((slug) => {
|
|
20
|
+
const found = allSpeakers.find((s) => s.id === slug);
|
|
21
|
+
if (!found) console.warn(`events/${event.id}: speaker "${slug}" matches no speaker page`);
|
|
22
|
+
return found?.data.title;
|
|
23
|
+
}).filter(Boolean);
|
|
24
|
+
const speakerLine = names.length ? names.join(', ') : d.speaker;
|
|
25
|
+
|
|
26
|
+
const dow = d.date.toLocaleDateString(SITE.locale, { weekday: 'short' });
|
|
27
|
+
const day = d.date.toLocaleDateString(SITE.locale, { month: 'short', day: 'numeric' });
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
<div class="g-event">
|
|
31
|
+
<div class="g-event__date">
|
|
32
|
+
<span class="g-event__dow">{dow}</span>
|
|
33
|
+
<span class="g-event__day">{day}</span>
|
|
34
|
+
{d.time && <span class="g-event__time">{d.time}</span>}
|
|
35
|
+
</div>
|
|
36
|
+
<div class="g-event__body">
|
|
37
|
+
{d.tags.length > 0 && (
|
|
38
|
+
<div class="g-card__footer" style="padding-top:0">
|
|
39
|
+
{d.tags.map((t) => <span class="g-tag">{t}</span>)}
|
|
40
|
+
</div>
|
|
41
|
+
)}
|
|
42
|
+
<h3><a href={`/events/${event.id}/`} style="color:inherit">{d.title}</a></h3>
|
|
43
|
+
{d.description && <p>{d.description}</p>}
|
|
44
|
+
<div class="g-event__meta">
|
|
45
|
+
<i class="fa-solid fa-location-dot" aria-hidden="true"></i>{venueName ?? STRINGS.venueTba}{venueAddress && ` · ${venueAddress}`}
|
|
46
|
+
</div>
|
|
47
|
+
{speakerLine && (
|
|
48
|
+
<div class="g-event__meta"><i class="fa-solid fa-microphone-lines" aria-hidden="true"></i>{speakerLine}</div>
|
|
49
|
+
)}
|
|
50
|
+
</div>
|
|
51
|
+
<div class="g-event__aside">
|
|
52
|
+
{tbd
|
|
53
|
+
? <span class="g-badge g-badge--warning g-badge--soft">{STRINGS.venueWanted}</span>
|
|
54
|
+
: <span class="g-badge g-badge--success g-badge--soft">{STRINGS.confirmed}</span>}
|
|
55
|
+
{d.rsvp && (
|
|
56
|
+
<a class="g-btn g-btn--primary g-btn--sm" href={d.rsvp}><i class="fa-solid fa-calendar" aria-hidden="true"></i>{STRINGS.rsvp}</a>
|
|
57
|
+
)}
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SITE, FOOTER, SOCIAL } from 'popular:config';
|
|
3
|
+
const year = new Date().getFullYear();
|
|
4
|
+
const credit = (FOOTER as { credit?: { label: string; url: string } }).credit;
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<footer class="g-footer">
|
|
8
|
+
<div class="g-container g-footer__top">
|
|
9
|
+
<div class="g-footer__brand">
|
|
10
|
+
{SITE.logo && <img src={SITE.logo} alt={SITE.title} />}
|
|
11
|
+
{SITE.landAcknowledgement && <p class="g-footer__ack">{SITE.landAcknowledgement}</p>}
|
|
12
|
+
{SOCIAL.length > 0 && (
|
|
13
|
+
<div style="display:flex;gap:0.5rem;margin-top:0.25rem">
|
|
14
|
+
{SOCIAL.map((s) => (
|
|
15
|
+
<a class="g-social" href={s.url} aria-label={s.label} rel="me noopener" target="_blank">
|
|
16
|
+
<i class={s.icon} aria-hidden="true"></i>
|
|
17
|
+
</a>
|
|
18
|
+
))}
|
|
19
|
+
</div>
|
|
20
|
+
)}
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
{FOOTER.columns.map((col) => (
|
|
24
|
+
<div class="g-footer__col">
|
|
25
|
+
<div class="g-footer__col-title">{col.title}</div>
|
|
26
|
+
<ul>
|
|
27
|
+
{col.links.map((l) => (
|
|
28
|
+
<li><a href={l.href}>{l.label}</a></li>
|
|
29
|
+
))}
|
|
30
|
+
</ul>
|
|
31
|
+
</div>
|
|
32
|
+
))}
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div class="g-footer__bottom">
|
|
36
|
+
<div class="g-container">
|
|
37
|
+
<span>© {year} {SITE.title}</span>
|
|
38
|
+
{credit && <span><a href={credit.url} rel="noopener">{credit.label}</a></span>}
|
|
39
|
+
{FOOTER.tagline && <span>{FOOTER.tagline}</span>}
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
</footer>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SITE, NAV, CTA, STRINGS } from 'popular:config';
|
|
3
|
+
const path = Astro.url.pathname;
|
|
4
|
+
const isActive = (href: string) => href !== '/' && path.startsWith(href.replace(/\/$/, ''));
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<header class="g-header">
|
|
8
|
+
<nav class="g-container g-nav" aria-label={STRINGS.primaryNav}>
|
|
9
|
+
<a class="g-brand" href="/">
|
|
10
|
+
{SITE.logo && <img src={SITE.logo} alt={SITE.title} />}
|
|
11
|
+
<span class="g-brand__text">
|
|
12
|
+
<span class="g-brand__name">{SITE.brandName ?? SITE.title}</span>
|
|
13
|
+
{SITE.brandSub && <span class="g-brand__sub">{SITE.brandSub}</span>}
|
|
14
|
+
</span>
|
|
15
|
+
</a>
|
|
16
|
+
|
|
17
|
+
<button class="g-nav__toggle" aria-label={STRINGS.toggleMenu} aria-expanded="false">
|
|
18
|
+
<i class="fa-solid fa-bars" aria-hidden="true"></i>
|
|
19
|
+
</button>
|
|
20
|
+
|
|
21
|
+
<ul class="g-navlinks">
|
|
22
|
+
{NAV.map((l) =>
|
|
23
|
+
l.children?.length ? (
|
|
24
|
+
<li class="has-sub">
|
|
25
|
+
<a
|
|
26
|
+
href={l.href}
|
|
27
|
+
class={isActive(l.href) || l.children.some((c) => isActive(c.href)) ? 'is-active' : ''}
|
|
28
|
+
>
|
|
29
|
+
{l.label}<i class="fa-solid fa-chevron-down g-navlinks__caret" aria-hidden="true"></i>
|
|
30
|
+
</a>
|
|
31
|
+
<ul class="g-navlinks__sub">
|
|
32
|
+
{l.children.map((c) => (
|
|
33
|
+
<li><a href={c.href} class={isActive(c.href) ? 'is-active' : ''}>{c.label}</a></li>
|
|
34
|
+
))}
|
|
35
|
+
</ul>
|
|
36
|
+
</li>
|
|
37
|
+
) : (
|
|
38
|
+
<li><a href={l.href} class={isActive(l.href) ? 'is-active' : ''}>{l.label}</a></li>
|
|
39
|
+
)
|
|
40
|
+
)}
|
|
41
|
+
</ul>
|
|
42
|
+
|
|
43
|
+
{CTA && (
|
|
44
|
+
<a class="g-btn g-btn--primary g-btn--sm g-nav__cta" href={CTA.url}>
|
|
45
|
+
{CTA.icon && <i class={CTA.icon} aria-hidden="true"></i>}{CTA.label}
|
|
46
|
+
</a>
|
|
47
|
+
)}
|
|
48
|
+
</nav>
|
|
49
|
+
</header>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { CollectionEntry } from 'astro:content';
|
|
3
|
+
|
|
4
|
+
interface Props { organizer: CollectionEntry<'organizers'> }
|
|
5
|
+
const { organizer } = Astro.props;
|
|
6
|
+
const d = organizer.data;
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<div class="g-org">
|
|
10
|
+
{d.photo && <img class="g-org__img" src={d.photo} alt={d.title} loading="lazy" />}
|
|
11
|
+
<div class="g-org__name">{d.title}</div>
|
|
12
|
+
{d.role && <div class="g-org__role">{d.role}</div>}
|
|
13
|
+
{d.description && <p class="g-org__bio">{d.description}</p>}
|
|
14
|
+
{d.social.length > 0 && (
|
|
15
|
+
<div class="g-persona__socials">
|
|
16
|
+
{d.social.map((s) => (
|
|
17
|
+
<a href={s.url} aria-label={s.label} rel="noopener" target="_blank"><i class={s.icon} aria-hidden="true"></i></a>
|
|
18
|
+
))}
|
|
19
|
+
</div>
|
|
20
|
+
)}
|
|
21
|
+
</div>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
eyebrow?: string;
|
|
4
|
+
title: string;
|
|
5
|
+
lead?: string;
|
|
6
|
+
}
|
|
7
|
+
const { eyebrow, title, lead } = Astro.props;
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
<header class="g-pagehero">
|
|
11
|
+
<div class="g-container">
|
|
12
|
+
{eyebrow && <span class="g-eyebrow">{eyebrow}</span>}
|
|
13
|
+
<h1>{title}</h1>
|
|
14
|
+
{lead && <p class="g-pagehero__lead">{lead}</p>}
|
|
15
|
+
{Astro.slots.has('default') && (
|
|
16
|
+
<div class="g-row" style="margin-top:var(--space-4)"><slot /></div>
|
|
17
|
+
)}
|
|
18
|
+
</div>
|
|
19
|
+
</header>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
/* Pager for paginated lists. Mirrors the Hugo theme's
|
|
3
|
+
layouts/partials/pagination.html (see PARITY.md).
|
|
4
|
+
`base` is the section's root URL ("/blog/"); page 1 links to the base
|
|
5
|
+
itself, page N to `${base}page/N/`, matching Hugo's paginator URLs.
|
|
6
|
+
Renders nothing when everything fits on one page. */
|
|
7
|
+
import { STRINGS } from 'popular:config';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
current: number;
|
|
11
|
+
total: number;
|
|
12
|
+
base: string;
|
|
13
|
+
}
|
|
14
|
+
const { current, total, base } = Astro.props;
|
|
15
|
+
const url = (n: number) => (n === 1 ? base : `${base}page/${n}/`);
|
|
16
|
+
const pages = Array.from({ length: total }, (_, i) => i + 1);
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
{total > 1 && (
|
|
20
|
+
<nav class="g-pager" aria-label={STRINGS.paginationLabel}>
|
|
21
|
+
{current > 1 && (
|
|
22
|
+
<a class="g-pager__nav" href={url(current - 1)} rel="prev"><i class="fa-solid fa-chevron-left" aria-hidden="true"></i>{STRINGS.paginationPrev}</a>
|
|
23
|
+
)}
|
|
24
|
+
{pages.map((n) =>
|
|
25
|
+
n === current ? (
|
|
26
|
+
<span class="g-pager__num is-active" aria-current="page">{n}</span>
|
|
27
|
+
) : (
|
|
28
|
+
<a class="g-pager__num" href={url(n)}>{n}</a>
|
|
29
|
+
)
|
|
30
|
+
)}
|
|
31
|
+
{current < total && (
|
|
32
|
+
<a class="g-pager__nav" href={url(current + 1)} rel="next">{STRINGS.paginationNext}<i class="fa-solid fa-chevron-right" aria-hidden="true"></i></a>
|
|
33
|
+
)}
|
|
34
|
+
</nav>
|
|
35
|
+
)}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SITE } from 'popular:config';
|
|
3
|
+
import { tagSlug } from '../lib/slugs';
|
|
4
|
+
import type { CollectionEntry } from 'astro:content';
|
|
5
|
+
import { getCollection } from 'astro:content';
|
|
6
|
+
|
|
7
|
+
interface Props { post: CollectionEntry<'blog'> }
|
|
8
|
+
const { post } = Astro.props;
|
|
9
|
+
const d = post.data;
|
|
10
|
+
const href = `/blog/${post.id}/`;
|
|
11
|
+
const month = d.date.toLocaleDateString(SITE.locale, { month: 'short', year: 'numeric' });
|
|
12
|
+
const allAuthors = await getCollection('authors');
|
|
13
|
+
const names = [
|
|
14
|
+
...d.authors.map((slug) => allAuthors.find((a) => a.id === slug)?.data.title ?? slug),
|
|
15
|
+
...d.guestAuthors.map((g) => g.name),
|
|
16
|
+
];
|
|
17
|
+
if (!names.length && d.author) names.push(d.author);
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
<article class="g-card" data-tags={d.tags.join(',')}>
|
|
21
|
+
{d.image && (
|
|
22
|
+
<a href={href} class="g-card__media"><img src={d.image} alt={d.title} loading="lazy" /></a>
|
|
23
|
+
)}
|
|
24
|
+
<div class="g-card__body">
|
|
25
|
+
<span class="g-card__eyebrow">{month}{names.length > 0 && ` · ${names.join(', ')}`}</span>
|
|
26
|
+
<h3 class="g-card__title"><a href={href}>{d.title}</a></h3>
|
|
27
|
+
{d.description && <div class="g-card__text">{d.description}</div>}
|
|
28
|
+
{d.tags.length > 0 && (
|
|
29
|
+
<div class="g-card__footer">
|
|
30
|
+
{d.tags.slice(0, 2).map((t) => (
|
|
31
|
+
<a class="g-tag" href={`/tags/${tagSlug(t)}/`}>{t}</a>
|
|
32
|
+
))}
|
|
33
|
+
</div>
|
|
34
|
+
)}
|
|
35
|
+
</div>
|
|
36
|
+
</article>
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** Popular: base layout: head, brand token overrides, header, footer, shared JS. */
|
|
3
|
+
import '../styles/tokens/fonts.css';
|
|
4
|
+
import '../styles/tokens/colors.css';
|
|
5
|
+
import '../styles/tokens/typography.css';
|
|
6
|
+
import '../styles/tokens/spacing.css';
|
|
7
|
+
import '../styles/tokens/elevation.css';
|
|
8
|
+
import '../styles/base.css';
|
|
9
|
+
import '../styles/components.css';
|
|
10
|
+
import Header from '../components/Header.astro';
|
|
11
|
+
import Footer from '../components/Footer.astro';
|
|
12
|
+
import DemoBar from '../components/DemoBar.astro';
|
|
13
|
+
import { SITE, BRAND, STRINGS } from 'popular:config';
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
title?: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
image?: string;
|
|
19
|
+
/** Set on blog posts: emits og:type=article plus article meta. */
|
|
20
|
+
article?: { published?: Date; modified?: Date; authors?: string[]; tags?: string[] };
|
|
21
|
+
}
|
|
22
|
+
const { title, description, image, article } = Astro.props;
|
|
23
|
+
const pageTitle = title ? `${title} · ${SITE.title}` : `${SITE.title}, ${SITE.tagline}`;
|
|
24
|
+
const desc = description ?? SITE.description;
|
|
25
|
+
const ogImage = image ?? SITE.ogImage;
|
|
26
|
+
const canonical = new URL(Astro.url.pathname, Astro.site ?? Astro.url);
|
|
27
|
+
|
|
28
|
+
/* Brand overrides: mirrors the Hugo theme's brand-vars.html partial. */
|
|
29
|
+
const vars: string[] = [];
|
|
30
|
+
const b = BRAND;
|
|
31
|
+
if (b.primary) {
|
|
32
|
+
vars.push(`--color-primary:${b.primary}`, `--gold-500:${b.primary}`);
|
|
33
|
+
vars.push(`--gold-50:color-mix(in srgb, ${b.primary} 7%, #fff)`);
|
|
34
|
+
vars.push(`--gold-100:color-mix(in srgb, ${b.primary} 20%, #fff)`);
|
|
35
|
+
vars.push(`--gold-300:color-mix(in srgb, ${b.primary} 55%, #fff)`);
|
|
36
|
+
}
|
|
37
|
+
if (b.primaryHover) vars.push(`--color-primary-hover:${b.primaryHover}`);
|
|
38
|
+
if (b.primaryActive) vars.push(`--color-primary-active:${b.primaryActive}`, `--gold-700:${b.primaryActive}`, `--text-link:${b.primaryActive}`);
|
|
39
|
+
if (b.link) vars.push(`--text-link:${b.link}`);
|
|
40
|
+
if (b.linkHover) vars.push(`--text-link-hover:${b.linkHover}`);
|
|
41
|
+
if (b.secondary) vars.push(`--color-secondary:${b.secondary}`);
|
|
42
|
+
if (b.accent) vars.push(`--color-accent:${b.accent}`, `--indigo-500:${b.accent}`);
|
|
43
|
+
if (b.accentHover) vars.push(`--color-accent-hover:${b.accentHover}`);
|
|
44
|
+
if (b.ink) vars.push(`--ink-800:${b.ink}`, `--text-strong:${b.ink}`);
|
|
45
|
+
if (b.surfaceWash) vars.push(`--surface-wash:${b.surfaceWash}`);
|
|
46
|
+
if (b.surfaceWashSoft) vars.push(`--surface-wash-soft:${b.surfaceWashSoft}`);
|
|
47
|
+
if (b.surfaceInk) vars.push(`--surface-ink:${b.surfaceInk}`);
|
|
48
|
+
/* Dark-palette keys: page/card surfaces, body text, borders, and the text
|
|
49
|
+
color on filled buttons. Setting these lets a site run on a dark background. */
|
|
50
|
+
if (b.surfacePage) vars.push(`--surface-page:${b.surfacePage}`);
|
|
51
|
+
if (b.surfaceCard) vars.push(`--surface-card:${b.surfaceCard}`);
|
|
52
|
+
if (b.surfaceTertiary) vars.push(`--surface-tertiary:${b.surfaceTertiary}`);
|
|
53
|
+
if (b.textBody) vars.push(`--text-body:${b.textBody}`, `--ink-700:${b.textBody}`);
|
|
54
|
+
if (b.textMuted) vars.push(`--text-muted:${b.textMuted}`, `--ink-500:${b.textMuted}`);
|
|
55
|
+
if (b.textOnBrand) vars.push(`--text-on-brand:${b.textOnBrand}`);
|
|
56
|
+
if (b.borderSubtle) vars.push(`--border-subtle:${b.borderSubtle}`, `--border-default:${b.borderSubtle}`);
|
|
57
|
+
if (b.fontSans) vars.push(`--font-sans:${b.fontSans}`);
|
|
58
|
+
if (b.fontDisplay) vars.push(`--font-display:${b.fontDisplay}`);
|
|
59
|
+
if (b.radiusCard) vars.push(`--radius-xl:${b.radiusCard}`);
|
|
60
|
+
if (b.containerMax) vars.push(`--container-max:${b.containerMax}`);
|
|
61
|
+
/* html:root outranks the token files' :root so the override wins regardless of
|
|
62
|
+
where Astro injects the bundled stylesheet in <head>. */
|
|
63
|
+
const brandCss = `html:root{${vars.join(';')}}`;
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
<!DOCTYPE html>
|
|
67
|
+
<html lang={SITE.locale}>
|
|
68
|
+
<head>
|
|
69
|
+
<meta charset="utf-8" />
|
|
70
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
71
|
+
<title>{pageTitle}</title>
|
|
72
|
+
<meta name="description" content={desc} />
|
|
73
|
+
<meta property="og:site_name" content={SITE.title} />
|
|
74
|
+
<meta property="og:title" content={title ?? SITE.title} />
|
|
75
|
+
<meta property="og:description" content={desc} />
|
|
76
|
+
<meta property="og:url" content={canonical} />
|
|
77
|
+
<link rel="canonical" href={canonical} />
|
|
78
|
+
<meta property="og:type" content={article ? 'article' : 'website'} />
|
|
79
|
+
{article?.published && <meta property="article:published_time" content={article.published.toISOString()} />}
|
|
80
|
+
{article?.modified && <meta property="article:modified_time" content={article.modified.toISOString()} />}
|
|
81
|
+
{article?.authors?.map((a) => <meta property="article:author" content={a} />)}
|
|
82
|
+
{article?.tags?.map((t) => <meta property="article:tag" content={t} />)}
|
|
83
|
+
{ogImage && <meta property="og:image" content={new URL(ogImage, Astro.site ?? Astro.url)} />}
|
|
84
|
+
<meta name="twitter:card" content={ogImage ? 'summary_large_image' : 'summary'} />
|
|
85
|
+
<link rel="icon" href={SITE.favicon ?? SITE.logo} />
|
|
86
|
+
<link rel="alternate" type="application/rss+xml" title={(SITE as Record<string, any>).rssTitle ?? SITE.title} href={new URL("rss.xml", Astro.site ?? Astro.url)} />
|
|
87
|
+
<link rel="stylesheet" href={SITE.fontAwesome} referrerpolicy="no-referrer"
|
|
88
|
+
integrity={SITE.fontAwesome.includes("font-awesome/6.5.2/css/all.min.css") ? "sha384-PPIZEGYM1v8zp5Py7UjFb79S58UeqCL9pYVnVPURKEqvioPROaVAJKKLzvH2rDnI" : undefined}
|
|
89
|
+
crossorigin={SITE.fontAwesome.includes("font-awesome/6.5.2/css/all.min.css") ? "anonymous" : undefined} />
|
|
90
|
+
{(SITE as Record<string, any>).customCSS?.map((href: string) => <link rel="stylesheet" href={href} />)}
|
|
91
|
+
<style is:inline id="popular-brand" set:html={brandCss}></style>
|
|
92
|
+
</head>
|
|
93
|
+
<body data-copy-label={STRINGS.copyCode} data-checklist-done={STRINGS.checklistDone}>
|
|
94
|
+
<a class="g-skip" href="#main">{STRINGS.skipToContent}</a>
|
|
95
|
+
<Header />
|
|
96
|
+
<main id="main">
|
|
97
|
+
<slot />
|
|
98
|
+
</main>
|
|
99
|
+
<Footer />
|
|
100
|
+
<DemoBar />
|
|
101
|
+
</body>
|
|
102
|
+
</html>
|
package/src/lib/slugs.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Slugify a tag the way Hugo urlizes taxonomy terms, so both theme
|
|
2
|
+
implementations generate the same tag URLs (see PARITY.md).
|
|
3
|
+
Display labels keep the tag's first-seen spelling; only URLs use this. */
|
|
4
|
+
export function tagSlug(tag: string): string {
|
|
5
|
+
return tag
|
|
6
|
+
.toLowerCase()
|
|
7
|
+
.replace(/['"]/g, '')
|
|
8
|
+
.replace(/[^\p{L}\p{N}]+/gu, '-')
|
|
9
|
+
.replace(/^-+|-+$/g, '');
|
|
10
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { STRINGS } from 'popular:config';
|
|
3
|
+
/** Root-level content pages: prose pages (about, code-of-conduct, get-involved)
|
|
4
|
+
* and docs pages (handbook, runbooks, rendered with a scroll-spy TOC). */
|
|
5
|
+
import { getCollection, render } from 'astro:content';
|
|
6
|
+
import BaseLayout from '../layouts/BaseLayout.astro';
|
|
7
|
+
import PageHero from '../components/PageHero.astro';
|
|
8
|
+
|
|
9
|
+
export async function getStaticPaths() {
|
|
10
|
+
const pages = await getCollection('pages');
|
|
11
|
+
const docs = await getCollection('docs');
|
|
12
|
+
return [
|
|
13
|
+
...pages.map((entry) => ({ params: { slug: entry.id }, props: { entry, kind: 'page' } })),
|
|
14
|
+
...docs.map((entry) => ({ params: { slug: entry.id }, props: { entry, kind: 'doc' } })),
|
|
15
|
+
];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const { entry, kind } = Astro.props;
|
|
19
|
+
const d = entry.data;
|
|
20
|
+
const { Content, headings } = await render(entry);
|
|
21
|
+
/* Current page's h2/h3 links nest under its sidebar entry, in document
|
|
22
|
+
order, mirroring the Hugo theme's docs/single.html. */
|
|
23
|
+
const sections = headings.filter((h) => h.depth === 2 || h.depth === 3);
|
|
24
|
+
const allDocs = (await getCollection('docs')).sort(
|
|
25
|
+
(a, b) => (a.data.weight ?? 100) - (b.data.weight ?? 100)
|
|
26
|
+
);
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
<BaseLayout title={d.title} description={d.lead}>
|
|
30
|
+
<PageHero eyebrow={d.eyebrow} title={d.title} lead={d.lead} />
|
|
31
|
+
|
|
32
|
+
{kind === 'doc' ? (
|
|
33
|
+
<div class="g-container g-doc">
|
|
34
|
+
<nav class="g-doc-toc" aria-label={STRINGS.eyebrowDocs}>
|
|
35
|
+
<div class="g-doc-toc__label">{STRINGS.eyebrowDocs}</div>
|
|
36
|
+
{allDocs.map((e) => (
|
|
37
|
+
<>
|
|
38
|
+
<a
|
|
39
|
+
href={`/${e.id}/`}
|
|
40
|
+
class={e.id === entry.id ? 'is-active' : ''}
|
|
41
|
+
aria-current={e.id === entry.id ? 'page' : undefined}
|
|
42
|
+
>{e.data.title}</a>
|
|
43
|
+
{e.id === entry.id && sections.map((h) => (
|
|
44
|
+
<a href={`#${h.slug}`} style={`padding-left:${h.depth === 2 ? '1.35rem' : '2rem'};font-size:var(--text-xs)`}>{h.text}</a>
|
|
45
|
+
))}
|
|
46
|
+
</>
|
|
47
|
+
))}
|
|
48
|
+
</nav>
|
|
49
|
+
<div class="g-doc__content g-prose">
|
|
50
|
+
<Content />
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
) : (
|
|
54
|
+
<section class="g-bg-white g-section">
|
|
55
|
+
<div class="g-container">
|
|
56
|
+
<div class="g-article g-prose">
|
|
57
|
+
<Content />
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</section>
|
|
61
|
+
)}
|
|
62
|
+
</BaseLayout>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { STRINGS } from 'popular:config';
|
|
3
|
+
import { getCollection, render } from 'astro:content';
|
|
4
|
+
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
5
|
+
import PageHero from '../../components/PageHero.astro';
|
|
6
|
+
import AuthorBox from '../../components/AuthorBox.astro';
|
|
7
|
+
import PostCard from '../../components/PostCard.astro';
|
|
8
|
+
|
|
9
|
+
export async function getStaticPaths() {
|
|
10
|
+
const authors = await getCollection('authors');
|
|
11
|
+
return authors.map((entry) => ({ params: { slug: entry.id }, props: { entry } }));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const { entry } = Astro.props;
|
|
15
|
+
const { Content } = await render(entry);
|
|
16
|
+
const posts = (await getCollection('blog'))
|
|
17
|
+
.filter((p) => !p.data.draft && p.data.authors.includes(entry.id))
|
|
18
|
+
.sort((a, b) => +b.data.date - +a.data.date);
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
<BaseLayout title={entry.data.title} description={entry.data.bio}>
|
|
22
|
+
<PageHero eyebrow={STRINGS.eyebrowAuthor} title={entry.data.title} lead={entry.data.bio} />
|
|
23
|
+
<section class="g-bg-white g-section">
|
|
24
|
+
<div class="g-container">
|
|
25
|
+
<div class="g-article" style="margin-bottom:var(--space-5)">
|
|
26
|
+
<AuthorBox entry={entry} />
|
|
27
|
+
<div class="g-prose" style="margin-top:var(--space-4)"><Content /></div>
|
|
28
|
+
</div>
|
|
29
|
+
{posts.length > 0 && (
|
|
30
|
+
<>
|
|
31
|
+
<div class="g-section-head"><span class="g-eyebrow">{STRINGS.posts}</span><h2>{STRINGS.writtenBy.replace('{name}', entry.data.title)}</h2></div>
|
|
32
|
+
<div class="g-grid-3" style="margin-top:var(--space-4)">
|
|
33
|
+
{posts.map((p) => <PostCard post={p} />)}
|
|
34
|
+
</div>
|
|
35
|
+
</>
|
|
36
|
+
)}
|
|
37
|
+
</div>
|
|
38
|
+
</section>
|
|
39
|
+
</BaseLayout>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getCollection } from 'astro:content';
|
|
3
|
+
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
4
|
+
import PageHero from '../../components/PageHero.astro';
|
|
5
|
+
import PostCard from '../../components/PostCard.astro';
|
|
6
|
+
import Pagination from '../../components/Pagination.astro';
|
|
7
|
+
import { SECTIONS, STRINGS, PAGINATION } from 'popular:config';
|
|
8
|
+
|
|
9
|
+
/* One route serves /blog/ and /blog/page/N/, matching the Hugo theme's
|
|
10
|
+
paginator URLs. /blog/<slug> still goes to [slug].astro: named params
|
|
11
|
+
take priority over rest params. */
|
|
12
|
+
export async function getStaticPaths() {
|
|
13
|
+
const posts = (await getCollection('blog')).filter((p) => !p.data.draft).sort((a, b) => +b.data.date - +a.data.date);
|
|
14
|
+
const size = PAGINATION.pageSize;
|
|
15
|
+
const total = Math.max(1, Math.ceil(posts.length / size));
|
|
16
|
+
const tags = [...new Set(posts.flatMap((p) => p.data.tags))];
|
|
17
|
+
return Array.from({ length: total }, (_, i) => ({
|
|
18
|
+
params: { page: i === 0 ? undefined : `page/${i + 1}` },
|
|
19
|
+
props: { current: i + 1, total, posts: posts.slice(i * size, (i + 1) * size), tags },
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { current, total, posts, tags } = Astro.props;
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
<BaseLayout title="Blog">
|
|
27
|
+
<PageHero
|
|
28
|
+
eyebrow={SECTIONS.blog.eyebrow}
|
|
29
|
+
title={SECTIONS.blog.title}
|
|
30
|
+
lead={SECTIONS.blog.lead}
|
|
31
|
+
/>
|
|
32
|
+
<section class="g-bg-white g-section">
|
|
33
|
+
<div class="g-container">
|
|
34
|
+
<div class="g-row" style="justify-content:space-between;align-items:center;margin-bottom:var(--space-5)">
|
|
35
|
+
<div class="g-row" data-filterbar>
|
|
36
|
+
{tags.length > 0 && (
|
|
37
|
+
<>
|
|
38
|
+
<span style="font-size:var(--text-sm);color:var(--text-muted)">{STRINGS.filterLabel}</span>
|
|
39
|
+
<button class="g-filter is-active" data-filter="*">{STRINGS.filterAll}</button>
|
|
40
|
+
{tags.map((t) => <button class="g-filter" data-filter={t}>#{t}</button>)}
|
|
41
|
+
</>
|
|
42
|
+
)}
|
|
43
|
+
</div>
|
|
44
|
+
<a class="g-btn g-btn--ghost g-btn--sm" href="/rss.xml" title={STRINGS.subscribeRssTitle}><i class="fa-solid fa-rss" aria-hidden="true"></i> {STRINGS.subscribeRss}</a>
|
|
45
|
+
</div>
|
|
46
|
+
<div class="g-grid-3">
|
|
47
|
+
{posts.map((p) => <PostCard post={p} />)}
|
|
48
|
+
</div>
|
|
49
|
+
<Pagination current={current} total={total} base="/blog/" />
|
|
50
|
+
</div>
|
|
51
|
+
</section>
|
|
52
|
+
</BaseLayout>
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getCollection, render } from 'astro:content';
|
|
3
|
+
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
4
|
+
import AuthorBox from '../../components/AuthorBox.astro';
|
|
5
|
+
import { SUPPORT, SITE, STRINGS } from 'popular:config';
|
|
6
|
+
import { tagSlug } from '../../lib/slugs';
|
|
7
|
+
|
|
8
|
+
export async function getStaticPaths() {
|
|
9
|
+
const posts = (await getCollection('blog')).filter((p) => !p.data.draft);
|
|
10
|
+
/* PARITY.md pins the ordering: date desc, then title, then id, matching
|
|
11
|
+
Hugo's section order (posts must not set weight). next = newer. */
|
|
12
|
+
const cmp = (x: string, y: string) => (x < y ? -1 : x > y ? 1 : 0);
|
|
13
|
+
const sorted = [...posts].sort(
|
|
14
|
+
(a, b) => +b.data.date - +a.data.date || cmp(a.data.title, b.data.title) || cmp(a.id, b.id),
|
|
15
|
+
);
|
|
16
|
+
return sorted.map((post, i) => ({
|
|
17
|
+
params: { slug: post.id },
|
|
18
|
+
props: { post, newer: sorted[i - 1], older: sorted[i + 1] },
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const { post, newer, older } = Astro.props;
|
|
23
|
+
const d = post.data;
|
|
24
|
+
const { Content } = await render(post);
|
|
25
|
+
const dateStr = d.date.toLocaleDateString(SITE.locale, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
26
|
+
|
|
27
|
+
const allAuthors = await getCollection('authors');
|
|
28
|
+
const resolved = d.authors.map((slug) => allAuthors.find((a) => a.id === slug)).filter(Boolean);
|
|
29
|
+
const bylineNames = [
|
|
30
|
+
...d.authors.map((slug) => allAuthors.find((a) => a.id === slug)?.data.title ?? slug),
|
|
31
|
+
...d.guestAuthors.map((g) => g.name),
|
|
32
|
+
];
|
|
33
|
+
if (!bylineNames.length && d.author) bylineNames.push(d.author);
|
|
34
|
+
const byline = bylineNames.length > 1
|
|
35
|
+
? bylineNames.slice(0, -1).join(STRINGS.listSeparator) + STRINGS.listSeparatorFinal + bylineNames[bylineNames.length - 1]
|
|
36
|
+
: bylineNames.join('');
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
<BaseLayout title={d.title} description={d.description} image={d.image} article={{ published: d.date, authors: bylineNames, tags: d.tags }}>
|
|
40
|
+
<article>
|
|
41
|
+
<header class="g-pagehero">
|
|
42
|
+
<div class="g-container" style="padding-block:var(--space-6) var(--space-5)">
|
|
43
|
+
<a href="/blog/" style="display:inline-flex;gap:.4rem;align-items:center;font-size:var(--text-sm);font-weight:var(--weight-medium);margin-bottom:1rem">
|
|
44
|
+
<i class="fa-solid fa-arrow-left" aria-hidden="true"></i> {STRINGS.allPosts}
|
|
45
|
+
</a>
|
|
46
|
+
{d.tags.length > 0 && (
|
|
47
|
+
<div class="g-row" style="margin-bottom:.8rem">
|
|
48
|
+
{d.tags.map((t) => <a class="g-tag" href={`/tags/${tagSlug(t)}/`}>{t}</a>)}
|
|
49
|
+
</div>
|
|
50
|
+
)}
|
|
51
|
+
<h1 style="margin:0">{d.title}</h1>
|
|
52
|
+
<p style="margin:.9rem 0 0;font-size:var(--text-sm);color:var(--ink-700)">
|
|
53
|
+
{byline && `${STRINGS.authorsBy} ${byline} · `}{dateStr}
|
|
54
|
+
</p>
|
|
55
|
+
</div>
|
|
56
|
+
</header>
|
|
57
|
+
|
|
58
|
+
<div class="g-container g-section--sm">
|
|
59
|
+
<div class="g-article">
|
|
60
|
+
{d.image && (
|
|
61
|
+
<div style="border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-md);margin-bottom:var(--space-5)">
|
|
62
|
+
<img src={d.image} alt={d.title} style="width:100%;aspect-ratio:16/9;object-fit:cover" />
|
|
63
|
+
</div>
|
|
64
|
+
)}
|
|
65
|
+
|
|
66
|
+
<div class="g-prose"><Content /></div>
|
|
67
|
+
|
|
68
|
+
{d.speaker && (
|
|
69
|
+
<>
|
|
70
|
+
<h2 style="font-size:var(--text-xl);margin:var(--space-5) 0 1rem">{STRINGS.ourSpeaker}</h2>
|
|
71
|
+
<div class="g-persona">
|
|
72
|
+
{d.speaker.photo && <img class="g-persona__img" src={d.speaker.photo} alt={d.speaker.name} />}
|
|
73
|
+
<div>
|
|
74
|
+
<div class="g-persona__name">{d.speaker.name}</div>
|
|
75
|
+
{d.speaker.title && <div class="g-persona__role">{d.speaker.title}</div>}
|
|
76
|
+
{d.speaker.bio && <p class="g-persona__bio">{d.speaker.bio}</p>}
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
</>
|
|
80
|
+
)}
|
|
81
|
+
|
|
82
|
+
{(resolved.length > 0 || d.guestAuthors.length > 0) && (
|
|
83
|
+
<>
|
|
84
|
+
<h2 style="font-size:var(--text-xl);margin:var(--space-5) 0 1rem">About the author{resolved.length + d.guestAuthors.length > 1 ? 's' : ''}</h2>
|
|
85
|
+
<div class="g-stack" style="gap:var(--space-3)">
|
|
86
|
+
{resolved.map((a) => <AuthorBox entry={a} />)}
|
|
87
|
+
{d.guestAuthors.map((g) => <AuthorBox guest={g} />)}
|
|
88
|
+
</div>
|
|
89
|
+
</>
|
|
90
|
+
)}
|
|
91
|
+
|
|
92
|
+
{(older || newer) && (
|
|
93
|
+
<nav class="g-postnav" aria-label={STRINGS.postNavigation}>
|
|
94
|
+
{older ? (
|
|
95
|
+
<a class="g-postnav__card" href={`/blog/${older.id}/`} rel="prev">
|
|
96
|
+
<span class="g-postnav__label"><i class="fa-solid fa-arrow-left" aria-hidden="true"></i> {STRINGS.olderPost}</span>
|
|
97
|
+
<span class="g-postnav__title">{older.data.title}</span>
|
|
98
|
+
</a>
|
|
99
|
+
) : <span></span>}
|
|
100
|
+
{newer && (
|
|
101
|
+
<a class="g-postnav__card g-postnav__card--next" href={`/blog/${newer.id}/`} rel="next">
|
|
102
|
+
<span class="g-postnav__label">{STRINGS.newerPost} <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></span>
|
|
103
|
+
<span class="g-postnav__title">{newer.data.title}</span>
|
|
104
|
+
</a>
|
|
105
|
+
)}
|
|
106
|
+
</nav>
|
|
107
|
+
)}
|
|
108
|
+
{SUPPORT && (
|
|
109
|
+
<div class="g-callout" style="margin-top:var(--space-5);flex-direction:column;gap:.9rem">
|
|
110
|
+
<span class="g-badge g-badge--secondary g-badge--soft"><i class="fa-solid fa-heart" aria-hidden="true"></i> {SUPPORT.badge}</span>
|
|
111
|
+
<div class="g-callout__body">{SUPPORT.text}</div>
|
|
112
|
+
<div class="g-row">
|
|
113
|
+
{SUPPORT.ctas.map((c) => (
|
|
114
|
+
<a class={`g-btn g-btn--${c.variant ?? 'secondary'}`} href={c.url}>{c.label}</a>
|
|
115
|
+
))}
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
</article>
|
|
122
|
+
</BaseLayout>
|