astro-theme-popular 0.6.0 → 0.7.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/README.md +10 -0
- package/index.mjs +35 -2
- package/package.json +1 -1
- package/schemas.ts +15 -0
- package/src/components/DemoBar.astro +2 -0
- package/src/components/EventRow.astro +5 -0
- package/src/components/EventsEmpty.astro +25 -0
- package/src/components/Footer.astro +8 -2
- package/src/components/Gallery.astro +7 -0
- package/src/components/Notice.astro +19 -0
- package/src/components/PageHero.astro +6 -1
- package/src/components/Photo.astro +12 -0
- package/src/components/Pullquote.astro +11 -0
- package/src/components/SpeakerInvite.astro +16 -0
- package/src/layouts/BaseLayout.astro +4 -2
- package/src/lib/ical.ts +93 -0
- package/src/lib/inline-md.ts +21 -0
- package/src/lib/talks.ts +87 -0
- package/src/pages/[...slug].astro +1 -1
- package/src/pages/authors/[slug].astro +4 -4
- package/src/pages/blog/[...page].astro +2 -2
- package/src/pages/blog/[slug].astro +1 -1
- package/src/pages/events/[...page].astro +5 -5
- package/src/pages/events/[slug].astro +33 -1
- package/src/pages/events/calendar.ics.ts +34 -0
- package/src/pages/index.astro +17 -4
- package/src/pages/llms.txt.ts +46 -0
- package/src/pages/organizers/[...page].astro +2 -2
- package/src/pages/organizers/[slug].astro +4 -4
- package/src/pages/speakers/[slug].astro +4 -4
- package/src/pages/speakers/index.astro +26 -0
- package/src/pages/tags/[tag]/[...page].astro +2 -2
- package/src/pages/talks/[...page].astro +58 -0
- package/src/pages/venues/[slug].astro +12 -1
- package/src/pages/venues/index.astro +33 -0
- package/src/styles/components.css +29 -0
package/README.md
CHANGED
|
@@ -21,6 +21,16 @@ export default defineConfig({
|
|
|
21
21
|
});
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
+
Override any theme component with your own, without forking:
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
popular({ overrides: { Header: './src/overrides/Header.astro' } })
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Overridable: `Header`, `Footer`, `Hero`, `EventRow`, `PostCard`,
|
|
31
|
+
`OrganizerCard`, `AuthorBox`, `PageHero`. Your file receives the same props
|
|
32
|
+
the theme component does.
|
|
33
|
+
|
|
24
34
|
Disable any injected route group when your site replaces it (also the
|
|
25
35
|
answer when you provide your own `/` or `/rss.xml`):
|
|
26
36
|
|
package/index.mjs
CHANGED
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
* integrations: [mdx(), popular()],
|
|
7
7
|
* });
|
|
8
8
|
*
|
|
9
|
+
* Override components without forking: popular({ overrides: { Header:
|
|
10
|
+
* './src/overrides/Header.astro' } }). Theme code imports overridable
|
|
11
|
+
* components from popular:component/<Name>; the integration resolves each
|
|
12
|
+
* to the adopter's file or the theme default.
|
|
13
|
+
*
|
|
9
14
|
* Theme config (SITE, STRINGS, BRAND, NAV, ...) lives in the adopter's
|
|
10
15
|
* `popular.config.ts` (same named exports as the classic src/config.ts;
|
|
11
16
|
* the shape is the parity contract, see PARITY.md). Theme code reads it
|
|
@@ -50,15 +55,29 @@ const ROUTES = {
|
|
|
50
55
|
['/organizers/[...page]', 'organizers/[...page].astro'],
|
|
51
56
|
['/organizers/[...slug]', 'organizers/[slug].astro'],
|
|
52
57
|
],
|
|
53
|
-
speakers: [
|
|
54
|
-
|
|
58
|
+
speakers: [
|
|
59
|
+
['/speakers', 'speakers/index.astro'],
|
|
60
|
+
['/speakers/[...slug]', 'speakers/[slug].astro'],
|
|
61
|
+
],
|
|
62
|
+
venues: [
|
|
63
|
+
['/venues', 'venues/index.astro'],
|
|
64
|
+
['/venues/[...slug]', 'venues/[slug].astro'],
|
|
65
|
+
],
|
|
55
66
|
tags: [['/tags/[tag]/[...page]', 'tags/[tag]/[...page].astro']],
|
|
67
|
+
// Opt-in talk archive: injected always, but the page only builds when
|
|
68
|
+
// SITE.talks is true (gated in the route's getStaticPaths).
|
|
69
|
+
talks: [['/talks/[...page]', 'talks/[...page].astro']],
|
|
56
70
|
rss: [['/rss.xml', 'rss.xml.js']],
|
|
57
71
|
robots: [['/robots.txt', 'robots.txt.ts']],
|
|
72
|
+
calendar: [['/events/calendar.ics', 'events/calendar.ics.ts']],
|
|
73
|
+
llms: [['/llms.txt', 'llms.txt.ts']],
|
|
58
74
|
};
|
|
59
75
|
|
|
76
|
+
const OVERRIDABLE = ['Header', 'Footer', 'Hero', 'EventRow', 'PostCard', 'OrganizerCard', 'AuthorBox', 'PageHero'];
|
|
77
|
+
|
|
60
78
|
export default function popular(options = {}) {
|
|
61
79
|
const configFile = options.configFile ?? './popular.config.ts';
|
|
80
|
+
const overrides = options.overrides ?? {};
|
|
62
81
|
return {
|
|
63
82
|
name: 'astro-theme-popular',
|
|
64
83
|
hooks: {
|
|
@@ -82,6 +101,20 @@ export default function popular(options = {}) {
|
|
|
82
101
|
}
|
|
83
102
|
},
|
|
84
103
|
},
|
|
104
|
+
{
|
|
105
|
+
name: 'popular:component',
|
|
106
|
+
resolveId(id) {
|
|
107
|
+
if (id.startsWith('popular:component/')) return '\0' + id;
|
|
108
|
+
},
|
|
109
|
+
load(id) {
|
|
110
|
+
if (!id.startsWith('\0popular:component/')) return;
|
|
111
|
+
const name = id.slice('\0popular:component/'.length);
|
|
112
|
+
const target = overrides[name]
|
|
113
|
+
? path.resolve(root, overrides[name])
|
|
114
|
+
: `astro-theme-popular/components/${name}.astro`;
|
|
115
|
+
return `export { default } from ${JSON.stringify(target)};`;
|
|
116
|
+
},
|
|
117
|
+
},
|
|
85
118
|
],
|
|
86
119
|
},
|
|
87
120
|
});
|
package/package.json
CHANGED
package/schemas.ts
CHANGED
|
@@ -62,6 +62,17 @@ const events = defineCollection({
|
|
|
62
62
|
price: z.union([z.number(), z.string()]).optional(), // machine price for Offer
|
|
63
63
|
currency: z.string().optional(), // ISO currency for price
|
|
64
64
|
cost: z.string().optional(), // free-text displayed cost (e.g. "15 CAD")
|
|
65
|
+
// §3 talk archive (all optional). Simple case: event-level recording/slides
|
|
66
|
+
// (single-talk meetups). Multi-talk case: a talks[] array, which wins over
|
|
67
|
+
// the event-level recording/slides when present.
|
|
68
|
+
recording: z.string().optional(),
|
|
69
|
+
slides: z.string().optional(),
|
|
70
|
+
talks: z.array(z.object({
|
|
71
|
+
title: z.string(),
|
|
72
|
+
speaker: z.string().optional(), // speaker-collection slug, same resolution as `speakers`
|
|
73
|
+
recording: z.string().optional(),
|
|
74
|
+
slides: z.string().optional(),
|
|
75
|
+
})).optional(),
|
|
65
76
|
}),
|
|
66
77
|
});
|
|
67
78
|
|
|
@@ -114,6 +125,10 @@ const venues = defineCollection({
|
|
|
114
125
|
photo: z.string().optional(),
|
|
115
126
|
notes: z.string().optional(), // arrival notes inherited by events held here
|
|
116
127
|
accessibility: z.string().optional(),
|
|
128
|
+
wheelchair: z.boolean().default(false), // step-free access badge
|
|
129
|
+
transit: z.string().optional(),
|
|
130
|
+
parking: z.string().optional(),
|
|
131
|
+
access: z.string().optional(), // freeform markdown: washrooms, quiet space, childcare, scent policy
|
|
117
132
|
website: z.string().optional(),
|
|
118
133
|
}),
|
|
119
134
|
});
|
|
@@ -22,6 +22,8 @@ const bar = import.meta.env.DEV ? null : ((cfg as Record<string, any>).DEMO_BAR
|
|
|
22
22
|
.g-demobar a:hover { color: #fff; background: rgba(255,255,255,0.08); }
|
|
23
23
|
.g-demobar a.is-current { background: var(--color-primary); color: #fff; }
|
|
24
24
|
@media (max-width: 640px) { .g-demobar__label { display: none; } }
|
|
25
|
+
/* Reserve space so the fixed demo bar never covers the footer credit. */
|
|
26
|
+
.g-footer__bottom { padding-bottom: 72px; }
|
|
25
27
|
</style>
|
|
26
28
|
<nav class="g-demobar" aria-label="Theme demos">
|
|
27
29
|
<a href="#" data-demo-home><i class="fa-solid fa-star" aria-hidden="true"></i>Popular</a>
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
import { SITE, STRINGS } from 'popular:config';
|
|
3
3
|
import type { CollectionEntry } from 'astro:content';
|
|
4
4
|
import { safeCollection } from '../lib/collections';
|
|
5
|
+
import { eventHasRecording } from '../lib/talks';
|
|
5
6
|
|
|
6
7
|
interface Props { event: CollectionEntry<'events'> }
|
|
7
8
|
const { event } = Astro.props;
|
|
8
9
|
const d = event.data;
|
|
10
|
+
const hasRecording = eventHasRecording(d);
|
|
9
11
|
|
|
10
12
|
/* Venue: a venueRef to the venues collection wins over the flat fields. */
|
|
11
13
|
const venuePage = d.venueRef ? (await safeCollection('venues')).find((v) => v.id === d.venueRef) : undefined;
|
|
@@ -47,6 +49,9 @@ const day = d.date.toLocaleDateString(SITE.locale, { month: 'short', day: 'numer
|
|
|
47
49
|
{speakerLine && (
|
|
48
50
|
<div class="g-event__meta"><i class="fa-solid fa-microphone-lines" aria-hidden="true"></i>{speakerLine}</div>
|
|
49
51
|
)}
|
|
52
|
+
{hasRecording && (
|
|
53
|
+
<div class="g-event__meta"><i class="fa-solid fa-circle-play" aria-hidden="true"></i>{STRINGS.hasRecording}</div>
|
|
54
|
+
)}
|
|
50
55
|
</div>
|
|
51
56
|
<div class="g-event__aside">
|
|
52
57
|
{d.cancelled
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** Designed empty state for zero upcoming events (§5). Shown on the home page
|
|
3
|
+
* and events list instead of a blank section: a message, then the chat CTA
|
|
4
|
+
* (if SITE.community.chat is set, §4) and the calendar-subscribe link.
|
|
5
|
+
* The calendar link mirrors Hugo's OutputFormats "Calendar" endpoint. */
|
|
6
|
+
import { SITE, STRINGS } from 'popular:config';
|
|
7
|
+
|
|
8
|
+
const chat = (SITE as Record<string, any>).community?.chat;
|
|
9
|
+
const calendar = '/events/calendar.ics';
|
|
10
|
+
const external = (url: string) => !url.startsWith('/');
|
|
11
|
+
---
|
|
12
|
+
<div class="g-empty">
|
|
13
|
+
<span class="g-medallion g-medallion--lg"><i class="fa-solid fa-calendar-day" aria-hidden="true"></i></span>
|
|
14
|
+
<p class="g-lead">{STRINGS.noUpcomingEvents}</p>
|
|
15
|
+
{(chat?.url || calendar) && (
|
|
16
|
+
<div class="g-row" style="justify-content:center">
|
|
17
|
+
{chat?.url && (
|
|
18
|
+
<a class="g-btn g-btn--primary" href={chat.url} {...(external(chat.url) ? { rel: 'noopener', target: '_blank' } : {})}>
|
|
19
|
+
<i class="fa-solid fa-comments" aria-hidden="true"></i> {chat.label ?? STRINGS.joinChat}
|
|
20
|
+
</a>
|
|
21
|
+
)}
|
|
22
|
+
<a class="g-btn g-btn--outline" href={calendar}><i class="fa-solid fa-calendar-plus" aria-hidden="true"></i> {STRINGS.subscribeCalendar}</a>
|
|
23
|
+
</div>
|
|
24
|
+
)}
|
|
25
|
+
</div>
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
---
|
|
2
|
-
import { SITE, FOOTER, SOCIAL } from 'popular:config';
|
|
2
|
+
import { SITE, FOOTER, SOCIAL, STRINGS } from 'popular:config';
|
|
3
|
+
import { version } from '../../package.json';
|
|
3
4
|
const year = new Date().getFullYear();
|
|
4
5
|
const credit = (FOOTER as { credit?: { label: string; url: string } }).credit;
|
|
6
|
+
const chat = (SITE as Record<string, any>).community?.chat;
|
|
7
|
+
const showVersion = !!credit && credit.url.includes('mariatta.ca');
|
|
5
8
|
---
|
|
6
9
|
|
|
7
10
|
<footer class="g-footer">
|
|
@@ -35,7 +38,10 @@ const credit = (FOOTER as { credit?: { label: string; url: string } }).credit;
|
|
|
35
38
|
<div class="g-footer__bottom">
|
|
36
39
|
<div class="g-container">
|
|
37
40
|
<span>© {year} {SITE.title}</span>
|
|
38
|
-
{
|
|
41
|
+
{chat && (
|
|
42
|
+
<span><a href={chat.url} {...(chat.url.startsWith('/') ? {} : { rel: 'noopener', target: '_blank' })}><i class="fa-solid fa-comments" aria-hidden="true"></i> {chat.label ?? STRINGS.joinChat}</a></span>
|
|
43
|
+
)}
|
|
44
|
+
{credit && <span><a href={credit.url} rel="noopener">{credit.label}</a>{showVersion && <> · v{version}</>}</span>}
|
|
39
45
|
{FOOTER.tagline && <span>{FOOTER.tagline}</span>}
|
|
40
46
|
</div>
|
|
41
47
|
</div>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** Photo gallery (§6): a CSS-grid of <Photo> figures. No JS, no lightbox
|
|
3
|
+
* (each photo links to its full-size image). Mirrors the Hugo `gallery`
|
|
4
|
+
* shortcode. Use in MDX:
|
|
5
|
+
* <Gallery><Photo src="/images/recap-1.jpg" alt="…" caption="…" /></Gallery> */
|
|
6
|
+
---
|
|
7
|
+
<div class="g-gallery"><slot /></div>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** Site-wide notice banner (§5). Reads optional SITE.notice = { text, url? }.
|
|
3
|
+
* Static, not dismissible, no JS. Rendered by BaseLayout above the header.
|
|
4
|
+
* Text is inline-markdown rendered (parity with Hugo's notice.html). */
|
|
5
|
+
import { SITE, STRINGS } from 'popular:config';
|
|
6
|
+
import { inlineMarkdown } from '../lib/inline-md';
|
|
7
|
+
|
|
8
|
+
const notice = (SITE as Record<string, any>).notice;
|
|
9
|
+
const text: string | undefined = notice?.text;
|
|
10
|
+
const html = text ? inlineMarkdown(text) : '';
|
|
11
|
+
---
|
|
12
|
+
{text && (
|
|
13
|
+
<div class="g-notice" role="status">
|
|
14
|
+
<div class="g-notice__inner">
|
|
15
|
+
<i class="fa-solid fa-circle-info" aria-hidden="true"></i>
|
|
16
|
+
<span><Fragment set:html={html} />{notice.url && <> <a href={notice.url}>{STRINGS.learnMore}</a></>}</span>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
)}
|
|
@@ -3,12 +3,17 @@ interface Props {
|
|
|
3
3
|
eyebrow?: string;
|
|
4
4
|
title: string;
|
|
5
5
|
lead?: string;
|
|
6
|
+
backHref?: string;
|
|
7
|
+
backLabel?: string;
|
|
6
8
|
}
|
|
7
|
-
const { eyebrow, title, lead } = Astro.props;
|
|
9
|
+
const { eyebrow, title, lead, backHref, backLabel } = Astro.props;
|
|
8
10
|
---
|
|
9
11
|
|
|
10
12
|
<header class="g-pagehero">
|
|
11
13
|
<div class="g-container">
|
|
14
|
+
{backHref && (
|
|
15
|
+
<div style="margin-bottom:1rem"><a href={backHref} style="display:inline-flex;gap:.4rem;align-items:center;font-size:var(--text-sm);font-weight:var(--weight-medium)"><i class="fa-solid fa-arrow-left" aria-hidden="true"></i> {backLabel}</a></div>
|
|
16
|
+
)}
|
|
12
17
|
{eyebrow && <span class="g-eyebrow">{eyebrow}</span>}
|
|
13
18
|
<h1>{title}</h1>
|
|
14
19
|
{lead && <p class="g-pagehero__lead">{lead}</p>}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** A gallery photo (§6). `alt` is required: the build throws with an
|
|
3
|
+
* actionable message when it is missing, keeping the image-alt CI green by
|
|
4
|
+
* construction. No lightbox, the figure links to the full-size image. */
|
|
5
|
+
interface Props { src: string; alt: string; caption?: string; }
|
|
6
|
+
const { src, alt, caption } = Astro.props;
|
|
7
|
+
if (!alt) throw new Error(`<Photo src="${src}"> requires a non-empty alt`);
|
|
8
|
+
---
|
|
9
|
+
<figure class="g-gallery__item">
|
|
10
|
+
<a href={src}><img src={src} alt={alt} loading="lazy" decoding="async" /></a>
|
|
11
|
+
{caption && <figcaption>{caption}</figcaption>}
|
|
12
|
+
</figure>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** Pull-quote (§6): a styled blockquote with optional attribution. Mirrors the
|
|
3
|
+
* Hugo `pullquote` shortcode. Use in MDX:
|
|
4
|
+
* <Pullquote cite="A happy member">The best Tuesday of my month.</Pullquote> */
|
|
5
|
+
interface Props { cite?: string; }
|
|
6
|
+
const { cite } = Astro.props;
|
|
7
|
+
---
|
|
8
|
+
<figure class="g-pullquote">
|
|
9
|
+
<blockquote><slot /></blockquote>
|
|
10
|
+
{cite && <figcaption>{cite}</figcaption>}
|
|
11
|
+
</figure>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** §7: "Your name here?" invite card appended to the speakers list when
|
|
3
|
+
* SITE.speakers.invite is true. Links to the /speak/ CFP page. Off by
|
|
4
|
+
* default, so sites that do not solicit talks show nothing. */
|
|
5
|
+
import { SITE, STRINGS } from 'popular:config';
|
|
6
|
+
const invite = (SITE as Record<string, any>).speakers?.invite === true;
|
|
7
|
+
---
|
|
8
|
+
{invite && (
|
|
9
|
+
<div class="g-persona g-persona--primary">
|
|
10
|
+
<span class="g-medallion g-medallion--lg" aria-hidden="true"><i class="fa-solid fa-microphone-lines"></i></span>
|
|
11
|
+
<div>
|
|
12
|
+
<div class="g-persona__name"><a href="/speak/" style="color:inherit">{STRINGS.speakerInviteTitle} <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
|
|
13
|
+
<p class="g-persona__bio">{STRINGS.speakerInviteBody}</p>
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
16
|
+
)}
|
|
@@ -7,9 +7,10 @@ import '../styles/tokens/spacing.css';
|
|
|
7
7
|
import '../styles/tokens/elevation.css';
|
|
8
8
|
import '../styles/base.css';
|
|
9
9
|
import '../styles/components.css';
|
|
10
|
-
import Header from '
|
|
11
|
-
import Footer from '
|
|
10
|
+
import Header from 'popular:component/Header';
|
|
11
|
+
import Footer from 'popular:component/Footer';
|
|
12
12
|
import DemoBar from '../components/DemoBar.astro';
|
|
13
|
+
import Notice from '../components/Notice.astro';
|
|
13
14
|
import { SITE, BRAND, STRINGS } from 'popular:config';
|
|
14
15
|
|
|
15
16
|
interface Props {
|
|
@@ -100,6 +101,7 @@ const brandCss = `html:root{${vars.join(';')}}`;
|
|
|
100
101
|
</head>
|
|
101
102
|
<body data-copy-label={STRINGS.copyCode} data-checklist-done={STRINGS.checklistDone}>
|
|
102
103
|
<a class="g-skip" href="#main">{STRINGS.skipToContent}</a>
|
|
104
|
+
<Notice />
|
|
103
105
|
<Header />
|
|
104
106
|
<main id="main">
|
|
105
107
|
<slot />
|
package/src/lib/ical.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** iCalendar feed helpers, mirroring the Hugo events/list.calendar.ics
|
|
2
|
+
* template. DTSTART reuses parseEventTime (lib/eventld) so the feed and the
|
|
3
|
+
* Event JSON-LD agree. Folding is byte-accurate at 75 octets. */
|
|
4
|
+
import { parseEventTime } from './eventld';
|
|
5
|
+
|
|
6
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
7
|
+
const enc = new TextEncoder();
|
|
8
|
+
|
|
9
|
+
function ymd(d: Date): string {
|
|
10
|
+
return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function icsEscape(s: string): string {
|
|
14
|
+
return s.replace(/\\/g, '\\\\').replace(/;/g, '\;').replace(/,/g, '\\,').replace(/\n/g, '\\n');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** One content line, CRLF-terminated, folded at 75 octets (continuations
|
|
18
|
+
* begin with a space, which counts toward the limit). */
|
|
19
|
+
export function fold(line: string): string {
|
|
20
|
+
if (enc.encode(line).length <= 75) return line + '\r\n';
|
|
21
|
+
let out = '';
|
|
22
|
+
let cur = '';
|
|
23
|
+
let curBytes = 0;
|
|
24
|
+
let first = true;
|
|
25
|
+
for (const ch of line) {
|
|
26
|
+
const cb = enc.encode(ch).length;
|
|
27
|
+
const limit = first ? 75 : 74;
|
|
28
|
+
if (curBytes + cb > limit) {
|
|
29
|
+
out += (first ? '' : ' ') + cur + '\r\n';
|
|
30
|
+
first = false;
|
|
31
|
+
cur = ch;
|
|
32
|
+
curBytes = cb;
|
|
33
|
+
} else {
|
|
34
|
+
cur += ch;
|
|
35
|
+
curBytes += cb;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
out += (first ? '' : ' ') + cur + '\r\n';
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface IcsEvent {
|
|
43
|
+
title: string;
|
|
44
|
+
date: Date;
|
|
45
|
+
time?: string;
|
|
46
|
+
description?: string;
|
|
47
|
+
checkin?: string;
|
|
48
|
+
cancelled?: boolean;
|
|
49
|
+
venue?: { name: string; address?: string };
|
|
50
|
+
permalink: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function buildIcs(
|
|
54
|
+
events: IcsEvent[],
|
|
55
|
+
opts: { siteTitle: string; dtstamp: string; durationMinutes: number },
|
|
56
|
+
): string {
|
|
57
|
+
const L: string[] = [];
|
|
58
|
+
L.push(fold('BEGIN:VCALENDAR'));
|
|
59
|
+
L.push(fold('VERSION:2.0'));
|
|
60
|
+
L.push(fold(`PRODID:-//${opts.siteTitle}//Popular theme//EN`));
|
|
61
|
+
L.push(fold('CALSCALE:GREGORIAN'));
|
|
62
|
+
L.push(fold('METHOD:PUBLISH'));
|
|
63
|
+
L.push(fold(`X-WR-CALNAME:${opts.siteTitle} events`));
|
|
64
|
+
for (const e of events) {
|
|
65
|
+
L.push(fold('BEGIN:VEVENT'));
|
|
66
|
+
L.push(fold(`UID:${e.permalink}`));
|
|
67
|
+
L.push(fold(`DTSTAMP:${opts.dtstamp}`));
|
|
68
|
+
const hm = parseEventTime(e.time);
|
|
69
|
+
if (hm) {
|
|
70
|
+
const [h, m] = hm.split(':').map(Number);
|
|
71
|
+
L.push(fold(`DTSTART:${ymd(e.date)}T${pad(h)}${pad(m)}00`));
|
|
72
|
+
const total = h * 60 + m + opts.durationMinutes;
|
|
73
|
+
const end = new Date(Date.UTC(e.date.getUTCFullYear(), e.date.getUTCMonth(), e.date.getUTCDate()) + total * 60000);
|
|
74
|
+
L.push(fold(`DTEND:${ymd(end)}T${pad(end.getUTCHours())}${pad(end.getUTCMinutes())}00`));
|
|
75
|
+
} else {
|
|
76
|
+
L.push(fold(`DTSTART;VALUE=DATE:${ymd(e.date)}`));
|
|
77
|
+
}
|
|
78
|
+
L.push(fold(`SUMMARY:${icsEscape(e.title)}`));
|
|
79
|
+
if (e.venue) {
|
|
80
|
+
const loc = e.venue.address ? `${e.venue.name}, ${e.venue.address}` : e.venue.name;
|
|
81
|
+
L.push(fold(`LOCATION:${icsEscape(loc)}`));
|
|
82
|
+
}
|
|
83
|
+
let desc = (e.description ?? '').trim();
|
|
84
|
+
if (e.checkin) desc = `${desc}\n${e.checkin}`;
|
|
85
|
+
desc = `${desc}\n${e.permalink}`;
|
|
86
|
+
L.push(fold(`DESCRIPTION:${icsEscape(desc)}`));
|
|
87
|
+
L.push(fold(`URL:${e.permalink}`));
|
|
88
|
+
L.push(fold(`STATUS:${e.cancelled ? 'CANCELLED' : 'CONFIRMED'}`));
|
|
89
|
+
L.push(fold('END:VEVENT'));
|
|
90
|
+
}
|
|
91
|
+
L.push(fold('END:VCALENDAR'));
|
|
92
|
+
return L.join('');
|
|
93
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Minimal inline-markdown renderer for short, author-controlled config
|
|
2
|
+
* strings (e.g. the notice banner, §5). Parity with Hugo's inline
|
|
3
|
+
* `.RenderString`: supports links, bold, emphasis, and inline code, and
|
|
4
|
+
* HTML-escapes everything else. Not a full Markdown parser; block syntax
|
|
5
|
+
* (headings, lists, images) is intentionally out of scope. */
|
|
6
|
+
const escapeHtml = (s: string): string =>
|
|
7
|
+
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
8
|
+
|
|
9
|
+
export function inlineMarkdown(src: string): string {
|
|
10
|
+
let s = escapeHtml(src);
|
|
11
|
+
// [label](http(s)://… | /path) — only http(s) and root-relative targets
|
|
12
|
+
s = s.replace(
|
|
13
|
+
/\[([^\]]+)\]\((https?:\/\/[^\s)]+|\/[^\s)]*)\)/g,
|
|
14
|
+
(_m, label, href) =>
|
|
15
|
+
`<a href="${href}"${href.startsWith('/') ? '' : ' rel="noopener" target="_blank"'}>${label}</a>`,
|
|
16
|
+
);
|
|
17
|
+
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
18
|
+
s = s.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
|
|
19
|
+
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
20
|
+
return s;
|
|
21
|
+
}
|
package/src/lib/talks.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** §3 talk archive helpers, shared by the event page, the event row, and the
|
|
2
|
+
* /talks/ index. A "talk" is either an entry in an event's talks[] array or,
|
|
3
|
+
* for single-talk meetups, the event itself when it carries event-level
|
|
4
|
+
* recording/slides. When talks[] is present it wins; event-level
|
|
5
|
+
* recording/slides are ignored (not merged), matching the Hugo side. */
|
|
6
|
+
import type { CollectionEntry } from 'astro:content';
|
|
7
|
+
|
|
8
|
+
type EventData = CollectionEntry<'events'>['data'];
|
|
9
|
+
|
|
10
|
+
export interface Talk {
|
|
11
|
+
title: string;
|
|
12
|
+
speakerSlug?: string; // resolved speaker-profile slug, for linking
|
|
13
|
+
speakerName?: string; // display name (profile title, or the free-text speaker)
|
|
14
|
+
recording?: string;
|
|
15
|
+
slides?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TalkRow extends Talk {
|
|
19
|
+
eventId: string;
|
|
20
|
+
eventTitle: string;
|
|
21
|
+
date: Date;
|
|
22
|
+
tags: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** True if an event has any recording (event-level or in a talk), for the
|
|
26
|
+
* browsing cue on past-event rows. */
|
|
27
|
+
export function eventHasRecording(d: EventData): boolean {
|
|
28
|
+
const talks = (d as { talks?: { recording?: string }[] }).talks;
|
|
29
|
+
if (talks?.length) return talks.some((t) => !!t.recording);
|
|
30
|
+
return !!(d as { recording?: string }).recording;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The talks for one event, normalized. speakers is the resolved collection so
|
|
34
|
+
* a talk's speaker slug (or the event's first speaker, in the simple case)
|
|
35
|
+
* becomes a linkable name; an unresolved slug warns and renders name-less. */
|
|
36
|
+
export function eventTalks(
|
|
37
|
+
d: EventData,
|
|
38
|
+
speakers: CollectionEntry<'speakers'>[],
|
|
39
|
+
eventId: string,
|
|
40
|
+
): Talk[] {
|
|
41
|
+
const nameOf = (slug?: string): string | undefined => {
|
|
42
|
+
if (!slug) return undefined;
|
|
43
|
+
const found = speakers.find((s) => s.id === slug);
|
|
44
|
+
if (!found) console.warn(`events/${eventId}: talk speaker "${slug}" matches no speaker page`);
|
|
45
|
+
return found?.data.title;
|
|
46
|
+
};
|
|
47
|
+
const anyD = d as EventData & {
|
|
48
|
+
talks?: Talk[]; recording?: string; slides?: string;
|
|
49
|
+
};
|
|
50
|
+
if (anyD.talks?.length) {
|
|
51
|
+
return anyD.talks.map((t) => ({
|
|
52
|
+
title: t.title,
|
|
53
|
+
speakerSlug: nameOf(t.speaker) ? t.speaker : undefined,
|
|
54
|
+
speakerName: nameOf(t.speaker),
|
|
55
|
+
recording: t.recording,
|
|
56
|
+
slides: t.slides,
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
if (anyD.recording || anyD.slides) {
|
|
60
|
+
const slug = d.speakers[0];
|
|
61
|
+
const name = nameOf(slug) ?? d.speaker;
|
|
62
|
+
return [{
|
|
63
|
+
title: d.title,
|
|
64
|
+
speakerSlug: nameOf(slug) ? slug : undefined,
|
|
65
|
+
speakerName: name,
|
|
66
|
+
recording: anyD.recording,
|
|
67
|
+
slides: anyD.slides,
|
|
68
|
+
}];
|
|
69
|
+
}
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Every talk across the given events, newest event first, for the /talks/
|
|
74
|
+
* index. Each row carries its parent event's link, date, and tags (the tags
|
|
75
|
+
* drive the shared blog-filter.js tag filter). */
|
|
76
|
+
export function collectTalks(
|
|
77
|
+
events: CollectionEntry<'events'>[],
|
|
78
|
+
speakers: CollectionEntry<'speakers'>[],
|
|
79
|
+
): TalkRow[] {
|
|
80
|
+
const rows: TalkRow[] = [];
|
|
81
|
+
for (const e of [...events].sort((a, b) => +b.data.date - +a.data.date)) {
|
|
82
|
+
for (const t of eventTalks(e.data, speakers, e.id)) {
|
|
83
|
+
rows.push({ ...t, eventId: e.id, eventTitle: e.data.title, date: e.data.date, tags: e.data.tags });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return rows;
|
|
87
|
+
}
|
|
@@ -5,7 +5,7 @@ import { STRINGS } from 'popular:config';
|
|
|
5
5
|
import { render } from 'astro:content';
|
|
6
6
|
import { safeCollection } from '../lib/collections';
|
|
7
7
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
|
8
|
-
import PageHero from '
|
|
8
|
+
import PageHero from 'popular:component/PageHero';
|
|
9
9
|
|
|
10
10
|
export async function getStaticPaths() {
|
|
11
11
|
const pages = await safeCollection('pages');
|
|
@@ -3,9 +3,9 @@ import { STRINGS } from 'popular:config';
|
|
|
3
3
|
import { render } from 'astro:content';
|
|
4
4
|
import { safeCollection } from '../../lib/collections';
|
|
5
5
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
6
|
-
import PageHero from '
|
|
7
|
-
import AuthorBox from '
|
|
8
|
-
import PostCard from '
|
|
6
|
+
import PageHero from 'popular:component/PageHero';
|
|
7
|
+
import AuthorBox from 'popular:component/AuthorBox';
|
|
8
|
+
import PostCard from 'popular:component/PostCard';
|
|
9
9
|
|
|
10
10
|
export async function getStaticPaths() {
|
|
11
11
|
const authors = await safeCollection('authors');
|
|
@@ -20,7 +20,7 @@ const posts = (await safeCollection('blog'))
|
|
|
20
20
|
---
|
|
21
21
|
|
|
22
22
|
<BaseLayout title={entry.data.title} description={entry.data.bio}>
|
|
23
|
-
<PageHero eyebrow={STRINGS.eyebrowAuthor} title={entry.data.title}
|
|
23
|
+
<PageHero eyebrow={STRINGS.eyebrowAuthor} title={entry.data.title} />
|
|
24
24
|
<section class="g-bg-white g-section">
|
|
25
25
|
<div class="g-container">
|
|
26
26
|
<div class="g-article" style="margin-bottom:var(--space-5)">
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { safeCollection } from '../../lib/collections';
|
|
3
3
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
4
|
-
import PageHero from '
|
|
5
|
-
import PostCard from '
|
|
4
|
+
import PageHero from 'popular:component/PageHero';
|
|
5
|
+
import PostCard from 'popular:component/PostCard';
|
|
6
6
|
import Pagination from '../../components/Pagination.astro';
|
|
7
7
|
import { SECTIONS, STRINGS, PAGINATION } from 'popular:config';
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { render } from 'astro:content';
|
|
3
3
|
import { safeCollection } from '../../lib/collections';
|
|
4
4
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
5
|
-
import AuthorBox from '
|
|
5
|
+
import AuthorBox from 'popular:component/AuthorBox';
|
|
6
6
|
import { SUPPORT, SITE, STRINGS, SECTIONS_MAP } from 'popular:config';
|
|
7
7
|
import { buildBlogPostingLd, jsonld } from '../../lib/jsonld';
|
|
8
8
|
import { tagSlug } from '../../lib/slugs';
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { safeCollection } from '../../lib/collections';
|
|
3
3
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
4
|
-
import PageHero from '
|
|
5
|
-
import EventRow from '
|
|
4
|
+
import PageHero from 'popular:component/PageHero';
|
|
5
|
+
import EventRow from 'popular:component/EventRow';
|
|
6
6
|
import Pagination from '../../components/Pagination.astro';
|
|
7
|
+
import EventsEmpty from '../../components/EventsEmpty.astro';
|
|
7
8
|
import { SECTIONS, STRINGS, PAGINATION } from 'popular:config';
|
|
8
9
|
|
|
9
10
|
/* One route serves /events/ and /events/page/N/, matching the Hugo theme's
|
|
@@ -52,6 +53,7 @@ const groupByYear = (events: EventEntry[]) => {
|
|
|
52
53
|
<span class="g-eyebrow">{STRINGS.comingUp}</span>
|
|
53
54
|
<h2>{STRINGS.upcomingEvents}</h2>
|
|
54
55
|
<p class="g-lead">{STRINGS.upcomingLead}</p>
|
|
56
|
+
<div class="g-row" style="margin-top:var(--space-3)"><a class="g-btn g-btn--outline g-btn--sm" href="/events/calendar.ics"><i class="fa-solid fa-calendar-plus" aria-hidden="true"></i> {STRINGS.subscribeCalendar}</a></div>
|
|
55
57
|
</div>
|
|
56
58
|
<div class="g-stack" style="margin-top:var(--space-4);gap:var(--space-3)">
|
|
57
59
|
{groupByYear(upcoming).map((group) => (
|
|
@@ -60,9 +62,7 @@ const groupByYear = (events: EventEntry[]) => {
|
|
|
60
62
|
{group.events.map((e) => <EventRow event={e} />)}
|
|
61
63
|
</>
|
|
62
64
|
))}
|
|
63
|
-
{upcoming.length === 0 &&
|
|
64
|
-
<p class="g-lead">{STRINGS.noUpcomingEvents}</p>
|
|
65
|
-
)}
|
|
65
|
+
{upcoming.length === 0 && <EventsEmpty />}
|
|
66
66
|
</div>
|
|
67
67
|
</div>
|
|
68
68
|
</section>
|
|
@@ -4,7 +4,8 @@ import { buildEventLd, jsonld } from '../../lib/eventld';
|
|
|
4
4
|
import { render } from 'astro:content';
|
|
5
5
|
import { safeCollection } from '../../lib/collections';
|
|
6
6
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
7
|
-
import AuthorBox from '
|
|
7
|
+
import AuthorBox from 'popular:component/AuthorBox';
|
|
8
|
+
import { eventTalks } from '../../lib/talks';
|
|
8
9
|
|
|
9
10
|
export async function getStaticPaths() {
|
|
10
11
|
const events = (await safeCollection('events')).filter((e) => !e.data.draft);
|
|
@@ -30,6 +31,11 @@ const speakerEntries = d.speakers.map((slug) => {
|
|
|
30
31
|
return entry;
|
|
31
32
|
});
|
|
32
33
|
|
|
34
|
+
/* §3 talks: multi-talk events render a Talks section; single-talk events
|
|
35
|
+
render recording/slides links inline in the meta block. */
|
|
36
|
+
const talks = eventTalks(d, allSpeakers, event.id);
|
|
37
|
+
const hasTalksArray = !!(d.talks && d.talks.length > 0);
|
|
38
|
+
|
|
33
39
|
/* schema.org/Event JSON-LD (see lib/eventld; contract in PARITY.md). */
|
|
34
40
|
const siteUrl = new URL('/', Astro.site ?? Astro.url).href;
|
|
35
41
|
const permalink = new URL(`/events/${event.id}/`, Astro.site ?? Astro.url).href;
|
|
@@ -74,7 +80,11 @@ const eventLd = jsonld(buildEventLd({
|
|
|
74
80
|
<span><i class="fa-solid fa-location-dot" aria-hidden="true"></i> {venuePage
|
|
75
81
|
? <><a href={`/venues/${venuePage.id}/`}>{venuePage.data.title}</a>{venuePage.data.address && ` · ${venuePage.data.address}`}</>
|
|
76
82
|
: <>{d.venue ?? STRINGS.venueTba}{d.address && ` · ${d.address}`}</>}</span>
|
|
83
|
+
{venuePage?.data.wheelchair && <span><span class="g-badge g-badge--success g-badge--soft"><i class="fa-solid fa-wheelchair" aria-hidden="true"></i> {STRINGS.wheelchairAccessible}</span></span>}
|
|
84
|
+
{venuePage && (venuePage.data.transit || venuePage.data.parking || venuePage.data.access) && <span><a href={`/venues/${venuePage.id}/`}>{STRINGS.gettingThere}</a></span>}
|
|
77
85
|
{d.speaker && <span><i class="fa-solid fa-microphone-lines" aria-hidden="true"></i> {d.speaker}</span>}
|
|
86
|
+
{!hasTalksArray && d.recording && <span><a href={d.recording} rel="noopener" target="_blank"><i class="fa-solid fa-circle-play" aria-hidden="true"></i> {STRINGS.watchRecording}</a></span>}
|
|
87
|
+
{!hasTalksArray && d.slides && <span><a href={d.slides} rel="noopener" target="_blank"><i class="fa-solid fa-file-lines" aria-hidden="true"></i> {STRINGS.viewSlides}</a></span>}
|
|
78
88
|
</div>
|
|
79
89
|
|
|
80
90
|
{(d.checkin || venueNotes) && (
|
|
@@ -96,6 +106,28 @@ const eventLd = jsonld(buildEventLd({
|
|
|
96
106
|
</>
|
|
97
107
|
)}
|
|
98
108
|
|
|
109
|
+
{hasTalksArray && (
|
|
110
|
+
<>
|
|
111
|
+
<h2 style="font-size:var(--text-xl);margin:var(--space-5) 0 1rem">{STRINGS.talks}</h2>
|
|
112
|
+
<div class="g-stack" style="gap:var(--space-3)">
|
|
113
|
+
{talks.map((t) => (
|
|
114
|
+
<div class="g-talk">
|
|
115
|
+
<div class="g-talk__title">{t.title}</div>
|
|
116
|
+
{t.speakerName && (
|
|
117
|
+
<div class="g-talk__by"><i class="fa-solid fa-microphone-lines" aria-hidden="true"></i> {t.speakerSlug ? <a href={`/speakers/${t.speakerSlug}/`}>{t.speakerName}</a> : t.speakerName}</div>
|
|
118
|
+
)}
|
|
119
|
+
{(t.recording || t.slides) && (
|
|
120
|
+
<div class="g-row" style="gap:var(--space-2)">
|
|
121
|
+
{t.recording && <a class="g-btn g-btn--sm g-btn--outline" href={t.recording} rel="noopener" target="_blank"><i class="fa-solid fa-circle-play" aria-hidden="true"></i> {STRINGS.watchRecording}</a>}
|
|
122
|
+
{t.slides && <a class="g-btn g-btn--sm g-btn--ghost" href={t.slides} rel="noopener" target="_blank"><i class="fa-solid fa-file-lines" aria-hidden="true"></i> {STRINGS.viewSlides}</a>}
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
))}
|
|
127
|
+
</div>
|
|
128
|
+
</>
|
|
129
|
+
)}
|
|
130
|
+
|
|
99
131
|
{d.rsvp && (
|
|
100
132
|
<div class="g-row" style="margin-top:var(--space-4)">
|
|
101
133
|
<a class="g-btn g-btn--primary g-btn--lg" href={d.rsvp}><i class="fa-solid fa-calendar" aria-hidden="true"></i> {STRINGS.rsvpExternal}</a>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { getCollection } from 'astro:content';
|
|
3
|
+
import { buildIcs } from '../../lib/ical';
|
|
4
|
+
import { SITE } from 'popular:config';
|
|
5
|
+
|
|
6
|
+
/* iCalendar feed: upcoming events plus the last 90 days. Mirrors the Hugo
|
|
7
|
+
events/list.calendar.ics template. */
|
|
8
|
+
export const GET: APIRoute = async ({ site }) => {
|
|
9
|
+
const base = site ?? new URL('https://example.com');
|
|
10
|
+
const cutoff = Date.now() - 90 * 24 * 60 * 60 * 1000;
|
|
11
|
+
const events = (await getCollection('events'))
|
|
12
|
+
.filter((e) => !e.data.draft && +e.data.date >= cutoff)
|
|
13
|
+
.sort((a, b) => +a.data.date - +b.data.date);
|
|
14
|
+
const venues = await getCollection('venues');
|
|
15
|
+
const duration = (SITE as Record<string, any>).events?.defaultDurationMinutes ?? 120;
|
|
16
|
+
const dtstamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
|
17
|
+
const body = buildIcs(
|
|
18
|
+
events.map((e) => {
|
|
19
|
+
const v = e.data.venueRef ? venues.find((x) => x.id === e.data.venueRef) : undefined;
|
|
20
|
+
return {
|
|
21
|
+
title: e.data.title,
|
|
22
|
+
date: e.data.date,
|
|
23
|
+
time: e.data.time,
|
|
24
|
+
description: (e.data.description ?? '').trim(),
|
|
25
|
+
checkin: e.data.checkin,
|
|
26
|
+
cancelled: e.data.cancelled,
|
|
27
|
+
venue: v ? { name: v.data.title, address: v.data.address } : (e.data.venue ? { name: e.data.venue } : undefined),
|
|
28
|
+
permalink: new URL(`/events/${e.id}/`, base).href,
|
|
29
|
+
};
|
|
30
|
+
}),
|
|
31
|
+
{ siteTitle: SITE.title, dtstamp, durationMinutes: duration },
|
|
32
|
+
);
|
|
33
|
+
return new Response(body, { headers: { 'Content-Type': 'text/calendar; charset=utf-8' } });
|
|
34
|
+
};
|
package/src/pages/index.astro
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
import { safeCollection } from '../lib/collections';
|
|
3
3
|
import { formatStat, countSectionsIn } from '../lib/stats';
|
|
4
4
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
|
5
|
-
import PostCard from '
|
|
6
|
-
import OrganizerCard from '
|
|
5
|
+
import PostCard from 'popular:component/PostCard';
|
|
6
|
+
import OrganizerCard from 'popular:component/OrganizerCard';
|
|
7
|
+
import EventsEmpty from '../components/EventsEmpty.astro';
|
|
7
8
|
import { HOME, SITE, STRINGS, SECTIONS_MAP, SOCIAL } from 'popular:config';
|
|
8
9
|
import { buildOrgLd, jsonld } from '../lib/jsonld';
|
|
9
10
|
|
|
@@ -26,6 +27,7 @@ const nextVenue = next?.data.venueRef ? (await safeCollection('venues')).find((v
|
|
|
26
27
|
const organizers = (await safeCollection(SECTIONS_MAP.team)).sort((a, b) => a.data.weight - b.data.weight);
|
|
27
28
|
|
|
28
29
|
const h = HOME;
|
|
30
|
+
const chat = (SITE as Record<string, any>).community?.chat;
|
|
29
31
|
const orgLd = jsonld(buildOrgLd({
|
|
30
32
|
name: SITE.title,
|
|
31
33
|
url: new URL('/', Astro.site ?? Astro.url).href,
|
|
@@ -44,13 +46,18 @@ const orgLd = jsonld(buildOrgLd({
|
|
|
44
46
|
{h.hero.eyebrow && <span class="g-eyebrow">{h.hero.eyebrow}</span>}
|
|
45
47
|
<h1>{h.hero.title}</h1>
|
|
46
48
|
{h.hero.lead && <p class="g-hero__lead">{h.hero.lead}</p>}
|
|
47
|
-
{h.hero.ctas && (
|
|
49
|
+
{(h.hero.ctas || chat) && (
|
|
48
50
|
<div class="g-row">
|
|
49
|
-
{h.hero.ctas.map((c) => (
|
|
51
|
+
{(h.hero.ctas ?? []).map((c) => (
|
|
50
52
|
<a class={`g-btn g-btn--lg g-btn--${c.variant ?? 'primary'}`} href={c.url}>
|
|
51
53
|
{'icon' in c && c.icon && <i class={c.icon} aria-hidden="true"></i>}{c.label}
|
|
52
54
|
</a>
|
|
53
55
|
))}
|
|
56
|
+
{chat && (
|
|
57
|
+
<a class="g-btn g-btn--lg g-btn--secondary" href={chat.url} {...(chat.url.startsWith('/') ? {} : { rel: 'noopener', target: '_blank' })}>
|
|
58
|
+
<i class="fa-solid fa-comments" aria-hidden="true"></i> {chat.label ?? STRINGS.joinChat}
|
|
59
|
+
</a>
|
|
60
|
+
)}
|
|
54
61
|
</div>
|
|
55
62
|
)}
|
|
56
63
|
</div>
|
|
@@ -115,6 +122,12 @@ const orgLd = jsonld(buildOrgLd({
|
|
|
115
122
|
</section>
|
|
116
123
|
)}
|
|
117
124
|
|
|
125
|
+
{events.length > 0 && !next && (
|
|
126
|
+
<section class="g-bg-wash-soft g-section">
|
|
127
|
+
<div class="g-container"><EventsEmpty /></div>
|
|
128
|
+
</section>
|
|
129
|
+
)}
|
|
130
|
+
|
|
118
131
|
{posts.length > 0 && (
|
|
119
132
|
<section class="g-bg-white g-section">
|
|
120
133
|
<div class="g-container">
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { getCollection } from 'astro:content';
|
|
3
|
+
import { SITE } from 'popular:config';
|
|
4
|
+
|
|
5
|
+
/* §8 llms.txt: a build-time plain-text summary for AI agents. Names the next
|
|
6
|
+
upcoming event (regenerated every build so it stays true), how to join, and
|
|
7
|
+
links to the key pages and the calendar feed. Mirrors Hugo's
|
|
8
|
+
layouts/index.llms.txt. Always generated (SEO/discovery plumbing, like
|
|
9
|
+
robots.txt). */
|
|
10
|
+
export const GET: APIRoute = async ({ site }) => {
|
|
11
|
+
const base = site ?? new URL('https://example.com');
|
|
12
|
+
const abs = (p: string) => new URL(p, base).href;
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
|
|
15
|
+
const events = (await getCollection('events'))
|
|
16
|
+
.filter((e) => !e.data.draft)
|
|
17
|
+
.sort((a, b) => +a.data.date - +b.data.date);
|
|
18
|
+
const next = events.find((e) => +e.data.date >= now);
|
|
19
|
+
|
|
20
|
+
const pageIds = new Set((await getCollection('pages')).map((p) => p.id));
|
|
21
|
+
const chat = (SITE as Record<string, any>).community?.chat as { url: string; label?: string } | undefined;
|
|
22
|
+
const talksOn = (SITE as Record<string, any>).talks === true;
|
|
23
|
+
const fmt = (d: Date) =>
|
|
24
|
+
d.toLocaleDateString(SITE.locale, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
|
|
25
|
+
|
|
26
|
+
const lines: string[] = [`# ${SITE.title}`, ''];
|
|
27
|
+
if (SITE.description) lines.push(SITE.description, '');
|
|
28
|
+
lines.push(
|
|
29
|
+
next
|
|
30
|
+
? `Next event: ${next.data.title} on ${fmt(next.data.date)}${next.data.time ? `, ${next.data.time}` : ''} (${abs(`/events/${next.id}/`)})`
|
|
31
|
+
: 'No upcoming events are scheduled right now.',
|
|
32
|
+
'',
|
|
33
|
+
);
|
|
34
|
+
if (chat) lines.push(`Join the community chat: ${chat.url}`, '');
|
|
35
|
+
lines.push('## Links');
|
|
36
|
+
if (pageIds.has('start')) lines.push(`Start here (newcomers): ${abs('/start/')}`);
|
|
37
|
+
if (pageIds.has('about')) lines.push(`About and FAQ: ${abs('/about/')}`);
|
|
38
|
+
lines.push(`Events: ${abs('/events/')}`);
|
|
39
|
+
lines.push(`Calendar feed (iCalendar): ${abs('/events/calendar.ics')}`);
|
|
40
|
+
if (talksOn) lines.push(`Talk archive: ${abs('/talks/')}`);
|
|
41
|
+
lines.push('', 'Built with the Popular theme.');
|
|
42
|
+
|
|
43
|
+
return new Response(lines.join('\n') + '\n', {
|
|
44
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
45
|
+
});
|
|
46
|
+
};
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { safeCollection } from '../../lib/collections';
|
|
3
3
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
4
|
-
import PageHero from '
|
|
5
|
-
import OrganizerCard from '
|
|
4
|
+
import PageHero from 'popular:component/PageHero';
|
|
5
|
+
import OrganizerCard from 'popular:component/OrganizerCard';
|
|
6
6
|
import Pagination from '../../components/Pagination.astro';
|
|
7
7
|
import { SECTIONS, PAGINATION } from 'popular:config';
|
|
8
8
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
|
-
import { STRINGS } from 'popular:config';
|
|
2
|
+
import { STRINGS, SECTIONS_MAP } from 'popular:config';
|
|
3
3
|
import { render } from 'astro:content';
|
|
4
4
|
import { safeCollection } from '../../lib/collections';
|
|
5
5
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
6
|
-
import PageHero from '
|
|
7
|
-
import AuthorBox from '
|
|
6
|
+
import PageHero from 'popular:component/PageHero';
|
|
7
|
+
import AuthorBox from 'popular:component/AuthorBox';
|
|
8
8
|
|
|
9
9
|
export async function getStaticPaths() {
|
|
10
10
|
const organizers = await safeCollection('organizers');
|
|
@@ -17,7 +17,7 @@ const { Content } = await render(entry);
|
|
|
17
17
|
---
|
|
18
18
|
|
|
19
19
|
<BaseLayout title={d.title} description={d.description}>
|
|
20
|
-
<PageHero eyebrow={d.role ?? STRINGS.eyebrowOrganizer}
|
|
20
|
+
<PageHero eyebrow={d.role ?? STRINGS.eyebrowOrganizer} backHref={`/${SECTIONS_MAP.team}/`} backLabel={STRINGS.allOrganizers} title={d.title} />
|
|
21
21
|
<section class="g-bg-white g-section">
|
|
22
22
|
<div class="g-container">
|
|
23
23
|
<div class="g-article">
|
|
@@ -3,9 +3,9 @@ import { STRINGS } from 'popular:config';
|
|
|
3
3
|
import { render } from 'astro:content';
|
|
4
4
|
import { safeCollection } from '../../lib/collections';
|
|
5
5
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
6
|
-
import PageHero from '
|
|
7
|
-
import AuthorBox from '
|
|
8
|
-
import EventRow from '
|
|
6
|
+
import PageHero from 'popular:component/PageHero';
|
|
7
|
+
import AuthorBox from 'popular:component/AuthorBox';
|
|
8
|
+
import EventRow from 'popular:component/EventRow';
|
|
9
9
|
|
|
10
10
|
export async function getStaticPaths() {
|
|
11
11
|
const speakers = await safeCollection('speakers');
|
|
@@ -20,7 +20,7 @@ const events = (await safeCollection('events'))
|
|
|
20
20
|
---
|
|
21
21
|
|
|
22
22
|
<BaseLayout title={entry.data.title} description={entry.data.bio}>
|
|
23
|
-
<PageHero eyebrow={STRINGS.eyebrowSpeaker}
|
|
23
|
+
<PageHero eyebrow={STRINGS.eyebrowSpeaker} backHref="/speakers/" backLabel={STRINGS.allSpeakers} title={entry.data.title} />
|
|
24
24
|
<section class="g-bg-white g-section">
|
|
25
25
|
<div class="g-container">
|
|
26
26
|
<div class="g-article" style="margin-bottom:var(--space-5)">
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** Speakers list, mirroring Hugo's layouts/speakers/list.html: every speaker
|
|
3
|
+
* rendered as an AuthorBox, sorted by name. Fixes the "All speakers" backlink
|
|
4
|
+
* on speaker detail pages, which previously had no list page to point to. */
|
|
5
|
+
import { safeCollection } from '../../lib/collections';
|
|
6
|
+
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
7
|
+
import PageHero from '../../components/PageHero.astro';
|
|
8
|
+
import AuthorBox from 'popular:component/AuthorBox';
|
|
9
|
+
import SpeakerInvite from '../../components/SpeakerInvite.astro';
|
|
10
|
+
import { SECTIONS } from 'popular:config';
|
|
11
|
+
|
|
12
|
+
const speakers = (await safeCollection('speakers')).sort((a, b) => a.data.title.localeCompare(b.data.title));
|
|
13
|
+
const sec = SECTIONS.speakers;
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
<BaseLayout title={sec.title} description={sec.lead}>
|
|
17
|
+
<PageHero eyebrow={sec.eyebrow} title={sec.title} lead={sec.lead} />
|
|
18
|
+
<section class="g-bg-white g-section">
|
|
19
|
+
<div class="g-container">
|
|
20
|
+
<div class="g-stack" style="max-width:52rem;gap:var(--space-3)">
|
|
21
|
+
{speakers.map((s) => <AuthorBox entry={s} base="/speakers/" />)}
|
|
22
|
+
<SpeakerInvite />
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
</section>
|
|
26
|
+
</BaseLayout>
|
|
@@ -3,8 +3,8 @@ import { SITE, STRINGS, PAGINATION } from 'popular:config';
|
|
|
3
3
|
import { tagSlug } from '../../../lib/slugs';
|
|
4
4
|
import { safeCollection } from '../../../lib/collections';
|
|
5
5
|
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
|
6
|
-
import PageHero from '
|
|
7
|
-
import PostCard from '
|
|
6
|
+
import PageHero from 'popular:component/PageHero';
|
|
7
|
+
import PostCard from 'popular:component/PostCard';
|
|
8
8
|
import Pagination from '../../../components/Pagination.astro';
|
|
9
9
|
|
|
10
10
|
/* One route serves /tags/<tag>/ and /tags/<tag>/page/N/, matching the Hugo
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** §3 talk archive index. Opt-in: builds only when SITE.talks is true (the
|
|
3
|
+
* Astro counterpart of Hugo's "create content/talks/_index.md to enable"),
|
|
4
|
+
* so sites without recordings do not grow an empty page. Aggregates every
|
|
5
|
+
* talk across events, newest first, filterable by the parent event's tags
|
|
6
|
+
* via the shared blog-filter.js (same [data-filterbar] / [data-tags] markup
|
|
7
|
+
* as the blog list). */
|
|
8
|
+
import { SITE, STRINGS } from 'popular:config';
|
|
9
|
+
import { safeCollection } from '../../lib/collections';
|
|
10
|
+
import { collectTalks } from '../../lib/talks';
|
|
11
|
+
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
12
|
+
import PageHero from 'popular:component/PageHero';
|
|
13
|
+
|
|
14
|
+
export async function getStaticPaths() {
|
|
15
|
+
if ((SITE as Record<string, any>).talks !== true) return [];
|
|
16
|
+
const events = (await safeCollection('events')).filter((e) => !e.data.draft);
|
|
17
|
+
const speakers = await safeCollection('speakers');
|
|
18
|
+
return [{ params: { page: undefined }, props: { talks: collectTalks(events, speakers) } }];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const { talks } = Astro.props;
|
|
22
|
+
const tags = [...new Set(talks.flatMap((t) => t.tags))].sort();
|
|
23
|
+
const fmt = (d: Date) => d.toLocaleDateString(SITE.locale, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
<BaseLayout title={STRINGS.talks} description={STRINGS.talksLead}>
|
|
27
|
+
<PageHero eyebrow={STRINGS.talks} title={STRINGS.talks} lead={STRINGS.talksLead} />
|
|
28
|
+
<section class="g-bg-white g-section">
|
|
29
|
+
<div class="g-container">
|
|
30
|
+
<div class="g-row" data-filterbar style="margin-bottom:var(--space-5)">
|
|
31
|
+
{tags.length > 0 && (
|
|
32
|
+
<>
|
|
33
|
+
<span style="font-size:var(--text-sm);color:var(--text-muted)">{STRINGS.filterLabel}</span>
|
|
34
|
+
<button class="g-filter is-active" data-filter="*">{STRINGS.filterAll}</button>
|
|
35
|
+
{tags.map((t) => <button class="g-filter" data-filter={t}>#{t}</button>)}
|
|
36
|
+
</>
|
|
37
|
+
)}
|
|
38
|
+
</div>
|
|
39
|
+
<div class="g-stack" style="gap:var(--space-3)">
|
|
40
|
+
{talks.map((t) => (
|
|
41
|
+
<div class="g-talk" data-tags={t.tags.join(',')}>
|
|
42
|
+
<div class="g-talk__title">{t.title}</div>
|
|
43
|
+
{t.speakerName && (
|
|
44
|
+
<div class="g-talk__by"><i class="fa-solid fa-microphone-lines" aria-hidden="true"></i> {t.speakerSlug ? <a href={`/speakers/${t.speakerSlug}/`}>{t.speakerName}</a> : t.speakerName}</div>
|
|
45
|
+
)}
|
|
46
|
+
<div class="g-talk__event"><i class="fa-solid fa-calendar-day" aria-hidden="true"></i> <a href={`/events/${t.eventId}/`}>{t.eventTitle}</a> · {fmt(t.date)}</div>
|
|
47
|
+
{(t.recording || t.slides) && (
|
|
48
|
+
<div class="g-row" style="gap:var(--space-2)">
|
|
49
|
+
{t.recording && <a class="g-btn g-btn--sm g-btn--outline" href={t.recording} rel="noopener" target="_blank"><i class="fa-solid fa-circle-play" aria-hidden="true"></i> {STRINGS.watchRecording}</a>}
|
|
50
|
+
{t.slides && <a class="g-btn g-btn--sm g-btn--ghost" href={t.slides} rel="noopener" target="_blank"><i class="fa-solid fa-file-lines" aria-hidden="true"></i> {STRINGS.viewSlides}</a>}
|
|
51
|
+
</div>
|
|
52
|
+
)}
|
|
53
|
+
</div>
|
|
54
|
+
))}
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</section>
|
|
58
|
+
</BaseLayout>
|
|
@@ -20,7 +20,7 @@ const events = (await safeCollection('events'))
|
|
|
20
20
|
---
|
|
21
21
|
|
|
22
22
|
<BaseLayout title={d.title} description={d.address}>
|
|
23
|
-
<PageHero eyebrow={STRINGS.eyebrowVenue} title={d.title} lead={d.address} />
|
|
23
|
+
<PageHero eyebrow={STRINGS.eyebrowVenue} backHref="/venues/" backLabel={STRINGS.allVenues} title={d.title} lead={d.address} />
|
|
24
24
|
<section class="g-bg-white g-section">
|
|
25
25
|
<div class="g-container">
|
|
26
26
|
<div class="g-article" style="margin-bottom:var(--space-5)">
|
|
@@ -36,6 +36,17 @@ const events = (await safeCollection('events'))
|
|
|
36
36
|
{d.accessibility && <div class="g-callout__body"><i class="fa-solid fa-universal-access" aria-hidden="true"></i> {d.accessibility}</div>}
|
|
37
37
|
</div>
|
|
38
38
|
)}
|
|
39
|
+
{(d.wheelchair || d.transit || d.parking || d.access) && (
|
|
40
|
+
<>
|
|
41
|
+
<div class="g-section-head" style="margin-top:var(--space-4)"><span class="g-eyebrow">{STRINGS.eyebrowVenue}</span><h2 style="font-size:var(--text-xl)">{STRINGS.gettingThere}</h2></div>
|
|
42
|
+
{d.wheelchair && <p><span class="g-badge g-badge--success g-badge--soft"><i class="fa-solid fa-wheelchair" aria-hidden="true"></i> {STRINGS.wheelchairAccessible}</span></p>}
|
|
43
|
+
<div class="g-meta-line" style="flex-direction:column;align-items:flex-start;gap:.4rem">
|
|
44
|
+
{d.transit && <span><i class="fa-solid fa-train-subway" aria-hidden="true"></i> <strong>{STRINGS.transit}:</strong> {d.transit}</span>}
|
|
45
|
+
{d.parking && <span><i class="fa-solid fa-square-parking" aria-hidden="true"></i> <strong>{STRINGS.parking}:</strong> {d.parking}</span>}
|
|
46
|
+
</div>
|
|
47
|
+
{d.access && <div class="g-prose" style="margin-top:var(--space-3)"><p>{d.access}</p></div>}
|
|
48
|
+
</>
|
|
49
|
+
)}
|
|
39
50
|
<div class="g-prose" style="margin-top:var(--space-4)"><Content /></div>
|
|
40
51
|
</div>
|
|
41
52
|
{events.length > 0 && (
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** Venues list, mirroring Hugo's layouts/venues/list.html: a card grid of every
|
|
3
|
+
* venue, sorted by name. Fixes the "All venues" backlink on venue detail pages,
|
|
4
|
+
* which previously had no list page to point to. */
|
|
5
|
+
import { safeCollection } from '../../lib/collections';
|
|
6
|
+
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
7
|
+
import PageHero from '../../components/PageHero.astro';
|
|
8
|
+
import { SECTIONS } from 'popular:config';
|
|
9
|
+
|
|
10
|
+
const venues = (await safeCollection('venues')).sort((a, b) => a.data.title.localeCompare(b.data.title));
|
|
11
|
+
const sec = SECTIONS.venues;
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
<BaseLayout title={sec.title} description={sec.lead}>
|
|
15
|
+
<PageHero eyebrow={sec.eyebrow} title={sec.title} lead={sec.lead} />
|
|
16
|
+
<section class="g-bg-white g-section">
|
|
17
|
+
<div class="g-container">
|
|
18
|
+
<div class="g-grid-3">
|
|
19
|
+
{venues.map((v) => (
|
|
20
|
+
<article class="g-card">
|
|
21
|
+
{v.data.photo && (
|
|
22
|
+
<a href={`/venues/${v.id}/`} class="g-card__media"><img src={v.data.photo} alt={v.data.title} loading="lazy" decoding="async" /></a>
|
|
23
|
+
)}
|
|
24
|
+
<div class="g-card__body">
|
|
25
|
+
<h3 class="g-card__title"><a href={`/venues/${v.id}/`}>{v.data.title}</a></h3>
|
|
26
|
+
{v.data.address && <div class="g-card__text"><i class="fa-solid fa-location-dot" aria-hidden="true"></i> {v.data.address}</div>}
|
|
27
|
+
</div>
|
|
28
|
+
</article>
|
|
29
|
+
))}
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
</section>
|
|
33
|
+
</BaseLayout>
|
|
@@ -320,3 +320,32 @@ pre:hover .g-copy, .g-copy:focus-visible { opacity: 1; }
|
|
|
320
320
|
.g-nav__toggle { display: block; }
|
|
321
321
|
.g-nav__cta { display: none; }
|
|
322
322
|
}
|
|
323
|
+
|
|
324
|
+
/* Notice banner (§5): site-wide static announcement above the header. */
|
|
325
|
+
.g-notice { background: var(--surface-wash); border-bottom: var(--border-width) solid var(--border-subtle); color: var(--text-body); font-size: var(--text-sm); }
|
|
326
|
+
.g-notice__inner { display: flex; gap: 0.4rem 0.7rem; align-items: center; justify-content: center; flex-wrap: wrap; padding: 0.6rem var(--space-4); text-align: center; }
|
|
327
|
+
.g-notice__inner > i { color: var(--text-link); }
|
|
328
|
+
.g-notice a { color: var(--text-link); font-weight: var(--weight-medium); text-decoration: underline; }
|
|
329
|
+
|
|
330
|
+
/* Designed empty state (§5): zero upcoming events on home + events list. */
|
|
331
|
+
.g-empty { display: flex; flex-direction: column; align-items: center; gap: var(--space-3); text-align: center; padding: var(--space-5) 0; }
|
|
332
|
+
.g-empty .g-lead { margin: 0; max-width: 32rem; }
|
|
333
|
+
|
|
334
|
+
/* Talk archive (§3): a talk entry on the event page and the /talks/ index. */
|
|
335
|
+
.g-talk { display: flex; flex-direction: column; gap: 0.4rem; padding: var(--space-3); border: var(--border-width) solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface-card); }
|
|
336
|
+
.g-talk__title { font-weight: var(--weight-medium); }
|
|
337
|
+
.g-talk__by { font-size: var(--text-sm); color: var(--text-muted); }
|
|
338
|
+
.g-talk__by a { color: var(--text-link); }
|
|
339
|
+
.g-talk__event { font-size: var(--text-sm); color: var(--text-muted); }
|
|
340
|
+
.g-talk__event a { color: var(--text-link); }
|
|
341
|
+
|
|
342
|
+
/* Photo gallery + pullquote (§6). No JS, no lightbox: each photo links to its
|
|
343
|
+
full-size image (native <a>, the deliberate non-feature). */
|
|
344
|
+
.g-gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: var(--space-3); margin: var(--space-4) 0; }
|
|
345
|
+
.g-gallery__item { margin: 0; }
|
|
346
|
+
.g-gallery__item img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: var(--radius-lg); display: block; }
|
|
347
|
+
.g-gallery__item figcaption { font-size: var(--text-sm); color: var(--text-muted); margin-top: 0.35rem; }
|
|
348
|
+
.g-pullquote { margin: var(--space-4) 0; padding-left: var(--space-4); border-left: 3px solid var(--text-link); }
|
|
349
|
+
.g-pullquote blockquote { margin: 0; font-size: var(--text-lg); font-style: italic; }
|
|
350
|
+
.g-pullquote figcaption { margin-top: 0.5rem; font-size: var(--text-sm); color: var(--text-muted); font-style: normal; }
|
|
351
|
+
.g-pullquote figcaption::before { content: "\2014 \00a0"; }
|