astro-theme-popular 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.mjs +86 -10
- package/package.json +2 -1
- package/src/components/AuthorBox.astro +8 -7
- package/src/components/EventRow.astro +3 -2
- package/src/components/EventsEmpty.astro +3 -2
- package/src/components/Footer.astro +6 -5
- package/src/components/Header.astro +10 -7
- package/src/components/Notice.astro +2 -1
- package/src/components/OrganizerCard.astro +4 -3
- package/src/components/PageHero.astro +3 -1
- package/src/components/Pagination.astro +5 -4
- package/src/components/Photo.astro +3 -1
- package/src/components/PostCard.astro +4 -3
- package/src/components/SpeakerInvite.astro +2 -1
- package/src/layouts/BaseLayout.astro +7 -4
- package/src/lib/inline-md.ts +3 -1
- package/src/lib/url.ts +39 -0
- package/src/pages/[...slug].astro +3 -2
- package/src/pages/blog/[...page].astro +2 -1
- package/src/pages/blog/[slug].astro +11 -10
- package/src/pages/events/[...page].astro +2 -1
- package/src/pages/events/[slug].astro +13 -12
- package/src/pages/events/calendar.ics.ts +3 -2
- package/src/pages/index.astro +13 -12
- package/src/pages/llms.txt.ts +3 -3
- package/src/pages/robots.txt.ts +2 -1
- package/src/pages/rss.xml.js +3 -2
- package/src/pages/talks/[...page].astro +5 -4
- package/src/pages/venues/[slug].astro +3 -2
- package/src/pages/venues/index.astro +3 -2
package/index.mjs
CHANGED
|
@@ -20,18 +20,94 @@ import path from 'node:path';
|
|
|
20
20
|
import { fileURLToPath } from 'node:url';
|
|
21
21
|
import sitemap from '@astrojs/sitemap';
|
|
22
22
|
|
|
23
|
-
/* loading="lazy" decoding="async" on
|
|
24
|
-
Hugo render
|
|
25
|
-
|
|
23
|
+
/* Markdown body hooks: loading="lazy" decoding="async" on images (CWV parity
|
|
24
|
+
with Hugo's render-image.html), and site-absolute links/images prefixed with
|
|
25
|
+
the configured `base` so `` and `[the handbook](/handbook/)`
|
|
26
|
+
survive a subpath install (parity with Hugo's render hooks passing paths
|
|
27
|
+
through relURL). Everything the templates write is handled by src/lib/url.ts
|
|
28
|
+
instead; MDX component props (<Photo src="/images/x.png">) are JSX, not HAST,
|
|
29
|
+
so the components prefix those themselves.
|
|
30
|
+
|
|
31
|
+
Registered on the active markdown processor rather than through
|
|
32
|
+
`markdown.rehypePlugins`: Astro 7's default processor is Sätteri, which does
|
|
33
|
+
not run remark/rehype plugins at all, and plugins added by an integration
|
|
34
|
+
after config validation are never coerced, so they are silently dropped
|
|
35
|
+
(Astro warns "your satteri processor doesn't run them" on every build).
|
|
36
|
+
Mutating processor.options is what does reach both Markdown and MDX: Astro
|
|
37
|
+
preserves the processor's reference identity, and @astrojs/mdx reads the same
|
|
38
|
+
object at astro:config:done. */
|
|
39
|
+
|
|
40
|
+
const prefixPath = (value, prefix) =>
|
|
41
|
+
typeof value === 'string' &&
|
|
42
|
+
prefix &&
|
|
43
|
+
value.startsWith('/') &&
|
|
44
|
+
!value.startsWith('//') &&
|
|
45
|
+
value !== prefix &&
|
|
46
|
+
!value.startsWith(`${prefix}/`)
|
|
47
|
+
? prefix + value
|
|
48
|
+
: value;
|
|
49
|
+
|
|
50
|
+
/* Sätteri shape: filtered visitors, mutations through ctx.setProperty. */
|
|
51
|
+
function satteriMarkdownHooks(prefix) {
|
|
52
|
+
return {
|
|
53
|
+
name: 'popular-markdown-hooks',
|
|
54
|
+
element: [
|
|
55
|
+
{
|
|
56
|
+
filter: ['img'],
|
|
57
|
+
visit(node, ctx) {
|
|
58
|
+
const props = node.properties ?? {};
|
|
59
|
+
if (!('loading' in props)) ctx.setProperty(node, 'loading', 'lazy');
|
|
60
|
+
if (!('decoding' in props)) ctx.setProperty(node, 'decoding', 'async');
|
|
61
|
+
const src = prefixPath(props.src, prefix);
|
|
62
|
+
if (src !== props.src) ctx.setProperty(node, 'src', src);
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
filter: ['a'],
|
|
67
|
+
visit(node, ctx) {
|
|
68
|
+
const props = node.properties ?? {};
|
|
69
|
+
const href = prefixPath(props.href, prefix);
|
|
70
|
+
if (href !== props.href) ctx.setProperty(node, 'href', href);
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* unified/rehype shape, for a site that opted into `markdown.processor:
|
|
78
|
+
unified({...})` (or set the deprecated markdown.rehypePlugins, which Astro
|
|
79
|
+
coerces into one). Dependency-free HAST walk. */
|
|
80
|
+
function rehypeMarkdownHooks(prefix) {
|
|
26
81
|
const walk = (node) => {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if (
|
|
30
|
-
|
|
82
|
+
const props = node.properties;
|
|
83
|
+
if (props) {
|
|
84
|
+
if (node.tagName === 'img') {
|
|
85
|
+
if (!('loading' in props)) props.loading = 'lazy';
|
|
86
|
+
if (!('decoding' in props)) props.decoding = 'async';
|
|
87
|
+
props.src = prefixPath(props.src, prefix);
|
|
88
|
+
}
|
|
89
|
+
if (node.tagName === 'a') props.href = prefixPath(props.href, prefix);
|
|
31
90
|
}
|
|
32
91
|
(node.children || []).forEach(walk);
|
|
33
92
|
};
|
|
34
|
-
return (tree) => walk(tree);
|
|
93
|
+
return () => (tree) => walk(tree);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function registerMarkdownHooks(config, logger) {
|
|
97
|
+
/* config.base is '/' with no base configured, '/my-community' with one. */
|
|
98
|
+
const prefix = (config.base ?? '/').replace(/\/+$/, '');
|
|
99
|
+
const processor = config.markdown?.processor;
|
|
100
|
+
if (Array.isArray(processor?.options?.hastPlugins)) {
|
|
101
|
+
processor.options.hastPlugins.push(satteriMarkdownHooks(prefix));
|
|
102
|
+
} else if (Array.isArray(processor?.options?.rehypePlugins)) {
|
|
103
|
+
processor.options.rehypePlugins.push(rehypeMarkdownHooks(prefix));
|
|
104
|
+
} else {
|
|
105
|
+
logger.warn(
|
|
106
|
+
`markdown.processor "${processor?.name ?? 'unknown'}" takes neither hastPlugins nor rehypePlugins: ` +
|
|
107
|
+
'markdown images will not get loading="lazy"' +
|
|
108
|
+
(prefix ? ', and site-absolute links in content will not carry the base' : ''),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
35
111
|
}
|
|
36
112
|
|
|
37
113
|
/* Injected routes, grouped by opt-out key: popular({ routes: { speakers:
|
|
@@ -81,13 +157,13 @@ export default function popular(options = {}) {
|
|
|
81
157
|
return {
|
|
82
158
|
name: 'astro-theme-popular',
|
|
83
159
|
hooks: {
|
|
84
|
-
'astro:config:setup': ({ config, injectRoute, injectScript, updateConfig, addWatchFile }) => {
|
|
160
|
+
'astro:config:setup': ({ config, injectRoute, injectScript, updateConfig, addWatchFile, logger }) => {
|
|
85
161
|
const root = fileURLToPath(config.root);
|
|
86
162
|
const userConfig = path.resolve(root, configFile);
|
|
87
163
|
addWatchFile(userConfig);
|
|
164
|
+
registerMarkdownHooks(config, logger);
|
|
88
165
|
updateConfig({
|
|
89
166
|
integrations: [sitemap()],
|
|
90
|
-
markdown: { rehypePlugins: [rehypeLazyImages] },
|
|
91
167
|
vite: {
|
|
92
168
|
plugins: [
|
|
93
169
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "astro-theme-popular",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"exports": {
|
|
21
21
|
".": "./index.mjs",
|
|
22
22
|
"./schemas": "./schemas.ts",
|
|
23
|
+
"./url": "./src/lib/url.ts",
|
|
23
24
|
"./pages/*": "./src/pages/*",
|
|
24
25
|
"./components/*": "./src/components/*",
|
|
25
26
|
"./layouts/*": "./src/layouts/*",
|
|
@@ -4,6 +4,7 @@ import { STRINGS } from 'popular:config';
|
|
|
4
4
|
* Pass either `entry` (an authors-collection entry, linked to its profile)
|
|
5
5
|
* or `guest` (inline guestAuthors object from a post's front matter). */
|
|
6
6
|
import type { CollectionEntry } from 'astro:content';
|
|
7
|
+
import { withBase } from '../lib/url';
|
|
7
8
|
|
|
8
9
|
interface Guest {
|
|
9
10
|
name: string; title?: string; photo?: string; bio?: string; website?: string;
|
|
@@ -23,28 +24,28 @@ const bio = d ? ((d as any).bio ?? (d as any).description) : undefined;
|
|
|
23
24
|
<div class="g-persona g-persona--primary">
|
|
24
25
|
{d && (
|
|
25
26
|
<>
|
|
26
|
-
{d.photo && <img class="g-persona__img" src={d.photo} alt={entry.data.title} loading="lazy" decoding="async" />}
|
|
27
|
+
{d.photo && <img class="g-persona__img" src={withBase(d.photo)} alt={entry.data.title} loading="lazy" decoding="async" />}
|
|
27
28
|
<div>
|
|
28
|
-
<div class="g-persona__name"><a href={`${base}${entry.id}/`} style="color:inherit">{d.title}</a></div>
|
|
29
|
+
<div class="g-persona__name"><a href={withBase(`${base}${entry.id}/`)} style="color:inherit">{d.title}</a></div>
|
|
29
30
|
{d.role && <div class="g-persona__role">{d.role}</div>}
|
|
30
31
|
{bio && <p class="g-persona__bio">{bio}</p>}
|
|
31
32
|
<div class="g-persona__socials">
|
|
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>}
|
|
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
|
+
{(d as any).website && <a href={withBase((d as any).website)} aria-label={STRINGS.website} rel="noopener" target="_blank"><i class="fa-solid fa-globe" aria-hidden="true"></i></a>}
|
|
34
|
+
{d.social.map((s) => <a href={withBase(s.url)} aria-label={s.label} rel="noopener" target="_blank"><i class={s.icon} aria-hidden="true"></i></a>)}
|
|
34
35
|
</div>
|
|
35
36
|
</div>
|
|
36
37
|
</>
|
|
37
38
|
)}
|
|
38
39
|
{guest && (
|
|
39
40
|
<>
|
|
40
|
-
{guest.photo && <img class="g-persona__img" src={guest.photo} alt={guest.name} loading="lazy" decoding="async" />}
|
|
41
|
+
{guest.photo && <img class="g-persona__img" src={withBase(guest.photo)} alt={guest.name} loading="lazy" decoding="async" />}
|
|
41
42
|
<div>
|
|
42
43
|
<div class="g-persona__name">{guest.name} <span class="g-badge g-badge--soft" style="vertical-align:middle">{STRINGS.guest}</span></div>
|
|
43
44
|
{guest.title && <div class="g-persona__role">{guest.title}</div>}
|
|
44
45
|
{guest.bio && <p class="g-persona__bio">{guest.bio}</p>}
|
|
45
46
|
<div class="g-persona__socials">
|
|
46
|
-
{guest.website && <a href={guest.website} aria-label={STRINGS.website} rel="noopener" target="_blank"><i class="fa-solid fa-globe" aria-hidden="true"></i></a>}
|
|
47
|
-
{(guest.social ?? []).map((s) => <a href={s.url} aria-label={s.label} rel="noopener" target="_blank"><i class={s.icon} aria-hidden="true"></i></a>)}
|
|
47
|
+
{guest.website && <a href={withBase(guest.website)} aria-label={STRINGS.website} rel="noopener" target="_blank"><i class="fa-solid fa-globe" aria-hidden="true"></i></a>}
|
|
48
|
+
{(guest.social ?? []).map((s) => <a href={withBase(s.url)} aria-label={s.label} rel="noopener" target="_blank"><i class={s.icon} aria-hidden="true"></i></a>)}
|
|
48
49
|
</div>
|
|
49
50
|
</div>
|
|
50
51
|
</>
|
|
@@ -2,6 +2,7 @@
|
|
|
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 { withBase } from '../lib/url';
|
|
5
6
|
import { eventHasRecording } from '../lib/talks';
|
|
6
7
|
|
|
7
8
|
interface Props { event: CollectionEntry<'events'> }
|
|
@@ -41,7 +42,7 @@ const day = d.date.toLocaleDateString(SITE.locale, { month: 'short', day: 'numer
|
|
|
41
42
|
{d.tags.map((t) => <span class="g-tag">{t}</span>)}
|
|
42
43
|
</div>
|
|
43
44
|
)}
|
|
44
|
-
<h3><a href={`/events/${event.id}/`} style="color:inherit">{d.title}</a></h3>
|
|
45
|
+
<h3><a href={withBase(`/events/${event.id}/`)} style="color:inherit">{d.title}</a></h3>
|
|
45
46
|
{d.description && <p>{d.description}</p>}
|
|
46
47
|
<div class="g-event__meta">
|
|
47
48
|
<i class="fa-solid fa-location-dot" aria-hidden="true"></i>{venueName ?? STRINGS.venueTba}{venueAddress && ` · ${venueAddress}`}
|
|
@@ -60,7 +61,7 @@ const day = d.date.toLocaleDateString(SITE.locale, { month: 'short', day: 'numer
|
|
|
60
61
|
? <span class="g-badge g-badge--warning g-badge--soft">{STRINGS.venueWanted}</span>
|
|
61
62
|
: <span class="g-badge g-badge--success g-badge--soft">{STRINGS.confirmed}</span>}
|
|
62
63
|
{d.rsvp && (
|
|
63
|
-
<a class="g-btn g-btn--primary g-btn--sm" href={d.rsvp}><i class="fa-solid fa-calendar" aria-hidden="true"></i>{STRINGS.rsvp}</a>
|
|
64
|
+
<a class="g-btn g-btn--primary g-btn--sm" href={withBase(d.rsvp)}><i class="fa-solid fa-calendar" aria-hidden="true"></i>{STRINGS.rsvp}</a>
|
|
64
65
|
)}
|
|
65
66
|
</div>
|
|
66
67
|
</div>
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* (if SITE.community.chat is set, §4) and the calendar-subscribe link.
|
|
5
5
|
* The calendar link mirrors Hugo's OutputFormats "Calendar" endpoint. */
|
|
6
6
|
import { SITE, STRINGS } from 'popular:config';
|
|
7
|
+
import { withBase } from '../lib/url';
|
|
7
8
|
|
|
8
9
|
const chat = (SITE as Record<string, any>).community?.chat;
|
|
9
10
|
const calendar = '/events/calendar.ics';
|
|
@@ -15,11 +16,11 @@ const external = (url: string) => !url.startsWith('/');
|
|
|
15
16
|
{(chat?.url || calendar) && (
|
|
16
17
|
<div class="g-row" style="justify-content:center">
|
|
17
18
|
{chat?.url && (
|
|
18
|
-
<a class="g-btn g-btn--primary" href={chat.url} {...(external(chat.url) ? { rel: 'noopener', target: '_blank' } : {})}>
|
|
19
|
+
<a class="g-btn g-btn--primary" href={withBase(chat.url)} {...(external(chat.url) ? { rel: 'noopener', target: '_blank' } : {})}>
|
|
19
20
|
<i class="fa-solid fa-comments" aria-hidden="true"></i> {chat.label ?? STRINGS.joinChat}
|
|
20
21
|
</a>
|
|
21
22
|
)}
|
|
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
|
+
<a class="g-btn g-btn--outline" href={withBase(calendar)}><i class="fa-solid fa-calendar-plus" aria-hidden="true"></i> {STRINGS.subscribeCalendar}</a>
|
|
23
24
|
</div>
|
|
24
25
|
)}
|
|
25
26
|
</div>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { SITE, FOOTER, SOCIAL, STRINGS } from 'popular:config';
|
|
3
|
+
import { withBase } from '../lib/url';
|
|
3
4
|
import { version } from '../../package.json';
|
|
4
5
|
const year = new Date().getFullYear();
|
|
5
6
|
const credit = (FOOTER as { credit?: { label: string; url: string } }).credit;
|
|
@@ -10,12 +11,12 @@ const showVersion = !!credit && credit.url.includes('mariatta.ca');
|
|
|
10
11
|
<footer class="g-footer">
|
|
11
12
|
<div class="g-container g-footer__top">
|
|
12
13
|
<div class="g-footer__brand">
|
|
13
|
-
{SITE.logo && <img src={SITE.logo} alt={SITE.title} />}
|
|
14
|
+
{SITE.logo && <img src={withBase(SITE.logo)} alt={SITE.title} />}
|
|
14
15
|
{SITE.landAcknowledgement && <p class="g-footer__ack">{SITE.landAcknowledgement}</p>}
|
|
15
16
|
{SOCIAL.length > 0 && (
|
|
16
17
|
<div style="display:flex;gap:0.5rem;margin-top:0.25rem">
|
|
17
18
|
{SOCIAL.map((s) => (
|
|
18
|
-
<a class="g-social" href={s.url} aria-label={s.label} rel="me noopener" target="_blank">
|
|
19
|
+
<a class="g-social" href={withBase(s.url)} aria-label={s.label} rel="me noopener" target="_blank">
|
|
19
20
|
<i class={s.icon} aria-hidden="true"></i>
|
|
20
21
|
</a>
|
|
21
22
|
))}
|
|
@@ -28,7 +29,7 @@ const showVersion = !!credit && credit.url.includes('mariatta.ca');
|
|
|
28
29
|
<div class="g-footer__col-title">{col.title}</div>
|
|
29
30
|
<ul>
|
|
30
31
|
{col.links.map((l) => (
|
|
31
|
-
<li><a href={l.href}>{l.label}</a></li>
|
|
32
|
+
<li><a href={withBase(l.href)}>{l.label}</a></li>
|
|
32
33
|
))}
|
|
33
34
|
</ul>
|
|
34
35
|
</div>
|
|
@@ -39,9 +40,9 @@ const showVersion = !!credit && credit.url.includes('mariatta.ca');
|
|
|
39
40
|
<div class="g-container">
|
|
40
41
|
<span>© {year} {SITE.title}</span>
|
|
41
42
|
{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
|
+
<span><a href={withBase(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
|
)}
|
|
44
|
-
{credit && <span><a href={credit.url} rel="noopener">{credit.label}</a>{showVersion && <> · v{version}</>}</span>}
|
|
45
|
+
{credit && <span><a href={withBase(credit.url)} rel="noopener">{credit.label}</a>{showVersion && <> · v{version}</>}</span>}
|
|
45
46
|
{FOOTER.tagline && <span>{FOOTER.tagline}</span>}
|
|
46
47
|
</div>
|
|
47
48
|
</div>
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { SITE, NAV, CTA, STRINGS } from 'popular:config';
|
|
3
|
+
import { withBase } from '../lib/url';
|
|
3
4
|
const path = Astro.url.pathname;
|
|
4
|
-
|
|
5
|
+
/* Astro.url.pathname carries the site's base, so a config href has to gain
|
|
6
|
+
it too, or nothing is ever active on a subpath install. */
|
|
7
|
+
const isActive = (href: string) => href !== '/' && path.startsWith(withBase(href).replace(/\/$/, ''));
|
|
5
8
|
---
|
|
6
9
|
|
|
7
10
|
<header class="g-header">
|
|
8
11
|
<nav class="g-container g-nav" aria-label={STRINGS.primaryNav}>
|
|
9
|
-
<a class="g-brand" href=
|
|
10
|
-
{SITE.logo && <img src={SITE.logo} alt={SITE.title} />}
|
|
12
|
+
<a class="g-brand" href={withBase('/')}>
|
|
13
|
+
{SITE.logo && <img src={withBase(SITE.logo)} alt={SITE.title} />}
|
|
11
14
|
<span class="g-brand__text">
|
|
12
15
|
<span class="g-brand__name">{SITE.brandName ?? SITE.title}</span>
|
|
13
16
|
{SITE.brandSub && <span class="g-brand__sub">{SITE.brandSub}</span>}
|
|
@@ -23,25 +26,25 @@ const isActive = (href: string) => href !== '/' && path.startsWith(href.replace(
|
|
|
23
26
|
l.children?.length ? (
|
|
24
27
|
<li class="has-sub">
|
|
25
28
|
<a
|
|
26
|
-
href={l.href}
|
|
29
|
+
href={withBase(l.href)}
|
|
27
30
|
class={isActive(l.href) || l.children.some((c) => isActive(c.href)) ? 'is-active' : ''}
|
|
28
31
|
>
|
|
29
32
|
{l.label}<i class="fa-solid fa-chevron-down g-navlinks__caret" aria-hidden="true"></i>
|
|
30
33
|
</a>
|
|
31
34
|
<ul class="g-navlinks__sub">
|
|
32
35
|
{l.children.map((c) => (
|
|
33
|
-
<li><a href={c.href} class={isActive(c.href) ? 'is-active' : ''}>{c.label}</a></li>
|
|
36
|
+
<li><a href={withBase(c.href)} class={isActive(c.href) ? 'is-active' : ''}>{c.label}</a></li>
|
|
34
37
|
))}
|
|
35
38
|
</ul>
|
|
36
39
|
</li>
|
|
37
40
|
) : (
|
|
38
|
-
<li><a href={l.href} class={isActive(l.href) ? 'is-active' : ''}>{l.label}</a></li>
|
|
41
|
+
<li><a href={withBase(l.href)} class={isActive(l.href) ? 'is-active' : ''}>{l.label}</a></li>
|
|
39
42
|
)
|
|
40
43
|
)}
|
|
41
44
|
</ul>
|
|
42
45
|
|
|
43
46
|
{CTA && (
|
|
44
|
-
<a class="g-btn g-btn--primary g-btn--sm g-nav__cta" href={CTA.url}>
|
|
47
|
+
<a class="g-btn g-btn--primary g-btn--sm g-nav__cta" href={withBase(CTA.url)}>
|
|
45
48
|
{CTA.icon && <i class={CTA.icon} aria-hidden="true"></i>}{CTA.label}
|
|
46
49
|
</a>
|
|
47
50
|
)}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Text is inline-markdown rendered (parity with Hugo's notice.html). */
|
|
5
5
|
import { SITE, STRINGS } from 'popular:config';
|
|
6
6
|
import { inlineMarkdown } from '../lib/inline-md';
|
|
7
|
+
import { withBase } from '../lib/url';
|
|
7
8
|
|
|
8
9
|
const notice = (SITE as Record<string, any>).notice;
|
|
9
10
|
const text: string | undefined = notice?.text;
|
|
@@ -13,7 +14,7 @@ const html = text ? inlineMarkdown(text) : '';
|
|
|
13
14
|
<div class="g-notice" role="status">
|
|
14
15
|
<div class="g-notice__inner">
|
|
15
16
|
<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
|
+
<span><Fragment set:html={html} />{notice.url && <> <a href={withBase(notice.url)}>{STRINGS.learnMore}</a></>}</span>
|
|
17
18
|
</div>
|
|
18
19
|
</div>
|
|
19
20
|
)}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
import type { CollectionEntry } from 'astro:content';
|
|
3
|
+
import { withBase } from '../lib/url';
|
|
3
4
|
|
|
4
5
|
interface Props { organizer: CollectionEntry<'organizers'> }
|
|
5
6
|
const { organizer } = Astro.props;
|
|
@@ -7,14 +8,14 @@ const d = organizer.data;
|
|
|
7
8
|
---
|
|
8
9
|
|
|
9
10
|
<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" decoding="async" /></a>}
|
|
11
|
-
<div class="g-org__name"><a href={`/organizers/${organizer.id}/`} style="color:inherit">{d.title}</a></div>
|
|
11
|
+
{d.photo && <a href={withBase(`/organizers/${organizer.id}/`)}><img class="g-org__img" src={withBase(d.photo)} alt={d.title} loading="lazy" decoding="async" /></a>}
|
|
12
|
+
<div class="g-org__name"><a href={withBase(`/organizers/${organizer.id}/`)} style="color:inherit">{d.title}</a></div>
|
|
12
13
|
{d.role && <div class="g-org__role">{d.role}</div>}
|
|
13
14
|
{d.description && <p class="g-org__bio">{d.description}</p>}
|
|
14
15
|
{d.social.length > 0 && (
|
|
15
16
|
<div class="g-persona__socials">
|
|
16
17
|
{d.social.map((s) => (
|
|
17
|
-
<a href={s.url} aria-label={s.label} rel="noopener" target="_blank"><i class={s.icon} aria-hidden="true"></i></a>
|
|
18
|
+
<a href={withBase(s.url)} aria-label={s.label} rel="noopener" target="_blank"><i class={s.icon} aria-hidden="true"></i></a>
|
|
18
19
|
))}
|
|
19
20
|
</div>
|
|
20
21
|
)}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { withBase } from '../lib/url';
|
|
3
|
+
|
|
2
4
|
interface Props {
|
|
3
5
|
eyebrow?: string;
|
|
4
6
|
title: string;
|
|
@@ -12,7 +14,7 @@ const { eyebrow, title, lead, backHref, backLabel } = Astro.props;
|
|
|
12
14
|
<header class="g-pagehero">
|
|
13
15
|
<div class="g-container">
|
|
14
16
|
{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>
|
|
17
|
+
<div style="margin-bottom:1rem"><a href={withBase(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
18
|
)}
|
|
17
19
|
{eyebrow && <span class="g-eyebrow">{eyebrow}</span>}
|
|
18
20
|
<h1>{title}</h1>
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
itself, page N to `${base}page/N/`, matching Hugo's paginator URLs.
|
|
6
6
|
Renders nothing when everything fits on one page. */
|
|
7
7
|
import { STRINGS } from 'popular:config';
|
|
8
|
+
import { withBase } from '../lib/url';
|
|
8
9
|
|
|
9
10
|
interface Props {
|
|
10
11
|
current: number;
|
|
@@ -12,24 +13,24 @@ interface Props {
|
|
|
12
13
|
base: string;
|
|
13
14
|
}
|
|
14
15
|
const { current, total, base } = Astro.props;
|
|
15
|
-
const
|
|
16
|
+
const pageHref = (n: number) => withBase(n === 1 ? base : `${base}page/${n}/`);
|
|
16
17
|
const pages = Array.from({ length: total }, (_, i) => i + 1);
|
|
17
18
|
---
|
|
18
19
|
|
|
19
20
|
{total > 1 && (
|
|
20
21
|
<nav class="g-pager" aria-label={STRINGS.paginationLabel}>
|
|
21
22
|
{current > 1 && (
|
|
22
|
-
<a class="g-pager__nav" href={
|
|
23
|
+
<a class="g-pager__nav" href={pageHref(current - 1)} rel="prev"><i class="fa-solid fa-chevron-left" aria-hidden="true"></i>{STRINGS.paginationPrev}</a>
|
|
23
24
|
)}
|
|
24
25
|
{pages.map((n) =>
|
|
25
26
|
n === current ? (
|
|
26
27
|
<span class="g-pager__num is-active" aria-current="page">{n}</span>
|
|
27
28
|
) : (
|
|
28
|
-
<a class="g-pager__num" href={
|
|
29
|
+
<a class="g-pager__num" href={pageHref(n)}>{n}</a>
|
|
29
30
|
)
|
|
30
31
|
)}
|
|
31
32
|
{current < total && (
|
|
32
|
-
<a class="g-pager__nav" href={
|
|
33
|
+
<a class="g-pager__nav" href={pageHref(current + 1)} rel="next">{STRINGS.paginationNext}<i class="fa-solid fa-chevron-right" aria-hidden="true"></i></a>
|
|
33
34
|
)}
|
|
34
35
|
</nav>
|
|
35
36
|
)}
|
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
/** A gallery photo (§6). `alt` is required: the build throws with an
|
|
3
3
|
* actionable message when it is missing, keeping the image-alt CI green by
|
|
4
4
|
* construction. No lightbox, the figure links to the full-size image. */
|
|
5
|
+
import { withBase } from '../lib/url';
|
|
6
|
+
|
|
5
7
|
interface Props { src: string; alt: string; caption?: string; }
|
|
6
8
|
const { src, alt, caption } = Astro.props;
|
|
7
9
|
if (!alt) throw new Error(`<Photo src="${src}"> requires a non-empty alt`);
|
|
8
10
|
---
|
|
9
11
|
<figure class="g-gallery__item">
|
|
10
|
-
<a href={src}><img src={src} alt={alt} loading="lazy" decoding="async" /></a>
|
|
12
|
+
<a href={withBase(src)}><img src={withBase(src)} alt={alt} loading="lazy" decoding="async" /></a>
|
|
11
13
|
{caption && <figcaption>{caption}</figcaption>}
|
|
12
14
|
</figure>
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { SITE, SECTIONS_MAP } from 'popular:config';
|
|
3
3
|
import { tagSlug } from '../lib/slugs';
|
|
4
|
+
import { withBase } from '../lib/url';
|
|
4
5
|
import type { CollectionEntry } from 'astro:content';
|
|
5
6
|
import { safeCollection } from '../lib/collections';
|
|
6
7
|
|
|
7
8
|
interface Props { post: CollectionEntry<'blog'> }
|
|
8
9
|
const { post } = Astro.props;
|
|
9
10
|
const d = post.data;
|
|
10
|
-
const href = `/blog/${post.id}
|
|
11
|
+
const href = withBase(`/blog/${post.id}/`);
|
|
11
12
|
// Mirror of the Hugo card's date guard: this component can render date-less
|
|
12
13
|
// content, so omit the date (and, below, the whole eyebrow) when absent.
|
|
13
14
|
const hasDate = d.date instanceof Date && !Number.isNaN(+d.date) && d.date.getFullYear() > 1;
|
|
@@ -22,7 +23,7 @@ if (!names.length && d.author) names.push(d.author);
|
|
|
22
23
|
|
|
23
24
|
<article class="g-card" data-tags={d.tags.join(',')}>
|
|
24
25
|
{d.image && (
|
|
25
|
-
<a href={href} class="g-card__media"><img src={d.image} alt={d.title} loading="lazy" decoding="async" /></a>
|
|
26
|
+
<a href={href} class="g-card__media"><img src={withBase(d.image)} alt={d.title} loading="lazy" decoding="async" /></a>
|
|
26
27
|
)}
|
|
27
28
|
<div class="g-card__body">
|
|
28
29
|
{(month || names.length > 0) && (
|
|
@@ -33,7 +34,7 @@ if (!names.length && d.author) names.push(d.author);
|
|
|
33
34
|
{d.tags.length > 0 && (
|
|
34
35
|
<div class="g-card__footer">
|
|
35
36
|
{d.tags.slice(0, 2).map((t) => (
|
|
36
|
-
<a class="g-tag" href={`/tags/${tagSlug(t)}/`}>{t}</a>
|
|
37
|
+
<a class="g-tag" href={withBase(`/tags/${tagSlug(t)}/`)}>{t}</a>
|
|
37
38
|
))}
|
|
38
39
|
</div>
|
|
39
40
|
)}
|
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
* SITE.speakers.invite is true. Links to the /speak/ CFP page. Off by
|
|
4
4
|
* default, so sites that do not solicit talks show nothing. */
|
|
5
5
|
import { SITE, STRINGS } from 'popular:config';
|
|
6
|
+
import { withBase } from '../lib/url';
|
|
6
7
|
const invite = (SITE as Record<string, any>).speakers?.invite === true;
|
|
7
8
|
---
|
|
8
9
|
{invite && (
|
|
9
10
|
<div class="g-persona g-persona--primary">
|
|
10
11
|
<span class="g-medallion g-medallion--lg" aria-hidden="true"><i class="fa-solid fa-microphone-lines"></i></span>
|
|
11
12
|
<div>
|
|
12
|
-
<div class="g-persona__name"><a href=
|
|
13
|
+
<div class="g-persona__name"><a href={withBase('/speak/')} style="color:inherit">{STRINGS.speakerInviteTitle} <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
|
|
13
14
|
<p class="g-persona__bio">{STRINGS.speakerInviteBody}</p>
|
|
14
15
|
</div>
|
|
15
16
|
</div>
|
|
@@ -12,6 +12,7 @@ import Footer from 'popular:component/Footer';
|
|
|
12
12
|
import DemoBar from '../components/DemoBar.astro';
|
|
13
13
|
import Notice from '../components/Notice.astro';
|
|
14
14
|
import { SITE, BRAND, STRINGS } from 'popular:config';
|
|
15
|
+
import { withBase, absoluteUrl } from '../lib/url';
|
|
15
16
|
|
|
16
17
|
interface Props {
|
|
17
18
|
title?: string;
|
|
@@ -27,6 +28,8 @@ const ogLocale = SITE.locale.replace('-', '_');
|
|
|
27
28
|
const pageTitle = title ? `${title} · ${SITE.title}` : `${SITE.title}, ${SITE.tagline}`;
|
|
28
29
|
const desc = description ?? SITE.description;
|
|
29
30
|
const ogImage = image ?? SITE.ogImage;
|
|
31
|
+
/* Astro.url.pathname already carries the base; Astro.site does not, hence
|
|
32
|
+
absoluteUrl() for everything built from a path we wrote ourselves. */
|
|
30
33
|
const canonical = new URL(Astro.url.pathname, Astro.site ?? Astro.url);
|
|
31
34
|
|
|
32
35
|
/* Brand overrides: mirrors the Hugo theme's brand-vars.html partial. */
|
|
@@ -85,16 +88,16 @@ const brandCss = `html:root{${vars.join(';')}}`;
|
|
|
85
88
|
{article?.modified && <meta property="article:modified_time" content={article.modified.toISOString()} />}
|
|
86
89
|
{article?.authors?.map((a) => <meta property="article:author" content={a} />)}
|
|
87
90
|
{article?.tags?.map((t) => <meta property="article:tag" content={t} />)}
|
|
88
|
-
{ogImage && <meta property="og:image" content={
|
|
91
|
+
{ogImage && <meta property="og:image" content={absoluteUrl(ogImage, Astro.site ?? Astro.url)} />}
|
|
89
92
|
{ogImage && <meta property="og:image:alt" content={imageAlt ?? title ?? SITE.title} />}
|
|
90
93
|
<meta name="twitter:card" content={ogImage ? 'summary_large_image' : 'summary'} />
|
|
91
|
-
<link rel="icon" href={SITE.favicon ?? SITE.logo} />
|
|
92
|
-
<link rel="alternate" type="application/rss+xml" title={(SITE as Record<string, any>).rssTitle ?? SITE.title} href={
|
|
94
|
+
<link rel="icon" href={withBase(SITE.favicon ?? SITE.logo)} />
|
|
95
|
+
<link rel="alternate" type="application/rss+xml" title={(SITE as Record<string, any>).rssTitle ?? SITE.title} href={absoluteUrl('/rss.xml', Astro.site ?? Astro.url)} />
|
|
93
96
|
{SITE.fontAwesome.includes("font-awesome/6.5.2/css/all.min.css") && <link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin />}
|
|
94
97
|
<link rel="stylesheet" href={SITE.fontAwesome} referrerpolicy="no-referrer"
|
|
95
98
|
integrity={SITE.fontAwesome.includes("font-awesome/6.5.2/css/all.min.css") ? "sha384-PPIZEGYM1v8zp5Py7UjFb79S58UeqCL9pYVnVPURKEqvioPROaVAJKKLzvH2rDnI" : undefined}
|
|
96
99
|
crossorigin={SITE.fontAwesome.includes("font-awesome/6.5.2/css/all.min.css") ? "anonymous" : undefined} />
|
|
97
|
-
{(SITE as Record<string, any>).customCSS?.map((href: string) => <link rel="stylesheet" href={href} />)}
|
|
100
|
+
{(SITE as Record<string, any>).customCSS?.map((href: string) => <link rel="stylesheet" href={withBase(href)} />)}
|
|
98
101
|
{noindex && <meta name="robots" content="noindex,follow" />}
|
|
99
102
|
<style is:inline id="popular-brand" set:html={brandCss}></style>
|
|
100
103
|
<slot name="head" />
|
package/src/lib/inline-md.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* `.RenderString`: supports links, bold, emphasis, and inline code, and
|
|
4
4
|
* HTML-escapes everything else. Not a full Markdown parser; block syntax
|
|
5
5
|
* (headings, lists, images) is intentionally out of scope. */
|
|
6
|
+
import { withBase } from './url';
|
|
7
|
+
|
|
6
8
|
const escapeHtml = (s: string): string =>
|
|
7
9
|
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
8
10
|
|
|
@@ -12,7 +14,7 @@ export function inlineMarkdown(src: string): string {
|
|
|
12
14
|
s = s.replace(
|
|
13
15
|
/\[([^\]]+)\]\((https?:\/\/[^\s)]+|\/[^\s)]*)\)/g,
|
|
14
16
|
(_m, label, href) =>
|
|
15
|
-
`<a href="${href}"${href.startsWith('/') ? '' : ' rel="noopener" target="_blank"'}>${label}</a>`,
|
|
17
|
+
`<a href="${withBase(href)}"${href.startsWith('/') ? '' : ' rel="noopener" target="_blank"'}>${label}</a>`,
|
|
16
18
|
);
|
|
17
19
|
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
18
20
|
s = s.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
|
package/src/lib/url.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Base-aware URL helpers.
|
|
2
|
+
*
|
|
3
|
+
* A site can be served from a subpath: `base: '/my-community'` in
|
|
4
|
+
* astro.config.mjs, which is what a GitHub project page
|
|
5
|
+
* (user.github.io/repo/) needs. Astro rewrites the asset URLs it generates
|
|
6
|
+
* itself, but every path the theme writes by hand is a literal string Astro
|
|
7
|
+
* never sees, so it has to be prefixed here or it 404s on a subpath install.
|
|
8
|
+
* The Hugo twin does the same job with relURL/absURL (see PARITY.md).
|
|
9
|
+
*
|
|
10
|
+
* Both helpers are deliberately tolerant: they pass through anything that is
|
|
11
|
+
* not a site-absolute path (external URLs, mailto:, tel:, #anchors, relative
|
|
12
|
+
* paths) and anything already carrying the base. That makes them safe to
|
|
13
|
+
* apply to adopter-authored values from popular.config.ts and to content
|
|
14
|
+
* front matter, neither of which knows the base, so adopters never have to
|
|
15
|
+
* rewrite their own links.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/* BASE_URL is '/' when no base is configured, and '/my-community' when one
|
|
19
|
+
is (with a trailing slash if trailingSlash is 'always', hence the strip).
|
|
20
|
+
Normalizes to '' or '/my-community', so joining is plain concatenation. */
|
|
21
|
+
const BASE = import.meta.env.BASE_URL.replace(/\/+$/, '');
|
|
22
|
+
|
|
23
|
+
export function withBase(path: string): string;
|
|
24
|
+
export function withBase(path: string | undefined | null): string | undefined;
|
|
25
|
+
export function withBase(path: string | undefined | null): string | undefined {
|
|
26
|
+
if (!path || !BASE) return path ?? undefined;
|
|
27
|
+
/* Not site-absolute (external, protocol-relative, anchor, relative): leave it. */
|
|
28
|
+
if (path[0] !== '/' || path[1] === '/') return path;
|
|
29
|
+
if (path === BASE || path.startsWith(`${BASE}/`)) return path;
|
|
30
|
+
return BASE + path;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Absolute URL for canonicals, feeds and structured data: origin + base +
|
|
34
|
+
* path. `Astro.site` (and the `site` passed to endpoints) is the bare
|
|
35
|
+
* origin and does not carry the base, so a path resolved against it alone
|
|
36
|
+
* silently drops the subpath. */
|
|
37
|
+
export function absoluteUrl(path: string, site: URL | string | undefined): string {
|
|
38
|
+
return new URL(withBase(path), site ?? 'https://example.com').href;
|
|
39
|
+
}
|
|
@@ -4,6 +4,7 @@ import { STRINGS } from 'popular:config';
|
|
|
4
4
|
* and docs pages (handbook, runbooks, rendered with a scroll-spy TOC). */
|
|
5
5
|
import { render } from 'astro:content';
|
|
6
6
|
import { safeCollection } from '../lib/collections';
|
|
7
|
+
import { withBase } from '../lib/url';
|
|
7
8
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
|
8
9
|
import PageHero from 'popular:component/PageHero';
|
|
9
10
|
|
|
@@ -18,7 +19,7 @@ export async function getStaticPaths() {
|
|
|
18
19
|
const cut = entry.id.lastIndexOf('/');
|
|
19
20
|
const parentEntry = cut > 0 ? byId.get(entry.id.slice(0, cut)) : undefined;
|
|
20
21
|
const parent = parentEntry
|
|
21
|
-
? { href: `/${parentEntry.id}
|
|
22
|
+
? { href: withBase(`/${parentEntry.id}/`), label: parentEntry.data.shortTitle ?? parentEntry.data.title }
|
|
22
23
|
: null;
|
|
23
24
|
return { params: { slug: entry.id }, props: { entry, kind: 'page', parent } };
|
|
24
25
|
}),
|
|
@@ -47,7 +48,7 @@ const allDocs = (await safeCollection('docs')).sort(
|
|
|
47
48
|
{allDocs.map((e) => (
|
|
48
49
|
<>
|
|
49
50
|
<a
|
|
50
|
-
href={`/${e.id}/`}
|
|
51
|
+
href={withBase(`/${e.id}/`)}
|
|
51
52
|
class={e.id === entry.id ? 'is-active' : ''}
|
|
52
53
|
aria-current={e.id === entry.id ? 'page' : undefined}
|
|
53
54
|
>{e.data.title}</a>
|
|
@@ -4,6 +4,7 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
|
4
4
|
import PageHero from 'popular:component/PageHero';
|
|
5
5
|
import PostCard from 'popular:component/PostCard';
|
|
6
6
|
import Pagination from '../../components/Pagination.astro';
|
|
7
|
+
import { withBase } from '../../lib/url';
|
|
7
8
|
import { SECTIONS, STRINGS, PAGINATION } from 'popular:config';
|
|
8
9
|
|
|
9
10
|
/* One route serves /blog/ and /blog/page/N/, matching the Hugo theme's
|
|
@@ -41,7 +42,7 @@ const { current, total, posts, tags } = Astro.props;
|
|
|
41
42
|
</>
|
|
42
43
|
)}
|
|
43
44
|
</div>
|
|
44
|
-
<a class="g-btn g-btn--ghost g-btn--sm" href=
|
|
45
|
+
<a class="g-btn g-btn--ghost g-btn--sm" href={withBase('/rss.xml')} title={STRINGS.subscribeRssTitle}><i class="fa-solid fa-rss" aria-hidden="true"></i> {STRINGS.subscribeRss}</a>
|
|
45
46
|
</div>
|
|
46
47
|
<div class="g-grid-3">
|
|
47
48
|
{posts.map((p) => <PostCard post={p} />)}
|
|
@@ -5,6 +5,7 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
|
5
5
|
import AuthorBox from 'popular:component/AuthorBox';
|
|
6
6
|
import { SUPPORT, SITE, STRINGS, SECTIONS_MAP } from 'popular:config';
|
|
7
7
|
import { buildBlogPostingLd, jsonld } from '../../lib/jsonld';
|
|
8
|
+
import { withBase, absoluteUrl } from '../../lib/url';
|
|
8
9
|
import { tagSlug } from '../../lib/slugs';
|
|
9
10
|
|
|
10
11
|
export async function getStaticPaths() {
|
|
@@ -33,14 +34,14 @@ const bylineNames = [
|
|
|
33
34
|
...d.guestAuthors.map((g) => g.name),
|
|
34
35
|
];
|
|
35
36
|
if (!bylineNames.length && d.author) bylineNames.push(d.author);
|
|
36
|
-
const siteUrl =
|
|
37
|
+
const siteUrl = absoluteUrl('/', Astro.site ?? Astro.url);
|
|
37
38
|
const postLd = jsonld(buildBlogPostingLd({
|
|
38
39
|
title: d.title,
|
|
39
40
|
datePublished: d.date.toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
|
40
41
|
dateModified: d.date.toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
|
41
42
|
authors: bylineNames,
|
|
42
|
-
image: d.image ?
|
|
43
|
-
url:
|
|
43
|
+
image: d.image ? absoluteUrl(d.image, Astro.site ?? Astro.url) : undefined,
|
|
44
|
+
url: absoluteUrl(`/blog/${post.id}/`, Astro.site ?? Astro.url),
|
|
44
45
|
description: d.description,
|
|
45
46
|
siteTitle: SITE.title,
|
|
46
47
|
siteUrl,
|
|
@@ -55,12 +56,12 @@ const byline = bylineNames.length > 1
|
|
|
55
56
|
<article>
|
|
56
57
|
<header class="g-pagehero">
|
|
57
58
|
<div class="g-container" style="padding-block:var(--space-6) var(--space-5)">
|
|
58
|
-
<a href=
|
|
59
|
+
<a href={withBase('/blog/')} style="display:inline-flex;gap:.4rem;align-items:center;font-size:var(--text-sm);font-weight:var(--weight-medium);margin-bottom:1rem">
|
|
59
60
|
<i class="fa-solid fa-arrow-left" aria-hidden="true"></i> {STRINGS.allPosts}
|
|
60
61
|
</a>
|
|
61
62
|
{d.tags.length > 0 && (
|
|
62
63
|
<div class="g-row" style="margin-bottom:.8rem">
|
|
63
|
-
{d.tags.map((t) => <a class="g-tag" href={`/tags/${tagSlug(t)}/`}>{t}</a>)}
|
|
64
|
+
{d.tags.map((t) => <a class="g-tag" href={withBase(`/tags/${tagSlug(t)}/`)}>{t}</a>)}
|
|
64
65
|
</div>
|
|
65
66
|
)}
|
|
66
67
|
<h1 style="margin:0">{d.title}</h1>
|
|
@@ -74,7 +75,7 @@ const byline = bylineNames.length > 1
|
|
|
74
75
|
<div class="g-article">
|
|
75
76
|
{d.image && (
|
|
76
77
|
<div style="border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-md);margin-bottom:var(--space-5)">
|
|
77
|
-
<img src={d.image} alt={d.title} style="width:100%;aspect-ratio:16/9;object-fit:cover" />
|
|
78
|
+
<img src={withBase(d.image)} alt={d.title} style="width:100%;aspect-ratio:16/9;object-fit:cover" />
|
|
78
79
|
</div>
|
|
79
80
|
)}
|
|
80
81
|
|
|
@@ -84,7 +85,7 @@ const byline = bylineNames.length > 1
|
|
|
84
85
|
<>
|
|
85
86
|
<h2 style="font-size:var(--text-xl);margin:var(--space-5) 0 1rem">{STRINGS.ourSpeaker}</h2>
|
|
86
87
|
<div class="g-persona">
|
|
87
|
-
{d.speaker.photo && <img class="g-persona__img" src={d.speaker.photo} alt={d.speaker.name} />}
|
|
88
|
+
{d.speaker.photo && <img class="g-persona__img" src={withBase(d.speaker.photo)} alt={d.speaker.name} />}
|
|
88
89
|
<div>
|
|
89
90
|
<div class="g-persona__name">{d.speaker.name}</div>
|
|
90
91
|
{d.speaker.title && <div class="g-persona__role">{d.speaker.title}</div>}
|
|
@@ -107,13 +108,13 @@ const byline = bylineNames.length > 1
|
|
|
107
108
|
{(older || newer) && (
|
|
108
109
|
<nav class="g-postnav" aria-label={STRINGS.postNavigation}>
|
|
109
110
|
{older ? (
|
|
110
|
-
<a class="g-postnav__card" href={`/blog/${older.id}/`} rel="prev">
|
|
111
|
+
<a class="g-postnav__card" href={withBase(`/blog/${older.id}/`)} rel="prev">
|
|
111
112
|
<span class="g-postnav__label"><i class="fa-solid fa-arrow-left" aria-hidden="true"></i> {STRINGS.olderPost}</span>
|
|
112
113
|
<span class="g-postnav__title">{older.data.title}</span>
|
|
113
114
|
</a>
|
|
114
115
|
) : <span></span>}
|
|
115
116
|
{newer && (
|
|
116
|
-
<a class="g-postnav__card g-postnav__card--next" href={`/blog/${newer.id}/`} rel="next">
|
|
117
|
+
<a class="g-postnav__card g-postnav__card--next" href={withBase(`/blog/${newer.id}/`)} rel="next">
|
|
117
118
|
<span class="g-postnav__label">{STRINGS.newerPost} <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></span>
|
|
118
119
|
<span class="g-postnav__title">{newer.data.title}</span>
|
|
119
120
|
</a>
|
|
@@ -126,7 +127,7 @@ const byline = bylineNames.length > 1
|
|
|
126
127
|
<div class="g-callout__body">{SUPPORT.text}</div>
|
|
127
128
|
<div class="g-row">
|
|
128
129
|
{SUPPORT.ctas.map((c) => (
|
|
129
|
-
<a class={`g-btn g-btn--${c.variant ?? 'secondary'}`} href={c.url}>{c.label}</a>
|
|
130
|
+
<a class={`g-btn g-btn--${c.variant ?? 'secondary'}`} href={withBase(c.url)}>{c.label}</a>
|
|
130
131
|
))}
|
|
131
132
|
</div>
|
|
132
133
|
</div>
|
|
@@ -4,6 +4,7 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
|
4
4
|
import PageHero from 'popular:component/PageHero';
|
|
5
5
|
import EventRow from 'popular:component/EventRow';
|
|
6
6
|
import Pagination from '../../components/Pagination.astro';
|
|
7
|
+
import { withBase } from '../../lib/url';
|
|
7
8
|
import EventsEmpty from '../../components/EventsEmpty.astro';
|
|
8
9
|
import { SECTIONS, STRINGS, PAGINATION } from 'popular:config';
|
|
9
10
|
|
|
@@ -53,7 +54,7 @@ const groupByYear = (events: EventEntry[]) => {
|
|
|
53
54
|
<span class="g-eyebrow">{STRINGS.comingUp}</span>
|
|
54
55
|
<h2>{STRINGS.upcomingEvents}</h2>
|
|
55
56
|
<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=
|
|
57
|
+
<div class="g-row" style="margin-top:var(--space-3)"><a class="g-btn g-btn--outline g-btn--sm" href={withBase('/events/calendar.ics')}><i class="fa-solid fa-calendar-plus" aria-hidden="true"></i> {STRINGS.subscribeCalendar}</a></div>
|
|
57
58
|
</div>
|
|
58
59
|
<div class="g-stack" style="margin-top:var(--space-4);gap:var(--space-3)">
|
|
59
60
|
{groupByYear(upcoming).map((group) => (
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { SITE, STRINGS } from 'popular:config';
|
|
3
3
|
import { buildEventLd, jsonld } from '../../lib/eventld';
|
|
4
|
+
import { withBase, absoluteUrl } from '../../lib/url';
|
|
4
5
|
import { render } from 'astro:content';
|
|
5
6
|
import { safeCollection } from '../../lib/collections';
|
|
6
7
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
@@ -37,8 +38,8 @@ const talks = eventTalks(d, allSpeakers, event.id);
|
|
|
37
38
|
const hasTalksArray = !!(d.talks && d.talks.length > 0);
|
|
38
39
|
|
|
39
40
|
/* schema.org/Event JSON-LD (see lib/eventld; contract in PARITY.md). */
|
|
40
|
-
const siteUrl =
|
|
41
|
-
const permalink =
|
|
41
|
+
const siteUrl = absoluteUrl('/', Astro.site ?? Astro.url);
|
|
42
|
+
const permalink = absoluteUrl(`/events/${event.id}/`, Astro.site ?? Astro.url);
|
|
42
43
|
const eventLd = jsonld(buildEventLd({
|
|
43
44
|
title: d.title,
|
|
44
45
|
date: d.date,
|
|
@@ -49,7 +50,7 @@ const eventLd = jsonld(buildEventLd({
|
|
|
49
50
|
price: (d as any).price,
|
|
50
51
|
currency: (d as any).currency,
|
|
51
52
|
cost: (d as any).cost,
|
|
52
|
-
image: d.image ?
|
|
53
|
+
image: d.image ? absoluteUrl(d.image, Astro.site ?? Astro.url) : undefined,
|
|
53
54
|
rsvp: d.rsvp,
|
|
54
55
|
permalink,
|
|
55
56
|
siteUrl,
|
|
@@ -66,7 +67,7 @@ const eventLd = jsonld(buildEventLd({
|
|
|
66
67
|
<header class="g-pagehero">
|
|
67
68
|
<div class="g-container" style="padding-block:var(--space-6) var(--space-5)">
|
|
68
69
|
<div style="margin-bottom:1rem">
|
|
69
|
-
<a href=
|
|
70
|
+
<a href={withBase('/events/')} style="display:inline-flex;gap:.4rem;align-items:center;font-size:var(--text-sm);font-weight:var(--weight-medium)">
|
|
70
71
|
<i class="fa-solid fa-arrow-left" aria-hidden="true"></i> {STRINGS.allEvents}
|
|
71
72
|
</a>
|
|
72
73
|
</div>
|
|
@@ -78,13 +79,13 @@ const eventLd = jsonld(buildEventLd({
|
|
|
78
79
|
<div class="g-article">
|
|
79
80
|
<div class="g-meta-line" style="margin-bottom:var(--space-4)">
|
|
80
81
|
<span><i class="fa-solid fa-location-dot" aria-hidden="true"></i> {venuePage
|
|
81
|
-
? <><a href={`/venues/${venuePage.id}/`}>{venuePage.data.title}</a>{venuePage.data.address && ` · ${venuePage.data.address}`}</>
|
|
82
|
+
? <><a href={withBase(`/venues/${venuePage.id}/`)}>{venuePage.data.title}</a>{venuePage.data.address && ` · ${venuePage.data.address}`}</>
|
|
82
83
|
: <>{d.venue ?? STRINGS.venueTba}{d.address && ` · ${d.address}`}</>}</span>
|
|
83
84
|
{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>}
|
|
85
|
+
{venuePage && (venuePage.data.transit || venuePage.data.parking || venuePage.data.access) && <span><a href={withBase(`/venues/${venuePage.id}/`)}>{STRINGS.gettingThere}</a></span>}
|
|
85
86
|
{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>}
|
|
87
|
+
{!hasTalksArray && d.recording && <span><a href={withBase(d.recording)} rel="noopener" target="_blank"><i class="fa-solid fa-circle-play" aria-hidden="true"></i> {STRINGS.watchRecording}</a></span>}
|
|
88
|
+
{!hasTalksArray && d.slides && <span><a href={withBase(d.slides)} rel="noopener" target="_blank"><i class="fa-solid fa-file-lines" aria-hidden="true"></i> {STRINGS.viewSlides}</a></span>}
|
|
88
89
|
</div>
|
|
89
90
|
|
|
90
91
|
{(d.checkin || venueNotes) && (
|
|
@@ -114,12 +115,12 @@ const eventLd = jsonld(buildEventLd({
|
|
|
114
115
|
<div class="g-talk">
|
|
115
116
|
<div class="g-talk__title">{t.title}</div>
|
|
116
117
|
{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
|
+
<div class="g-talk__by"><i class="fa-solid fa-microphone-lines" aria-hidden="true"></i> {t.speakerSlug ? <a href={withBase(`/speakers/${t.speakerSlug}/`)}>{t.speakerName}</a> : t.speakerName}</div>
|
|
118
119
|
)}
|
|
119
120
|
{(t.recording || t.slides) && (
|
|
120
121
|
<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>}
|
|
122
|
+
{t.recording && <a class="g-btn g-btn--sm g-btn--outline" href={withBase(t.recording)} rel="noopener" target="_blank"><i class="fa-solid fa-circle-play" aria-hidden="true"></i> {STRINGS.watchRecording}</a>}
|
|
123
|
+
{t.slides && <a class="g-btn g-btn--sm g-btn--ghost" href={withBase(t.slides)} rel="noopener" target="_blank"><i class="fa-solid fa-file-lines" aria-hidden="true"></i> {STRINGS.viewSlides}</a>}
|
|
123
124
|
</div>
|
|
124
125
|
)}
|
|
125
126
|
</div>
|
|
@@ -130,7 +131,7 @@ const eventLd = jsonld(buildEventLd({
|
|
|
130
131
|
|
|
131
132
|
{d.rsvp && (
|
|
132
133
|
<div class="g-row" style="margin-top:var(--space-4)">
|
|
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>
|
|
134
|
+
<a class="g-btn g-btn--primary g-btn--lg" href={withBase(d.rsvp)}><i class="fa-solid fa-calendar" aria-hidden="true"></i> {STRINGS.rsvpExternal}</a>
|
|
134
135
|
</div>
|
|
135
136
|
)}
|
|
136
137
|
</div>
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro';
|
|
2
2
|
import { getCollection } from 'astro:content';
|
|
3
3
|
import { buildIcs } from '../../lib/ical';
|
|
4
|
+
import { absoluteUrl } from '../../lib/url';
|
|
4
5
|
import { SITE } from 'popular:config';
|
|
5
6
|
|
|
6
7
|
/* iCalendar feed: upcoming events plus the last 90 days. Mirrors the Hugo
|
|
7
8
|
events/list.calendar.ics template. */
|
|
8
9
|
export const GET: APIRoute = async ({ site }) => {
|
|
9
|
-
const
|
|
10
|
+
const origin = site ?? new URL('https://example.com');
|
|
10
11
|
const cutoff = Date.now() - 90 * 24 * 60 * 60 * 1000;
|
|
11
12
|
const events = (await getCollection('events'))
|
|
12
13
|
.filter((e) => !e.data.draft && +e.data.date >= cutoff)
|
|
@@ -25,7 +26,7 @@ export const GET: APIRoute = async ({ site }) => {
|
|
|
25
26
|
checkin: e.data.checkin,
|
|
26
27
|
cancelled: e.data.cancelled,
|
|
27
28
|
venue: v ? { name: v.data.title, address: v.data.address } : (e.data.venue ? { name: e.data.venue } : undefined),
|
|
28
|
-
permalink:
|
|
29
|
+
permalink: absoluteUrl(`/events/${e.id}/`, origin),
|
|
29
30
|
};
|
|
30
31
|
}),
|
|
31
32
|
{ siteTitle: SITE.title, dtstamp, durationMinutes: duration },
|
package/src/pages/index.astro
CHANGED
|
@@ -7,6 +7,7 @@ import OrganizerCard from 'popular:component/OrganizerCard';
|
|
|
7
7
|
import EventsEmpty from '../components/EventsEmpty.astro';
|
|
8
8
|
import { HOME, SITE, STRINGS, SECTIONS_MAP, SOCIAL } from 'popular:config';
|
|
9
9
|
import { buildOrgLd, jsonld } from '../lib/jsonld';
|
|
10
|
+
import { withBase, absoluteUrl } from '../lib/url';
|
|
10
11
|
|
|
11
12
|
const now = new Date();
|
|
12
13
|
const events = (await safeCollection('events')).filter((e) => !e.data.draft);
|
|
@@ -30,8 +31,8 @@ const h = HOME;
|
|
|
30
31
|
const chat = (SITE as Record<string, any>).community?.chat;
|
|
31
32
|
const orgLd = jsonld(buildOrgLd({
|
|
32
33
|
name: SITE.title,
|
|
33
|
-
url:
|
|
34
|
-
logo: SITE.logo ?
|
|
34
|
+
url: absoluteUrl('/', Astro.site ?? Astro.url),
|
|
35
|
+
logo: SITE.logo ? absoluteUrl(SITE.logo, Astro.site ?? Astro.url) : undefined,
|
|
35
36
|
description: SITE.description,
|
|
36
37
|
sameAs: SOCIAL.map((s) => s.url),
|
|
37
38
|
}));
|
|
@@ -49,12 +50,12 @@ const orgLd = jsonld(buildOrgLd({
|
|
|
49
50
|
{(h.hero.ctas || chat) && (
|
|
50
51
|
<div class="g-row">
|
|
51
52
|
{(h.hero.ctas ?? []).map((c) => (
|
|
52
|
-
<a class={`g-btn g-btn--lg g-btn--${c.variant ?? 'primary'}`} href={c.url}>
|
|
53
|
+
<a class={`g-btn g-btn--lg g-btn--${c.variant ?? 'primary'}`} href={withBase(c.url)}>
|
|
53
54
|
{'icon' in c && c.icon && <i class={c.icon} aria-hidden="true"></i>}{c.label}
|
|
54
55
|
</a>
|
|
55
56
|
))}
|
|
56
57
|
{chat && (
|
|
57
|
-
<a class="g-btn g-btn--lg g-btn--secondary" href={chat.url} {...(chat.url.startsWith('/') ? {} : { rel: 'noopener', target: '_blank' })}>
|
|
58
|
+
<a class="g-btn g-btn--lg g-btn--secondary" href={withBase(chat.url)} {...(chat.url.startsWith('/') ? {} : { rel: 'noopener', target: '_blank' })}>
|
|
58
59
|
<i class="fa-solid fa-comments" aria-hidden="true"></i> {chat.label ?? STRINGS.joinChat}
|
|
59
60
|
</a>
|
|
60
61
|
)}
|
|
@@ -62,7 +63,7 @@ const orgLd = jsonld(buildOrgLd({
|
|
|
62
63
|
)}
|
|
63
64
|
</div>
|
|
64
65
|
{h.hero.image && (
|
|
65
|
-
<div class="g-hero__media"><img src={h.hero.image} alt={SITE.title} /></div>
|
|
66
|
+
<div class="g-hero__media"><img src={withBase(h.hero.image)} alt={SITE.title} /></div>
|
|
66
67
|
)}
|
|
67
68
|
</div>
|
|
68
69
|
</section>
|
|
@@ -104,7 +105,7 @@ const orgLd = jsonld(buildOrgLd({
|
|
|
104
105
|
{next && (
|
|
105
106
|
<section class="g-bg-wash-soft">
|
|
106
107
|
<div class="g-container g-feature">
|
|
107
|
-
{(next.data.image ?? h.hero.image) && <div class="g-feature__media"><img src={next.data.image ?? h.hero.image} alt={next.data.title} /></div>}
|
|
108
|
+
{(next.data.image ?? h.hero.image) && <div class="g-feature__media"><img src={withBase(next.data.image ?? h.hero.image)} alt={next.data.title} /></div>}
|
|
108
109
|
<div class="g-feature__body">
|
|
109
110
|
<span class="g-badge g-badge--soft"><i class="fa-solid fa-calendar" aria-hidden="true"></i> {STRINGS.nextMeetup}</span>
|
|
110
111
|
<h2 style="margin:0">{next.data.title}</h2>
|
|
@@ -114,8 +115,8 @@ const orgLd = jsonld(buildOrgLd({
|
|
|
114
115
|
</div>
|
|
115
116
|
{next.data.description && <p style="margin:0;color:var(--text-muted);max-width:34rem">{next.data.description}</p>}
|
|
116
117
|
<div class="g-row">
|
|
117
|
-
{next.data.rsvp && <a class="g-btn g-btn--primary" href={next.data.rsvp}><i class="fa-solid fa-calendar" aria-hidden="true"></i> {STRINGS.rsvpExternal}</a>}
|
|
118
|
-
<a class="g-btn g-btn--outline" href=
|
|
118
|
+
{next.data.rsvp && <a class="g-btn g-btn--primary" href={withBase(next.data.rsvp)}><i class="fa-solid fa-calendar" aria-hidden="true"></i> {STRINGS.rsvpExternal}</a>}
|
|
119
|
+
<a class="g-btn g-btn--outline" href={withBase('/events/')}>{STRINGS.allEvents}</a>
|
|
119
120
|
</div>
|
|
120
121
|
</div>
|
|
121
122
|
</div>
|
|
@@ -136,7 +137,7 @@ const orgLd = jsonld(buildOrgLd({
|
|
|
136
137
|
<span class="g-eyebrow">{STRINGS.fromTheBlog}</span>
|
|
137
138
|
<h2>{STRINGS.latestFromCommunity}</h2>
|
|
138
139
|
</div>
|
|
139
|
-
<a class="g-btn g-btn--ghost" href=
|
|
140
|
+
<a class="g-btn g-btn--ghost" href={withBase('/blog/')}>{STRINGS.readTheBlog} <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a>
|
|
140
141
|
</div>
|
|
141
142
|
<div class="g-grid-3" style="margin-top:var(--space-4)">
|
|
142
143
|
{posts.map((p) => <PostCard post={p} />)}
|
|
@@ -157,7 +158,7 @@ const orgLd = jsonld(buildOrgLd({
|
|
|
157
158
|
{organizers.slice(0, 3).map((o) => <OrganizerCard organizer={o} />)}
|
|
158
159
|
</div>
|
|
159
160
|
<div class="g-row" style="justify-content:center;margin-top:var(--space-4)">
|
|
160
|
-
<a class="g-btn g-btn--outline" href={`/${SECTIONS_MAP.team}/`}>All organizers</a>
|
|
161
|
+
<a class="g-btn g-btn--outline" href={withBase(`/${SECTIONS_MAP.team}/`)}>All organizers</a>
|
|
161
162
|
</div>
|
|
162
163
|
</div>
|
|
163
164
|
</section>
|
|
@@ -178,7 +179,7 @@ const orgLd = jsonld(buildOrgLd({
|
|
|
178
179
|
<figure class="g-quote">
|
|
179
180
|
<blockquote>{t.quote}</blockquote>
|
|
180
181
|
<figcaption>
|
|
181
|
-
{t.photo && <img src={t.photo} alt={t.name} />}
|
|
182
|
+
{t.photo && <img src={withBase(t.photo)} alt={t.name} />}
|
|
182
183
|
<div><span class="g-quote__name">{t.name}</span>{t.role && <span class="g-quote__role">{t.role}</span>}</div>
|
|
183
184
|
</figcaption>
|
|
184
185
|
</figure>
|
|
@@ -196,7 +197,7 @@ const orgLd = jsonld(buildOrgLd({
|
|
|
196
197
|
{h.getInvolved.lead && <p style="margin:0;color:var(--ink-700);max-width:38rem">{h.getInvolved.lead}</p>}
|
|
197
198
|
<div class="g-row" style="justify-content:center">
|
|
198
199
|
{h.getInvolved.ctas.map((c) => (
|
|
199
|
-
<a class={`g-btn g-btn--${c.variant ?? 'primary'}`} href={c.url}>{c.label}</a>
|
|
200
|
+
<a class={`g-btn g-btn--${c.variant ?? 'primary'}`} href={withBase(c.url)}>{c.label}</a>
|
|
200
201
|
))}
|
|
201
202
|
</div>
|
|
202
203
|
</div>
|
package/src/pages/llms.txt.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro';
|
|
2
2
|
import { getCollection } from 'astro:content';
|
|
3
|
+
import { absoluteUrl } from '../lib/url';
|
|
3
4
|
import { SITE } from 'popular:config';
|
|
4
5
|
|
|
5
6
|
/* §8 llms.txt: a build-time plain-text summary for AI agents. Names the next
|
|
@@ -8,8 +9,7 @@ import { SITE } from 'popular:config';
|
|
|
8
9
|
layouts/index.llms.txt. Always generated (SEO/discovery plumbing, like
|
|
9
10
|
robots.txt). */
|
|
10
11
|
export const GET: APIRoute = async ({ site }) => {
|
|
11
|
-
const
|
|
12
|
-
const abs = (p: string) => new URL(p, base).href;
|
|
12
|
+
const abs = (p: string) => absoluteUrl(p, site);
|
|
13
13
|
const now = Date.now();
|
|
14
14
|
|
|
15
15
|
const events = (await getCollection('events'))
|
|
@@ -31,7 +31,7 @@ export const GET: APIRoute = async ({ site }) => {
|
|
|
31
31
|
: 'No upcoming events are scheduled right now.',
|
|
32
32
|
'',
|
|
33
33
|
);
|
|
34
|
-
if (chat) lines.push(`Join the community chat: ${chat.url}`, '');
|
|
34
|
+
if (chat) lines.push(`Join the community chat: ${abs(chat.url)}`, '');
|
|
35
35
|
lines.push('## Links');
|
|
36
36
|
if (pageIds.has('start')) lines.push(`Start here (newcomers): ${abs('/start/')}`);
|
|
37
37
|
if (pageIds.has('about')) lines.push(`About and FAQ: ${abs('/about/')}`);
|
package/src/pages/robots.txt.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro';
|
|
2
|
+
import { absoluteUrl } from '../lib/url';
|
|
2
3
|
|
|
3
4
|
/* Generated so the Sitemap URL respects `site` + base path. @astrojs/sitemap
|
|
4
5
|
emits sitemap-index.xml. */
|
|
5
6
|
export const GET: APIRoute = ({ site }) => {
|
|
6
|
-
const sitemap =
|
|
7
|
+
const sitemap = absoluteUrl('/sitemap-index.xml', site);
|
|
7
8
|
return new Response(`User-agent: *\nAllow: /\nSitemap: ${sitemap}\n`, {
|
|
8
9
|
headers: { 'Content-Type': 'text/plain' },
|
|
9
10
|
});
|
package/src/pages/rss.xml.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import rss from '@astrojs/rss';
|
|
2
2
|
import { safeCollection } from '../lib/collections';
|
|
3
|
+
import { withBase, absoluteUrl } from '../lib/url';
|
|
3
4
|
import { SITE, SECTIONS_MAP } from 'popular:config';
|
|
4
5
|
|
|
5
6
|
const escapeXml = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
@@ -12,7 +13,7 @@ export async function GET(context) {
|
|
|
12
13
|
return rss({
|
|
13
14
|
title: SITE.title,
|
|
14
15
|
description: SITE.description,
|
|
15
|
-
site: context.site,
|
|
16
|
+
site: absoluteUrl('/', context.site),
|
|
16
17
|
xmlns: { dc: 'http://purl.org/dc/elements/1.1/' },
|
|
17
18
|
items: posts.map((post) => {
|
|
18
19
|
const names = [
|
|
@@ -24,7 +25,7 @@ export async function GET(context) {
|
|
|
24
25
|
title: post.data.title,
|
|
25
26
|
pubDate: post.data.date,
|
|
26
27
|
description: post.data.description,
|
|
27
|
-
link: `/blog/${post.id}
|
|
28
|
+
link: withBase(`/blog/${post.id}/`),
|
|
28
29
|
categories: post.data.tags,
|
|
29
30
|
customData: names.length ? `<dc:creator>${escapeXml(names.join(', '))}</dc:creator>` : undefined,
|
|
30
31
|
};
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { SITE, STRINGS } from 'popular:config';
|
|
9
9
|
import { safeCollection } from '../../lib/collections';
|
|
10
10
|
import { collectTalks } from '../../lib/talks';
|
|
11
|
+
import { withBase } from '../../lib/url';
|
|
11
12
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
12
13
|
import PageHero from 'popular:component/PageHero';
|
|
13
14
|
|
|
@@ -41,13 +42,13 @@ const fmt = (d: Date) => d.toLocaleDateString(SITE.locale, { month: 'short', day
|
|
|
41
42
|
<div class="g-talk" data-tags={t.tags.join(',')}>
|
|
42
43
|
<div class="g-talk__title">{t.title}</div>
|
|
43
44
|
{t.speakerName && (
|
|
44
|
-
<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>
|
|
45
|
+
<div class="g-talk__by"><i class="fa-solid fa-microphone-lines" aria-hidden="true"></i> {t.speakerSlug ? <a href={withBase(`/speakers/${t.speakerSlug}/`)}>{t.speakerName}</a> : t.speakerName}</div>
|
|
45
46
|
)}
|
|
46
|
-
<div class="g-talk__event"><i class="fa-solid fa-calendar-day" aria-hidden="true"></i> <a href={`/events/${t.eventId}/`}>{t.eventTitle}</a> · {fmt(t.date)}</div>
|
|
47
|
+
<div class="g-talk__event"><i class="fa-solid fa-calendar-day" aria-hidden="true"></i> <a href={withBase(`/events/${t.eventId}/`)}>{t.eventTitle}</a> · {fmt(t.date)}</div>
|
|
47
48
|
{(t.recording || t.slides) && (
|
|
48
49
|
<div class="g-row" style="gap:var(--space-2)">
|
|
49
|
-
{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>}
|
|
50
|
-
{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>}
|
|
50
|
+
{t.recording && <a class="g-btn g-btn--sm g-btn--outline" href={withBase(t.recording)} rel="noopener" target="_blank"><i class="fa-solid fa-circle-play" aria-hidden="true"></i> {STRINGS.watchRecording}</a>}
|
|
51
|
+
{t.slides && <a class="g-btn g-btn--sm g-btn--ghost" href={withBase(t.slides)} rel="noopener" target="_blank"><i class="fa-solid fa-file-lines" aria-hidden="true"></i> {STRINGS.viewSlides}</a>}
|
|
51
52
|
</div>
|
|
52
53
|
)}
|
|
53
54
|
</div>
|
|
@@ -5,6 +5,7 @@ import { safeCollection } from '../../lib/collections';
|
|
|
5
5
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
6
6
|
import PageHero from '../../components/PageHero.astro';
|
|
7
7
|
import EventRow from '../../components/EventRow.astro';
|
|
8
|
+
import { withBase } from '../../lib/url';
|
|
8
9
|
|
|
9
10
|
export async function getStaticPaths() {
|
|
10
11
|
const venues = await safeCollection('venues');
|
|
@@ -24,10 +25,10 @@ const events = (await safeCollection('events'))
|
|
|
24
25
|
<section class="g-bg-white g-section">
|
|
25
26
|
<div class="g-container">
|
|
26
27
|
<div class="g-article" style="margin-bottom:var(--space-5)">
|
|
27
|
-
{d.photo && <img src={d.photo} alt={d.title} style="width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:var(--radius-xl);box-shadow:var(--shadow-md);margin-bottom:var(--space-4)" loading="lazy" decoding="async" />}
|
|
28
|
+
{d.photo && <img src={withBase(d.photo)} alt={d.title} style="width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:var(--radius-xl);box-shadow:var(--shadow-md);margin-bottom:var(--space-4)" loading="lazy" decoding="async" />}
|
|
28
29
|
<div class="g-meta-line" style="margin-bottom:var(--space-3)">
|
|
29
30
|
{d.address && <span><i class="fa-solid fa-location-dot" aria-hidden="true"></i> {d.address}</span>}
|
|
30
|
-
{d.website && <span><i class="fa-solid fa-globe" aria-hidden="true"></i> <a href={d.website} rel="noopener" target="_blank">{STRINGS.website}</a></span>}
|
|
31
|
+
{d.website && <span><i class="fa-solid fa-globe" aria-hidden="true"></i> <a href={withBase(d.website)} rel="noopener" target="_blank">{STRINGS.website}</a></span>}
|
|
31
32
|
</div>
|
|
32
33
|
{(d.notes || d.accessibility) && (
|
|
33
34
|
<div class="g-callout" style="flex-direction:column;gap:.6rem">
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* venue, sorted by name. Fixes the "All venues" backlink on venue detail pages,
|
|
4
4
|
* which previously had no list page to point to. */
|
|
5
5
|
import { safeCollection } from '../../lib/collections';
|
|
6
|
+
import { withBase } from '../../lib/url';
|
|
6
7
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
7
8
|
import PageHero from '../../components/PageHero.astro';
|
|
8
9
|
import { SECTIONS } from 'popular:config';
|
|
@@ -19,10 +20,10 @@ const sec = SECTIONS.venues;
|
|
|
19
20
|
{venues.map((v) => (
|
|
20
21
|
<article class="g-card">
|
|
21
22
|
{v.data.photo && (
|
|
22
|
-
<a href={`/venues/${v.id}/`} class="g-card__media"><img src={v.data.photo} alt={v.data.title} loading="lazy" decoding="async" /></a>
|
|
23
|
+
<a href={withBase(`/venues/${v.id}/`)} class="g-card__media"><img src={withBase(v.data.photo)} alt={v.data.title} loading="lazy" decoding="async" /></a>
|
|
23
24
|
)}
|
|
24
25
|
<div class="g-card__body">
|
|
25
|
-
<h3 class="g-card__title"><a href={`/venues/${v.id}/`}>{v.data.title}</a></h3>
|
|
26
|
+
<h3 class="g-card__title"><a href={withBase(`/venues/${v.id}/`)}>{v.data.title}</a></h3>
|
|
26
27
|
{v.data.address && <div class="g-card__text"><i class="fa-solid fa-location-dot" aria-hidden="true"></i> {v.data.address}</div>}
|
|
27
28
|
</div>
|
|
28
29
|
</article>
|