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.
Files changed (44) hide show
  1. package/README.md +10 -0
  2. package/index.mjs +53 -2
  3. package/package.json +3 -2
  4. package/schemas.ts +20 -0
  5. package/src/components/AuthorBox.astro +2 -2
  6. package/src/components/DemoBar.astro +2 -0
  7. package/src/components/EventRow.astro +8 -1
  8. package/src/components/EventsEmpty.astro +25 -0
  9. package/src/components/FAQ.astro +30 -0
  10. package/src/components/FAQItem.astro +10 -0
  11. package/src/components/Footer.astro +8 -2
  12. package/src/components/Gallery.astro +7 -0
  13. package/src/components/Notice.astro +19 -0
  14. package/src/components/OrganizerCard.astro +1 -1
  15. package/src/components/PageHero.astro +6 -1
  16. package/src/components/Photo.astro +12 -0
  17. package/src/components/PostCard.astro +1 -1
  18. package/src/components/Pullquote.astro +11 -0
  19. package/src/components/SpeakerInvite.astro +16 -0
  20. package/src/layouts/BaseLayout.astro +13 -3
  21. package/src/lib/eventld.ts +95 -0
  22. package/src/lib/ical.ts +93 -0
  23. package/src/lib/inline-md.ts +21 -0
  24. package/src/lib/jsonld.ts +48 -0
  25. package/src/lib/talks.ts +87 -0
  26. package/src/pages/[...slug].astro +1 -1
  27. package/src/pages/authors/[slug].astro +4 -4
  28. package/src/pages/blog/[...page].astro +2 -2
  29. package/src/pages/blog/[slug].astro +15 -1
  30. package/src/pages/events/[...page].astro +5 -5
  31. package/src/pages/events/[slug].astro +59 -2
  32. package/src/pages/events/calendar.ics.ts +34 -0
  33. package/src/pages/index.astro +27 -5
  34. package/src/pages/llms.txt.ts +46 -0
  35. package/src/pages/organizers/[...page].astro +2 -2
  36. package/src/pages/organizers/[slug].astro +4 -4
  37. package/src/pages/robots.txt.ts +10 -0
  38. package/src/pages/speakers/[slug].astro +4 -4
  39. package/src/pages/speakers/index.astro +26 -0
  40. package/src/pages/tags/[tag]/[...page].astro +4 -4
  41. package/src/pages/talks/[...page].astro +58 -0
  42. package/src/pages/venues/[slug].astro +13 -2
  43. package/src/pages/venues/index.astro +33 -0
  44. package/src/styles/components.css +41 -0
package/README.md CHANGED
@@ -21,6 +21,16 @@ export default defineConfig({
21
21
  });
