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/src/lib/ical.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** iCalendar feed helpers, mirroring the Hugo events/list.calendar.ics
|
|
2
|
+
* template. DTSTART reuses parseEventTime (lib/eventld) so the feed and the
|
|
3
|
+
* Event JSON-LD agree. Folding is byte-accurate at 75 octets. */
|
|
4
|
+
import { parseEventTime } from './eventld';
|
|
5
|
+
|
|
6
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
7
|
+
const enc = new TextEncoder();
|
|
8
|
+
|
|
9
|
+
function ymd(d: Date): string {
|
|
10
|
+
return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function icsEscape(s: string): string {
|
|
14
|
+
return s.replace(/\\/g, '\\\\').replace(/;/g, '\;').replace(/,/g, '\\,').replace(/\n/g, '\\n');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** One content line, CRLF-terminated, folded at 75 octets (continuations
|
|
18
|
+
* begin with a space, which counts toward the limit). */
|
|
19
|
+
export function fold(line: string): string {
|
|
20
|
+
if (enc.encode(line).length <= 75) return line + '\r\n';
|
|
21
|
+
let out = '';
|
|
22
|
+
let cur = '';
|
|
23
|
+
let curBytes = 0;
|
|
24
|
+
let first = true;
|
|
25
|
+
for (const ch of line) {
|
|
26
|
+
const cb = enc.encode(ch).length;
|
|
27
|
+
const limit = first ? 75 : 74;
|
|
28
|
+
if (curBytes + cb > limit) {
|
|
29
|
+
out += (first ? '' : ' ') + cur + '\r\n';
|
|
30
|
+
first = false;
|
|
31
|
+
cur = ch;
|
|
32
|
+
curBytes = cb;
|
|
33
|
+
} else {
|
|
34
|
+
cur += ch;
|
|
35
|
+
curBytes += cb;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
out += (first ? '' : ' ') + cur + '\r\n';
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface IcsEvent {
|
|
43
|
+
title: string;
|
|
44
|
+
date: Date;
|
|
45
|
+
time?: string;
|
|
46
|
+
description?: string;
|
|
47
|
+
checkin?: string;
|
|
48
|
+
cancelled?: boolean;
|
|
49
|
+
venue?: { name: string; address?: string };
|
|
50
|
+
permalink: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function buildIcs(
|
|
54
|
+
events: IcsEvent[],
|
|
55
|
+
opts: { siteTitle: string; dtstamp: string; durationMinutes: number },
|
|
56
|
+
): string {
|
|
57
|
+
const L: string[] = [];
|
|
58
|
+
L.push(fold('BEGIN:VCALENDAR'));
|
|
59
|
+
L.push(fold('VERSION:2.0'));
|
|
60
|
+
L.push(fold(`PRODID:-//${opts.siteTitle}//Popular theme//EN`));
|
|
61
|
+
L.push(fold('CALSCALE:GREGORIAN'));
|
|
62
|
+
L.push(fold('METHOD:PUBLISH'));
|
|
63
|
+
L.push(fold(`X-WR-CALNAME:${opts.siteTitle} events`));
|
|
64
|
+
for (const e of events) {
|
|
65
|
+
L.push(fold('BEGIN:VEVENT'));
|
|
66
|
+
L.push(fold(`UID:${e.permalink}`));
|
|
67
|
+
L.push(fold(`DTSTAMP:${opts.dtstamp}`));
|
|
68
|
+
const hm = parseEventTime(e.time);
|
|
69
|
+
if (hm) {
|
|
70
|
+
const [h, m] = hm.split(':').map(Number);
|
|
71
|
+
L.push(fold(`DTSTART:${ymd(e.date)}T${pad(h)}${pad(m)}00`));
|
|
72
|
+
const total = h * 60 + m + opts.durationMinutes;
|
|
73
|
+
const end = new Date(Date.UTC(e.date.getUTCFullYear(), e.date.getUTCMonth(), e.date.getUTCDate()) + total * 60000);
|
|
74
|
+
L.push(fold(`DTEND:${ymd(end)}T${pad(end.getUTCHours())}${pad(end.getUTCMinutes())}00`));
|
|
75
|
+
} else {
|
|
76
|
+
L.push(fold(`DTSTART;VALUE=DATE:${ymd(e.date)}`));
|
|
77
|
+
}
|
|
78
|
+
L.push(fold(`SUMMARY:${icsEscape(e.title)}`));
|
|
79
|
+
if (e.venue) {
|
|
80
|
+
const loc = e.venue.address ? `${e.venue.name}, ${e.venue.address}` : e.venue.name;
|
|
81
|
+
L.push(fold(`LOCATION:${icsEscape(loc)}`));
|
|
82
|
+
}
|
|
83
|
+
let desc = (e.description ?? '').trim();
|
|
84
|
+
if (e.checkin) desc = `${desc}\n${e.checkin}`;
|
|
85
|
+
desc = `${desc}\n${e.permalink}`;
|
|
86
|
+
L.push(fold(`DESCRIPTION:${icsEscape(desc)}`));
|
|
87
|
+
L.push(fold(`URL:${e.permalink}`));
|
|
88
|
+
L.push(fold(`STATUS:${e.cancelled ? 'CANCELLED' : 'CONFIRMED'}`));
|
|
89
|
+
L.push(fold('END:VEVENT'));
|
|
90
|
+
}
|
|
91
|
+
L.push(fold('END:VCALENDAR'));
|
|
92
|
+
return L.join('');
|
|
93
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Minimal inline-markdown renderer for short, author-controlled config
|
|
2
|
+
* strings (e.g. the notice banner, §5). Parity with Hugo's inline
|
|
3
|
+
* `.RenderString`: supports links, bold, emphasis, and inline code, and
|
|
4
|
+
* HTML-escapes everything else. Not a full Markdown parser; block syntax
|
|
5
|
+
* (headings, lists, images) is intentionally out of scope. */
|
|
6
|
+
const escapeHtml = (s: string): string =>
|
|
7
|
+
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
8
|
+
|
|
9
|
+
export function inlineMarkdown(src: string): string {
|
|
10
|
+
let s = escapeHtml(src);
|
|
11
|
+
// [label](http(s)://… | /path) — only http(s) and root-relative targets
|
|
12
|
+
s = s.replace(
|
|
13
|
+
/\[([^\]]+)\]\((https?:\/\/[^\s)]+|\/[^\s)]*)\)/g,
|
|
14
|
+
(_m, label, href) =>
|
|
15
|
+
`<a href="${href}"${href.startsWith('/') ? '' : ' rel="noopener" target="_blank"'}>${label}</a>`,
|
|
16
|
+
);
|
|
17
|
+
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
18
|
+
s = s.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
|
|
19
|
+
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
20
|
+
return s;
|
|
21
|
+
}
|
|
@@ -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/talks.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** §3 talk archive helpers, shared by the event page, the event row, and the
|
|
2
|
+
* /talks/ index. A "talk" is either an entry in an event's talks[] array or,
|
|
3
|
+
* for single-talk meetups, the event itself when it carries event-level
|
|
4
|
+
* recording/slides. When talks[] is present it wins; event-level
|
|
5
|
+
* recording/slides are ignored (not merged), matching the Hugo side. */
|
|
6
|
+
import type { CollectionEntry } from 'astro:content';
|
|
7
|
+
|
|
8
|
+
type EventData = CollectionEntry<'events'>['data'];
|
|
9
|
+
|
|
10
|
+
export interface Talk {
|
|
11
|
+
title: string;
|
|
12
|
+
speakerSlug?: string; // resolved speaker-profile slug, for linking
|
|
13
|
+
speakerName?: string; // display name (profile title, or the free-text speaker)
|
|
14
|
+
recording?: string;
|
|
15
|
+
slides?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TalkRow extends Talk {
|
|
19
|
+
eventId: string;
|
|
20
|
+
eventTitle: string;
|
|
21
|
+
date: Date;
|
|
22
|
+
tags: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** True if an event has any recording (event-level or in a talk), for the
|
|
26
|
+
* browsing cue on past-event rows. */
|
|
27
|
+
export function eventHasRecording(d: EventData): boolean {
|
|
28
|
+
const talks = (d as { talks?: { recording?: string }[] }).talks;
|
|
29
|
+
if (talks?.length) return talks.some((t) => !!t.recording);
|
|
30
|
+
return !!(d as { recording?: string }).recording;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The talks for one event, normalized. speakers is the resolved collection so
|
|
34
|
+
* a talk's speaker slug (or the event's first speaker, in the simple case)
|
|
35
|
+
* becomes a linkable name; an unresolved slug warns and renders name-less. */
|
|
36
|
+
export function eventTalks(
|
|
37
|
+
d: EventData,
|
|
38
|
+
speakers: CollectionEntry<'speakers'>[],
|
|
39
|
+
eventId: string,
|
|
40
|
+
): Talk[] {
|
|
41
|
+
const nameOf = (slug?: string): string | undefined => {
|
|
42
|
+
if (!slug) return undefined;
|
|
43
|
+
const found = speakers.find((s) => s.id === slug);
|
|
44
|
+
if (!found) console.warn(`events/${eventId}: talk speaker "${slug}" matches no speaker page`);
|
|
45
|
+
return found?.data.title;
|
|
46
|
+
};
|
|
47
|
+
const anyD = d as EventData & {
|
|
48
|
+
talks?: Talk[]; recording?: string; slides?: string;
|
|
49
|
+
};
|
|
50
|
+
if (anyD.talks?.length) {
|
|
51
|
+
return anyD.talks.map((t) => ({
|
|
52
|
+
title: t.title,
|
|
53
|
+
speakerSlug: nameOf(t.speaker) ? t.speaker : undefined,
|
|
54
|
+
speakerName: nameOf(t.speaker),
|
|
55
|
+
recording: t.recording,
|
|
56
|
+
slides: t.slides,
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
if (anyD.recording || anyD.slides) {
|
|
60
|
+
const slug = d.speakers[0];
|
|
61
|
+
const name = nameOf(slug) ?? d.speaker;
|
|
62
|
+
return [{
|
|
63
|
+
title: d.title,
|
|
64
|
+
speakerSlug: nameOf(slug) ? slug : undefined,
|
|
65
|
+
speakerName: name,
|
|
66
|
+
recording: anyD.recording,
|
|
67
|
+
slides: anyD.slides,
|
|
68
|
+
}];
|
|
69
|
+
}
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Every talk across the given events, newest event first, for the /talks/
|
|
74
|
+
* index. Each row carries its parent event's link, date, and tags (the tags
|
|
75
|
+
* drive the shared blog-filter.js tag filter). */
|
|
76
|
+
export function collectTalks(
|
|
77
|
+
events: CollectionEntry<'events'>[],
|
|
78
|
+
speakers: CollectionEntry<'speakers'>[],
|
|
79
|
+
): TalkRow[] {
|
|
80
|
+
const rows: TalkRow[] = [];
|
|
81
|
+
for (const e of [...events].sort((a, b) => +b.data.date - +a.data.date)) {
|
|
82
|
+
for (const t of eventTalks(e.data, speakers, e.id)) {
|
|
83
|
+
rows.push({ ...t, eventId: e.id, eventTitle: e.data.title, date: e.data.date, tags: e.data.tags });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return rows;
|
|
87
|
+
}
|
|
@@ -5,7 +5,7 @@ import { STRINGS } from 'popular:config';
|
|
|
5
5
|
import { render } from 'astro:content';
|
|
6
6
|
import { safeCollection } from '../lib/collections';
|
|
7
7
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
|
8
|
-
import PageHero from '
|
|
8
|
+
import PageHero from 'popular:component/PageHero';
|
|
9
9
|
|
|
10
10
|
export async function getStaticPaths() {
|
|
11
11
|
const pages = await safeCollection('pages');
|
|
@@ -3,9 +3,9 @@ import { STRINGS } from 'popular:config';
|
|
|
3
3
|
import { render } from 'astro:content';
|
|
4
4
|
import { safeCollection } from '../../lib/collections';
|
|
5
5
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
6
|
-
import PageHero from '
|
|
7
|
-
import AuthorBox from '
|
|
8
|
-
import PostCard from '
|
|
6
|
+
import PageHero from 'popular:component/PageHero';
|
|
7
|
+
import AuthorBox from 'popular:component/AuthorBox';
|
|
8
|
+
import PostCard from 'popular:component/PostCard';
|
|
9
9
|
|
|
10
10
|
export async function getStaticPaths() {
|
|
11
11
|
const authors = await safeCollection('authors');
|
|
@@ -20,7 +20,7 @@ const posts = (await safeCollection('blog'))
|
|
|
20
20
|
---
|
|
21
21
|
|
|
22
22
|
<BaseLayout title={entry.data.title} description={entry.data.bio}>
|
|
23
|
-
<PageHero eyebrow={STRINGS.eyebrowAuthor} title={entry.data.title}
|
|
23
|
+
<PageHero eyebrow={STRINGS.eyebrowAuthor} title={entry.data.title} />
|
|
24
24
|
<section class="g-bg-white g-section">
|
|
25
25
|
<div class="g-container">
|
|
26
26
|
<div class="g-article" style="margin-bottom:var(--space-5)">
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { safeCollection } from '../../lib/collections';
|
|
3
3
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
4
|
-
import PageHero from '
|
|
5
|
-
import PostCard from '
|
|
4
|
+
import PageHero from 'popular:component/PageHero';
|
|
5
|
+
import PostCard from 'popular:component/PostCard';
|
|
6
6
|
import Pagination from '../../components/Pagination.astro';
|
|
7
7
|
import { SECTIONS, STRINGS, PAGINATION } from 'popular:config';
|
|
8
8
|
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
import { render } from 'astro:content';
|
|
3
3
|
import { safeCollection } from '../../lib/collections';
|
|
4
4
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
5
|
-
import AuthorBox from '
|
|
5
|
+
import AuthorBox from 'popular:component/AuthorBox';
|
|
6
6
|
import { SUPPORT, SITE, STRINGS, SECTIONS_MAP } from 'popular:config';
|
|
7
|
+
import { buildBlogPostingLd, jsonld } from '../../lib/jsonld';
|
|
7
8
|
import { tagSlug } from '../../lib/slugs';
|
|
8
9
|
|
|
9
10
|
export async function getStaticPaths() {
|
|
@@ -32,12 +33,25 @@ const bylineNames = [
|
|
|
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)">
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { safeCollection } from '../../lib/collections';
|
|
3
3
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
4
|
-
import PageHero from '
|
|
5
|
-
import EventRow from '
|
|
4
|
+
import PageHero from 'popular:component/PageHero';
|
|
5
|
+
import EventRow from 'popular:component/EventRow';
|
|
6
6
|
import Pagination from '../../components/Pagination.astro';
|
|
7
|
+
import EventsEmpty from '../../components/EventsEmpty.astro';
|
|
7
8
|
import { SECTIONS, STRINGS, PAGINATION } from 'popular:config';
|
|
8
9
|
|
|
9
10
|
/* One route serves /events/ and /events/page/N/, matching the Hugo theme's
|
|
@@ -52,6 +53,7 @@ const groupByYear = (events: EventEntry[]) => {
|
|
|
52
53
|
<span class="g-eyebrow">{STRINGS.comingUp}</span>
|
|
53
54
|
<h2>{STRINGS.upcomingEvents}</h2>
|
|
54
55
|
<p class="g-lead">{STRINGS.upcomingLead}</p>
|
|
56
|
+
<div class="g-row" style="margin-top:var(--space-3)"><a class="g-btn g-btn--outline g-btn--sm" href="/events/calendar.ics"><i class="fa-solid fa-calendar-plus" aria-hidden="true"></i> {STRINGS.subscribeCalendar}</a></div>
|
|
55
57
|
</div>
|
|
56
58
|
<div class="g-stack" style="margin-top:var(--space-4);gap:var(--space-3)">
|
|
57
59
|
{groupByYear(upcoming).map((group) => (
|
|
@@ -60,9 +62,7 @@ const groupByYear = (events: EventEntry[]) => {
|
|
|
60
62
|
{group.events.map((e) => <EventRow event={e} />)}
|
|
61
63
|
</>
|
|
62
64
|
))}
|
|
63
|
-
{upcoming.length === 0 &&
|
|
64
|
-
<p class="g-lead">{STRINGS.noUpcomingEvents}</p>
|
|
65
|
-
)}
|
|
65
|
+
{upcoming.length === 0 && <EventsEmpty />}
|
|
66
66
|
</div>
|
|
67
67
|
</div>
|
|
68
68
|
</section>
|
|
@@ -1,9 +1,11 @@
|
|
|
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';
|
|
6
|
-
import AuthorBox from '
|
|
7
|
+
import AuthorBox from 'popular:component/AuthorBox';
|
|
8
|
+
import { eventTalks } from '../../lib/talks';
|
|
7
9
|
|
|
8
10
|
export async function getStaticPaths() {
|
|
9
11
|
const events = (await safeCollection('events')).filter((e) => !e.data.draft);
|
|
@@ -28,9 +30,38 @@ const speakerEntries = d.speakers.map((slug) => {
|
|
|
28
30
|
if (!entry) throw new Error(`events/${event.id}: speaker "${slug}" matches no speaker page`);
|
|
29
31
|
return entry;
|
|
30
32
|
});
|
|
33
|
+
|
|
34
|
+
/* §3 talks: multi-talk events render a Talks section; single-talk events
|
|
35
|
+
render recording/slides links inline in the meta block. */
|
|
36
|
+
const talks = eventTalks(d, allSpeakers, event.id);
|
|
37
|
+
const hasTalksArray = !!(d.talks && d.talks.length > 0);
|
|
38
|
+
|
|
39
|
+
/* schema.org/Event JSON-LD (see lib/eventld; contract in PARITY.md). */
|
|
40
|
+
const siteUrl = new URL('/', Astro.site ?? Astro.url).href;
|
|
41
|
+
const permalink = new URL(`/events/${event.id}/`, Astro.site ?? Astro.url).href;
|
|
42
|
+
const eventLd = jsonld(buildEventLd({
|
|
43
|
+
title: d.title,
|
|
44
|
+
date: d.date,
|
|
45
|
+
time: d.time,
|
|
46
|
+
description: d.description,
|
|
47
|
+
cancelled: (d as any).cancelled,
|
|
48
|
+
online: (d as any).online,
|
|
49
|
+
price: (d as any).price,
|
|
50
|
+
currency: (d as any).currency,
|
|
51
|
+
cost: (d as any).cost,
|
|
52
|
+
image: d.image ? new URL(d.image, Astro.site ?? Astro.url).href : undefined,
|
|
53
|
+
rsvp: d.rsvp,
|
|
54
|
+
permalink,
|
|
55
|
+
siteUrl,
|
|
56
|
+
siteTitle: SITE.title,
|
|
57
|
+
defaultCurrency: (SITE as Record<string, any>).seo?.currency ?? 'USD',
|
|
58
|
+
venue: venuePage ? { name: venuePage.data.title, address: venuePage.data.address } : undefined,
|
|
59
|
+
performers: speakerEntries.map((s) => s.data.title),
|
|
60
|
+
}));
|
|
31
61
|
---
|
|
32
62
|
|
|
33
63
|
<BaseLayout title={d.title} description={d.description} image={d.image}>
|
|
64
|
+
<script type="application/ld+json" slot="head" set:html={eventLd} />
|
|
34
65
|
<article>
|
|
35
66
|
<header class="g-pagehero">
|
|
36
67
|
<div class="g-container" style="padding-block:var(--space-6) var(--space-5)">
|
|
@@ -40,7 +71,7 @@ const speakerEntries = d.speakers.map((slug) => {
|
|
|
40
71
|
</a>
|
|
41
72
|
</div>
|
|
42
73
|
<span class="g-eyebrow">{dateStr}{d.time && ` · ${d.time}`}</span>
|
|
43
|
-
<h1 style="margin:.5rem 0 0">{d.title}</h1>
|
|
74
|
+
<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
75
|
</div>
|
|
45
76
|
</header>
|
|
46
77
|
<div class="g-container g-section--sm">
|
|
@@ -49,7 +80,11 @@ const speakerEntries = d.speakers.map((slug) => {
|
|
|
49
80
|
<span><i class="fa-solid fa-location-dot" aria-hidden="true"></i> {venuePage
|
|
50
81
|
? <><a href={`/venues/${venuePage.id}/`}>{venuePage.data.title}</a>{venuePage.data.address && ` · ${venuePage.data.address}`}</>
|
|
51
82
|
: <>{d.venue ?? STRINGS.venueTba}{d.address && ` · ${d.address}`}</>}</span>
|
|
83
|
+
{venuePage?.data.wheelchair && <span><span class="g-badge g-badge--success g-badge--soft"><i class="fa-solid fa-wheelchair" aria-hidden="true"></i> {STRINGS.wheelchairAccessible}</span></span>}
|
|
84
|
+
{venuePage && (venuePage.data.transit || venuePage.data.parking || venuePage.data.access) && <span><a href={`/venues/${venuePage.id}/`}>{STRINGS.gettingThere}</a></span>}
|
|
52
85
|
{d.speaker && <span><i class="fa-solid fa-microphone-lines" aria-hidden="true"></i> {d.speaker}</span>}
|
|
86
|
+
{!hasTalksArray && d.recording && <span><a href={d.recording} rel="noopener" target="_blank"><i class="fa-solid fa-circle-play" aria-hidden="true"></i> {STRINGS.watchRecording}</a></span>}
|
|
87
|
+
{!hasTalksArray && d.slides && <span><a href={d.slides} rel="noopener" target="_blank"><i class="fa-solid fa-file-lines" aria-hidden="true"></i> {STRINGS.viewSlides}</a></span>}
|
|
53
88
|
</div>
|
|
54
89
|
|
|
55
90
|
{(d.checkin || venueNotes) && (
|
|
@@ -71,6 +106,28 @@ const speakerEntries = d.speakers.map((slug) => {
|
|
|
71
106
|
</>
|
|
72
107
|
)}
|
|
73
108
|
|
|
109
|
+
{hasTalksArray && (
|
|
110
|
+
<>
|
|
111
|
+
<h2 style="font-size:var(--text-xl);margin:var(--space-5) 0 1rem">{STRINGS.talks}</h2>
|
|
112
|
+
<div class="g-stack" style="gap:var(--space-3)">
|
|
113
|
+
{talks.map((t) => (
|
|
114
|
+
<div class="g-talk">
|
|
115
|
+
<div class="g-talk__title">{t.title}</div>
|
|
116
|
+
{t.speakerName && (
|
|
117
|
+
<div class="g-talk__by"><i class="fa-solid fa-microphone-lines" aria-hidden="true"></i> {t.speakerSlug ? <a href={`/speakers/${t.speakerSlug}/`}>{t.speakerName}</a> : t.speakerName}</div>
|
|
118
|
+
)}
|
|
119
|
+
{(t.recording || t.slides) && (
|
|
120
|
+
<div class="g-row" style="gap:var(--space-2)">
|
|
121
|
+
{t.recording && <a class="g-btn g-btn--sm g-btn--outline" href={t.recording} rel="noopener" target="_blank"><i class="fa-solid fa-circle-play" aria-hidden="true"></i> {STRINGS.watchRecording}</a>}
|
|
122
|
+
{t.slides && <a class="g-btn g-btn--sm g-btn--ghost" href={t.slides} rel="noopener" target="_blank"><i class="fa-solid fa-file-lines" aria-hidden="true"></i> {STRINGS.viewSlides}</a>}
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
))}
|
|
127
|
+
</div>
|
|
128
|
+
</>
|
|
129
|
+
)}
|
|
130
|
+
|
|
74
131
|
{d.rsvp && (
|
|
75
132
|
<div class="g-row" style="margin-top:var(--space-4)">
|
|
76
133
|
<a class="g-btn g-btn--primary g-btn--lg" href={d.rsvp}><i class="fa-solid fa-calendar" aria-hidden="true"></i> {STRINGS.rsvpExternal}</a>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { getCollection } from 'astro:content';
|
|
3
|
+
import { buildIcs } from '../../lib/ical';
|
|
4
|
+
import { SITE } from 'popular:config';
|
|
5
|
+
|
|
6
|
+
/* iCalendar feed: upcoming events plus the last 90 days. Mirrors the Hugo
|
|
7
|
+
events/list.calendar.ics template. */
|
|
8
|
+
export const GET: APIRoute = async ({ site }) => {
|
|
9
|
+
const base = site ?? new URL('https://example.com');
|
|
10
|
+
const cutoff = Date.now() - 90 * 24 * 60 * 60 * 1000;
|
|
11
|
+
const events = (await getCollection('events'))
|
|
12
|
+
.filter((e) => !e.data.draft && +e.data.date >= cutoff)
|
|
13
|
+
.sort((a, b) => +a.data.date - +b.data.date);
|
|
14
|
+
const venues = await getCollection('venues');
|
|
15
|
+
const duration = (SITE as Record<string, any>).events?.defaultDurationMinutes ?? 120;
|
|
16
|
+
const dtstamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
|
17
|
+
const body = buildIcs(
|
|
18
|
+
events.map((e) => {
|
|
19
|
+
const v = e.data.venueRef ? venues.find((x) => x.id === e.data.venueRef) : undefined;
|
|
20
|
+
return {
|
|
21
|
+
title: e.data.title,
|
|
22
|
+
date: e.data.date,
|
|
23
|
+
time: e.data.time,
|
|
24
|
+
description: (e.data.description ?? '').trim(),
|
|
25
|
+
checkin: e.data.checkin,
|
|
26
|
+
cancelled: e.data.cancelled,
|
|
27
|
+
venue: v ? { name: v.data.title, address: v.data.address } : (e.data.venue ? { name: e.data.venue } : undefined),
|
|
28
|
+
permalink: new URL(`/events/${e.id}/`, base).href,
|
|
29
|
+
};
|
|
30
|
+
}),
|
|
31
|
+
{ siteTitle: SITE.title, dtstamp, durationMinutes: duration },
|
|
32
|
+
);
|
|
33
|
+
return new Response(body, { headers: { 'Content-Type': 'text/calendar; charset=utf-8' } });
|
|
34
|
+
};
|
package/src/pages/index.astro
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
import { safeCollection } from '../lib/collections';
|
|
3
3
|
import { formatStat, countSectionsIn } from '../lib/stats';
|
|
4
4
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
|
5
|
-
import PostCard from '
|
|
6
|
-
import OrganizerCard from '
|
|
7
|
-
import
|
|
5
|
+
import PostCard from 'popular:component/PostCard';
|
|
6
|
+
import OrganizerCard from 'popular:component/OrganizerCard';
|
|
7
|
+
import EventsEmpty from '../components/EventsEmpty.astro';
|
|
8
|
+
import { HOME, SITE, STRINGS, SECTIONS_MAP, SOCIAL } from 'popular:config';
|
|
9
|
+
import { buildOrgLd, jsonld } from '../lib/jsonld';
|
|
8
10
|
|
|
9
11
|
const now = new Date();
|
|
10
12
|
const events = (await safeCollection('events')).filter((e) => !e.data.draft);
|
|
@@ -25,9 +27,18 @@ const nextVenue = next?.data.venueRef ? (await safeCollection('venues')).find((v
|
|
|
25
27
|
const organizers = (await safeCollection(SECTIONS_MAP.team)).sort((a, b) => a.data.weight - b.data.weight);
|
|
26
28
|
|
|
27
29
|
const h = HOME;
|
|
30
|
+
const chat = (SITE as Record<string, any>).community?.chat;
|
|
31
|
+
const orgLd = jsonld(buildOrgLd({
|
|
32
|
+
name: SITE.title,
|
|
33
|
+
url: new URL('/', Astro.site ?? Astro.url).href,
|
|
34
|
+
logo: SITE.logo ? new URL(SITE.logo, Astro.site ?? Astro.url).href : undefined,
|
|
35
|
+
description: SITE.description,
|
|
36
|
+
sameAs: SOCIAL.map((s) => s.url),
|
|
37
|
+
}));
|
|
28
38
|
---
|
|
29
39
|
|
|
30
40
|
<BaseLayout>
|
|
41
|
+
<script type="application/ld+json" slot="head" set:html={orgLd} />
|
|
31
42
|
{h.hero && (
|
|
32
43
|
<section class="g-hero">
|
|
33
44
|
<div class="g-container g-hero__grid">
|
|
@@ -35,13 +46,18 @@ const h = HOME;
|
|
|
35
46
|
{h.hero.eyebrow && <span class="g-eyebrow">{h.hero.eyebrow}</span>}
|
|
36
47
|
<h1>{h.hero.title}</h1>
|
|
37
48
|
{h.hero.lead && <p class="g-hero__lead">{h.hero.lead}</p>}
|
|
38
|
-
{h.hero.ctas && (
|
|
49
|
+
{(h.hero.ctas || chat) && (
|
|
39
50
|
<div class="g-row">
|
|
40
|
-
{h.hero.ctas.map((c) => (
|
|
51
|
+
{(h.hero.ctas ?? []).map((c) => (
|
|
41
52
|
<a class={`g-btn g-btn--lg g-btn--${c.variant ?? 'primary'}`} href={c.url}>
|
|
42
53
|
{'icon' in c && c.icon && <i class={c.icon} aria-hidden="true"></i>}{c.label}
|
|
43
54
|
</a>
|
|
44
55
|
))}
|
|
56
|
+
{chat && (
|
|
57
|
+
<a class="g-btn g-btn--lg g-btn--secondary" href={chat.url} {...(chat.url.startsWith('/') ? {} : { rel: 'noopener', target: '_blank' })}>
|
|
58
|
+
<i class="fa-solid fa-comments" aria-hidden="true"></i> {chat.label ?? STRINGS.joinChat}
|
|
59
|
+
</a>
|
|
60
|
+
)}
|
|
45
61
|
</div>
|
|
46
62
|
)}
|
|
47
63
|
</div>
|
|
@@ -106,6 +122,12 @@ const h = HOME;
|
|
|
106
122
|
</section>
|
|
107
123
|
)}
|
|
108
124
|
|
|
125
|
+
{events.length > 0 && !next && (
|
|
126
|
+
<section class="g-bg-wash-soft g-section">
|
|
127
|
+
<div class="g-container"><EventsEmpty /></div>
|
|
128
|
+
</section>
|
|
129
|
+
)}
|
|
130
|
+
|
|
109
131
|
{posts.length > 0 && (
|
|
110
132
|
<section class="g-bg-white g-section">
|
|
111
133
|
<div class="g-container">
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { getCollection } from 'astro:content';
|
|
3
|
+
import { SITE } from 'popular:config';
|
|
4
|
+
|
|
5
|
+
/* §8 llms.txt: a build-time plain-text summary for AI agents. Names the next
|
|
6
|
+
upcoming event (regenerated every build so it stays true), how to join, and
|
|
7
|
+
links to the key pages and the calendar feed. Mirrors Hugo's
|
|
8
|
+
layouts/index.llms.txt. Always generated (SEO/discovery plumbing, like
|
|
9
|
+
robots.txt). */
|
|
10
|
+
export const GET: APIRoute = async ({ site }) => {
|
|
11
|
+
const base = site ?? new URL('https://example.com');
|
|
12
|
+
const abs = (p: string) => new URL(p, base).href;
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
|
|
15
|
+
const events = (await getCollection('events'))
|
|
16
|
+
.filter((e) => !e.data.draft)
|
|
17
|
+
.sort((a, b) => +a.data.date - +b.data.date);
|
|
18
|
+
const next = events.find((e) => +e.data.date >= now);
|
|
19
|
+
|
|
20
|
+
const pageIds = new Set((await getCollection('pages')).map((p) => p.id));
|
|
21
|
+
const chat = (SITE as Record<string, any>).community?.chat as { url: string; label?: string } | undefined;
|
|
22
|
+
const talksOn = (SITE as Record<string, any>).talks === true;
|
|
23
|
+
const fmt = (d: Date) =>
|
|
24
|
+
d.toLocaleDateString(SITE.locale, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
|
|
25
|
+
|
|
26
|
+
const lines: string[] = [`# ${SITE.title}`, ''];
|
|
27
|
+
if (SITE.description) lines.push(SITE.description, '');
|
|
28
|
+
lines.push(
|
|
29
|
+
next
|
|
30
|
+
? `Next event: ${next.data.title} on ${fmt(next.data.date)}${next.data.time ? `, ${next.data.time}` : ''} (${abs(`/events/${next.id}/`)})`
|
|
31
|
+
: 'No upcoming events are scheduled right now.',
|
|
32
|
+
'',
|
|
33
|
+
);
|
|
34
|
+
if (chat) lines.push(`Join the community chat: ${chat.url}`, '');
|
|
35
|
+
lines.push('## Links');
|
|
36
|
+
if (pageIds.has('start')) lines.push(`Start here (newcomers): ${abs('/start/')}`);
|
|
37
|
+
if (pageIds.has('about')) lines.push(`About and FAQ: ${abs('/about/')}`);
|
|
38
|
+
lines.push(`Events: ${abs('/events/')}`);
|
|
39
|
+
lines.push(`Calendar feed (iCalendar): ${abs('/events/calendar.ics')}`);
|
|
40
|
+
if (talksOn) lines.push(`Talk archive: ${abs('/talks/')}`);
|
|
41
|
+
lines.push('', 'Built with the Popular theme.');
|
|
42
|
+
|
|
43
|
+
return new Response(lines.join('\n') + '\n', {
|
|
44
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
45
|
+
});
|
|
46
|
+
};
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { safeCollection } from '../../lib/collections';
|
|
3
3
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
4
|
-
import PageHero from '
|
|
5
|
-
import OrganizerCard from '
|
|
4
|
+
import PageHero from 'popular:component/PageHero';
|
|
5
|
+
import OrganizerCard from 'popular:component/OrganizerCard';
|
|
6
6
|
import Pagination from '../../components/Pagination.astro';
|
|
7
7
|
import { SECTIONS, PAGINATION } from 'popular:config';
|
|
8
8
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
|
-
import { STRINGS } from 'popular:config';
|
|
2
|
+
import { STRINGS, SECTIONS_MAP } from 'popular:config';
|
|
3
3
|
import { render } from 'astro:content';
|
|
4
4
|
import { safeCollection } from '../../lib/collections';
|
|
5
5
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
6
|
-
import PageHero from '
|
|
7
|
-
import AuthorBox from '
|
|
6
|
+
import PageHero from 'popular:component/PageHero';
|
|
7
|
+
import AuthorBox from 'popular:component/AuthorBox';
|
|
8
8
|
|
|
9
9
|
export async function getStaticPaths() {
|
|
10
10
|
const organizers = await safeCollection('organizers');
|
|
@@ -17,7 +17,7 @@ const { Content } = await render(entry);
|
|
|
17
17
|
---
|
|
18
18
|
|
|
19
19
|
<BaseLayout title={d.title} description={d.description}>
|
|
20
|
-
<PageHero eyebrow={d.role ?? STRINGS.eyebrowOrganizer}
|
|
20
|
+
<PageHero eyebrow={d.role ?? STRINGS.eyebrowOrganizer} backHref={`/${SECTIONS_MAP.team}/`} backLabel={STRINGS.allOrganizers} title={d.title} />
|
|
21
21
|
<section class="g-bg-white g-section">
|
|
22
22
|
<div class="g-container">
|
|
23
23
|
<div class="g-article">
|