astro-theme-popular 0.3.0 → 0.5.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 CHANGED
@@ -21,6 +21,13 @@ export default defineConfig({
21
21
  });
22
22
  ```
23
23
 
24
+ Disable any injected route group when your site replaces it (also the
25
+ answer when you provide your own `/` or `/rss.xml`):
26
+
27
+ ```js
28
+ popular({ routes: { speakers: false, venues: false } })
29
+ ```
30
+
24
31
  Your site keeps its own content (`src/content/`) and one typed config file
25
32
  (`popular.config.ts`); the theme injects the routes, layouts, styles and
26
33
  behavior. Adopt the content model in one line:
package/index.mjs CHANGED
@@ -14,20 +14,32 @@
14
14
  import path from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
16
 
17
- const PAGES = [
18
- ['/', 'index.astro'],
19
- ['/[slug]', '[slug].astro'],
20
- ['/authors/[slug]', 'authors/[slug].astro'],
21
- ['/blog/[...page]', 'blog/[...page].astro'],
22
- ['/blog/[slug]', 'blog/[slug].astro'],
23
- ['/events/[...page]', 'events/[...page].astro'],
24
- ['/events/[slug]', 'events/[slug].astro'],
25
- ['/organizers/[...page]', 'organizers/[...page].astro'],
26
- ['/speakers/[slug]', 'speakers/[slug].astro'],
27
- ['/venues/[slug]', 'venues/[slug].astro'],
28
- ['/tags/[tag]/[...page]', 'tags/[tag]/[...page].astro'],
29
- ['/rss.xml', 'rss.xml.js'],
30
- ];
17
+ /* Injected routes, grouped by opt-out key: popular({ routes: { speakers:
18
+ false } }) skips a group. Disabling is also the durable answer when your
19
+ site provides its own page at a theme path (`/`, `/rss.xml`): Astro is
20
+ deprecating silent route collisions. Slug routes use rest params so
21
+ folder-organized content ids (2019-pycon-us/cooper-lees) resolve. */
22
+ const ROUTES = {
23
+ home: [['/', 'index.astro']],
24
+ pages: [['/[...slug]', '[...slug].astro']],
25
+ authors: [['/authors/[...slug]', 'authors/[slug].astro']],
26
+ blog: [
27
+ ['/blog/[...page]', 'blog/[...page].astro'],
28
+ ['/blog/[...slug]', 'blog/[slug].astro'],
29
+ ],
30
+ events: [
31
+ ['/events/[...page]', 'events/[...page].astro'],
32
+ ['/events/[...slug]', 'events/[slug].astro'],
33
+ ],
34
+ organizers: [
35
+ ['/organizers/[...page]', 'organizers/[...page].astro'],
36
+ ['/organizers/[...slug]', 'organizers/[slug].astro'],
37
+ ],
38
+ speakers: [['/speakers/[...slug]', 'speakers/[slug].astro']],
39
+ venues: [['/venues/[...slug]', 'venues/[slug].astro']],
40
+ tags: [['/tags/[tag]/[...page]', 'tags/[tag]/[...page].astro']],
41
+ rss: [['/rss.xml', 'rss.xml.js']],
42
+ };
31
43
 
32
44
  export default function popular(options = {}) {
33
45
  const configFile = options.configFile ?? './popular.config.ts';
@@ -68,12 +80,15 @@ export default function popular(options = {}) {
68
80
  'copy-code',
69
81
  ].map((s) => `import 'astro-theme-popular/scripts/${s}.js';`).join('\n'),
70
82
  );
71
- for (const [pattern, entry] of PAGES) {
72
- injectRoute({
73
- pattern,
74
- entrypoint: `astro-theme-popular/pages/${entry}`,
75
- prerender: true,
76
- });
83
+ for (const [key, routes] of Object.entries(ROUTES)) {
84
+ if (options.routes?.[key] === false) continue;
85
+ for (const [pattern, entry] of routes) {
86
+ injectRoute({
87
+ pattern,
88
+ entrypoint: `astro-theme-popular/pages/${entry}`,
89
+ prerender: true,
90
+ });
91
+ }
77
92
  }
78
93
  },
79
94
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-theme-popular",
3
- "version": "0.3.0",
3
+ "version": "0.5.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",
package/schemas.ts CHANGED
@@ -131,6 +131,7 @@ const pages = defineCollection({
131
131
  title: z.string(),
132
132
  eyebrow: z.string().optional(),
133
133
  lead: z.string().optional(),
134
+ shortTitle: z.string().optional(),
134
135
  }),
135
136
  });
136
137
 
@@ -10,13 +10,14 @@ 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">
@@ -26,9 +27,9 @@ const d = entry?.data;
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
- {d.bio && <p class="g-persona__bio">{d.bio}</p>}
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>
@@ -1,21 +1,21 @@
1
1
  ---
2
2
  import { SITE, STRINGS } from 'popular:config';
3
3
  import type { CollectionEntry } from 'astro:content';
4
- import { getCollection } from 'astro:content';
4
+ import { safeCollection } from '../lib/collections';
5
5
 
6
6
  interface Props { event: CollectionEntry<'events'> }
7
7
  const { event } = Astro.props;
8
8
  const d = event.data;
9
9
 
10
10
  /* Venue: a venueRef to the venues collection wins over the flat fields. */
11
- const venuePage = d.venueRef ? (await getCollection('venues')).find((v) => v.id === d.venueRef) : undefined;
11
+ const venuePage = d.venueRef ? (await safeCollection('venues')).find((v) => v.id === d.venueRef) : undefined;
12
12
  if (d.venueRef && !venuePage) console.warn(`events/${event.id}: venueRef "${d.venueRef}" matches no venue page`);
13
13
  const venueName = venuePage?.data.title ?? d.venue;
14
14
  const venueAddress = venuePage?.data.address ?? (venuePage ? undefined : d.address);
15
15
  const tbd = d.venueWanted || !venueName;
16
16
 
17
17
  /* Speaker line: resolved speaker-profile names, falling back to the string. */
18
- const allSpeakers = d.speakers.length ? await getCollection('speakers') : [];
18
+ const allSpeakers = d.speakers.length ? await safeCollection('speakers') : [];
19
19
  const names = d.speakers.map((slug) => {
20
20
  const found = allSpeakers.find((s) => s.id === slug);
21
21
  if (!found) console.warn(`events/${event.id}: speaker "${slug}" matches no speaker page`);
@@ -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" /></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,15 +1,18 @@
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
- import { getCollection } from 'astro:content';
5
+ import { safeCollection } from '../lib/collections';
6
6
 
7
7
  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
- const month = d.date.toLocaleDateString(SITE.locale, { month: 'short', year: 'numeric' });
12
- const allAuthors = await getCollection('authors');
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),
@@ -22,7 +25,9 @@ if (!names.length && d.author) names.push(d.author);
22
25
  <a href={href} class="g-card__media"><img src={d.image} alt={d.title} loading="lazy" /></a>
23
26
  )}
24
27
  <div class="g-card__body">
25
- <span class="g-card__eyebrow">{month}{names.length > 0 && ` · ${names.join(', ')}`}</span>
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 && (
@@ -0,0 +1,14 @@
1
+ import { getCollection } from 'astro:content';
2
+
3
+ /* Collection access that tolerates an adopter who never defined (or has no
4
+ entries in) a collection: returns [] instead of undefined. Sites that
5
+ replace part of the content model should also disable the matching
6
+ injected route (popular({ routes: { venues: false } })), which silences
7
+ Astro's missing-collection warning entirely. */
8
+ export async function safeCollection(name: string): Promise<any[]> {
9
+ try {
10
+ return ((await getCollection(name as any)) as any[]) ?? [];
11
+ } catch {
12
+ return [];
13
+ }
14
+ }
@@ -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
+ }
@@ -2,26 +2,37 @@
2
2
  import { STRINGS } from 'popular:config';
3
3
  /** Root-level content pages: prose pages (about, code-of-conduct, get-involved)
4
4
  * and docs pages (handbook, runbooks, rendered with a scroll-spy TOC). */
5
- import { getCollection, render } from 'astro:content';
5
+ import { render } from 'astro:content';
6
+ import { safeCollection } from '../lib/collections';
6
7
  import BaseLayout from '../layouts/BaseLayout.astro';
7
8
  import PageHero from '../components/PageHero.astro';
8
9
 
9
10
  export async function getStaticPaths() {
10
- const pages = await getCollection('pages');
11
- const docs = await getCollection('docs');
11
+ const pages = await safeCollection('pages');
12
+ const docs = await safeCollection('docs');
13
+ const byId = new Map(pages.map((p) => [p.id, p]));
12
14
  return [
13
- ...pages.map((entry) => ({ params: { slug: entry.id }, props: { entry, kind: 'page' } })),
14
- ...docs.map((entry) => ({ params: { slug: entry.id }, props: { entry, kind: 'doc' } })),
15
+ ...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 } })),
15
26
  ];
16
27
  }
17
28
 
18
- const { entry, kind } = Astro.props;
29
+ const { entry, kind, parent } = Astro.props;
19
30
  const d = entry.data;
20
31
  const { Content, headings } = await render(entry);
21
32
  /* Current page's h2/h3 links nest under its sidebar entry, in document
22
33
  order, mirroring the Hugo theme's docs/single.html. */
23
34
  const sections = headings.filter((h) => h.depth === 2 || h.depth === 3);
24
- const allDocs = (await getCollection('docs')).sort(
35
+ const allDocs = (await safeCollection('docs')).sort(
25
36
  (a, b) => (a.data.weight ?? 100) - (b.data.weight ?? 100)
26
37
  );
27
38
  ---
@@ -53,8 +64,11 @@ const allDocs = (await getCollection('docs')).sort(
53
64
  ) : (
54
65
  <section class="g-bg-white g-section">
55
66
  <div class="g-container">
56
- <div class="g-article g-prose">
57
- <Content />
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>
58
72
  </div>
59
73
  </div>
60
74
  </section>
@@ -1,19 +1,20 @@
1
1
  ---
2
2
  import { STRINGS } from 'popular:config';
3
- import { getCollection, render } from 'astro:content';
3
+ import { render } from 'astro:content';
4
+ import { safeCollection } from '../../lib/collections';
4
5
  import BaseLayout from '../../layouts/BaseLayout.astro';
5
6
  import PageHero from '../../components/PageHero.astro';
6
7
  import AuthorBox from '../../components/AuthorBox.astro';
7
8
  import PostCard from '../../components/PostCard.astro';
8
9
 
9
10
  export async function getStaticPaths() {
10
- const authors = await getCollection('authors');
11
+ const authors = await safeCollection('authors');
11
12
  return authors.map((entry) => ({ params: { slug: entry.id }, props: { entry } }));
12
13
  }
13
14
 
14
15
  const { entry } = Astro.props;
15
16
  const { Content } = await render(entry);
16
- const posts = (await getCollection('blog'))
17
+ const posts = (await safeCollection('blog'))
17
18
  .filter((p) => !p.data.draft && p.data.authors.includes(entry.id))
18
19
  .sort((a, b) => +b.data.date - +a.data.date);
19
20
  ---
@@ -1,5 +1,5 @@
1
1
  ---
2
- import { getCollection } from 'astro:content';
2
+ import { safeCollection } from '../../lib/collections';
3
3
  import BaseLayout from '../../layouts/BaseLayout.astro';
4
4
  import PageHero from '../../components/PageHero.astro';
5
5
  import PostCard from '../../components/PostCard.astro';
@@ -10,7 +10,7 @@ import { SECTIONS, STRINGS, PAGINATION } from 'popular:config';
10
10
  paginator URLs. /blog/<slug> still goes to [slug].astro: named params
11
11
  take priority over rest params. */
12
12
  export async function getStaticPaths() {
13
- const posts = (await getCollection('blog')).filter((p) => !p.data.draft).sort((a, b) => +b.data.date - +a.data.date);
13
+ const posts = (await safeCollection('blog')).filter((p) => !p.data.draft).sort((a, b) => +b.data.date - +a.data.date);
14
14
  const size = PAGINATION.pageSize;
15
15
  const total = Math.max(1, Math.ceil(posts.length / size));
16
16
  const tags = [...new Set(posts.flatMap((p) => p.data.tags))];
@@ -1,12 +1,13 @@
1
1
  ---
2
- import { getCollection, render } from 'astro:content';
2
+ import { render } from 'astro:content';
3
+ import { safeCollection } from '../../lib/collections';
3
4
  import BaseLayout from '../../layouts/BaseLayout.astro';
4
5
  import AuthorBox from '../../components/AuthorBox.astro';
5
- import { SUPPORT, SITE, STRINGS } from 'popular:config';
6
+ import { SUPPORT, SITE, STRINGS, SECTIONS_MAP } from 'popular:config';
6
7
  import { tagSlug } from '../../lib/slugs';
7
8
 
8
9
  export async function getStaticPaths() {
9
- const posts = (await getCollection('blog')).filter((p) => !p.data.draft);
10
+ const posts = (await safeCollection('blog')).filter((p) => !p.data.draft);
10
11
  /* PARITY.md pins the ordering: date desc, then title, then id, matching
11
12
  Hugo's section order (posts must not set weight). next = newer. */
12
13
  const cmp = (x: string, y: string) => (x < y ? -1 : x > y ? 1 : 0);
@@ -24,7 +25,7 @@ const d = post.data;
24
25
  const { Content } = await render(post);
25
26
  const dateStr = d.date.toLocaleDateString(SITE.locale, { month: 'short', day: 'numeric', year: 'numeric' });
26
27
 
27
- const allAuthors = await getCollection('authors');
28
+ const allAuthors = await safeCollection(SECTIONS_MAP.authors);
28
29
  const resolved = d.authors.map((slug) => allAuthors.find((a) => a.id === slug)).filter(Boolean);
29
30
  const bylineNames = [
30
31
  ...d.authors.map((slug) => allAuthors.find((a) => a.id === slug)?.data.title ?? slug),
@@ -83,7 +84,7 @@ const byline = bylineNames.length > 1
83
84
  <>
84
85
  <h2 style="font-size:var(--text-xl);margin:var(--space-5) 0 1rem">About the author{resolved.length + d.guestAuthors.length > 1 ? 's' : ''}</h2>
85
86
  <div class="g-stack" style="gap:var(--space-3)">
86
- {resolved.map((a) => <AuthorBox entry={a} />)}
87
+ {resolved.map((a) => <AuthorBox entry={a} base={`/${SECTIONS_MAP.authors}/`} />)}
87
88
  {d.guestAuthors.map((g) => <AuthorBox guest={g} />)}
88
89
  </div>
89
90
  </>
@@ -1,5 +1,5 @@
1
1
  ---
2
- import { getCollection } from 'astro:content';
2
+ import { safeCollection } from '../../lib/collections';
3
3
  import BaseLayout from '../../layouts/BaseLayout.astro';
4
4
  import PageHero from '../../components/PageHero.astro';
5
5
  import EventRow from '../../components/EventRow.astro';
@@ -11,7 +11,7 @@ import { SECTIONS, STRINGS, PAGINATION } from 'popular:config';
11
11
  full, mirroring layouts/events/list.html on the Hugo side. */
12
12
  export async function getStaticPaths() {
13
13
  const now = new Date();
14
- const all = (await getCollection('events')).filter((e) => !e.data.draft);
14
+ const all = (await safeCollection('events')).filter((e) => !e.data.draft);
15
15
  const upcoming = all.filter((e) => e.data.date >= now).sort((a, b) => +a.data.date - +b.data.date);
16
16
  const past = all.filter((e) => e.data.date < now).sort((a, b) => +b.data.date - +a.data.date);
17
17
  const size = PAGINATION.pageSize;
@@ -1,11 +1,12 @@
1
1
  ---
2
2
  import { SITE, STRINGS } from 'popular:config';
3
- import { getCollection, render } from 'astro:content';
3
+ import { render } from 'astro:content';
4
+ import { safeCollection } from '../../lib/collections';
4
5
  import BaseLayout from '../../layouts/BaseLayout.astro';
5
6
  import AuthorBox from '../../components/AuthorBox.astro';
6
7
 
7
8
  export async function getStaticPaths() {
8
- const events = (await getCollection('events')).filter((e) => !e.data.draft);
9
+ const events = (await safeCollection('events')).filter((e) => !e.data.draft);
9
10
  return events.map((event) => ({ params: { slug: event.id }, props: { event } }));
10
11
  }
11
12
 
@@ -16,12 +17,12 @@ const dateStr = d.date.toLocaleDateString(SITE.locale, { weekday: 'long', month:
16
17
 
17
18
  /* Venue: a venueRef to the venues collection wins over the flat fields;
18
19
  its arrival notes are inherited unless the event sets venueNotes. */
19
- const venuePage = d.venueRef ? (await getCollection('venues')).find((v) => v.id === d.venueRef) : undefined;
20
+ const venuePage = d.venueRef ? (await safeCollection('venues')).find((v) => v.id === d.venueRef) : undefined;
20
21
  if (d.venueRef && !venuePage) throw new Error(`events/${event.id}: venueRef "${d.venueRef}" matches no venue page`);
21
22
  const venueNotes = d.venueNotes ?? venuePage?.data.notes;
22
23
 
23
24
  /* Speaker profile cards, resolved from the speakers collection. */
24
- const allSpeakers = await getCollection('speakers');
25
+ const allSpeakers = await safeCollection('speakers');
25
26
  const speakerEntries = d.speakers.map((slug) => {
26
27
  const entry = allSpeakers.find((s) => s.id === slug);
27
28
  if (!entry) throw new Error(`events/${event.id}: speaker "${slug}" matches no speaker page`);
@@ -1,23 +1,28 @@
1
1
  ---
2
- import { getCollection } from 'astro:content';
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 } from 'popular:config';
7
8
 
8
9
  const now = new Date();
9
- const events = (await getCollection('events')).filter((e) => !e.data.draft);
10
+ const events = (await safeCollection('events')).filter((e) => !e.data.draft);
10
11
  const upcoming = events.filter((e) => e.data.date >= now).sort((a, b) => +a.data.date - +b.data.date);
11
12
  const next = upcoming[0];
12
13
 
13
- const posts = (await getCollection('blog')).filter((p) => !p.data.draft).sort((a, b) => +b.data.date - +a.data.date).slice(0, 3);
14
- /* value = "@pastEventCount" renders the live number of past events,
15
- rounded down to a "70+" style once there are ten or more (Hugo parity). */
14
+ const posts = (await safeCollection('blog')).filter((p) => !p.data.draft).sort((a, b) => +b.data.date - +a.data.date).slice(0, 3);
15
+ /* Stat values: @pastEventCount and @count:<section>[:rounded] resolve to
16
+ live numbers (Hugo parity, see lib/stats). Draft entries are excluded to
17
+ match Hugo's RegularPages. */
16
18
  const pastCount = events.filter((e) => e.data.date < now).length;
17
- const statValue = (v: string) =>
18
- v === '@pastEventCount' ? (pastCount >= 10 ? `${Math.floor(pastCount / 10) * 10}+` : String(pastCount)) : v;
19
- const nextVenue = next?.data.venueRef ? (await getCollection('venues')).find((v) => v.id === next.data.venueRef)?.data.title : undefined;
20
- const organizers = (await getCollection('organizers')).sort((a, b) => a.data.weight - b.data.weight);
19
+ const counts: Record<string, number> = {};
20
+ for (const name of countSectionsIn(HOME.stats ?? [])) {
21
+ counts[name] = (await safeCollection(name)).filter((e) => !(e.data as any)?.draft).length;
22
+ }
23
+ const statValue = (v: string) => formatStat(v, { pastCount, counts });
24
+ const nextVenue = next?.data.venueRef ? (await safeCollection('venues')).find((v) => v.id === next.data.venueRef)?.data.title : undefined;
25
+ const organizers = (await safeCollection(SECTIONS_MAP.team)).sort((a, b) => a.data.weight - b.data.weight);
21
26
 
22
27
  const h = HOME;
23
28
  ---
@@ -130,7 +135,7 @@ const h = HOME;
130
135
  {organizers.slice(0, 3).map((o) => <OrganizerCard organizer={o} />)}
131
136
  </div>
132
137
  <div class="g-row" style="justify-content:center;margin-top:var(--space-4)">
133
- <a class="g-btn g-btn--outline" href="/organizers/">All organizers</a>
138
+ <a class="g-btn g-btn--outline" href={`/${SECTIONS_MAP.team}/`}>All organizers</a>
134
139
  </div>
135
140
  </div>
136
141
  </section>
@@ -1,5 +1,5 @@
1
1
  ---
2
- import { getCollection } from 'astro:content';
2
+ import { safeCollection } from '../../lib/collections';
3
3
  import BaseLayout from '../../layouts/BaseLayout.astro';
4
4
  import PageHero from '../../components/PageHero.astro';
5
5
  import OrganizerCard from '../../components/OrganizerCard.astro';
@@ -9,7 +9,7 @@ import { SECTIONS, PAGINATION } from 'popular:config';
9
9
  /* One route serves /organizers/ and /organizers/page/N/, matching the Hugo
10
10
  theme's paginator URLs. */
11
11
  export async function getStaticPaths() {
12
- const organizers = (await getCollection('organizers')).sort((a, b) => a.data.weight - b.data.weight);
12
+ const organizers = (await safeCollection('organizers')).sort((a, b) => a.data.weight - b.data.weight);
13
13
  const size = PAGINATION.pageSize;
14
14
  const total = Math.max(1, Math.ceil(organizers.length / size));
15
15
  return Array.from({ length: total }, (_, i) => ({
@@ -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>
@@ -1,14 +1,14 @@
1
1
  import rss from '@astrojs/rss';
2
- import { getCollection } from 'astro:content';
3
- import { SITE } from 'popular:config';
2
+ import { safeCollection } from '../lib/collections';
3
+ import { SITE, SECTIONS_MAP } from 'popular:config';
4
4
 
5
5
  const escapeXml = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
6
6
 
7
7
  export async function GET(context) {
8
- const posts = (await getCollection('blog'))
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 getCollection('authors');
11
+ const allAuthors = await safeCollection(SECTIONS_MAP.authors);
12
12
  return rss({
13
13
  title: SITE.title,
14
14
  description: SITE.description,
@@ -1,19 +1,20 @@
1
1
  ---
2
2
  import { STRINGS } from 'popular:config';
3
- import { getCollection, render } from 'astro:content';
3
+ import { render } from 'astro:content';
4
+ import { safeCollection } from '../../lib/collections';
4
5
  import BaseLayout from '../../layouts/BaseLayout.astro';
5
6
  import PageHero from '../../components/PageHero.astro';
6
7
  import AuthorBox from '../../components/AuthorBox.astro';
7
8
  import EventRow from '../../components/EventRow.astro';
8
9
 
9
10
  export async function getStaticPaths() {
10
- const speakers = await getCollection('speakers');
11
+ const speakers = await safeCollection('speakers');
11
12
  return speakers.map((entry) => ({ params: { slug: entry.id }, props: { entry } }));
12
13
  }
13
14
 
14
15
  const { entry } = Astro.props;
15
16
  const { Content } = await render(entry);
16
- const events = (await getCollection('events'))
17
+ const events = (await safeCollection('events'))
17
18
  .filter((e) => !e.data.draft && e.data.speakers.includes(entry.id))
18
19
  .sort((a, b) => +b.data.date - +a.data.date);
19
20
  ---
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  import { STRINGS, PAGINATION } from 'popular:config';
3
3
  import { tagSlug } from '../../../lib/slugs';
4
- import { getCollection } from 'astro:content';
4
+ import { safeCollection } from '../../../lib/collections';
5
5
  import BaseLayout from '../../../layouts/BaseLayout.astro';
6
6
  import PageHero from '../../../components/PageHero.astro';
7
7
  import PostCard from '../../../components/PostCard.astro';
@@ -11,7 +11,7 @@ import Pagination from '../../../components/Pagination.astro';
11
11
  theme's paginated term pages. URLs use the Hugo-compatible slug; the page
12
12
  displays the tag's first-seen spelling. */
13
13
  export async function getStaticPaths() {
14
- const posts = (await getCollection('blog')).filter((p) => !p.data.draft);
14
+ const posts = (await safeCollection('blog')).filter((p) => !p.data.draft);
15
15
  const labels = new Map(); // slug -> first-seen tag spelling
16
16
  for (const p of posts) for (const t of p.data.tags) {
17
17
  const slug = tagSlug(t);
@@ -1,19 +1,20 @@
1
1
  ---
2
2
  import { STRINGS } from 'popular:config';
3
- import { getCollection, render } from 'astro:content';
3
+ import { render } from 'astro:content';
4
+ import { safeCollection } from '../../lib/collections';
4
5
  import BaseLayout from '../../layouts/BaseLayout.astro';
5
6
  import PageHero from '../../components/PageHero.astro';
6
7
  import EventRow from '../../components/EventRow.astro';
7
8
 
8
9
  export async function getStaticPaths() {
9
- const venues = await getCollection('venues');
10
+ const venues = await safeCollection('venues');
10
11
  return venues.map((entry) => ({ params: { slug: entry.id }, props: { entry } }));
11
12
  }
12
13
 
13
14
  const { entry } = Astro.props;
14
15
  const d = entry.data;
15
16
  const { Content } = await render(entry);
16
- const events = (await getCollection('events'))
17
+ const events = (await safeCollection('events'))
17
18
  .filter((e) => !e.data.draft && e.data.venueRef === entry.id)
18
19
  .sort((a, b) => +b.data.date - +a.data.date);
19
20
  ---