@rocket/js 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +14 -23
  2. package/dist-types/exports/layouts/atlasBlog.d.ts +2 -0
  3. package/dist-types/exports/layouts/atlasBlog.d.ts.map +1 -0
  4. package/dist-types/exports/types/rocket.d.ts +68 -2
  5. package/dist-types/exports/types/rocket.d.ts.map +1 -1
  6. package/dist-types/exports/types.d.ts +1 -1
  7. package/dist-types/exports/types.d.ts.map +1 -1
  8. package/dist-types/src/PageData.d.ts +3 -1
  9. package/dist-types/src/PageData.d.ts.map +1 -1
  10. package/dist-types/src/RocketJsDemo.d.ts.map +1 -1
  11. package/dist-types/src/SocialPreviewPlayground.d.ts +1 -0
  12. package/dist-types/src/SocialPreviewPlayground.d.ts.map +1 -1
  13. package/dist-types/src/asyncMessage.d.ts +5 -0
  14. package/dist-types/src/asyncMessage.d.ts.map +1 -1
  15. package/dist-types/src/cli/RocketBuild.d.ts.map +1 -1
  16. package/dist-types/src/cli/RocketCli.d.ts.map +1 -1
  17. package/dist-types/src/cli/RocketInit.d.ts +5 -1
  18. package/dist-types/src/cli/RocketInit.d.ts.map +1 -1
  19. package/dist-types/src/cli/RocketStart.d.ts +6 -1
  20. package/dist-types/src/cli/RocketStart.d.ts.map +1 -1
  21. package/dist-types/src/component-hydration.d.ts.map +1 -1
  22. package/dist-types/src/components/RocketDrawer.d.ts +8 -0
  23. package/dist-types/src/components/RocketDrawer.d.ts.map +1 -1
  24. package/dist-types/src/components/RocketSocialLink.d.ts.map +1 -1
  25. package/dist-types/src/config.d.ts.map +1 -1
  26. package/dist-types/src/feeds.d.ts +38 -0
  27. package/dist-types/src/feeds.d.ts.map +1 -0
  28. package/dist-types/src/hydration/hydrationLoader.d.ts +13 -4
  29. package/dist-types/src/hydration/hydrationLoader.d.ts.map +1 -1
  30. package/dist-types/src/icons.d.ts.map +1 -1
  31. package/dist-types/src/layouts/atlas/atlasBlogLayout.d.ts +42 -0
  32. package/dist-types/src/layouts/atlas/atlasBlogLayout.d.ts.map +1 -0
  33. package/dist-types/src/layouts/atlas/atlasDocLayout.d.ts +4 -0
  34. package/dist-types/src/layouts/atlas/atlasDocLayout.d.ts.map +1 -1
  35. package/dist-types/src/layouts/atlas/atlasHeroLayout.d.ts.map +1 -1
  36. package/dist-types/src/layouts/atlas/atlasNotFoundLayout.d.ts.map +1 -1
  37. package/dist-types/src/layouts/layout-helper.d.ts +3 -3
  38. package/dist-types/src/layouts/layout-helper.d.ts.map +1 -1
  39. package/dist-types/src/markdownHook.d.ts.map +1 -1
  40. package/dist-types/src/menu.d.ts +8 -3
  41. package/dist-types/src/menu.d.ts.map +1 -1
  42. package/dist-types/src/menus/MainMenu.d.ts.map +1 -1
  43. package/dist-types/src/{development-page-module-loader.d.ts → page-module-loader.d.ts} +7 -2
  44. package/dist-types/src/page-module-loader.d.ts.map +1 -0
  45. package/dist-types/src/page-runtime.d.ts +11 -0
  46. package/dist-types/src/page-runtime.d.ts.map +1 -1
  47. package/dist-types/src/pages.d.ts.map +1 -1
  48. package/dist-types/src/route-pattern.d.ts +10 -0
  49. package/dist-types/src/route-pattern.d.ts.map +1 -0
  50. package/dist-types/src/siteDiscoverability.d.ts +4 -0
  51. package/dist-types/src/siteDiscoverability.d.ts.map +1 -1
  52. package/dist-types/src/socialPreviewImages.d.ts +2 -1
  53. package/dist-types/src/socialPreviewImages.d.ts.map +1 -1
  54. package/dist-types/src/static-params.d.ts +33 -0
  55. package/dist-types/src/static-params.d.ts.map +1 -0
  56. package/dist-types/src/transform.d.ts +4 -0
  57. package/dist-types/src/transform.d.ts.map +1 -1
  58. package/dist-types/src/wds-plugin.d.ts.map +1 -1
  59. package/exports/layouts/atlasBlog.js +11 -0
  60. package/exports/types/rocket.test.ts +26 -0
  61. package/exports/types/rocket.ts +78 -2
  62. package/exports/types.ts +7 -0
  63. package/package.json +16 -32
  64. package/src/PageData.js +12 -2
  65. package/src/RocketJsDemo.js +1 -0
  66. package/src/SocialPreviewPlayground.js +26 -20
  67. package/src/asyncMessage.js +8 -2
  68. package/src/cli/RocketBuild.js +110 -13
  69. package/src/cli/RocketCli.js +11 -1
  70. package/src/cli/RocketInit.js +64 -12
  71. package/src/cli/RocketStart.js +60 -16
  72. package/src/cli/cli.js +10 -1
  73. package/src/component-hydration.js +23 -3
  74. package/src/components/RocketDrawer.js +44 -10
  75. package/src/components/RocketSocialLink.js +26 -2
  76. package/src/config.js +33 -4
  77. package/src/feeds.js +212 -0
  78. package/src/hydration/hydrationLoader.js +80 -48
  79. package/src/icons.js +5 -0
  80. package/src/layouts/atlas/atlasBlog.css +143 -0
  81. package/src/layouts/atlas/atlasBlogLayout.js +234 -0
  82. package/src/layouts/atlas/atlasDocLayout.js +10 -1
  83. package/src/layouts/atlas/atlasHeroLayout.js +10 -6
  84. package/src/layouts/atlas/atlasNotFoundLayout.js +12 -22
  85. package/src/layouts/layout-helper.js +5 -5
  86. package/src/layouts/layout.js +2 -2
  87. package/src/loaded-page-module.js +3 -1
  88. package/src/markdownCompiler.js +2 -2
  89. package/src/markdownHook.js +7 -2
  90. package/src/menu.js +25 -19
  91. package/src/menus/MainMenu.js +10 -4
  92. package/src/{static-page-module-loader.js → page-module-loader.js} +6 -1
  93. package/src/page-pagination.js +2 -2
  94. package/src/page-runtime.js +48 -3
  95. package/src/pages.js +47 -56
  96. package/src/publicAssets.js +2 -2
  97. package/src/route-pattern.js +21 -0
  98. package/src/siteDiscoverability.js +14 -1
  99. package/src/socialPreviewImages.js +80 -14
  100. package/src/standalone-demo-url.js +2 -2
  101. package/src/static-params.js +114 -0
  102. package/src/transform.js +89 -22
  103. package/src/wds-plugin.js +16 -3
  104. package/dist-types/src/development-page-module-loader.d.ts.map +0 -1
  105. package/dist-types/src/static-page-module-loader.d.ts +0 -15
  106. package/dist-types/src/static-page-module-loader.d.ts.map +0 -1
  107. package/src/development-page-module-loader.js +0 -29
