astro-theme-popular 0.5.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 +53 -2
- package/package.json +3 -2
- package/schemas.ts +20 -0
- package/src/components/AuthorBox.astro +2 -2
- package/src/components/DemoBar.astro +2 -0
- package/src/components/EventRow.astro +8 -1
- package/src/components/EventsEmpty.astro +25 -0
- package/src/components/FAQ.astro +30 -0
- package/src/components/FAQItem.astro +10 -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/OrganizerCard.astro +1 -1
- package/src/components/PageHero.astro +6 -1
- package/src/components/Photo.astro +12 -0
- package/src/components/PostCard.astro +1 -1
- package/src/components/Pullquote.astro +11 -0
- package/src/components/SpeakerInvite.astro +16 -0
- package/src/layouts/BaseLayout.astro +13 -3
- package/src/lib/eventld.ts +95 -0
- package/src/lib/ical.ts +93 -0
- package/src/lib/inline-md.ts +21 -0
- package/src/lib/jsonld.ts +48 -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 +15 -1
- package/src/pages/events/[...page].astro +5 -5
- package/src/pages/events/[slug].astro +59 -2
- package/src/pages/events/calendar.ics.ts +34 -0
- package/src/pages/index.astro +27 -5
- 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/robots.txt.ts +10 -0
- package/src/pages/speakers/[slug].astro +4 -4
- package/src/pages/speakers/index.astro +26 -0
- package/src/pages/tags/[tag]/[...page].astro +4 -4
- package/src/pages/talks/[...page].astro +58 -0
- package/src/pages/venues/[slug].astro +13 -2
- package/src/pages/venues/index.astro +33 -0
- package/src/styles/components.css +41 -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
|
|
@@ -13,6 +18,21 @@
|
|
|
13
18
|
*/
|
|
14
19
|
import path from 'node:path';
|
|
15
20
|
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import sitemap from '@astrojs/sitemap';
|
|
22
|
+
|
|
23
|
+
/* loading="lazy" decoding="async" on markdown images (CWV parity with the
|
|
24
|
+
Hugo render hook). Dependency-free HAST walk. */
|
|
25
|
+
function rehypeLazyImages() {
|
|
26
|
+
const walk = (node) => {
|
|
27
|
+
if (node.tagName === 'img') {
|
|
28
|
+
node.properties = node.properties || {};
|
|
29
|
+
if (!('loading' in node.properties)) node.properties.loading = 'lazy';
|
|
30
|
+
if (!('decoding' in node.properties)) node.properties.decoding = 'async';
|
|
31
|
+
}
|
|
32
|
+
(node.children || []).forEach(walk);
|
|
33
|
+
};
|
|
34
|
+
return (tree) => walk(tree);
|
|
35
|
+
}
|
|
16
36
|
|
|
17
37
|
/* Injected routes, grouped by opt-out key: popular({ routes: { speakers:
|
|
18
38
|
false } }) skips a group. Disabling is also the durable answer when your
|
|
@@ -35,14 +55,29 @@ const ROUTES = {
|
|
|
35
55
|
['/organizers/[...page]', 'organizers/[...page].astro'],
|
|
36
56
|
['/organizers/[...slug]', 'organizers/[slug].astro'],
|
|
37
57
|
],
|
|
38
|
-
speakers: [
|
|
39
|
-
|
|
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
|
+
],
|
|
40
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']],
|
|
41
70
|
rss: [['/rss.xml', 'rss.xml.js']],
|
|
71
|
+
robots: [['/robots.txt', 'robots.txt.ts']],
|
|
72
|
+
calendar: [['/events/calendar.ics', 'events/calendar.ics.ts']],
|
|
73
|
+
llms: [['/llms.txt', 'llms.txt.ts']],
|
|
42
74
|
};
|
|
43
75
|
|
|
76
|
+
const OVERRIDABLE = ['Header', 'Footer', 'Hero', 'EventRow', 'PostCard', 'OrganizerCard', 'AuthorBox', 'PageHero'];
|
|
77
|
+
|
|
44
78
|
export default function popular(options = {}) {
|
|
45
79
|
const configFile = options.configFile ?? './popular.config.ts';
|
|
80
|
+
const overrides = options.overrides ?? {};
|
|
46
81
|
return {
|
|
47
82
|
name: 'astro-theme-popular',
|
|
48
83
|
hooks: {
|
|
@@ -51,6 +86,8 @@ export default function popular(options = {}) {
|
|
|
51
86
|
const userConfig = path.resolve(root, configFile);
|
|
52
87
|
addWatchFile(userConfig);
|
|
53
88
|
updateConfig({
|
|
89
|
+
integrations: [sitemap()],
|
|
90
|
+
markdown: { rehypePlugins: [rehypeLazyImages] },
|
|
54
91
|
vite: {
|
|
55
92
|
plugins: [
|
|
56
93
|
{
|
|
@@ -64,6 +101,20 @@ export default function popular(options = {}) {
|
|
|
64
101
|
}
|
|
65
102
|
},
|
|
66
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
|
+
},
|
|
67
118
|
],
|
|
68
119
|
},
|
|
69
120
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "astro-theme-popular",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Popular: a warm, community-first Astro theme for meetups, clubs and fan communities. Ships as an Astro integration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
"./scripts/*": "./scripts/*"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@astrojs/rss": "^4.0.0"
|
|
30
|
+
"@astrojs/rss": "^4.0.0",
|
|
31
|
+
"@astrojs/sitemap": "^3.0.0"
|
|
31
32
|
},
|
|
32
33
|
"peerDependencies": {
|
|
33
34
|
"astro": "^7.0.0"
|
package/schemas.ts
CHANGED
|
@@ -57,6 +57,22 @@ const events = defineCollection({
|
|
|
57
57
|
venueNotes: z.string().optional(), // e.g. "Buzz 204 at the side door."; overrides the venue page's notes
|
|
58
58
|
rsvp: z.string().optional(),
|
|
59
59
|
meetupUrl: z.string().optional(), // metadata convention; the theme renders no external links on event rows
|
|
60
|
+
cancelled: z.boolean().default(false), // EventCancelled JSON-LD + visible badge
|
|
61
|
+
online: z.boolean().default(false), // OnlineEventAttendanceMode + VirtualLocation
|
|
62
|
+
price: z.union([z.number(), z.string()]).optional(), // machine price for Offer
|
|
63
|
+
currency: z.string().optional(), // ISO currency for price
|
|
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(),
|
|
60
76
|
}),
|
|
61
77
|
});
|
|
62
78
|
|
|
@@ -109,6 +125,10 @@ const venues = defineCollection({
|
|
|
109
125
|
photo: z.string().optional(),
|
|
110
126
|
notes: z.string().optional(), // arrival notes inherited by events held here
|
|
111
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
|
|
112
132
|
website: z.string().optional(),
|
|
113
133
|
}),
|
|
114
134
|
});
|
|
@@ -23,7 +23,7 @@ const bio = d ? ((d as any).bio ?? (d as any).description) : undefined;
|
|
|
23
23
|
<div class="g-persona g-persona--primary">
|
|
24
24
|
{d && (
|
|
25
25
|
<>
|
|
26
|
-
{d.photo && <img class="g-persona__img" src={d.photo} alt={entry.data.title} />}
|
|
26
|
+
{d.photo && <img class="g-persona__img" src={d.photo} alt={entry.data.title} loading="lazy" decoding="async" />}
|
|
27
27
|
<div>
|
|
28
28
|
<div class="g-persona__name"><a href={`${base}${entry.id}/`} style="color:inherit">{d.title}</a></div>
|
|
29
29
|
{d.role && <div class="g-persona__role">{d.role}</div>}
|
|
@@ -37,7 +37,7 @@ const bio = d ? ((d as any).bio ?? (d as any).description) : undefined;
|
|
|
37
37
|
)}
|
|
38
38
|
{guest && (
|
|
39
39
|
<>
|
|
40
|
-
{guest.photo && <img class="g-persona__img" src={guest.photo} alt={guest.name} />}
|
|
40
|
+
{guest.photo && <img class="g-persona__img" src={guest.photo} alt={guest.name} loading="lazy" decoding="async" />}
|
|
41
41
|
<div>
|
|
42
42
|
<div class="g-persona__name">{guest.name} <span class="g-badge g-badge--soft" style="vertical-align:middle">{STRINGS.guest}</span></div>
|
|
43
43
|
{guest.title && <div class="g-persona__role">{guest.title}</div>}
|
|
@@ -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,9 +49,14 @@ 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
|
|
58
|
+
? <span class="g-badge g-badge--danger g-badge--soft">{STRINGS.eventCancelled}</span>
|
|
59
|
+
: tbd
|
|
53
60
|
? <span class="g-badge g-badge--warning g-badge--soft">{STRINGS.venueWanted}</span>
|
|
54
61
|
: <span class="g-badge g-badge--success g-badge--soft">{STRINGS.confirmed}</span>}
|
|
55
62
|
{d.rsvp && (
|
|
@@ -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>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** FAQ block: wraps <FAQItem> children (native <details>, zero JS; answers in
|
|
3
|
+
* the DOM for indexing + AI answer engines). Emits FAQPage JSON-LD collected
|
|
4
|
+
* from the rendered items (gated on SITE.seo.faqJsonLd, default true),
|
|
5
|
+
* mirroring Hugo's faq shortcode. No FAQ rich results are expected (Google
|
|
6
|
+
* restricts them); this aids agents and future-proofs. */
|
|
7
|
+
import { SITE } from 'popular:config';
|
|
8
|
+
import { jsonld } from '../lib/jsonld';
|
|
9
|
+
|
|
10
|
+
const html = await Astro.slots.render('default');
|
|
11
|
+
const strip = (s: string) => s.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
|
12
|
+
const items = [...html.matchAll(
|
|
13
|
+
/<summary class="g-faq__q">([\s\S]*?)<\/summary>\s*<div class="g-faq__a[^"]*">([\s\S]*?)<\/div>\s*<\/details>/g,
|
|
14
|
+
)].map((m) => ({ q: strip(m[1]), a: strip(m[2]) }));
|
|
15
|
+
|
|
16
|
+
const emit = (SITE as Record<string, any>).seo?.faqJsonLd !== false && items.length;
|
|
17
|
+
const faqLd = emit
|
|
18
|
+
? jsonld({
|
|
19
|
+
'@context': 'https://schema.org',
|
|
20
|
+
'@type': 'FAQPage',
|
|
21
|
+
mainEntity: items.map((i) => ({
|
|
22
|
+
'@type': 'Question',
|
|
23
|
+
name: i.q,
|
|
24
|
+
acceptedAnswer: { '@type': 'Answer', text: i.a },
|
|
25
|
+
})),
|
|
26
|
+
})
|
|
27
|
+
: null;
|
|
28
|
+
---
|
|
29
|
+
<div class="g-faq" set:html={html} />
|
|
30
|
+
{faqLd && <script type="application/ld+json" set:html={faqLd} />}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** One FAQ item: native <details> (zero JS). `q` is the question; the default
|
|
3
|
+
* slot is the markdown answer. `open` renders it expanded. */
|
|
4
|
+
interface Props { q: string; open?: boolean }
|
|
5
|
+
const { q, open = false } = Astro.props;
|
|
6
|
+
---
|
|
7
|
+
<details class="g-faq__item" open={open}>
|
|
8
|
+
<summary class="g-faq__q">{q}</summary>
|
|
9
|
+
<div class="g-faq__a g-prose"><slot /></div>
|
|
10
|
+
</details>
|
|
@@ -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
|
+
)}
|
|
@@ -7,7 +7,7 @@ const d = organizer.data;
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
<div class="g-org">
|
|
10
|
-
{d.photo && <a href={`/organizers/${organizer.id}/`}><img class="g-org__img" src={d.photo} alt={d.title} loading="lazy" /></a>}
|
|
10
|
+
{d.photo && <a href={`/organizers/${organizer.id}/`}><img class="g-org__img" src={d.photo} alt={d.title} loading="lazy" decoding="async" /></a>}
|
|
11
11
|
<div class="g-org__name"><a href={`/organizers/${organizer.id}/`} style="color:inherit">{d.title}</a></div>
|
|
12
12
|
{d.role && <div class="g-org__role">{d.role}</div>}
|
|
13
13
|
{d.description && <p class="g-org__bio">{d.description}</p>}
|
|
@@ -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>
|
|
@@ -22,7 +22,7 @@ if (!names.length && d.author) names.push(d.author);
|
|
|
22
22
|
|
|
23
23
|
<article class="g-card" data-tags={d.tags.join(',')}>
|
|
24
24
|
{d.image && (
|
|
25
|
-
<a href={href} class="g-card__media"><img src={d.image} alt={d.title} loading="lazy" /></a>
|
|
25
|
+
<a href={href} class="g-card__media"><img src={d.image} alt={d.title} loading="lazy" decoding="async" /></a>
|
|
26
26
|
)}
|
|
27
27
|
<div class="g-card__body">
|
|
28
28
|
{(month || names.length > 0) && (
|
|
@@ -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 {
|
|
@@ -18,8 +19,11 @@ interface Props {
|
|
|
18
19
|
image?: string;
|
|
19
20
|
/** Set on blog posts: emits og:type=article plus article meta. */
|
|
20
21
|
article?: { published?: Date; modified?: Date; authors?: string[]; tags?: string[] };
|
|
22
|
+
imageAlt?: string;
|
|
23
|
+
noindex?: boolean;
|
|
21
24
|
}
|
|
22
|
-
const { title, description, image, article } = Astro.props;
|
|
25
|
+
const { title, description, image, article, imageAlt, noindex } = Astro.props;
|
|
26
|
+
const ogLocale = SITE.locale.replace('-', '_');
|
|
23
27
|
const pageTitle = title ? `${title} · ${SITE.title}` : `${SITE.title}, ${SITE.tagline}`;
|
|
24
28
|
const desc = description ?? SITE.description;
|
|
25
29
|
const ogImage = image ?? SITE.ogImage;
|
|
@@ -71,6 +75,7 @@ const brandCss = `html:root{${vars.join(';')}}`;
|
|
|
71
75
|
<title>{pageTitle}</title>
|
|
72
76
|
<meta name="description" content={desc} />
|
|
73
77
|
<meta property="og:site_name" content={SITE.title} />
|
|
78
|
+
<meta property="og:locale" content={ogLocale} />
|
|
74
79
|
<meta property="og:title" content={title ?? SITE.title} />
|
|
75
80
|
<meta property="og:description" content={desc} />
|
|
76
81
|
<meta property="og:url" content={canonical} />
|
|
@@ -81,17 +86,22 @@ const brandCss = `html:root{${vars.join(';')}}`;
|
|
|
81
86
|
{article?.authors?.map((a) => <meta property="article:author" content={a} />)}
|
|
82
87
|
{article?.tags?.map((t) => <meta property="article:tag" content={t} />)}
|
|
83
88
|
{ogImage && <meta property="og:image" content={new URL(ogImage, Astro.site ?? Astro.url)} />}
|
|
89
|
+
{ogImage && <meta property="og:image:alt" content={imageAlt ?? title ?? SITE.title} />}
|
|
84
90
|
<meta name="twitter:card" content={ogImage ? 'summary_large_image' : 'summary'} />
|
|
85
91
|
<link rel="icon" href={SITE.favicon ?? SITE.logo} />
|
|
86
92
|
<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)} />
|
|
93
|
+
{SITE.fontAwesome.includes("font-awesome/6.5.2/css/all.min.css") && <link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin />}
|
|
87
94
|
<link rel="stylesheet" href={SITE.fontAwesome} referrerpolicy="no-referrer"
|
|
88
95
|
integrity={SITE.fontAwesome.includes("font-awesome/6.5.2/css/all.min.css") ? "sha384-PPIZEGYM1v8zp5Py7UjFb79S58UeqCL9pYVnVPURKEqvioPROaVAJKKLzvH2rDnI" : undefined}
|
|
89
96
|
crossorigin={SITE.fontAwesome.includes("font-awesome/6.5.2/css/all.min.css") ? "anonymous" : undefined} />
|
|
90
97
|
{(SITE as Record<string, any>).customCSS?.map((href: string) => <link rel="stylesheet" href={href} />)}
|
|
98
|
+
{noindex && <meta name="robots" content="noindex,follow" />}
|
|
91
99
|
<style is:inline id="popular-brand" set:html={brandCss}></style>
|
|
100
|
+
<slot name="head" />
|
|
92
101
|
</head>
|
|
93
102
|
<body data-copy-label={STRINGS.copyCode} data-checklist-done={STRINGS.checklistDone}>
|
|
94
103
|
<a class="g-skip" href="#main">{STRINGS.skipToContent}</a>
|
|
104
|
+
<Notice />
|
|
95
105
|
<Header />
|
|
96
106
|
<main id="main">
|
|
97
107
|
<slot />
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** Event JSON-LD helpers, mirroring partials/jsonld-event.html.
|
|
2
|
+
* The time-parsing contract and its vectors live in PARITY.md; the Hugo and
|
|
3
|
+
* Astro implementations must agree on every vector. */
|
|
4
|
+
|
|
5
|
+
/** Parse an event `time` free-text field to event-local "HH:MM", or null.
|
|
6
|
+
* Leading token only: "6:00 PM · doors 5:30" -> "18:00"; "doors 5:30" -> null. */
|
|
7
|
+
export function parseEventTime(raw: string | undefined): string | null {
|
|
8
|
+
const m = (raw ?? '').trim().match(/^(\d{1,2}):(\d{2})\s*(am|pm)?/i);
|
|
9
|
+
if (!m) return null;
|
|
10
|
+
let h = parseInt(m[1], 10);
|
|
11
|
+
const min = parseInt(m[2], 10);
|
|
12
|
+
const mer = m[3]?.toLowerCase();
|
|
13
|
+
if (mer) {
|
|
14
|
+
if (h < 1 || h > 12) return null;
|
|
15
|
+
if (mer === 'pm' && h < 12) h += 12;
|
|
16
|
+
if (mer === 'am' && h === 12) h = 0;
|
|
17
|
+
} else if (h > 23) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
if (min > 59) return null;
|
|
21
|
+
return `${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** startDate = YYYY-MM-DD, or YYYY-MM-DDTHH:MM when a leading time parses. */
|
|
25
|
+
export function eventStartDate(date: Date, time: string | undefined): string {
|
|
26
|
+
const day = date.toISOString().slice(0, 10);
|
|
27
|
+
const hm = parseEventTime(time);
|
|
28
|
+
return hm ? `${day}T${hm}` : day;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Recursively key-sort so output matches Hugo's jsonify (sorted, no spaces). */
|
|
32
|
+
function sortKeys(v: any): any {
|
|
33
|
+
if (Array.isArray(v)) return v.map(sortKeys);
|
|
34
|
+
if (v && typeof v === 'object') {
|
|
35
|
+
return Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeys(v[k])]));
|
|
36
|
+
}
|
|
37
|
+
return v;
|
|
38
|
+
}
|
|
39
|
+
export function jsonld(obj: Record<string, any>): string {
|
|
40
|
+
return JSON.stringify(sortKeys(obj));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface EventLdInput {
|
|
44
|
+
title: string;
|
|
45
|
+
date: Date;
|
|
46
|
+
time?: string;
|
|
47
|
+
description?: string;
|
|
48
|
+
cancelled?: boolean;
|
|
49
|
+
online?: boolean;
|
|
50
|
+
price?: number | string;
|
|
51
|
+
currency?: string;
|
|
52
|
+
cost?: string;
|
|
53
|
+
image?: string;
|
|
54
|
+
rsvp?: string;
|
|
55
|
+
permalink: string;
|
|
56
|
+
siteUrl: string;
|
|
57
|
+
siteTitle: string;
|
|
58
|
+
defaultCurrency: string;
|
|
59
|
+
venue?: { name: string; address?: string };
|
|
60
|
+
performers?: string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function buildEventLd(i: EventLdInput): Record<string, any> {
|
|
64
|
+
const ld: Record<string, any> = {
|
|
65
|
+
'@context': 'https://schema.org',
|
|
66
|
+
'@type': 'Event',
|
|
67
|
+
name: i.title,
|
|
68
|
+
startDate: eventStartDate(i.date, i.time),
|
|
69
|
+
eventStatus: i.cancelled ? 'https://schema.org/EventCancelled' : 'https://schema.org/EventScheduled',
|
|
70
|
+
eventAttendanceMode: i.online
|
|
71
|
+
? 'https://schema.org/OnlineEventAttendanceMode'
|
|
72
|
+
: 'https://schema.org/OfflineEventAttendanceMode',
|
|
73
|
+
organizer: { '@type': 'Organization', name: i.siteTitle, url: i.siteUrl },
|
|
74
|
+
};
|
|
75
|
+
const desc = (i.description ?? '').trim();
|
|
76
|
+
if (desc) ld.description = desc.length > 300 ? desc.slice(0, 299) + '…' : desc;
|
|
77
|
+
if (i.online) {
|
|
78
|
+
ld.location = { '@type': 'VirtualLocation', url: i.rsvp || i.permalink };
|
|
79
|
+
} else if (i.venue) {
|
|
80
|
+
ld.location = { '@type': 'Place', name: i.venue.name };
|
|
81
|
+
if (i.venue.address) ld.location.address = i.venue.address;
|
|
82
|
+
}
|
|
83
|
+
if (i.image) ld.image = i.image;
|
|
84
|
+
if (i.performers && i.performers.length) {
|
|
85
|
+
ld.performer = i.performers.map((name) => ({ '@type': 'Person', name }));
|
|
86
|
+
}
|
|
87
|
+
if (i.price !== undefined && i.price !== '') {
|
|
88
|
+
ld.isAccessibleForFree = false;
|
|
89
|
+
ld.offers = { '@type': 'Offer', price: String(i.price), priceCurrency: i.currency || i.defaultCurrency, availability: 'https://schema.org/InStock', url: i.permalink };
|
|
90
|
+
} else if (!i.cost) {
|
|
91
|
+
ld.isAccessibleForFree = true;
|
|
92
|
+
ld.offers = { '@type': 'Offer', price: '0', priceCurrency: i.defaultCurrency, availability: 'https://schema.org/InStock', url: i.permalink };
|
|
93
|
+
}
|
|
94
|
+
return ld;
|
|
95
|
+
}
|