22
22
  ```
23
23
 
24
+ Override any theme component with your own, without forking:
25
+
26
+ ```js
27
+ popular({ overrides: { Header: './src/overrides/Header.astro' } })
28
+ ```
29
+
30
+ Overridable: `Header`, `Footer`, `Hero`, `EventRow`, `PostCard`,
31
+ `OrganizerCard`, `AuthorBox`, `PageHero`. Your file receives the same props
32
+ the theme component does.
33
+
24
34
  Disable any injected route group when your site replaces it (also the
25
35
  answer when you provide your own `/` or `/rss.xml`):
26
36
 
package/index.mjs CHANGED
@@ -6,6 +6,11 @@
6
6
  * integrations: [mdx(), popular()],
7
7
  * });
8
8
  *
9
+ * Override components without forking: popular({ overrides: { Header:
10
+ * './src/overrides/Header.astro' } }). Theme code imports overridable
11
+ * components from popular:component/<Name>; the integration resolves each
12
+ * to the adopter's file or the theme default.
13
+ *
9
14
  * Theme config (SITE, STRINGS, BRAND, NAV, ...) lives in the adopter's
10
15
  * `popular.config.ts` (same named exports as the classic src/config.ts;
11
16
  * the shape is the parity contract, see PARITY.md). Theme code reads it
@@ -13,6 +18,21 @@
13
18
  */
14
19
  import path from 'node:path';
15
20
  import { fileURLToPath } from 'node:url';
21
+ import sitemap from '@astrojs/sitemap';
22
+
23
+ /* loading="lazy" decoding="async" on markdown images (CWV parity with the
24
+ Hugo render hook). Dependency-free HAST walk. */
25
+ function rehypeLazyImages() {
26
+ const walk = (node) => {
27
+ if (node.tagName === 'img') {
28
+ node.properties = node.properties || {};
29
+ if (!('loading' in node.properties)) node.properties.loading = 'lazy';
30
+ if (!('decoding' in node.properties)) node.properties.decoding = 'async';
31
+ }
32
+ (node.children || []).forEach(walk);
33
+ };
34
+ return (tree) => walk(tree);
35
+ }
16
36
 
17
37
  /* Injected routes, grouped by opt-out key: popular({ routes: { speakers:
18
38
  false } }) skips a group. Disabling is also the durable answer when your
@@ -35,14 +55,29 @@ const ROUTES = {
35
55
  ['/organizers/[...page]', 'organizers/[...page].astro'],
36
56
  ['/organizers/[...slug]', 'organizers/[slug].astro'],
37
57
  ],
38
- speakers: [['/speakers/[...slug]', 'speakers/[slug].astro']],
39
- venues: [['/venues/[...slug]', 'venues/[slug].astro']],
58
+ speakers: [
59
+ ['/speakers', 'speakers/index.astro'],
60
+ ['/speakers/[...slug]', 'speakers/[slug].astro'],
61
+ ],
62
+ venues: [
63
+ ['/venues', 'venues/index.astro'],
64
+ ['/venues/[...slug]', 'venues/[slug].astro'],
65
+ ],
40
66
  tags: [['/tags/[tag]/[...page]', 'tags/[tag]/[...page].astro']],
67
+ // Opt-in talk archive: injected always, but the page only builds when
68
+ // SITE.talks is true (gated in the route's getStaticPaths).
69
+ talks: [['/talks/[...page]', 'talks/[...page].astro']],
41
70
  rss: [['/rss.xml', 'rss.xml.js']],
71
+ robots: [['/robots.txt', 'robots.txt.ts']],
72
+ calendar: [['/events/calendar.ics', 'events/calendar.ics.ts']],
73
+ llms: [['/llms.txt', 'llms.txt.ts']],
42
74
  };
43
75
 
76
+ const OVERRIDABLE = ['Header', 'Footer', 'Hero', 'EventRow', 'PostCard', 'OrganizerCard', 'AuthorBox', 'PageHero'];
77
+
44
78
  export default function popular(options = {}) {
45
79
  const configFile = options.configFile ?? './popular.config.ts';
80
+ const overrides = options.overrides ?? {};
46
81
  return {
47
82
  name: 'astro-theme-popular',
48
83
  hooks: {
@@ -51,6 +86,8 @@ export default function popular(options = {}) {
51
86
  const userConfig = path.resolve(root, configFile);
52
87
  addWatchFile(userConfig);
53
88
  updateConfig({
89
+ integrations: [sitemap()],
90
+ markdown: { rehypePlugins: [rehypeLazyImages] },
54
91
  vite: {
55
92
  plugins: [
56
93
  {
@@ -64,6 +101,20 @@ export default function popular(options = {}) {
64
101
  }
65
102
  },
66
103
  },
104
+ {
105
+ name: 'popular:component',
106
+ resolveId(id) {
107
+ if (id.startsWith('popular:component/')) return '\0' + id;
108
+ },
109
+ load(id) {
110
+ if (!id.startsWith('\0popular:component/')) return;
111
+ const name = id.slice('\0popular:component/'.length);
112
+ const target = overrides[name]
113
+ ? path.resolve(root, overrides[name])
114
+ : `astro-theme-popular/components/${name}.astro`;
115
+ return `export { default } from ${JSON.stringify(target)};`;
116
+ },
117
+ },
67
118
  ],
68
119
  },
69
120
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-theme-popular",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Popular: a warm, community-first Astro theme for meetups, clubs and fan communities. Ships as an Astro integration.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,7 +27,8 @@
27
27
  "./scripts/*": "./scripts/*"
28
28
  },
29
29
  "dependencies": {
30
- "@astrojs/rss": "^4.0.0"
30
+ "@astrojs/rss": "^4.0.0",
31
+ "@astrojs/sitemap": "^3.0.0"
31
32
  },
32
33
  "peerDependencies": {
33
34
  "astro": "^7.0.0"
package/schemas.ts CHANGED
@@ -57,6 +57,22 @@ const events = defineCollection({
57
57
  venueNotes: z.string().optional(), // e.g. "Buzz 204 at the side door."; overrides the venue page's notes
58
58
  rsvp: z.string().optional(),
59
59
  meetupUrl: z.string().optional(), // metadata convention; the theme renders no external links on event rows
60
+ cancelled: z.boolean().default(false), // EventCancelled JSON-LD + visible badge
61
+ online: z.boolean().default(false), // OnlineEventAttendanceMode + VirtualLocation
62
+ price: z.union([z.number(), z.string()]).optional(), // machine price for Offer
63
+ currency: z.string().optional(), // ISO currency for price
64
+ cost: z.string().optional(), // free-text displayed cost (e.g. "15 CAD")
65
+ // §3 talk archive (all optional). Simple case: event-level recording/slides
66
+ // (single-talk meetups). Multi-talk case: a talks[] array, which wins over
67
+ // the event-level recording/slides when present.
68
+ recording: z.string().optional(),
69
+ slides: z.string().optional(),
70
+ talks: z.array(z.object({
71
+ title: z.string(),
72
+ speaker: z.string().optional(), // speaker-collection slug, same resolution as `speakers`
73
+ recording: z.string().optional(),
74
+ slides: z.string().optional(),
75
+ })).optional(),
60
76
  }),
61
77
  });
62
78
 
@@ -109,6 +125,10 @@ const venues = defineCollection({
109
125
  photo: z.string().optional(),
110
126
  notes: z.string().optional(), // arrival notes inherited by events held here
111
127
  accessibility: z.string().optional(),
128
+ wheelchair: z.boolean().default(false), // step-free access badge
129
+ transit: z.string().optional(),
130
+ parking: z.string().optional(),
131
+ access: z.string().optional(), // freeform markdown: washrooms, quiet space, childcare, scent policy
112
132
  website: z.string().optional(),
113
133
  }),
114
134
  });
@@ -23,7 +23,7 @@ const bio = d ? ((d as any).bio ?? (d as any).description) : undefined;
23
23
  <div class="g-persona g-persona--primary">
24
24
  {d && (
25
25
  <>
26
- {d.photo && <img class="g-persona__img" src={d.photo} alt={entry.data.title} />}
26
+ {d.photo && <img class="g-persona__img" src={d.photo} alt={entry.data.title} loading="lazy" decoding="async" />}
27
27
  <div>
28
28
  <div class="g-persona__name"><a href={`${base}${entry.id}/`} style="color:inherit">{d.title}</a></div>
29
29
  {d.role && <div class="g-persona__role">{d.role}</div>}
@@ -37,7 +37,7 @@ const bio = d ? ((d as any).bio ?? (d as any).description) : undefined;
37
37
  )}
38
38
  {guest && (
39
39
  <>
40
- {guest.photo && <img class="g-persona__img" src={guest.photo} alt={guest.name} />}
40
+ {guest.photo && <img class="g-persona__img" src={guest.photo} alt={guest.name} loading="lazy" decoding="async" />}
41
41
  <div>
42
42
  <div class="g-persona__name">{guest.name} <span class="g-badge g-badge--soft" style="vertical-align:middle">{STRINGS.guest}</span></div>
43
43
  {guest.title && <div class="g-persona__role">{guest.title}</div>}
@@ -22,6 +22,8 @@ const bar = import.meta.env.DEV ? null : ((cfg as Record<string, any>).DEMO_BAR
22
22
  .g-demobar a:hover { color: #fff; background: rgba(255,255,255,0.08); }
23
23
  .g-demobar a.is-current { background: var(--color-primary); color: #fff; }
24
24
  @media (max-width: 640px) { .g-demobar__label { display: none; } }
25
+ /* Reserve space so the fixed demo bar never covers the footer credit. */
26
+ .g-footer__bottom { padding-bottom: 72px; }
25
27
  </style>
26
28
  <nav class="g-demobar" aria-label="Theme demos">
27
29
  <a href="#" data-demo-home><i class="fa-solid fa-star" aria-hidden="true"></i>Popular</a>
@@ -2,10 +2,12 @@
2
2
  import { SITE, STRINGS } from 'popular:config';
3
3
  import type { CollectionEntry } from 'astro:content';
4
4
  import { safeCollection } from '../lib/collections';
5
+ import { eventHasRecording } from '../lib/talks';
5
6
 
6
7
  interface Props { event: CollectionEntry<'events'> }
7
8
  const { event } = Astro.props;
8
9
  const d = event.data;
10
+ const hasRecording = eventHasRecording(d);
9
11
 
10
12
  /* Venue: a venueRef to the venues collection wins over the flat fields. */
11
13
  const venuePage = d.venueRef ? (await safeCollection('venues')).find((v) => v.id === d.venueRef) : undefined;
@@ -47,9 +49,14 @@ const day = d.date.toLocaleDateString(SITE.locale, { month: 'short', day: 'numer
47
49
  {speakerLine && (
48
50
  <div class="g-event__meta"><i class="fa-solid fa-microphone-lines" aria-hidden="true"></i>{speakerLine}</div>
49
51
  )}
52
+ {hasRecording && (
53
+ <div class="g-event__meta"><i class="fa-solid fa-circle-play" aria-hidden="true"></i>{STRINGS.hasRecording}</div>
54
+ )}
50
55
  </div>
51
56
  <div class="g-event__aside">
52
- {tbd
57
+ {d.cancelled
58
+ ? <span class="g-badge g-badge--danger g-badge--soft">{STRINGS.eventCancelled}</span>
59
+ : tbd
53
60
  ? <span class="g-badge g-badge--warning g-badge--soft">{STRINGS.venueWanted}</span>
54
61
  : <span class="g-badge g-badge--success g-badge--soft">{STRINGS.confirmed}</span>}
55
62
  {d.rsvp && (
@@ -0,0 +1,25 @@
1
+ ---
2
+ /** Designed empty state for zero upcoming events (§5). Shown on the home page
3
+ * and events list instead of a blank section: a message, then the chat CTA
4
+ * (if SITE.community.chat is set, §4) and the calendar-subscribe link.
5
+ * The calendar link mirrors Hugo's OutputFormats "Calendar" endpoint. */
6
+ import { SITE, STRINGS } from 'popular:config';
7
+
8
+ const chat = (SITE as Record<string, any>).community?.chat;
9
+ const calendar = '/events/calendar.ics';
10
+ const external = (url: string) => !url.startsWith('/');
11
+ ---
12
+ <div class="g-empty">
13
+ <span class="g-medallion g-medallion--lg"><i class="fa-solid fa-calendar-day" aria-hidden="true"></i></span>
14
+ <p class="g-lead">{STRINGS.noUpcomingEvents}</p>
15
+ {(chat?.url || calendar) && (
16
+ <div class="g-row" style="justify-content:center">
17
+ {chat?.url && (
18
+ <a class="g-btn g-btn--primary" href={chat.url} {...(external(chat.url) ? { rel: 'noopener', target: '_blank' } : {})}>
19
+ <i class="fa-solid fa-comments" aria-hidden="true"></i> {chat.label ?? STRINGS.joinChat}
20
+ </a>
21
+ )}
22
+ <a class="g-btn g-btn--outline" href={calendar}><i class="fa-solid fa-calendar-plus" aria-hidden="true"></i> {STRINGS.subscribeCalendar}</a>
23
+ </div>
24
+ )}
25
+ </div>
@@ -0,0 +1,30 @@
1
+ ---
2
+ /** FAQ block: wraps <FAQItem> children (native <details>, zero JS; answers in
3
+ * the DOM for indexing + AI answer engines). Emits FAQPage JSON-LD collected
4
+ * from the rendered items (gated on SITE.seo.faqJsonLd, default true),
5
+ * mirroring Hugo's faq shortcode. No FAQ rich results are expected (Google
6
+ * restricts them); this aids agents and future-proofs. */
7
+ import { SITE } from 'popular:config';
8
+ import { jsonld } from '../lib/jsonld';
9
+
10
+ const html = await Astro.slots.render('default');
11
+ const strip = (s: string) => s.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
12
+ const items = [...html.matchAll(
13
+ /<summary class="g-faq__q">([\s\S]*?)<\/summary>\s*<div class="g-faq__a[^"]*">([\s\S]*?)<\/div>\s*<\/details>/g,
14
+ )].map((m) => ({ q: strip(m[1]), a: strip(m[2]) }));
15
+
16
+ const emit = (SITE as Record<string, any>).seo?.faqJsonLd !== false && items.length;
17
+ const faqLd = emit
18
+ ? jsonld({
19
+ '@context': 'https://schema.org',
20
+ '@type': 'FAQPage',
21
+ mainEntity: items.map((i) => ({
22
+ '@type': 'Question',
23
+ name: i.q,
24
+ acceptedAnswer: { '@type': 'Answer', text: i.a },
25
+ })),
26
+ })
27
+ : null;
28
+ ---
29
+ <div class="g-faq" set:html={html} />
30
+ {faqLd && <script type="application/ld+json" set:html={faqLd} />}
@@ -0,0 +1,10 @@
1
+ ---
2
+ /** One FAQ item: native <details> (zero JS). `q` is the question; the default
3
+ * slot is the markdown answer. `open` renders it expanded. */
4
+ interface Props { q: string; open?: boolean }
5
+ const { q, open = false } = Astro.props;
6
+ ---
7
+ <details class="g-faq__item" open={open}>
8
+ <summary class="g-faq__q">{q}</summary>
9
+ <div class="g-faq__a g-prose"><slot /></div>
10
+ </details>
@@ -1,7 +1,10 @@
1
1
  ---
2
- import { SITE, FOOTER, SOCIAL } from 'popular:config';
2
+ import { SITE, FOOTER, SOCIAL, STRINGS } from 'popular:config';
3
+ import { version } from '../../package.json';
3
4
  const year = new Date().getFullYear();
4
5
  const credit = (FOOTER as { credit?: { label: string; url: string } }).credit;
6
+ const chat = (SITE as Record<string, any>).community?.chat;
7
+ const showVersion = !!credit && credit.url.includes('mariatta.ca');
5
8
  ---
6
9
 
7
10
  <footer class="g-footer">
@@ -35,7 +38,10 @@ const credit = (FOOTER as { credit?: { label: string; url: string } }).credit;
35
38
  <div class="g-footer__bottom">
36
39
  <div class="g-container">
37
40
  <span>© {year} {SITE.title}</span>
38
- {credit && <span><a href={credit.url} rel="noopener">{credit.label}</a></span>}
41
+ {chat && (
42
+ <span><a href={chat.url} {...(chat.url.startsWith('/') ? {} : { rel: 'noopener', target: '_blank' })}><i class="fa-solid fa-comments" aria-hidden="true"></i> {chat.label ?? STRINGS.joinChat}</a></span>
43
+ )}
44
+ {credit && <span><a href={credit.url} rel="noopener">{credit.label}</a>{showVersion && <> · v{version}</>}</span>}
39
45
  {FOOTER.tagline && <span>{FOOTER.tagline}</span>}
40
46
  </div>
41
47
  </div>
@@ -0,0 +1,7 @@
1
+ ---
2
+ /** Photo gallery (§6): a CSS-grid of <Photo> figures. No JS, no lightbox
3
+ * (each photo links to its full-size image). Mirrors the Hugo `gallery`
4
+ * shortcode. Use in MDX:
5
+ * <Gallery><Photo src="/images/recap-1.jpg" alt="…" caption="…" /></Gallery> */
6
+ ---
7
+ <div class="g-gallery"><slot /></div>
@@ -0,0 +1,19 @@
1
+ ---
2
+ /** Site-wide notice banner (§5). Reads optional SITE.notice = { text, url? }.
3
+ * Static, not dismissible, no JS. Rendered by BaseLayout above the header.
4
+ * Text is inline-markdown rendered (parity with Hugo's notice.html). */
5
+ import { SITE, STRINGS } from 'popular:config';
6
+ import { inlineMarkdown } from '../lib/inline-md';
7
+
8
+ const notice = (SITE as Record<string, any>).notice;
9
+ const text: string | undefined = notice?.text;
10
+ const html = text ? inlineMarkdown(text) : '';
11
+ ---
12
+ {text && (
13
+ <div class="g-notice" role="status">
14
+ <div class="g-notice__inner">
15
+ <i class="fa-solid fa-circle-info" aria-hidden="true"></i>
16
+ <span><Fragment set:html={html} />{notice.url && <> <a href={notice.url}>{STRINGS.learnMore}</a></>}</span>
17
+ </div>
18
+ </div>
19
+ )}
@@ -7,7 +7,7 @@ const d = organizer.data;
7
7
  ---
8
8
 
9
9
  <div class="g-org">
10
- {d.photo && <a href={`/organizers/${organizer.id}/`}><img class="g-org__img" src={d.photo} alt={d.title} loading="lazy" /></a>}
10
+ {d.photo && <a href={`/organizers/${organizer.id}/`}><img class="g-org__img" src={d.photo} alt={d.title} loading="lazy" decoding="async" /></a>}
11
11
  <div class="g-org__name"><a href={`/organizers/${organizer.id}/`} style="color:inherit">{d.title}</a></div>
12
12
  {d.role && <div class="g-org__role">{d.role}</div>}
13
13
  {d.description && <p class="g-org__bio">{d.description}</p>}
@@ -3,12 +3,17 @@ interface Props {
3
3
  eyebrow?: string;
4
4
  title: string;
5
5
  lead?: string;
6
+ backHref?: string;
7
+ backLabel?: string;
6
8
  }
7
- const { eyebrow, title, lead } = Astro.props;
9
+ const { eyebrow, title, lead, backHref, backLabel } = Astro.props;
8
10
  ---
9
11
 
10
12
  <header class="g-pagehero">
11
13
  <div class="g-container">
14
+ {backHref && (
15
+ <div style="margin-bottom:1rem"><a href={backHref} style="display:inline-flex;gap:.4rem;align-items:center;font-size:var(--text-sm);font-weight:var(--weight-medium)"><i class="fa-solid fa-arrow-left" aria-hidden="true"></i> {backLabel}</a></div>
16
+ )}
12
17
  {eyebrow && <span class="g-eyebrow">{eyebrow}</span>}
13
18
  <h1>{title}</h1>
14
19
  {lead && <p class="g-pagehero__lead">{lead}</p>}
@@ -0,0 +1,12 @@
1
+ ---
2
+ /** A gallery photo (§6). `alt` is required: the build throws with an
3
+ * actionable message when it is missing, keeping the image-alt CI green by
4
+ * construction. No lightbox, the figure links to the full-size image. */
5
+ interface Props { src: string; alt: string; caption?: string; }
6
+ const { src, alt, caption } = Astro.props;
7
+ if (!alt) throw new Error(`<Photo src="${src}"> requires a non-empty alt`);
8
+ ---
9
+ <figure class="g-gallery__item">
10
+ <a href={src}><img src={src} alt={alt} loading="lazy" decoding="async" /></a>
11
+ {caption && <figcaption>{caption}</figcaption>}
12
+ </figure>
@@ -22,7 +22,7 @@ if (!names.length && d.author) names.push(d.author);
22
22
 
23
23
  <article class="g-card" data-tags={d.tags.join(',')}>
24
24
  {d.image && (
25
- <a href={href} class="g-card__media"><img src={d.image} alt={d.title} loading="lazy" /></a>
25
+ <a href={href} class="g-card__media"><img src={d.image} alt={d.title} loading="lazy" decoding="async" /></a>
26
26
  )}
27
27
  <div class="g-card__body">
28
28
  {(month || names.length > 0) && (
@@ -0,0 +1,11 @@
1
+ ---
2
+ /** Pull-quote (§6): a styled blockquote with optional attribution. Mirrors the
3
+ * Hugo `pullquote` shortcode. Use in MDX:
4
+ * <Pullquote cite="A happy member">The best Tuesday of my month.</Pullquote> */
5
+ interface Props { cite?: string; }
6
+ const { cite } = Astro.props;
7
+ ---
8
+ <figure class="g-pullquote">
9
+ <blockquote><slot /></blockquote>
10
+ {cite && <figcaption>{cite}</figcaption>}
11
+ </figure>
@@ -0,0 +1,16 @@
1
+ ---
2
+ /** §7: "Your name here?" invite card appended to the speakers list when
3
+ * SITE.speakers.invite is true. Links to the /speak/ CFP page. Off by
4
+ * default, so sites that do not solicit talks show nothing. */
5
+ import { SITE, STRINGS } from 'popular:config';
6
+ const invite = (SITE as Record<string, any>).speakers?.invite === true;
7
+ ---
8
+ {invite && (
9
+ <div class="g-persona g-persona--primary">
10
+ <span class="g-medallion g-medallion--lg" aria-hidden="true"><i class="fa-solid fa-microphone-lines"></i></span>
11
+ <div>
12
+ <div class="g-persona__name"><a href="/speak/" style="color:inherit">{STRINGS.speakerInviteTitle} <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
13
+ <p class="g-persona__bio">{STRINGS.speakerInviteBody}</p>
14
+ </div>
15
+ </div>
16
+ )}
@@ -7,9 +7,10 @@ import '../styles/tokens/spacing.css';
7
7
  import '../styles/tokens/elevation.css';
8
8
  import '../styles/base.css';
9
9
  import '../styles/components.css';
10
- import Header from '../components/Header.astro';
11
- import Footer from '../components/Footer.astro';
10
+ import Header from 'popular:component/Header';
11
+ import Footer from 'popular:component/Footer';
12
12
  import DemoBar from '../components/DemoBar.astro';
13
+ import Notice from '../components/Notice.astro';
13
14
  import { SITE, BRAND, STRINGS } from 'popular:config';
14
15
 
15
16
  interface Props {
@@ -18,8 +19,11 @@ interface Props {
18
19
  image?: string;
19
20
  /** Set on blog posts: emits og:type=article plus article meta. */
20
21
  article?: { published?: Date; modified?: Date; authors?: string[]; tags?: string[] };
22
+ imageAlt?: string;
23
+ noindex?: boolean;
21
24
  }
22
- const { title, description, image, article } = Astro.props;
25
+ const { title, description, image, article, imageAlt, noindex } = Astro.props;
26
+ const ogLocale = SITE.locale.replace('-', '_');
23
27
  const pageTitle = title ? `${title} · ${SITE.title}` : `${SITE.title}, ${SITE.tagline}`;
24
28
  const desc = description ?? SITE.description;
25
29
  const ogImage = image ?? SITE.ogImage;
@@ -71,6 +75,7 @@ const brandCss = `html:root{${vars.join(';')}}`;
71
75
  <title>{pageTitle}</title>
72
76
  <meta name="description" content={desc} />
73
77
  <meta property="og:site_name" content={SITE.title} />
78
+ <meta property="og:locale" content={ogLocale} />
74
79
  <meta property="og:title" content={title ?? SITE.title} />
75
80
  <meta property="og:description" content={desc} />
76
81
  <meta property="og:url" content={canonical} />
@@ -81,17 +86,22 @@ const brandCss = `html:root{${vars.join(';')}}`;
81
86
  {article?.authors?.map((a) => <meta property="article:author" content={a} />)}
82
87
  {article?.tags?.map((t) => <meta property="article:tag" content={t} />)}
83
88
  {ogImage && <meta property="og:image" content={new URL(ogImage, Astro.site ?? Astro.url)} />}
89
+ {ogImage && <meta property="og:image:alt" content={imageAlt ?? title ?? SITE.title} />}
84
90
  <meta name="twitter:card" content={ogImage ? 'summary_large_image' : 'summary'} />
85
91
  <link rel="icon" href={SITE.favicon ?? SITE.logo} />
86
92
  <link rel="alternate" type="application/rss+xml" title={(SITE as Record<string, any>).rssTitle ?? SITE.title} href={new URL("rss.xml", Astro.site ?? Astro.url)} />
93
+ {SITE.fontAwesome.includes("font-awesome/6.5.2/css/all.min.css") && <link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin />}
87
94
  <link rel="stylesheet" href={SITE.fontAwesome} referrerpolicy="no-referrer"
88
95
  integrity={SITE.fontAwesome.includes("font-awesome/6.5.2/css/all.min.css") ? "sha384-PPIZEGYM1v8zp5Py7UjFb79S58UeqCL9pYVnVPURKEqvioPROaVAJKKLzvH2rDnI" : undefined}
89
96
  crossorigin={SITE.fontAwesome.includes("font-awesome/6.5.2/css/all.min.css") ? "anonymous" : undefined} />
90
97
  {(SITE as Record<string, any>).customCSS?.map((href: string) => <link rel="stylesheet" href={href} />)}
98
+ {noindex && <meta name="robots" content="noindex,follow" />}
91
99
  <style is:inline id="popular-brand" set:html={brandCss}></style>
100
+ <slot name="head" />
92
101
  </head>
93
102
  <body data-copy-label={STRINGS.copyCode} data-checklist-done={STRINGS.checklistDone}>
94
103
  <a class="g-skip" href="#main">{STRINGS.skipToContent}</a>
104
+ <Notice />
95
105
  <Header />
96
106
  <main id="main">
97
107
  <slot />
@@ -0,0 +1,95 @@
1
+ /** Event JSON-LD helpers, mirroring partials/jsonld-event.html.
2
+ * The time-parsing contract and its vectors live in PARITY.md; the Hugo and
3
+ * Astro implementations must agree on every vector. */
4
+
5
+ /** Parse an event `time` free-text field to event-local "HH:MM", or null.
6
+ * Leading token only: "6:00 PM · doors 5:30" -> "18:00"; "doors 5:30" -> null. */
7
+ export function parseEventTime(raw: string | undefined): string | null {
8
+ const m = (raw ?? '').trim().match(/^(\d{1,2}):(\d{2})\s*(am|pm)?/i);
9
+ if (!m) return null;
10
+ let h = parseInt(m[1], 10);
11
+ const min = parseInt(m[2], 10);
12
+ const mer = m[3]?.toLowerCase();
13
+ if (mer) {
14
+ if (h < 1 || h > 12) return null;
15
+ if (mer === 'pm' && h < 12) h += 12;
16
+ if (mer === 'am' && h === 12) h = 0;
17
+ } else if (h > 23) {
18
+ return null;
19
+ }
20
+ if (min > 59) return null;
21
+ return `${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`;
22
+ }
23
+
24
+ /** startDate = YYYY-MM-DD, or YYYY-MM-DDTHH:MM when a leading time parses. */
25
+ export function eventStartDate(date: Date, time: string | undefined): string {
26
+ const day = date.toISOString().slice(0, 10);
27
+ const hm = parseEventTime(time);
28
+ return hm ? `${day}T${hm}` : day;
29
+ }
30
+
31
+ /** Recursively key-sort so output matches Hugo's jsonify (sorted, no spaces). */
32
+ function sortKeys(v: any): any {
33
+ if (Array.isArray(v)) return v.map(sortKeys);
34
+ if (v && typeof v === 'object') {
35
+ return Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeys(v[k])]));
36
+ }
37
+ return v;
38
+ }
39
+ export function jsonld(obj: Record<string, any>): string {
40
+ return JSON.stringify(sortKeys(obj));
41
+ }
42
+
43
+ export interface EventLdInput {
44
+ title: string;
45
+ date: Date;
46
+ time?: string;
47
+ description?: string;
48
+ cancelled?: boolean;
49
+ online?: boolean;
50
+ price?: number | string;
51
+ currency?: string;
52
+ cost?: string;
53
+ image?: string;
54
+ rsvp?: string;
55
+ permalink: string;
56
+ siteUrl: string;
57
+ siteTitle: string;
58
+ defaultCurrency: string;
59
+ venue?: { name: string; address?: string };
60
+ performers?: string[];
61
+ }
62
+
63
+ export function buildEventLd(i: EventLdInput): Record<string, any> {
64
+ const ld: Record<string, any> = {
65
+ '@context': 'https://schema.org',
66
+ '@type': 'Event',
67
+ name: i.title,
68
+ startDate: eventStartDate(i.date, i.time),
69
+ eventStatus: i.cancelled ? 'https://schema.org/EventCancelled' : 'https://schema.org/EventScheduled',
70
+ eventAttendanceMode: i.online
71
+ ? 'https://schema.org/OnlineEventAttendanceMode'
72
+ : 'https://schema.org/OfflineEventAttendanceMode',
73
+ organizer: { '@type': 'Organization', name: i.siteTitle, url: i.siteUrl },
74
+ };
75
+ const desc = (i.description ?? '').trim();
76
+ if (desc) ld.description = desc.length > 300 ? desc.slice(0, 299) + '…' : desc;
77
+ if (i.online) {
78
+ ld.location = { '@type': 'VirtualLocation', url: i.rsvp || i.permalink };
79
+ } else if (i.venue) {
80
+ ld.location = { '@type': 'Place', name: i.venue.name };
81
+ if (i.venue.address) ld.location.address = i.venue.address;
82
+ }
83
+ if (i.image) ld.image = i.image;
84
+ if (i.performers && i.performers.length) {
85
+ ld.performer = i.performers.map((name) => ({ '@type': 'Person', name }));
86
+ }
87
+ if (i.price !== undefined && i.price !== '') {
88
+ ld.isAccessibleForFree = false;
89
+ ld.offers = { '@type': 'Offer', price: String(i.price), priceCurrency: i.currency || i.defaultCurrency, availability: 'https://schema.org/InStock', url: i.permalink };
90
+ } else if (!i.cost) {
91
+ ld.isAccessibleForFree = true;
92
+ ld.offers = { '@type': 'Offer', price: '0', priceCurrency: i.defaultCurrency, availability: 'https://schema.org/InStock', url: i.permalink };
93
+ }
94
+ return ld;
95
+ }