@@ -30,7 +30,7 @@ export function createComponentHydration({
30
30
  serverOnlyComponents.push({ name, file, className, module });
31
31
  } else if (loading === 'client') {
32
32
  clientComponents.push({ name, file, className });
33
- } else if (loading.startsWith('hydrate:')) {
33
+ } else if (isHydrateLoading(loading)) {
34
34
  const module = await loadComponentModule(file);
35
35
  hydratedComponents.push({
36
36
  name,
@@ -39,13 +39,21 @@ export function createComponentHydration({
39
39
  module,
40
40
  strategy: loading.slice('hydrate:'.length),
41
41
  });
42
+ } else {
43
+ throw new Error(
44
+ `Registered Component "${name}" has an invalid loading value ${JSON.stringify(
45
+ loading,
46
+ )}. Use 'server', 'client', or 'hydrate:<strategy>'.`,
47
+ );
42
48
  }
43
49
  }
44
50
 
45
51
  const registry = customElementsRegistry || customElements;
46
- registry.__definitions.clear();
52
+ // The @lit-labs/ssr-dom-shim registry keeps definitions between renders;
53
+ // spec-compliant registries have no such internals to reset.
54
+ registry.__definitions?.clear();
47
55
  // @ts-ignore
48
- registry.__reverseDefinitions.clear();
56
+ registry.__reverseDefinitions?.clear();
49
57
  for (const component of serverOnlyComponents.concat(hydratedComponents)) {
50
58
  if (!component.module[component.className]) {
51
59
  throw new Error(
@@ -84,3 +92,15 @@ export function createComponentHydration({
84
92
  return clientCode;
85
93
  };
86
94
  }
95
+
96
+ /**
97
+ * @param {unknown} loading
98
+ * @returns {loading is string}
99
+ */
100
+ function isHydrateLoading(loading) {
101
+ return (
102
+ typeof loading === 'string' &&
103
+ loading.startsWith('hydrate:') &&
104
+ loading.length > 'hydrate:'.length
105
+ );
106
+ }
@@ -13,13 +13,47 @@ export class RocketDrawer extends LitElement {
13
13
  connectedCallback() {
14
14
  super.connectedCallback();
15
15
  this._onDocClick = this._onDocClick.bind(this);
16
+ this._onKeyDown = this._onKeyDown.bind(this);
16
17
  document.addEventListener('click', this._onDocClick, { capture: true });
18
+ document.addEventListener('keydown', this._onKeyDown);
17
19
  }
18
20
  disconnectedCallback() {
19
21
  document.removeEventListener('click', this._onDocClick, { capture: true });
22
+ document.removeEventListener('keydown', this._onKeyDown);
20
23
  super.disconnectedCallback();
21
24
  }
22
25
 
26
+ /**
27
+ * @param {KeyboardEvent} ev
28
+ */
29
+ _onKeyDown(ev) {
30
+ if (ev.key === 'Escape' && this.open) {
31
+ this.close();
32
+ }
33
+ }
34
+
35
+ /**
36
+ * @param {Map<string, unknown>} changedProperties
37
+ */
38
+ updated(changedProperties) {
39
+ // skip the initial render so the drawer never steals focus on page load
40
+ if (!changedProperties.has('open') || changedProperties.get('open') === undefined) {
41
+ return;
42
+ }
43
+ if (this.open) {
44
+ this.shadowRoot?.getElementById('content')?.focus();
45
+ } else {
46
+ const invokerSlot = /** @type {HTMLSlotElement | null} */ (
47
+ this.shadowRoot?.querySelector('slot[name="invoker"]') ?? null
48
+ );
49
+ const assigned = /** @type {HTMLElement | undefined} */ (invokerSlot?.assignedElements()[0]);
50
+ const fallback = /** @type {HTMLElement | null} */ (
51
+ this.shadowRoot?.querySelector('.burger-menu') ?? null
52
+ );
53
+ (assigned ?? fallback)?.focus();
54
+ }
55
+ }
56
+
23
57
  render() {
24
58
  return html`
25
59
  <slot name="invoker">
@@ -36,7 +70,7 @@ export class RocketDrawer extends LitElement {
36
70
  </slot>
37
71
 
38
72
  <dialog ?open=${this.open} id="rd-dialog">
39
- <div id="content">
73
+ <div id="content" tabindex="-1">
40
74
  <slot></slot>
41
75
  </div>
42
76
  </dialog>
@@ -149,23 +183,23 @@ export class RocketDrawer extends LitElement {
149
183
  overflow: hidden;
150
184
  opacity: 0;
151
185
  pointer-events: none;
186
+ /* visibility keeps the closed drawer out of tab order and the a11y tree */
187
+ visibility: hidden;
152
188
  transition:
153
189
  width 0.35s var(--rd-ease-3),
154
- opacity 0.35s var(--rd-ease-3);
190
+ opacity 0.35s var(--rd-ease-3),
191
+ visibility 0s var(--rd-ease-3) 0.35s;
155
192
  will-change: width;
156
193
  }
157
194
  dialog[open] {
158
195
  width: var(--rd-target-width);
159
196
  opacity: 1;
160
197
  pointer-events: auto;
161
- }
162
- dialog::backdrop {
163
- background: rgba(0, 0, 0, 0.4);
164
- transition: opacity 0.35s var(--rd-ease-3);
165
- opacity: 0;
166
- }
167
- dialog[open]::backdrop {
168
- opacity: 1;
198
+ visibility: visible;
199
+ transition:
200
+ width 0.35s var(--rd-ease-3),
201
+ opacity 0.35s var(--rd-ease-3),
202
+ visibility 0s;
169
203
  }
170
204
 
171
205
  #content {
@@ -2,14 +2,38 @@ import { readFileSync } from 'fs';
2
2
  import { LitElement, html, css } from 'lit';
3
3
  import { unsafeHTML } from 'lit/directives/unsafe-html.js';
4
4
 
5
+ const SUPPORTED_BRANDS = new Set([
6
+ 'discord',
7
+ 'github',
8
+ 'gitlab',
9
+ 'license',
10
+ 'npm',
11
+ 'slack',
12
+ 'telegram',
13
+ 'twitter',
14
+ ]);
15
+
16
+ /** @type {Map<string, string>} */
17
+ const iconCache = new Map();
18
+
5
19
  /**
6
20
  * @param {string} brandName
7
21
  * @returns {import('lit/directive.js').DirectiveResult}
8
22
  */
9
23
  function getIcon(brandName) {
10
24
  const brand = brandName.toLowerCase();
11
- const fileContent = readFileSync(new URL(`./assets/${brand}.svg`, import.meta.url), 'utf8');
12
- return unsafeHTML(fileContent.toString());
25
+ if (!SUPPORTED_BRANDS.has(brand)) {
26
+ throw new Error(
27
+ `rocket-social-link does not support name="${brandName}". ` +
28
+ `Supported names: ${[...SUPPORTED_BRANDS].join(', ')}.`,
29
+ );
30
+ }
31
+ let fileContent = iconCache.get(brand);
32
+ if (fileContent === undefined) {
33
+ fileContent = readFileSync(new URL(`./assets/${brand}.svg`, import.meta.url), 'utf8');
34
+ iconCache.set(brand, fileContent);
35
+ }
36
+ return unsafeHTML(fileContent);
13
37
  }
14
38
 
15
39
  export class RocketSocialLink extends LitElement {
package/src/config.js CHANGED
@@ -1,5 +1,7 @@
1
1
  /** Runs on: server */
2
+ import { existsSync } from 'node:fs';
2
3
  import path from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
3
5
  import { validateIconLibrariesConfig } from './icons.js';
4
6
 
5
7
  const REDIRECT_STATUSES = new Set([301, 302, 307, 308]);
@@ -9,14 +11,25 @@ const REDIRECT_STATUSES = new Set([301, 302, 307, 308]);
9
11
  * @returns {Promise<import("@rocket/js/types.js").ResolvedRocketConfig>}
10
12
  */
11
13
  export async function readConfig(filePath) {
12
- const configFile = await import(
13
- filePath ? path.join(process.cwd(), filePath) : path.join(process.cwd(), 'rocket-config.js')
14
- );
14
+ const configPath = path.join(process.cwd(), filePath || 'rocket-config.js');
15
+ if (!existsSync(configPath)) {
16
+ throw new Error(
17
+ `No Rocket config found at ${configPath}. ` +
18
+ `Run "npx rocket init" to create one, or pass --config-file <path>.`,
19
+ );
20
+ }
21
+ const configFile = await import(pathToFileURL(configPath).href);
15
22
  if (configFile.default === undefined) {
16
23
  throw new Error('rocket-config.js must have a default export');
17
24
  }
18
25
  /** @type {import("@rocket/js/types.js").RocketConfig} */
19
26
  const config = configFile.default;
27
+ if (!Array.isArray(config.includeGlobs)) {
28
+ throw new Error(
29
+ `Invalid Rocket config in ${configPath}: includeGlobs must be an array of glob strings, ` +
30
+ `e.g. includeGlobs: ['docs/pages/**/*.rocket.{md,js}'].`,
31
+ );
32
+ }
20
33
  validateRedirectConfig(config.urlLifecycle?.redirects);
21
34
  const siteHeadMetadata = normalizeSiteHeadMetadataConfig(config.siteHeadMetadata);
22
35
  validateIconLibrariesConfig(config.iconLibraries);
@@ -24,7 +37,7 @@ export async function readConfig(filePath) {
24
37
  const siteOrigin = siteHeadMetadata
25
38
  ? requireSiteHeadMetadataSiteOrigin(config.siteOrigin)
26
39
  : config.siteOrigin;
27
- config.includeGlobs = config.includeGlobs.map(glob => {
40
+ const includeGlobs = config.includeGlobs.map(glob => {
28
41
  if (!glob.includes('*') && !glob.includes('.')) {
29
42
  // If the glob is a directory, include all files in the directory
30
43
  return `${glob}${glob.endsWith('/') ? '' : '/'}**`;
@@ -36,6 +49,7 @@ export async function readConfig(filePath) {
36
49
  excludeRegex: [],
37
50
  adjustDevServerConfig: _ => _,
38
51
  ...config,
52
+ includeGlobs,
39
53
  ...(siteOrigin ? { siteOrigin } : {}),
40
54
  ...(siteHeadMetadata ? { siteHeadMetadata } : {}),
41
55
  };
@@ -248,6 +262,14 @@ function invalidSiteOriginError(siteOrigin) {
248
262
  function validateRedirectConfig(redirects = []) {
249
263
  const sources = new Set();
250
264
  for (const redirect of redirects) {
265
+ // whitespace would corrupt whitespace-delimited outputs like Netlify _redirects
266
+ if (containsWhitespace(redirect.source) || containsWhitespace(redirect.target)) {
267
+ throw new Error(
268
+ `Invalid Redirect from ${JSON.stringify(redirect.source)} to ${JSON.stringify(
269
+ redirect.target,
270
+ )}. Redirect paths must not contain whitespace; percent-encode it instead (%20).`,
271
+ );
272
+ }
251
273
  if (!isInternalAbsolutePath(redirect.source)) {
252
274
  throw new Error(
253
275
  `Invalid Redirect source ${JSON.stringify(
@@ -283,6 +305,13 @@ function isInternalAbsolutePath(value) {
283
305
  return typeof value === 'string' && value.startsWith('/') && !value.startsWith('//');
284
306
  }
285
307
 
308
+ /**
309
+ * @param {unknown} value
310
+ */
311
+ function containsWhitespace(value) {
312
+ return typeof value === 'string' && /\s/.test(value);
313
+ }
314
+
286
315
  /**
287
316
  * @param {unknown} value
288
317
  */
package/src/feeds.js ADDED
@@ -0,0 +1,212 @@
1
+ import { PageData } from './PageData.js';
2
+ import { normalizeDocumentPath } from './standalone-demo-url.js';
3
+
4
+ /** @typedef {import('@rocket/js/types.js').Page} Page */
5
+ /** @typedef {import('@rocket/js/types.js').PageCollectionEntry} PageCollectionEntry */
6
+ /** @typedef {import('@rocket/js/types.js').PageFeedConfig} PageFeedConfig */
7
+ /** @typedef {import('@rocket/js/types.js').PageFeedDeclaration} PageFeedDeclaration */
8
+ /** @typedef {import('@rocket/js/types.js').PageRegistry} PageRegistry */
9
+
10
+ // Atom requires a feed-level <updated> value even when no entry has dates yet.
11
+ const FALLBACK_UPDATED = '1970-01-01';
12
+
13
+ /**
14
+ * @param {Page} page
15
+ */
16
+ export function hasPageFeed(page) {
17
+ return page.file.endsWith('.js') && page.module.feed !== undefined;
18
+ }
19
+
20
+ /**
21
+ * @param {string} pagePath
22
+ */
23
+ export function pageFeedPath(pagePath) {
24
+ return `${normalizeDocumentPath(pagePath)}feed.xml`;
25
+ }
26
+
27
+ /**
28
+ * @param {string} pathname
29
+ * @param {PageRegistry} pages
30
+ * @returns {{ page: Page; routePath: string } | null}
31
+ */
32
+ export function matchPageFeedPath(pathname, pages) {
33
+ for (const [routePath, page] of pages) {
34
+ if (!hasPageFeed(page) || hasPathParameter(routePath)) {
35
+ continue;
36
+ }
37
+ if (pageFeedPath(routePath) === pathname) {
38
+ return { page, routePath };
39
+ }
40
+ }
41
+ return null;
42
+ }
43
+
44
+ /**
45
+ * @param {{
46
+ * pages: PageRegistry;
47
+ * page: Page;
48
+ * pagePath: string;
49
+ * siteOrigin: string;
50
+ * }} options
51
+ * @returns {string}
52
+ */
53
+ export function createPageFeed({ pages, page, pagePath, siteOrigin }) {
54
+ if (hasPathParameter(pagePath)) {
55
+ throw new Error(
56
+ `Page Feed for ${page.file} cannot use the parameterized path ${pagePath}. ` +
57
+ `Page Feeds need a concrete owning Page path.`,
58
+ );
59
+ }
60
+ const config = resolvePageFeedConfig({ pages, page, pagePath });
61
+ const feedUrl = absoluteUrl(siteOrigin, pageFeedPath(pagePath));
62
+ const pageUrl = absoluteUrl(siteOrigin, normalizeDocumentPath(pagePath));
63
+ const entries =
64
+ config.limit === undefined ? config.collection : config.collection.slice(0, config.limit);
65
+
66
+ const lines = [
67
+ '<?xml version="1.0" encoding="UTF-8"?>',
68
+ '<feed xmlns="http://www.w3.org/2005/Atom">',
69
+ ` <title>${escapeXml(config.title)}</title>`,
70
+ ];
71
+ if (config.description) {
72
+ lines.push(` <subtitle>${escapeXml(config.description)}</subtitle>`);
73
+ }
74
+ lines.push(
75
+ ` <id>${escapeXml(pageUrl)}</id>`,
76
+ ` <link rel="self" type="application/atom+xml" href="${escapeXml(feedUrl)}"/>`,
77
+ ` <link rel="alternate" type="text/html" href="${escapeXml(pageUrl)}"/>`,
78
+ ` <updated>${atomDate(newestEntryDate(entries))}</updated>`,
79
+ );
80
+ for (const entry of entries) {
81
+ lines.push(...feedEntryLines(entry, siteOrigin));
82
+ }
83
+ lines.push('</feed>', '');
84
+ return lines.join('\n');
85
+ }
86
+
87
+ /**
88
+ * @param {PageCollectionEntry} entry
89
+ * @param {string} siteOrigin
90
+ */
91
+ function feedEntryLines(entry, siteOrigin) {
92
+ const entryUrl = absoluteUrl(siteOrigin, normalizeDocumentPath(entry.path));
93
+ const lines = [
94
+ ' <entry>',
95
+ ` <title>${escapeXml(entry.metadata.title)}</title>`,
96
+ ` <id>${escapeXml(entryUrl)}</id>`,
97
+ ` <link rel="alternate" type="text/html" href="${escapeXml(entryUrl)}"/>`,
98
+ ` <updated>${atomDate(entry.metadata.updated || entry.metadata.date || FALLBACK_UPDATED)}</updated>`,
99
+ ];
100
+ if (entry.metadata.date) {
101
+ lines.push(` <published>${atomDate(entry.metadata.date)}</published>`);
102
+ }
103
+ if (entry.metadata.description) {
104
+ lines.push(` <summary>${escapeXml(entry.metadata.description)}</summary>`);
105
+ }
106
+ for (const author of entry.metadata.authors || []) {
107
+ lines.push(' <author>', ` <name>${escapeXml(author)}</name>`, ' </author>');
108
+ }
109
+ lines.push(' </entry>');
110
+ return lines;
111
+ }
112
+
113
+ /**
114
+ * @param {{
115
+ * pages: PageRegistry;
116
+ * page: Page;
117
+ * pagePath: string;
118
+ * }} options
119
+ * @returns {PageFeedConfig}
120
+ */
121
+ function resolvePageFeedConfig({ pages, page, pagePath }) {
122
+ const declaration = /** @type {PageFeedDeclaration} */ (page.module.feed);
123
+ const config =
124
+ typeof declaration === 'function'
125
+ ? declaration(new PageData(pages, page.metadata, pagePath))
126
+ : declaration;
127
+ return normalizePageFeedConfig(config, page);
128
+ }
129
+
130
+ /**
131
+ * @param {unknown} config
132
+ * @param {Page} page
133
+ * @returns {PageFeedConfig}
134
+ */
135
+ function normalizePageFeedConfig(config, page) {
136
+ if (typeof config !== 'object' || config === null) {
137
+ throw new Error(pageFeedConfigError(page));
138
+ }
139
+ const feedConfig = /** @type {Record<string, unknown>} */ (config);
140
+ if (
141
+ typeof feedConfig.title !== 'string' ||
142
+ feedConfig.title.trim() === '' ||
143
+ !Array.isArray(feedConfig.collection) ||
144
+ (feedConfig.description !== undefined && typeof feedConfig.description !== 'string') ||
145
+ (feedConfig.limit !== undefined &&
146
+ (typeof feedConfig.limit !== 'number' ||
147
+ !Number.isInteger(feedConfig.limit) ||
148
+ feedConfig.limit <= 0))
149
+ ) {
150
+ throw new Error(pageFeedConfigError(page));
151
+ }
152
+ return /** @type {PageFeedConfig} */ (config);
153
+ }
154
+
155
+ /**
156
+ * @param {Page} page
157
+ */
158
+ function pageFeedConfigError(page) {
159
+ return (
160
+ `Page Feed for ${page.file} must provide a non-empty title, a collection array, ` +
161
+ `an optional string description, and an optional positive integer limit.`
162
+ );
163
+ }
164
+
165
+ /**
166
+ * @param {PageCollectionEntry[]} entries
167
+ */
168
+ function newestEntryDate(entries) {
169
+ let newest = '';
170
+ for (const entry of entries) {
171
+ const date = entry.metadata.updated || entry.metadata.date || '';
172
+ if (date > newest) {
173
+ newest = date;
174
+ }
175
+ }
176
+ return newest || FALLBACK_UPDATED;
177
+ }
178
+
179
+ /**
180
+ * Page Metadata dates are date-only ISO strings; Atom requires full timestamps.
181
+ * @param {string} date
182
+ */
183
+ function atomDate(date) {
184
+ return `${date}T00:00:00Z`;
185
+ }
186
+
187
+ /**
188
+ * @param {string} siteOrigin
189
+ * @param {string} pagePath
190
+ */
191
+ function absoluteUrl(siteOrigin, pagePath) {
192
+ return new URL(pagePath, `${siteOrigin}/`).href;
193
+ }
194
+
195
+ /**
196
+ * @param {string} pagePath
197
+ */
198
+ function hasPathParameter(pagePath) {
199
+ return /(^|\/):[^/]+/.test(pagePath);
200
+ }
201
+
202
+ /**
203
+ * @param {string} value
204
+ */
205
+ function escapeXml(value) {
206
+ return value
207
+ .replaceAll('&', '&amp;')
208
+ .replaceAll('"', '&quot;')
209
+ .replaceAll("'", '&apos;')
210
+ .replaceAll('<', '&lt;')
211
+ .replaceAll('>', '&gt;');
212
+ }
@@ -30,7 +30,7 @@ export class HydrationLoader {
30
30
  /**
31
31
  * @param {number} index
32
32
  * @param {object} options
33
- * @param {HTMLElement[]} [options.composedPath]
33
+ * @param {EventTarget[]} [options.composedPath]
34
34
  * @param {Event} [options.event]
35
35
  * @returns {Promise<{ needsCleanup: boolean }>}
36
36
  */
@@ -124,11 +124,14 @@ export class HydrationLoader {
124
124
  * @param {String} param1.strategyType
125
125
  * @param {String} [param1.strategyOptions]
126
126
  * @param {Boolean} param1.resolveAble
127
- * @param {Object} [options]
127
+ * @param {{ composedPath?: EventTarget[]; event?: Event }} [options]
128
128
  */
129
129
  async setResolveAbleOn({ target, strategyType, strategyOptions, resolveAble }, options = {}) {
130
130
  let needsCleanup = false;
131
- const foundIndex = this.elements.findIndex(el => el.node === target && !el.deleteMe);
131
+ // Events fired on light-DOM descendants should still hydrate the host.
132
+ const foundIndex = this.elements.findIndex(
133
+ el => !el.deleteMe && (el.node === target || options.composedPath?.includes(el.node)),
134
+ );
132
135
  if (foundIndex !== -1) {
133
136
  const element = this.elements[foundIndex];
134
137
 
@@ -162,55 +165,61 @@ export class HydrationLoader {
162
165
 
163
166
  let needsCleanup = false;
164
167
  for (let i = 0; i < this.elements.length; i += 1) {
165
- ({ needsCleanup } = await this.checkElement(i));
168
+ const result = await this.checkElement(i);
169
+ needsCleanup = needsCleanup || result.needsCleanup;
166
170
  }
167
171
  if (needsCleanup) {
168
- await this.cleanup();
172
+ this.cleanup();
169
173
  }
170
174
 
171
175
  await this.handleOnVisible();
172
176
  await this.handleOnClick();
173
177
  await this.handleOnFocus();
178
+ await this.handleOnHover();
174
179
  await this.handleOnMedia();
175
180
  }
176
181
 
177
182
  async handleOnMedia() {
183
+ // One listener per media query; it looks up the current elements when it
184
+ // fires, so hydrated elements never keep stale per-element listeners alive.
185
+ /** @type {Set<string>} */
186
+ const queries = new Set();
178
187
  for (const el of this.elements) {
179
188
  for (const strategy of el.strategies) {
180
189
  if (strategy.type === 'onMedia' && strategy.options) {
181
- if (!this.mediaQueries[strategy.options]) {
182
- this.mediaQueries[strategy.options] = window.matchMedia(strategy.options);
183
- }
190
+ queries.add(strategy.options);
191
+ }
192
+ }
193
+ }
184
194
 
185
- // TODO: remove event listener on cleanup if element gets removed
186
- this.mediaQueries[strategy.options].addEventListener('change', ev => {
187
- if (ev.matches) {
188
- this.setResolveAbleOn({
189
- target: el.node,
190
- strategyType: strategy.type,
191
- strategyOptions: strategy.options,
192
- resolveAble: true,
193
- });
194
- } else {
195
- this.setResolveAbleOn({
196
- target: el.node,
197
- strategyType: strategy.type,
198
- strategyOptions: strategy.options,
199
- resolveAble: false,
200
- });
201
- }
202
- });
195
+ for (const query of queries) {
196
+ if (!this.mediaQueries[query]) {
197
+ const mediaQuery = window.matchMedia(query);
198
+ this.mediaQueries[query] = mediaQuery;
199
+ mediaQuery.addEventListener('change', ev => {
200
+ this.updateOnMediaElements(query, ev.matches);
201
+ });
202
+ }
203
+ if (this.mediaQueries[query].matches) {
204
+ await this.updateOnMediaElements(query, true);
205
+ }
206
+ }
207
+ }
203
208
 
204
- // Initial check
205
- if (this.mediaQueries[strategy.options].matches) {
206
- this.setResolveAbleOn({
207
- target: el.node,
208
- strategyType: strategy.type,
209
- strategyOptions: strategy.options,
210
- resolveAble: true,
211
- });
212
- }
213
- }
209
+ /**
210
+ * @param {string} query
211
+ * @param {boolean} matches
212
+ */
213
+ async updateOnMediaElements(query, matches) {
214
+ // iterate over a copy — hydration mutates this.elements
215
+ for (const el of [...this.elements]) {
216
+ if (el.strategies.some(s => s.type === 'onMedia' && s.options === query)) {
217
+ await this.setResolveAbleOn({
218
+ target: el.node,
219
+ strategyType: 'onMedia',
220
+ strategyOptions: query,
221
+ resolveAble: matches,
222
+ });
214
223
  }
215
224
  }
216
225
  }
@@ -220,9 +229,11 @@ export class HydrationLoader {
220
229
  if (!ev.target) {
221
230
  return;
222
231
  }
232
+ // composedPath() only returns the full path during dispatch, so capture it now
233
+ const composedPath = ev.composedPath();
223
234
  this.setResolveAbleOn(
224
235
  { target: ev.target, strategyType: 'onClick', resolveAble: true },
225
- { composedPath: ev.composedPath(), event: ev },
236
+ { composedPath, event: ev },
226
237
  );
227
238
 
228
239
  // reset the onClick resolveAble if the click did not result in the hydration of the element
@@ -230,11 +241,10 @@ export class HydrationLoader {
230
241
  if (!ev.target) {
231
242
  return;
232
243
  }
233
- this.setResolveAbleOn({
234
- target: ev.target,
235
- strategyType: 'onClick',
236
- resolveAble: false,
237
- });
244
+ this.setResolveAbleOn(
245
+ { target: ev.target, strategyType: 'onClick', resolveAble: false },
246
+ { composedPath },
247
+ );
238
248
  }, 50);
239
249
  });
240
250
  }
@@ -244,9 +254,11 @@ export class HydrationLoader {
244
254
  if (!ev.target) {
245
255
  return;
246
256
  }
257
+ // composedPath() only returns the full path during dispatch, so capture it now
258
+ const composedPath = ev.composedPath();
247
259
  this.setResolveAbleOn(
248
260
  { target: ev.target, strategyType: 'onFocus', resolveAble: true },
249
- { composedPath: ev.composedPath(), event: ev },
261
+ { composedPath, event: ev },
250
262
  );
251
263
 
252
264
  // reset the onFocus resolveAble if the focus did not resulted in the hydration of the element
@@ -254,15 +266,35 @@ export class HydrationLoader {
254
266
  if (!ev.target) {
255
267
  return;
256
268
  }
257
- this.setResolveAbleOn({
258
- target: ev.target,
259
- strategyType: 'onFocus',
260
- resolveAble: false,
261
- });
269
+ this.setResolveAbleOn(
270
+ { target: ev.target, strategyType: 'onFocus', resolveAble: false },
271
+ { composedPath },
272
+ );
262
273
  }, 50);
263
274
  });
264
275
  }
265
276
 
277
+ async handleOnHover() {
278
+ document.body.addEventListener('pointerover', ev => {
279
+ if (!ev.target) {
280
+ return;
281
+ }
282
+ this.setResolveAbleOn(
283
+ { target: ev.target, strategyType: 'onHover', resolveAble: true },
284
+ { composedPath: ev.composedPath() },
285
+ );
286
+ });
287
+ document.body.addEventListener('pointerout', ev => {
288
+ if (!ev.target) {
289
+ return;
290
+ }
291
+ this.setResolveAbleOn(
292
+ { target: ev.target, strategyType: 'onHover', resolveAble: false },
293
+ { composedPath: ev.composedPath() },
294
+ );
295
+ });
296
+ }
297
+
266
298
  async handleOnVisible() {
267
299
  this.intersectionObserver = new IntersectionObserver(entries => {
268
300
  for (const entry of entries) {