astro-theme-popular 0.12.0 → 0.14.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 +95 -13
- package/package.json +3 -2
- package/src/components/CalendarSubscribe.astro +24 -0
- package/src/components/EventsEmpty.astro +11 -13
- package/src/lib/ical.ts +5 -1
- package/src/pages/events/[...page].astro +2 -2
- package/src/styles/base.css +11 -0
- package/src/styles/components.css +3 -1
package/index.mjs
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
import { fileURLToPath } from 'node:url';
|
|
21
21
|
import sitemap from '@astrojs/sitemap';
|
|
22
|
+
import Slugger from 'github-slugger';
|
|
22
23
|
|
|
23
24
|
/* Markdown body hooks: loading="lazy" decoding="async" on images (CWV parity
|
|
24
25
|
with Hugo's render-image.html), and site-absolute links/images prefixed with
|
|
@@ -26,7 +27,8 @@ import sitemap from '@astrojs/sitemap';
|
|
|
26
27
|
survive a subpath install (parity with Hugo's render hooks passing paths
|
|
27
28
|
through relURL). Everything the templates write is handled by src/lib/url.ts
|
|
28
29
|
instead; MDX component props (<Photo src="/images/x.png">) are JSX, not HAST,
|
|
29
|
-
so the components prefix those themselves.
|
|
30
|
+
so the components prefix those themselves. And a hover "#" anchor on every
|
|
31
|
+
h2-h6, so each section has a copyable URL (Hugo's render-heading.html).
|
|
30
32
|
|
|
31
33
|
Registered on the active markdown processor rather than through
|
|
32
34
|
`markdown.rehypePlugins`: Astro 7's default processor is Sätteri, which does
|
|
@@ -47,6 +49,59 @@ const prefixPath = (value, prefix) =>
|
|
|
47
49
|
? prefix + value
|
|
48
50
|
: value;
|
|
49
51
|
|
|
52
|
+
/* Section permalinks: every h2-h6 ends in a "#" anchor linking to its own id
|
|
53
|
+
(parity with Hugo's render-heading.html; styled by .g-anchor in base.css).
|
|
54
|
+
Astro assigns heading ids in its own hast plugin, which runs *after* ours,
|
|
55
|
+
so the heading is slugged here with the same library and the same text
|
|
56
|
+
Astro would use, and the id set from here: Astro keeps an id that is
|
|
57
|
+
already present and reports it in `headings`, which keeps the docs sidebar
|
|
58
|
+
pointing at the same fragments. h1 is slugged too, so the duplicate counter
|
|
59
|
+
matches Astro's ("intro", "intro-1"), but gets no anchor: the page title
|
|
60
|
+
lives in the hero. The anchor is kept out of the accessibility tree and the
|
|
61
|
+
tab order on purpose: a labelled link inside a heading becomes part of the
|
|
62
|
+
heading's accessible name, and the docs sidebar already lists the same
|
|
63
|
+
fragments as real links. It is empty (the glyph comes from CSS) so feeds
|
|
64
|
+
and plain-text excerpts of the body never pick up a stray "#". */
|
|
65
|
+
const HEADINGS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
|
|
66
|
+
|
|
67
|
+
const anchorNode = (id) => ({
|
|
68
|
+
type: 'element',
|
|
69
|
+
tagName: 'a',
|
|
70
|
+
/* Attribute spellings, not hast's camelCase: MDX output keeps property
|
|
71
|
+
names as written (`tabIndex` would reach the HTML as-is). */
|
|
72
|
+
properties: { className: ['g-anchor'], href: `#${id}`, 'aria-hidden': 'true', tabindex: '-1' },
|
|
73
|
+
children: [], // the "#" is CSS generated content; see base.css
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/* Heading text the way Astro's own heading-id plugins read it: text nodes,
|
|
77
|
+
plus MDX `{frontmatter.x}` expressions resolved against the frontmatter
|
|
78
|
+
(mirrors collectHastText in @astrojs/markdown-satteri, which is not a
|
|
79
|
+
dependency the package can rely on). Without this a heading written as
|
|
80
|
+
`## {frontmatter.title}` would slug to "frontmattertitle". */
|
|
81
|
+
const resolveFrontmatter = (expr, frontmatter) => {
|
|
82
|
+
if (!expr.startsWith('frontmatter.') && !expr.startsWith('frontmatter[')) return undefined;
|
|
83
|
+
const parts = [];
|
|
84
|
+
const re = /\.(\w+)|\[(\d+)\]|\["([^"]+)"\]|\['([^']+)'\]/g;
|
|
85
|
+
let m;
|
|
86
|
+
while ((m = re.exec(expr.slice('frontmatter'.length))) !== null) parts.push(m[1] ?? m[2] ?? m[3] ?? m[4]);
|
|
87
|
+
let value = frontmatter;
|
|
88
|
+
for (const key of parts) {
|
|
89
|
+
if (value == null || typeof value !== 'object') return undefined;
|
|
90
|
+
value = value[key];
|
|
91
|
+
}
|
|
92
|
+
return parts.length && typeof value === 'string' ? value : undefined;
|
|
93
|
+
};
|
|
94
|
+
const headingText = (node, frontmatter) => {
|
|
95
|
+
let text = '';
|
|
96
|
+
if (node.type === 'mdxTextExpression' || node.type === 'mdxFlowExpression') {
|
|
97
|
+
if (node.value != null) text += resolveFrontmatter(node.value.trim(), frontmatter ?? {}) ?? node.value;
|
|
98
|
+
} else if (node.type === 'text' && node.value != null) {
|
|
99
|
+
text += node.value;
|
|
100
|
+
}
|
|
101
|
+
for (const child of node.children || []) text += headingText(child, frontmatter);
|
|
102
|
+
return text;
|
|
103
|
+
};
|
|
104
|
+
|
|
50
105
|
/* Sätteri shape: filtered visitors, mutations through ctx.setProperty. */
|
|
51
106
|
function satteriMarkdownHooks(prefix) {
|
|
52
107
|
return {
|
|
@@ -70,27 +125,54 @@ function satteriMarkdownHooks(prefix) {
|
|
|
70
125
|
if (href !== props.href) ctx.setProperty(node, 'href', href);
|
|
71
126
|
},
|
|
72
127
|
},
|
|
128
|
+
{
|
|
129
|
+
filter: HEADINGS,
|
|
130
|
+
visit(node, ctx) {
|
|
131
|
+
let id = node.properties?.id;
|
|
132
|
+
if (typeof id !== 'string') {
|
|
133
|
+
/* ctx.data is per document, so the duplicate counter resets per page. */
|
|
134
|
+
const slugger = (ctx.data.popularSlugger ??= new Slugger());
|
|
135
|
+
const raw = ctx.textContent(node);
|
|
136
|
+
id = slugger.slug(raw.includes('frontmatter') ? headingText(node, ctx.data.astro?.frontmatter) : raw);
|
|
137
|
+
ctx.setProperty(node, 'id', id);
|
|
138
|
+
}
|
|
139
|
+
/* No anchor on h1 (the page title lives in the hero) or on a heading
|
|
140
|
+
with no text to slug: href="#" would scroll to the top. Hugo's
|
|
141
|
+
hook guards both the same way. */
|
|
142
|
+
if (node.tagName !== 'h1' && id) ctx.appendChild(node, anchorNode(id));
|
|
143
|
+
},
|
|
144
|
+
},
|
|
73
145
|
],
|
|
74
146
|
};
|
|
75
147
|
}
|
|
76
148
|
|
|
77
149
|
/* unified/rehype shape, for a site that opted into `markdown.processor:
|
|
78
150
|
unified({...})` (or set the deprecated markdown.rehypePlugins, which Astro
|
|
79
|
-
coerces into one).
|
|
151
|
+
coerces into one). A plain recursive walk, no unist-util-visit. */
|
|
80
152
|
function rehypeMarkdownHooks(prefix) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
153
|
+
return () => (tree, file) => {
|
|
154
|
+
/* One slugger per document, like Astro's own rehypeHeadingIds (which runs
|
|
155
|
+
after user plugins and keeps an id that is already set). */
|
|
156
|
+
const slugger = new Slugger();
|
|
157
|
+
const frontmatter = file?.data?.astro?.frontmatter;
|
|
158
|
+
const walk = (node) => {
|
|
159
|
+
const props = node.properties;
|
|
160
|
+
if (props) {
|
|
161
|
+
if (node.tagName === 'img') {
|
|
162
|
+
if (!('loading' in props)) props.loading = 'lazy';
|
|
163
|
+
if (!('decoding' in props)) props.decoding = 'async';
|
|
164
|
+
props.src = prefixPath(props.src, prefix);
|
|
165
|
+
}
|
|
166
|
+
if (node.tagName === 'a') props.href = prefixPath(props.href, prefix);
|
|
167
|
+
if (HEADINGS.includes(node.tagName)) {
|
|
168
|
+
if (typeof props.id !== 'string') props.id = slugger.slug(headingText(node, frontmatter));
|
|
169
|
+
if (node.tagName !== 'h1' && props.id) (node.children ??= []).push(anchorNode(props.id));
|
|
170
|
+
}
|
|
88
171
|
}
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
(
|
|
172
|
+
(node.children || []).forEach(walk);
|
|
173
|
+
};
|
|
174
|
+
walk(tree);
|
|
92
175
|
};
|
|
93
|
-
return () => (tree) => walk(tree);
|
|
94
176
|
}
|
|
95
177
|
|
|
96
178
|
function registerMarkdownHooks(config, logger) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "astro-theme-popular",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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",
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@astrojs/rss": "^4.0.0",
|
|
32
|
-
"@astrojs/sitemap": "^3.0.0"
|
|
32
|
+
"@astrojs/sitemap": "^3.0.0",
|
|
33
|
+
"github-slugger": "^2.0.0"
|
|
33
34
|
},
|
|
34
35
|
"peerDependencies": {
|
|
35
36
|
"astro": "^7.0.0"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** Calendar subscribe buttons for the events feed: a webcal link that opens
|
|
3
|
+
* the user's calendar app subscribed to the live feed (Apple Calendar,
|
|
4
|
+
* Outlook, Thunderbird), a Google Calendar deep link (Google registers no
|
|
5
|
+
* webcal handler on the web or Android), and the plain .ics download as the
|
|
6
|
+
* universal fallback for "add calendar from URL" flows. webcal needs an
|
|
7
|
+
* absolute URL, so the feed path goes through absoluteUrl. Mirrors Hugo's
|
|
8
|
+
* partials/calendar-subscribe.html; callers supply the wrapping .g-row. */
|
|
9
|
+
import { STRINGS } from 'popular:config';
|
|
10
|
+
import { withBase, absoluteUrl } from '../lib/url';
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
small?: boolean;
|
|
14
|
+
}
|
|
15
|
+
const { small = false } = Astro.props;
|
|
16
|
+
|
|
17
|
+
const feedPath = '/events/calendar.ics';
|
|
18
|
+
const webcal = absoluteUrl(feedPath, Astro.site ?? Astro.url).replace(/^https?:/, 'webcal:');
|
|
19
|
+
const google = `https://calendar.google.com/calendar/render?cid=${encodeURIComponent(webcal)}`;
|
|
20
|
+
const size = small ? ' g-btn--sm' : '';
|
|
21
|
+
---
|
|
22
|
+
<a class={`g-btn g-btn--outline${size}`} href={webcal}><i class="fa-solid fa-calendar-plus" aria-hidden="true"></i> {STRINGS.subscribeCalendar}</a>
|
|
23
|
+
<a class={`g-btn g-btn--outline${size}`} href={google} rel="noopener" target="_blank"><i class="fa-brands fa-google" aria-hidden="true"></i> {STRINGS.subscribeGoogle}</a>
|
|
24
|
+
<a class={`g-btn g-btn--ghost${size}`} href={withBase(feedPath)} download><i class="fa-solid fa-download" aria-hidden="true"></i> {STRINGS.downloadCalendar}</a>
|
|
@@ -1,26 +1,24 @@
|
|
|
1
1
|
---
|
|
2
2
|
/** Designed empty state for zero upcoming events (§5). Shown on the home page
|
|
3
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
|
|
5
|
-
* The calendar
|
|
4
|
+
* (if SITE.community.chat is set, §4) and the calendar-subscribe buttons.
|
|
5
|
+
* The calendar feed mirrors Hugo's OutputFormats "Calendar" endpoint. */
|
|
6
6
|
import { SITE, STRINGS } from 'popular:config';
|
|
7
7
|
import { withBase } from '../lib/url';
|
|
8
|
+
import CalendarSubscribe from './CalendarSubscribe.astro';
|
|
8
9
|
|
|
9
10
|
const chat = (SITE as Record<string, any>).community?.chat;
|
|
10
|
-
const calendar = '/events/calendar.ics';
|
|
11
11
|
const external = (url: string) => !url.startsWith('/');
|
|
12
12
|
---
|
|
13
13
|
<div class="g-empty">
|
|
14
14
|
<span class="g-medallion g-medallion--lg"><i class="fa-solid fa-calendar-day" aria-hidden="true"></i></span>
|
|
15
15
|
<p class="g-lead">{STRINGS.noUpcomingEvents}</p>
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
{chat
|
|
19
|
-
<
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
</div>
|
|
25
|
-
)}
|
|
16
|
+
<div class="g-row" style="justify-content:center">
|
|
17
|
+
{chat?.url && (
|
|
18
|
+
<a class="g-btn g-btn--primary" href={withBase(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
|
+
<CalendarSubscribe />
|
|
23
|
+
</div>
|
|
26
24
|
</div>
|
package/src/lib/ical.ts
CHANGED
|
@@ -11,7 +11,7 @@ function ymd(d: Date): string {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export function icsEscape(s: string): string {
|
|
14
|
-
return s.replace(/\\/g, '\\\\').replace(/;/g, '
|
|
14
|
+
return s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n');
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/** One content line, CRLF-terminated, folded at 75 octets (continuations
|
|
@@ -61,6 +61,10 @@ export function buildIcs(
|
|
|
61
61
|
L.push(fold('CALSCALE:GREGORIAN'));
|
|
62
62
|
L.push(fold('METHOD:PUBLISH'));
|
|
63
63
|
L.push(fold(`X-WR-CALNAME:${opts.siteTitle} events`));
|
|
64
|
+
/* Subscription refresh hints (RFC 7986 + the Apple/Outlook legacy twin).
|
|
65
|
+
Google Calendar ignores both and polls on its own schedule. */
|
|
66
|
+
L.push(fold('REFRESH-INTERVAL;VALUE=DURATION:PT12H'));
|
|
67
|
+
L.push(fold('X-PUBLISHED-TTL:PT12H'));
|
|
64
68
|
for (const e of events) {
|
|
65
69
|
L.push(fold('BEGIN:VEVENT'));
|
|
66
70
|
L.push(fold(`UID:${e.permalink}`));
|
|
@@ -4,7 +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
|
|
7
|
+
import CalendarSubscribe from '../../components/CalendarSubscribe.astro';
|
|
8
8
|
import EventsEmpty from '../../components/EventsEmpty.astro';
|
|
9
9
|
import { SECTIONS, STRINGS, PAGINATION } from 'popular:config';
|
|
10
10
|
|
|
@@ -54,7 +54,7 @@ const groupByYear = (events: EventEntry[]) => {
|
|
|
54
54
|
<span class="g-eyebrow">{STRINGS.comingUp}</span>
|
|
55
55
|
<h2>{STRINGS.upcomingEvents}</h2>
|
|
56
56
|
<p class="g-lead">{STRINGS.upcomingLead}</p>
|
|
57
|
-
<div class="g-row" style="margin-top:var(--space-3)"><
|
|
57
|
+
<div class="g-row" style="margin-top:var(--space-3)"><CalendarSubscribe small /></div>
|
|
58
58
|
</div>
|
|
59
59
|
<div class="g-stack" style="margin-top:var(--space-4);gap:var(--space-3)">
|
|
60
60
|
{groupByYear(upcoming).map((group) => (
|
package/src/styles/base.css
CHANGED
|
@@ -97,6 +97,17 @@ pre code { font-size: inherit; background: none; padding: 0; border-radius: 0; }
|
|
|
97
97
|
.g-prose > * + * { margin-top: 1.1rem; }
|
|
98
98
|
.g-prose ul, .g-prose ol { padding-left: 1.2rem; display: flex; flex-direction: column; gap: 0.5rem; }
|
|
99
99
|
.g-prose h2 { margin-top: 1.8rem; }
|
|
100
|
+
/* Section permalinks: every markdown h2-h6 ends in a "#" anchor (Hugo:
|
|
101
|
+
_markup/render-heading.html; Astro: the markdown hook) so a section has a
|
|
102
|
+
URL a reader can copy. Shown while the heading is hovered; where there is
|
|
103
|
+
no hover (touch) it stays faintly visible so it can still be tapped. The
|
|
104
|
+
glyph is generated content: the anchor itself is empty so summaries, feeds
|
|
105
|
+
and plain-text excerpts of the body never pick up a stray "#". */
|
|
106
|
+
.g-anchor::before { content: "#"; }
|
|
107
|
+
.g-anchor { margin-left: 0.35em; font-weight: var(--weight-regular); color: var(--text-muted); text-decoration: none; opacity: 0; transition: opacity 0.15s; }
|
|
108
|
+
:is(h2, h3, h4, h5, h6):hover > .g-anchor { opacity: 1; }
|
|
109
|
+
.g-anchor:hover { color: var(--color-primary); }
|
|
110
|
+
@media (hover: none) { .g-anchor { opacity: 0.5; } }
|
|
100
111
|
.g-prose img { border-radius: var(--radius-xl); box-shadow: var(--shadow-sm); }
|
|
101
112
|
/* One background for every fenced block, whatever the highlighter did.
|
|
102
113
|
!important beats the inline background-color that Chroma (Hugo) and
|
|
@@ -258,7 +258,9 @@ pre:hover .g-copy, .g-copy:focus-visible { opacity: 1; }
|
|
|
258
258
|
.g-doc-toc a.is-active { color: var(--gold-700); background: var(--gold-50); border-left-color: var(--color-primary); font-weight: var(--weight-semibold); }
|
|
259
259
|
.g-doc__content { min-width: 0; }
|
|
260
260
|
.g-doc__content h2 { margin-top: 2.5rem; }
|
|
261
|
-
|
|
261
|
+
/* A fragment target stops clear of the sticky header. Every prose heading,
|
|
262
|
+
not only docs: blog posts and event pages carry section anchors too. */
|
|
263
|
+
.g-prose :is(h2, h3, h4, h5, h6) { scroll-margin-top: 108px; }
|
|
262
264
|
.g-doc__content h2:first-child { margin-top: 0; }
|
|
263
265
|
|
|
264
266
|
/* ---------- Callout ---------- */
|