astro-theme-popular 0.4.0 → 0.6.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/index.mjs +23 -2
- package/package.json +3 -2
- package/schemas.ts +6 -0
- package/src/components/AuthorBox.astro +6 -5
- package/src/components/EventRow.astro +3 -1
- package/src/components/FAQ.astro +30 -0
- package/src/components/FAQItem.astro +10 -0
- package/src/components/OrganizerCard.astro +2 -2
- package/src/components/PostCard.astro +10 -5
- package/src/layouts/BaseLayout.astro +9 -1
- package/src/lib/eventld.ts +95 -0
- package/src/lib/jsonld.ts +48 -0
- package/src/lib/stats.ts +36 -0
- package/src/pages/{[slug].astro → [...slug].astro} +18 -5
- package/src/pages/blog/[slug].astro +17 -3
- package/src/pages/events/[slug].astro +26 -1
- package/src/pages/index.astro +21 -7
- package/src/pages/organizers/[slug].astro +29 -0
- package/src/pages/robots.txt.ts +10 -0
- package/src/pages/rss.xml.js +2 -2
- package/src/pages/tags/[tag]/[...page].astro +2 -2
- package/src/pages/venues/[slug].astro +1 -1
- package/src/styles/components.css +12 -0
package/index.mjs
CHANGED
|
@@ -13,6 +13,21 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import sitemap from '@astrojs/sitemap';
|
|
17
|
+
|
|
18
|
+
/* loading="lazy" decoding="async" on markdown images (CWV parity with the
|
|
19
|
+
Hugo render hook). Dependency-free HAST walk. */
|
|
20
|
+
function rehypeLazyImages() {
|
|
21
|
+
const walk = (node) => {
|
|
22
|
+
if (node.tagName === 'img') {
|
|
23
|
+
node.properties = node.properties || {};
|
|
24
|
+
if (!('loading' in node.properties)) node.properties.loading = 'lazy';
|
|
25
|
+
if (!('decoding' in node.properties)) node.properties.decoding = 'async';
|
|
26
|
+
}
|
|
27
|
+
(node.children || []).forEach(walk);
|
|
28
|
+
};
|
|
29
|
+
return (tree) => walk(tree);
|
|
30
|
+
}
|
|
16
31
|
|
|
17
32
|
/* Injected routes, grouped by opt-out key: popular({ routes: { speakers:
|
|
18
33
|
false } }) skips a group. Disabling is also the durable answer when your
|
|
@@ -21,7 +36,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
21
36
|
folder-organized content ids (2019-pycon-us/cooper-lees) resolve. */
|
|
22
37
|
const ROUTES = {
|
|
23
38
|
home: [['/', 'index.astro']],
|
|
24
|
-
pages: [['/[...slug]', '[slug].astro']],
|
|
39
|
+
pages: [['/[...slug]', '[...slug].astro']],
|
|
25
40
|
authors: [['/authors/[...slug]', 'authors/[slug].astro']],
|
|
26
41
|
blog: [
|
|
27
42
|
['/blog/[...page]', 'blog/[...page].astro'],
|
|
@@ -31,11 +46,15 @@ const ROUTES = {
|
|
|
31
46
|
['/events/[...page]', 'events/[...page].astro'],
|
|
32
47
|
['/events/[...slug]', 'events/[slug].astro'],
|
|
33
48
|
],
|
|
34
|
-
organizers: [
|
|
49
|
+
organizers: [
|
|
50
|
+
['/organizers/[...page]', 'organizers/[...page].astro'],
|
|
51
|
+
['/organizers/[...slug]', 'organizers/[slug].astro'],
|
|
52
|
+
],
|
|
35
53
|
speakers: [['/speakers/[...slug]', 'speakers/[slug].astro']],
|
|
36
54
|
venues: [['/venues/[...slug]', 'venues/[slug].astro']],
|
|
37
55
|
tags: [['/tags/[tag]/[...page]', 'tags/[tag]/[...page].astro']],
|
|
38
56
|
rss: [['/rss.xml', 'rss.xml.js']],
|
|
57
|
+
robots: [['/robots.txt', 'robots.txt.ts']],
|
|
39
58
|
};
|
|
40
59
|
|
|
41
60
|
export default function popular(options = {}) {
|
|
@@ -48,6 +67,8 @@ export default function popular(options = {}) {
|
|
|
48
67
|
const userConfig = path.resolve(root, configFile);
|
|
49
68
|
addWatchFile(userConfig);
|
|
50
69
|
updateConfig({
|
|
70
|
+
integrations: [sitemap()],
|
|
71
|
+
markdown: { rehypePlugins: [rehypeLazyImages] },
|
|
51
72
|
vite: {
|
|
52
73
|
plugins: [
|
|
53
74
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "astro-theme-popular",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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,11 @@ 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")
|
|
60
65
|
}),
|
|
61
66
|
});
|
|
62
67
|
|
|
@@ -131,6 +136,7 @@ const pages = defineCollection({
|
|
|
131
136
|
title: z.string(),
|
|
132
137
|
eyebrow: z.string().optional(),
|
|
133
138
|
lead: z.string().optional(),
|
|
139
|
+
shortTitle: z.string().optional(),
|
|
134
140
|
}),
|
|
135
141
|
});
|
|
136
142
|
|
|
@@ -10,25 +10,26 @@ interface Guest {
|
|
|
10
10
|
social?: { label: string; icon: string; url: string }[];
|
|
11
11
|
}
|
|
12
12
|
interface Props {
|
|
13
|
-
entry?: CollectionEntry<'authors'> | CollectionEntry<'speakers'>;
|
|
13
|
+
entry?: CollectionEntry<'authors'> | CollectionEntry<'speakers'> | CollectionEntry<'organizers'>;
|
|
14
14
|
guest?: Guest;
|
|
15
15
|
/** Profile URL prefix; speakers pass '/speakers/'. */
|
|
16
16
|
base?: string;
|
|
17
17
|
}
|
|
18
18
|
const { entry, guest, base = '/authors/' } = Astro.props;
|
|
19
19
|
const d = entry?.data;
|
|
20
|
+
const bio = d ? ((d as any).bio ?? (d as any).description) : undefined;
|
|
20
21
|
---
|
|
21
22
|
|
|
22
23
|
<div class="g-persona g-persona--primary">
|
|
23
24
|
{d && (
|
|
24
25
|
<>
|
|
25
|
-
{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" />}
|
|
26
27
|
<div>
|
|
27
28
|
<div class="g-persona__name"><a href={`${base}${entry.id}/`} style="color:inherit">{d.title}</a></div>
|
|
28
29
|
{d.role && <div class="g-persona__role">{d.role}</div>}
|
|
29
|
-
{
|
|
30
|
+
{bio && <p class="g-persona__bio">{bio}</p>}
|
|
30
31
|
<div class="g-persona__socials">
|
|
31
|
-
{d.website && <a href={d.website} aria-label={STRINGS.website} rel="noopener" target="_blank"><i class="fa-solid fa-globe" aria-hidden="true"></i></a>}
|
|
32
|
+
{(d as any).website && <a href={(d as any).website} aria-label={STRINGS.website} rel="noopener" target="_blank"><i class="fa-solid fa-globe" aria-hidden="true"></i></a>}
|
|
32
33
|
{d.social.map((s) => <a href={s.url} aria-label={s.label} rel="noopener" target="_blank"><i class={s.icon} aria-hidden="true"></i></a>)}
|
|
33
34
|
</div>
|
|
34
35
|
</div>
|
|
@@ -36,7 +37,7 @@ const d = entry?.data;
|
|
|
36
37
|
)}
|
|
37
38
|
{guest && (
|
|
38
39
|
<>
|
|
39
|
-
{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" />}
|
|
40
41
|
<div>
|
|
41
42
|
<div class="g-persona__name">{guest.name} <span class="g-badge g-badge--soft" style="vertical-align:middle">{STRINGS.guest}</span></div>
|
|
42
43
|
{guest.title && <div class="g-persona__role">{guest.title}</div>}
|
|
@@ -49,7 +49,9 @@ const day = d.date.toLocaleDateString(SITE.locale, { month: 'short', day: 'numer
|
|
|
49
49
|
)}
|
|
50
50
|
</div>
|
|
51
51
|
<div class="g-event__aside">
|
|
52
|
-
{
|
|
52
|
+
{d.cancelled
|
|
53
|
+
? <span class="g-badge g-badge--danger g-badge--soft">{STRINGS.eventCancelled}</span>
|
|
54
|
+
: tbd
|
|
53
55
|
? <span class="g-badge g-badge--warning g-badge--soft">{STRINGS.venueWanted}</span>
|
|
54
56
|
: <span class="g-badge g-badge--success g-badge--soft">{STRINGS.confirmed}</span>}
|
|
55
57
|
{d.rsvp && (
|
|
@@ -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>
|
|
@@ -7,8 +7,8 @@ const d = organizer.data;
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
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>
|
|
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
|
+
<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>}
|
|
14
14
|
{d.social.length > 0 && (
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
import { SITE } from 'popular:config';
|
|
2
|
+
import { SITE, SECTIONS_MAP } from 'popular:config';
|
|
3
3
|
import { tagSlug } from '../lib/slugs';
|
|
4
4
|
import type { CollectionEntry } from 'astro:content';
|
|
5
5
|
import { safeCollection } from '../lib/collections';
|
|
@@ -8,8 +8,11 @@ interface Props { post: CollectionEntry<'blog'> }
|
|
|
8
8
|
const { post } = Astro.props;
|
|
9
9
|
const d = post.data;
|
|
10
10
|
const href = `/blog/${post.id}/`;
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
// Mirror of the Hugo card's date guard: this component can render date-less
|
|
12
|
+
// content, so omit the date (and, below, the whole eyebrow) when absent.
|
|
13
|
+
const hasDate = d.date instanceof Date && !Number.isNaN(+d.date) && d.date.getFullYear() > 1;
|
|
14
|
+
const month = hasDate ? d.date.toLocaleDateString(SITE.locale, { month: 'short', year: 'numeric' }) : '';
|
|
15
|
+
const allAuthors = await safeCollection(SECTIONS_MAP.authors);
|
|
13
16
|
const names = [
|
|
14
17
|
...d.authors.map((slug) => allAuthors.find((a) => a.id === slug)?.data.title ?? slug),
|
|
15
18
|
...d.guestAuthors.map((g) => g.name),
|
|
@@ -19,10 +22,12 @@ if (!names.length && d.author) names.push(d.author);
|
|
|
19
22
|
|
|
20
23
|
<article class="g-card" data-tags={d.tags.join(',')}>
|
|
21
24
|
{d.image && (
|
|
22
|
-
<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>
|
|
23
26
|
)}
|
|
24
27
|
<div class="g-card__body">
|
|
25
|
-
|
|
28
|
+
{(month || names.length > 0) && (
|
|
29
|
+
<span class="g-card__eyebrow">{month}{month && names.length > 0 && ' · '}{names.length > 0 && names.join(', ')}</span>
|
|
30
|
+
)}
|
|
26
31
|
<h3 class="g-card__title"><a href={href}>{d.title}</a></h3>
|
|
27
32
|
{d.description && <div class="g-card__text">{d.description}</div>}
|
|
28
33
|
{d.tags.length > 0 && (
|
|
@@ -18,8 +18,11 @@ interface Props {
|
|
|
18
18
|
image?: string;
|
|
19
19
|
/** Set on blog posts: emits og:type=article plus article meta. */
|
|
20
20
|
article?: { published?: Date; modified?: Date; authors?: string[]; tags?: string[] };
|
|
21
|
+
imageAlt?: string;
|
|
22
|
+
noindex?: boolean;
|
|
21
23
|
}
|
|
22
|
-
const { title, description, image, article } = Astro.props;
|
|
24
|
+
const { title, description, image, article, imageAlt, noindex } = Astro.props;
|
|
25
|
+
const ogLocale = SITE.locale.replace('-', '_');
|
|
23
26
|
const pageTitle = title ? `${title} · ${SITE.title}` : `${SITE.title}, ${SITE.tagline}`;
|
|
24
27
|
const desc = description ?? SITE.description;
|
|
25
28
|
const ogImage = image ?? SITE.ogImage;
|
|
@@ -71,6 +74,7 @@ const brandCss = `html:root{${vars.join(';')}}`;
|
|
|
71
74
|
<title>{pageTitle}</title>
|
|
72
75
|
<meta name="description" content={desc} />
|
|
73
76
|
<meta property="og:site_name" content={SITE.title} />
|
|
77
|
+
<meta property="og:locale" content={ogLocale} />
|
|
74
78
|
<meta property="og:title" content={title ?? SITE.title} />
|
|
75
79
|
<meta property="og:description" content={desc} />
|
|
76
80
|
<meta property="og:url" content={canonical} />
|
|
@@ -81,14 +85,18 @@ const brandCss = `html:root{${vars.join(';')}}`;
|
|
|
81
85
|
{article?.authors?.map((a) => <meta property="article:author" content={a} />)}
|
|
82
86
|
{article?.tags?.map((t) => <meta property="article:tag" content={t} />)}
|
|
83
87
|
{ogImage && <meta property="og:image" content={new URL(ogImage, Astro.site ?? Astro.url)} />}
|
|
88
|
+
{ogImage && <meta property="og:image:alt" content={imageAlt ?? title ?? SITE.title} />}
|
|
84
89
|
<meta name="twitter:card" content={ogImage ? 'summary_large_image' : 'summary'} />
|
|
85
90
|
<link rel="icon" href={SITE.favicon ?? SITE.logo} />
|
|
86
91
|
<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)} />
|
|
92
|
+
{SITE.fontAwesome.includes("font-awesome/6.5.2/css/all.min.css") && <link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin />}
|
|
87
93
|
<link rel="stylesheet" href={SITE.fontAwesome} referrerpolicy="no-referrer"
|
|
88
94
|
integrity={SITE.fontAwesome.includes("font-awesome/6.5.2/css/all.min.css") ? "sha384-PPIZEGYM1v8zp5Py7UjFb79S58UeqCL9pYVnVPURKEqvioPROaVAJKKLzvH2rDnI" : undefined}
|
|
89
95
|
crossorigin={SITE.fontAwesome.includes("font-awesome/6.5.2/css/all.min.css") ? "anonymous" : undefined} />
|
|
90
96
|
{(SITE as Record<string, any>).customCSS?.map((href: string) => <link rel="stylesheet" href={href} />)}
|
|
97
|
+
{noindex && <meta name="robots" content="noindex,follow" />}
|
|
91
98
|
<style is:inline id="popular-brand" set:html={brandCss}></style>
|
|
99
|
+
<slot name="head" />
|
|
92
100
|
</head>
|
|
93
101
|
<body data-copy-label={STRINGS.copyCode} data-checklist-done={STRINGS.checklistDone}>
|
|
94
102
|
<a class="g-skip" href="#main">{STRINGS.skipToContent}</a>
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Shared JSON-LD helpers for Organization and BlogPosting, mirroring
|
|
2
|
+
* partials/jsonld-org.html and jsonld-blogpost.html. Keys are recursively
|
|
3
|
+
* sorted so the emitted markup byte-matches Hugo's jsonify. */
|
|
4
|
+
function sortKeys(v: any): any {
|
|
5
|
+
if (Array.isArray(v)) return v.map(sortKeys);
|
|
6
|
+
if (v && typeof v === 'object') {
|
|
7
|
+
return Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeys(v[k])]));
|
|
8
|
+
}
|
|
9
|
+
return v;
|
|
10
|
+
}
|
|
11
|
+
export function jsonld(obj: Record<string, any>): string {
|
|
12
|
+
return JSON.stringify(sortKeys(obj));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function buildOrgLd(i: {
|
|
16
|
+
name: string; url: string; logo?: string; description?: string; sameAs?: string[];
|
|
17
|
+
}): Record<string, any> {
|
|
18
|
+
const ld: Record<string, any> = {
|
|
19
|
+
'@context': 'https://schema.org', '@type': 'Organization', name: i.name, url: i.url,
|
|
20
|
+
};
|
|
21
|
+
if (i.logo) ld.logo = i.logo;
|
|
22
|
+
if (i.description) ld.description = i.description;
|
|
23
|
+
if (i.sameAs && i.sameAs.length) ld.sameAs = i.sameAs;
|
|
24
|
+
return ld;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function buildBlogPostingLd(i: {
|
|
28
|
+
title: string; datePublished: string; dateModified: string;
|
|
29
|
+
authors: string[]; image?: string; url: string; description?: string;
|
|
30
|
+
siteTitle: string; siteUrl: string;
|
|
31
|
+
}): Record<string, any> {
|
|
32
|
+
const publisher = { '@type': 'Organization', name: i.siteTitle, url: i.siteUrl };
|
|
33
|
+
const author = i.authors.length
|
|
34
|
+
? i.authors.map((name) => ({ '@type': 'Person', name }))
|
|
35
|
+
: [publisher];
|
|
36
|
+
const headline = i.title.length > 110 ? i.title.slice(0, 109) + '…' : i.title;
|
|
37
|
+
const ld: Record<string, any> = {
|
|
38
|
+
'@context': 'https://schema.org', '@type': 'BlogPosting', headline,
|
|
39
|
+
datePublished: i.datePublished, dateModified: i.dateModified, author,
|
|
40
|
+
url: i.url, publisher,
|
|
41
|
+
};
|
|
42
|
+
if (i.image) ld.image = i.image;
|
|
43
|
+
if (i.description) {
|
|
44
|
+
const d = i.description.trim();
|
|
45
|
+
ld.description = d.length > 300 ? d.slice(0, 299) + '…' : d;
|
|
46
|
+
}
|
|
47
|
+
return ld;
|
|
48
|
+
}
|
package/src/lib/stats.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Resolve a home-page stat value, mirroring partials/stat-value.html.
|
|
2
|
+
* Reusable from any page that shows a stats strip.
|
|
3
|
+
* - @pastEventCount past-event count, rounded ("70+")
|
|
4
|
+
* - @count:<section> live entry count of a collection
|
|
5
|
+
* - @count:<section>:rounded same, rounded down to "70+" at ten or more
|
|
6
|
+
* Any other value passes through unchanged. */
|
|
7
|
+
const round = (n: number) => (n >= 10 ? `${Math.floor(n / 10) * 10}+` : String(n));
|
|
8
|
+
|
|
9
|
+
export function formatStat(
|
|
10
|
+
value: string,
|
|
11
|
+
ctx: { pastCount: number; counts: Record<string, number> },
|
|
12
|
+
): string {
|
|
13
|
+
if (value === '@pastEventCount') return round(ctx.pastCount);
|
|
14
|
+
if (value.startsWith('@count:')) {
|
|
15
|
+
let rest = value.slice('@count:'.length);
|
|
16
|
+
let rounded = false;
|
|
17
|
+
if (rest.endsWith(':rounded')) {
|
|
18
|
+
rounded = true;
|
|
19
|
+
rest = rest.slice(0, -':rounded'.length);
|
|
20
|
+
}
|
|
21
|
+
const n = ctx.counts[rest] ?? 0;
|
|
22
|
+
return rounded ? round(n) : String(n);
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Collection names referenced by @count: values in a stats array. */
|
|
28
|
+
export function countSectionsIn(stats: { value: string }[]): string[] {
|
|
29
|
+
const names = new Set<string>();
|
|
30
|
+
for (const s of stats ?? []) {
|
|
31
|
+
if (s?.value?.startsWith('@count:')) {
|
|
32
|
+
names.add(s.value.slice('@count:'.length).replace(/:rounded$/, ''));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return [...names];
|
|
36
|
+
}
|
|
@@ -10,13 +10,23 @@ import PageHero from '../components/PageHero.astro';
|
|
|
10
10
|
export async function getStaticPaths() {
|
|
11
11
|
const pages = await safeCollection('pages');
|
|
12
12
|
const docs = await safeCollection('docs');
|
|
13
|
+
const byId = new Map(pages.map((p) => [p.id, p]));
|
|
13
14
|
return [
|
|
14
|
-
...pages.map((entry) =>
|
|
15
|
-
|
|
15
|
+
...pages.map((entry) => {
|
|
16
|
+
// Nested page (policies/conduct) links back to its parent segment;
|
|
17
|
+
// root pages have no parent. Label prefers the parent's shortTitle.
|
|
18
|
+
const cut = entry.id.lastIndexOf('/');
|
|
19
|
+
const parentEntry = cut > 0 ? byId.get(entry.id.slice(0, cut)) : undefined;
|
|
20
|
+
const parent = parentEntry
|
|
21
|
+
? { href: `/${parentEntry.id}/`, label: parentEntry.data.shortTitle ?? parentEntry.data.title }
|
|
22
|
+
: null;
|
|
23
|
+
return { params: { slug: entry.id }, props: { entry, kind: 'page', parent } };
|
|
24
|
+
}),
|
|
25
|
+
...docs.map((entry) => ({ params: { slug: entry.id }, props: { entry, kind: 'doc', parent: null } })),
|
|
16
26
|
];
|
|
17
27
|
}
|
|
18
28
|
|
|
19
|
-
const { entry, kind } = Astro.props;
|
|
29
|
+
const { entry, kind, parent } = Astro.props;
|
|
20
30
|
const d = entry.data;
|
|
21
31
|
const { Content, headings } = await render(entry);
|
|
22
32
|
/* Current page's h2/h3 links nest under its sidebar entry, in document
|
|
@@ -54,8 +64,11 @@ const allDocs = (await safeCollection('docs')).sort(
|
|
|
54
64
|
) : (
|
|
55
65
|
<section class="g-bg-white g-section">
|
|
56
66
|
<div class="g-container">
|
|
57
|
-
<div class="g-article
|
|
58
|
-
|
|
67
|
+
<div class="g-article">
|
|
68
|
+
{parent && (
|
|
69
|
+
<a href={parent.href} style="display:inline-flex;gap:.4rem;align-items:center;font-size:var(--text-sm);font-weight:var(--weight-medium);margin-bottom:1rem"><i class="fa-solid fa-arrow-left" aria-hidden="true"></i> {parent.label}</a>
|
|
70
|
+
)}
|
|
71
|
+
<div class="g-prose"><Content /></div>
|
|
59
72
|
</div>
|
|
60
73
|
</div>
|
|
61
74
|
</section>
|
|
@@ -3,7 +3,8 @@ import { render } from 'astro:content';
|
|
|
3
3
|
import { safeCollection } from '../../lib/collections';
|
|
4
4
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
5
5
|
import AuthorBox from '../../components/AuthorBox.astro';
|
|
6
|
-
import { SUPPORT, SITE, STRINGS } from 'popular:config';
|
|
6
|
+
import { SUPPORT, SITE, STRINGS, SECTIONS_MAP } from 'popular:config';
|
|
7
|
+
import { buildBlogPostingLd, jsonld } from '../../lib/jsonld';
|
|
7
8
|
import { tagSlug } from '../../lib/slugs';
|
|
8
9
|
|
|
9
10
|
export async function getStaticPaths() {
|
|
@@ -25,19 +26,32 @@ const d = post.data;
|
|
|
25
26
|
const { Content } = await render(post);
|
|
26
27
|
const dateStr = d.date.toLocaleDateString(SITE.locale, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
27
28
|
|
|
28
|
-
const allAuthors = await safeCollection(
|
|
29
|
+
const allAuthors = await safeCollection(SECTIONS_MAP.authors);
|
|
29
30
|
const resolved = d.authors.map((slug) => allAuthors.find((a) => a.id === slug)).filter(Boolean);
|
|
30
31
|
const bylineNames = [
|
|
31
32
|
...d.authors.map((slug) => allAuthors.find((a) => a.id === slug)?.data.title ?? slug),
|
|
32
33
|
...d.guestAuthors.map((g) => g.name),
|
|
33
34
|
];
|
|
34
35
|
if (!bylineNames.length && d.author) bylineNames.push(d.author);
|
|
36
|
+
const siteUrl = new URL('/', Astro.site ?? Astro.url).href;
|
|
37
|
+
const postLd = jsonld(buildBlogPostingLd({
|
|
38
|
+
title: d.title,
|
|
39
|
+
datePublished: d.date.toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
|
40
|
+
dateModified: d.date.toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
|
41
|
+
authors: bylineNames,
|
|
42
|
+
image: d.image ? new URL(d.image, Astro.site ?? Astro.url).href : undefined,
|
|
43
|
+
url: new URL(`/blog/${post.id}/`, Astro.site ?? Astro.url).href,
|
|
44
|
+
description: d.description,
|
|
45
|
+
siteTitle: SITE.title,
|
|
46
|
+
siteUrl,
|
|
47
|
+
}));
|
|
35
48
|
const byline = bylineNames.length > 1
|
|
36
49
|
? bylineNames.slice(0, -1).join(STRINGS.listSeparator) + STRINGS.listSeparatorFinal + bylineNames[bylineNames.length - 1]
|
|
37
50
|
: bylineNames.join('');
|
|
38
51
|
---
|
|
39
52
|
|
|
40
53
|
<BaseLayout title={d.title} description={d.description} image={d.image} article={{ published: d.date, authors: bylineNames, tags: d.tags }}>
|
|
54
|
+
<script type="application/ld+json" slot="head" set:html={postLd} />
|
|
41
55
|
<article>
|
|
42
56
|
<header class="g-pagehero">
|
|
43
57
|
<div class="g-container" style="padding-block:var(--space-6) var(--space-5)">
|
|
@@ -84,7 +98,7 @@ const byline = bylineNames.length > 1
|
|
|
84
98
|
<>
|
|
85
99
|
<h2 style="font-size:var(--text-xl);margin:var(--space-5) 0 1rem">About the author{resolved.length + d.guestAuthors.length > 1 ? 's' : ''}</h2>
|
|
86
100
|
<div class="g-stack" style="gap:var(--space-3)">
|
|
87
|
-
{resolved.map((a) => <AuthorBox entry={a} />)}
|
|
101
|
+
{resolved.map((a) => <AuthorBox entry={a} base={`/${SECTIONS_MAP.authors}/`} />)}
|
|
88
102
|
{d.guestAuthors.map((g) => <AuthorBox guest={g} />)}
|
|
89
103
|
</div>
|
|
90
104
|
</>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { SITE, STRINGS } from 'popular:config';
|
|
3
|
+
import { buildEventLd, jsonld } from '../../lib/eventld';
|
|
3
4
|
import { render } from 'astro:content';
|
|
4
5
|
import { safeCollection } from '../../lib/collections';
|
|
5
6
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
@@ -28,9 +29,33 @@ const speakerEntries = d.speakers.map((slug) => {
|
|
|
28
29
|
if (!entry) throw new Error(`events/${event.id}: speaker "${slug}" matches no speaker page`);
|
|
29
30
|
return entry;
|
|
30
31
|
});
|
|
32
|
+
|
|
33
|
+
/* schema.org/Event JSON-LD (see lib/eventld; contract in PARITY.md). */
|
|
34
|
+
const siteUrl = new URL('/', Astro.site ?? Astro.url).href;
|
|
35
|
+
const permalink = new URL(`/events/${event.id}/`, Astro.site ?? Astro.url).href;
|
|
36
|
+
const eventLd = jsonld(buildEventLd({
|
|
37
|
+
title: d.title,
|
|
38
|
+
date: d.date,
|
|
39
|
+
time: d.time,
|
|
40
|
+
description: d.description,
|
|
41
|
+
cancelled: (d as any).cancelled,
|
|
42
|
+
online: (d as any).online,
|
|
43
|
+
price: (d as any).price,
|
|
44
|
+
currency: (d as any).currency,
|
|
45
|
+
cost: (d as any).cost,
|
|
46
|
+
image: d.image ? new URL(d.image, Astro.site ?? Astro.url).href : undefined,
|
|
47
|
+
rsvp: d.rsvp,
|
|
48
|
+
permalink,
|
|
49
|
+
siteUrl,
|
|
50
|
+
siteTitle: SITE.title,
|
|
51
|
+
defaultCurrency: (SITE as Record<string, any>).seo?.currency ?? 'USD',
|
|
52
|
+
venue: venuePage ? { name: venuePage.data.title, address: venuePage.data.address } : undefined,
|
|
53
|
+
performers: speakerEntries.map((s) => s.data.title),
|
|
54
|
+
}));
|
|
31
55
|
---
|
|
32
56
|
|
|
33
57
|
<BaseLayout title={d.title} description={d.description} image={d.image}>
|
|
58
|
+
<script type="application/ld+json" slot="head" set:html={eventLd} />
|
|
34
59
|
<article>
|
|
35
60
|
<header class="g-pagehero">
|
|
36
61
|
<div class="g-container" style="padding-block:var(--space-6) var(--space-5)">
|
|
@@ -40,7 +65,7 @@ const speakerEntries = d.speakers.map((slug) => {
|
|
|
40
65
|
</a>
|
|
41
66
|
</div>
|
|
42
67
|
<span class="g-eyebrow">{dateStr}{d.time && ` · ${d.time}`}</span>
|
|
43
|
-
<h1 style="margin:.5rem 0 0">{d.title}</h1>
|
|
68
|
+
<h1 style="margin:.5rem 0 0">{d.title}{d.cancelled && <> <span class="g-badge g-badge--danger" style="vertical-align:middle;font-size:var(--text-sm)">{STRINGS.eventCancelled}</span></>}</h1>
|
|
44
69
|
</div>
|
|
45
70
|
</header>
|
|
46
71
|
<div class="g-container g-section--sm">
|
package/src/pages/index.astro
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { safeCollection } from '../lib/collections';
|
|
3
|
+
import { formatStat, countSectionsIn } from '../lib/stats';
|
|
3
4
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
|
4
5
|
import PostCard from '../components/PostCard.astro';
|
|
5
6
|
import OrganizerCard from '../components/OrganizerCard.astro';
|
|
6
|
-
import { HOME, SITE, STRINGS } from 'popular:config';
|
|
7
|
+
import { HOME, SITE, STRINGS, SECTIONS_MAP, SOCIAL } from 'popular:config';
|
|
8
|
+
import { buildOrgLd, jsonld } from '../lib/jsonld';
|
|
7
9
|
|
|
8
10
|
const now = new Date();
|
|
9
11
|
const events = (await safeCollection('events')).filter((e) => !e.data.draft);
|
|
@@ -11,18 +13,30 @@ const upcoming = events.filter((e) => e.data.date >= now).sort((a, b) => +a.data
|
|
|
11
13
|
const next = upcoming[0];
|
|
12
14
|
|
|
13
15
|
const posts = (await safeCollection('blog')).filter((p) => !p.data.draft).sort((a, b) => +b.data.date - +a.data.date).slice(0, 3);
|
|
14
|
-
/*
|
|
15
|
-
|
|
16
|
+
/* Stat values: @pastEventCount and @count:<section>[:rounded] resolve to
|
|
17
|
+
live numbers (Hugo parity, see lib/stats). Draft entries are excluded to
|
|
18
|
+
match Hugo's RegularPages. */
|
|
16
19
|
const pastCount = events.filter((e) => e.data.date < now).length;
|
|
17
|
-
const
|
|
18
|
-
|
|
20
|
+
const counts: Record<string, number> = {};
|
|
21
|
+
for (const name of countSectionsIn(HOME.stats ?? [])) {
|
|
22
|
+
counts[name] = (await safeCollection(name)).filter((e) => !(e.data as any)?.draft).length;
|
|
23
|
+
}
|
|
24
|
+
const statValue = (v: string) => formatStat(v, { pastCount, counts });
|
|
19
25
|
const nextVenue = next?.data.venueRef ? (await safeCollection('venues')).find((v) => v.id === next.data.venueRef)?.data.title : undefined;
|
|
20
|
-
const organizers = (await safeCollection(
|
|
26
|
+
const organizers = (await safeCollection(SECTIONS_MAP.team)).sort((a, b) => a.data.weight - b.data.weight);
|
|
21
27
|
|
|
22
28
|
const h = HOME;
|
|
29
|
+
const orgLd = jsonld(buildOrgLd({
|
|
30
|
+
name: SITE.title,
|
|
31
|
+
url: new URL('/', Astro.site ?? Astro.url).href,
|
|
32
|
+
logo: SITE.logo ? new URL(SITE.logo, Astro.site ?? Astro.url).href : undefined,
|
|
33
|
+
description: SITE.description,
|
|
34
|
+
sameAs: SOCIAL.map((s) => s.url),
|
|
35
|
+
}));
|
|
23
36
|
---
|
|
24
37
|
|
|
25
38
|
<BaseLayout>
|
|
39
|
+
<script type="application/ld+json" slot="head" set:html={orgLd} />
|
|
26
40
|
{h.hero && (
|
|
27
41
|
<section class="g-hero">
|
|
28
42
|
<div class="g-container g-hero__grid">
|
|
@@ -130,7 +144,7 @@ const h = HOME;
|
|
|
130
144
|
{organizers.slice(0, 3).map((o) => <OrganizerCard organizer={o} />)}
|
|
131
145
|
</div>
|
|
132
146
|
<div class="g-row" style="justify-content:center;margin-top:var(--space-4)">
|
|
133
|
-
<a class="g-btn g-btn--outline" href=
|
|
147
|
+
<a class="g-btn g-btn--outline" href={`/${SECTIONS_MAP.team}/`}>All organizers</a>
|
|
134
148
|
</div>
|
|
135
149
|
</div>
|
|
136
150
|
</section>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { STRINGS } from 'popular:config';
|
|
3
|
+
import { render } from 'astro:content';
|
|
4
|
+
import { safeCollection } from '../../lib/collections';
|
|
5
|
+
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
6
|
+
import PageHero from '../../components/PageHero.astro';
|
|
7
|
+
import AuthorBox from '../../components/AuthorBox.astro';
|
|
8
|
+
|
|
9
|
+
export async function getStaticPaths() {
|
|
10
|
+
const organizers = await safeCollection('organizers');
|
|
11
|
+
return organizers.map((entry) => ({ params: { slug: entry.id }, props: { entry } }));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const { entry } = Astro.props;
|
|
15
|
+
const d = entry.data;
|
|
16
|
+
const { Content } = await render(entry);
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
<BaseLayout title={d.title} description={d.description}>
|
|
20
|
+
<PageHero eyebrow={d.role ?? STRINGS.eyebrowOrganizer} title={d.title} lead={d.description} />
|
|
21
|
+
<section class="g-bg-white g-section">
|
|
22
|
+
<div class="g-container">
|
|
23
|
+
<div class="g-article">
|
|
24
|
+
<AuthorBox entry={entry} base="/organizers/" />
|
|
25
|
+
<Content />
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
</section>
|
|
29
|
+
</BaseLayout>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
|
|
3
|
+
/* Generated so the Sitemap URL respects `site` + base path. @astrojs/sitemap
|
|
4
|
+
emits sitemap-index.xml. */
|
|
5
|
+
export const GET: APIRoute = ({ site }) => {
|
|
6
|
+
const sitemap = new URL('sitemap-index.xml', site ?? 'https://example.com').href;
|
|
7
|
+
return new Response(`User-agent: *\nAllow: /\nSitemap: ${sitemap}\n`, {
|
|
8
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
9
|
+
});
|
|
10
|
+
};
|
package/src/pages/rss.xml.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import rss from '@astrojs/rss';
|
|
2
2
|
import { safeCollection } from '../lib/collections';
|
|
3
|
-
import { SITE } from 'popular:config';
|
|
3
|
+
import { SITE, SECTIONS_MAP } from 'popular:config';
|
|
4
4
|
|
|
5
5
|
const escapeXml = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@ export async function GET(context) {
|
|
|
8
8
|
const posts = (await safeCollection('blog'))
|
|
9
9
|
.filter((p) => !p.data.draft)
|
|
10
10
|
.sort((a, b) => +b.data.date - +a.data.date);
|
|
11
|
-
const allAuthors = await safeCollection(
|
|
11
|
+
const allAuthors = await safeCollection(SECTIONS_MAP.authors);
|
|
12
12
|
return rss({
|
|
13
13
|
title: SITE.title,
|
|
14
14
|
description: SITE.description,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
import { STRINGS, PAGINATION } from 'popular:config';
|
|
2
|
+
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';
|
|
@@ -34,7 +34,7 @@ const { tag } = Astro.params;
|
|
|
34
34
|
const { label, current, total, count, posts } = Astro.props;
|
|
35
35
|
---
|
|
36
36
|
|
|
37
|
-
<BaseLayout title={`#${label}`}>
|
|
37
|
+
<BaseLayout title={`#${label}`} noindex={(SITE as Record<string, any>).seo?.noindexTaxonomies ?? false}>
|
|
38
38
|
<PageHero eyebrow={STRINGS.eyebrowTag} title={`#${label}`} lead={STRINGS.taggedCount.replace('{n}', String(count)).replace('{tag}', label)} />
|
|
39
39
|
<section class="g-bg-white g-section">
|
|
40
40
|
<div class="g-container">
|
|
@@ -24,7 +24,7 @@ const events = (await safeCollection('events'))
|
|
|
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)">
|
|
27
|
-
{d.photo && <img src={d.photo} alt={d.title} style="width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:var(--radius-xl);box-shadow:var(--shadow-md);margin-bottom:var(--space-4)" />}
|
|
27
|
+
{d.photo && <img src={d.photo} alt={d.title} style="width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:var(--radius-xl);box-shadow:var(--shadow-md);margin-bottom:var(--space-4)" loading="lazy" decoding="async" />}
|
|
28
28
|
<div class="g-meta-line" style="margin-bottom:var(--space-3)">
|
|
29
29
|
{d.address && <span><i class="fa-solid fa-location-dot" aria-hidden="true"></i> {d.address}</span>}
|
|
30
30
|
{d.website && <span><i class="fa-solid fa-globe" aria-hidden="true"></i> <a href={d.website} rel="noopener" target="_blank">{STRINGS.website}</a></span>}
|
|
@@ -49,6 +49,8 @@
|
|
|
49
49
|
.g-badge--success.g-badge--soft { background: #e6f3ec; color: #0f6e44; }
|
|
50
50
|
.g-badge--warning { background: var(--amber-500); color: #142a36; }
|
|
51
51
|
.g-badge--warning.g-badge--soft { background: #fff7e0; color: #946a00; }
|
|
52
|
+
.g-badge--danger { background: #c8102e; color: #fff; }
|
|
53
|
+
.g-badge--danger.g-badge--soft { background: #fde7ea; color: #a01023; }
|
|
52
54
|
.g-badge--ink { background: var(--ink-800); }
|
|
53
55
|
|
|
54
56
|
.g-tag {
|
|
@@ -148,6 +150,16 @@ a.g-pager__num:hover, .g-pager__nav:hover { border-color: var(--color-primary);
|
|
|
148
150
|
.g-org__role { font-size: var(--text-sm); color: var(--color-primary); font-weight: var(--weight-medium); }
|
|
149
151
|
.g-org__bio { margin: 0.5rem 0 0; font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-normal); }
|
|
150
152
|
|
|
153
|
+
/* ---------- FAQ (native <details>, zero JS) ---------- */
|
|
154
|
+
.g-faq { display: flex; flex-direction: column; gap: 0.6rem; margin: 1.2rem 0; }
|
|
155
|
+
.g-faq__item { border: var(--border-width) solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface-card); overflow: hidden; }
|
|
156
|
+
.g-faq__q { cursor: pointer; padding: var(--space-3) var(--space-4); font-weight: var(--weight-semibold); color: var(--text-strong); list-style: none; display: flex; justify-content: space-between; align-items: center; gap: var(--space-3); }
|
|
157
|
+
.g-faq__q::-webkit-details-marker { display: none; }
|
|
158
|
+
.g-faq__q::after { content: "\f078"; font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 0.75em; color: var(--text-muted); transition: transform 0.2s; }
|
|
159
|
+
.g-faq__item[open] .g-faq__q::after { transform: rotate(180deg); }
|
|
160
|
+
.g-faq__a { padding: 0 var(--space-4) var(--space-3); }
|
|
161
|
+
.g-faq__a > :first-child { margin-top: 0; }
|
|
162
|
+
|
|
151
163
|
/* ---------- Post navigation (older/newer neighbors on blog singles) ---------- */
|
|
152
164
|
.g-postnav { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3); margin-top: var(--space-5); }
|
|
153
165
|
.g-postnav__card { display: flex; flex-direction: column; gap: 0.35rem; padding: var(--space-3); border: var(--border-width) solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface-card); }
|