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 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). Dependency-free HAST walk. */
151
+ coerces into one). A plain recursive walk, no unist-util-visit. */
80
152
  function rehypeMarkdownHooks(prefix) {
81
- const walk = (node) => {
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);
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
- if (node.tagName === 'a') props.href = prefixPath(props.href, prefix);
90
- }
91
- (node.children || []).forEach(walk);
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.12.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 link.
5
- * The calendar link mirrors Hugo's OutputFormats "Calendar" endpoint. */
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
- {(chat?.url || calendar) && (
17
- <div class="g-row" style="justify-content:center">
18
- {chat?.url && (
19
- <a class="g-btn g-btn--primary" href={withBase(chat.url)} {...(external(chat.url) ? { rel: 'noopener', target: '_blank' } : {})}>
20
- <i class="fa-solid fa-comments" aria-hidden="true"></i> {chat.label ?? STRINGS.joinChat}
21
- </a>
22
- )}
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>
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, '\;').replace(/,/g, '\\,').replace(/\n/g, '\\n');
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 { withBase } from '../../lib/url';
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)"><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
+ <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) => (
@@ -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
- .g-doc__content h2, .g-doc__content h3, .g-doc__content h4 { scroll-margin-top: 108px; }
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 ---------- */