@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.
- package/README.md +14 -23
- package/dist-types/exports/layouts/atlasBlog.d.ts +2 -0
- package/dist-types/exports/layouts/atlasBlog.d.ts.map +1 -0
- package/dist-types/exports/types/rocket.d.ts +68 -2
- package/dist-types/exports/types/rocket.d.ts.map +1 -1
- package/dist-types/exports/types.d.ts +1 -1
- package/dist-types/exports/types.d.ts.map +1 -1
- package/dist-types/src/PageData.d.ts +3 -1
- package/dist-types/src/PageData.d.ts.map +1 -1
- package/dist-types/src/RocketJsDemo.d.ts.map +1 -1
- package/dist-types/src/SocialPreviewPlayground.d.ts +1 -0
- package/dist-types/src/SocialPreviewPlayground.d.ts.map +1 -1
- package/dist-types/src/asyncMessage.d.ts +5 -0
- package/dist-types/src/asyncMessage.d.ts.map +1 -1
- package/dist-types/src/cli/RocketBuild.d.ts.map +1 -1
- package/dist-types/src/cli/RocketCli.d.ts.map +1 -1
- package/dist-types/src/cli/RocketInit.d.ts +5 -1
- package/dist-types/src/cli/RocketInit.d.ts.map +1 -1
- package/dist-types/src/cli/RocketStart.d.ts +6 -1
- package/dist-types/src/cli/RocketStart.d.ts.map +1 -1
- package/dist-types/src/component-hydration.d.ts.map +1 -1
- package/dist-types/src/components/RocketDrawer.d.ts +8 -0
- package/dist-types/src/components/RocketDrawer.d.ts.map +1 -1
- package/dist-types/src/components/RocketSocialLink.d.ts.map +1 -1
- package/dist-types/src/config.d.ts.map +1 -1
- package/dist-types/src/feeds.d.ts +38 -0
- package/dist-types/src/feeds.d.ts.map +1 -0
- package/dist-types/src/hydration/hydrationLoader.d.ts +13 -4
- package/dist-types/src/hydration/hydrationLoader.d.ts.map +1 -1
- package/dist-types/src/icons.d.ts.map +1 -1
- package/dist-types/src/layouts/atlas/atlasBlogLayout.d.ts +42 -0
- package/dist-types/src/layouts/atlas/atlasBlogLayout.d.ts.map +1 -0
- package/dist-types/src/layouts/atlas/atlasDocLayout.d.ts +4 -0
- package/dist-types/src/layouts/atlas/atlasDocLayout.d.ts.map +1 -1
- package/dist-types/src/layouts/atlas/atlasHeroLayout.d.ts.map +1 -1
- package/dist-types/src/layouts/atlas/atlasNotFoundLayout.d.ts.map +1 -1
- package/dist-types/src/layouts/layout-helper.d.ts +3 -3
- package/dist-types/src/layouts/layout-helper.d.ts.map +1 -1
- package/dist-types/src/markdownHook.d.ts.map +1 -1
- package/dist-types/src/menu.d.ts +8 -3
- package/dist-types/src/menu.d.ts.map +1 -1
- package/dist-types/src/menus/MainMenu.d.ts.map +1 -1
- package/dist-types/src/{development-page-module-loader.d.ts → page-module-loader.d.ts} +7 -2
- package/dist-types/src/page-module-loader.d.ts.map +1 -0
- package/dist-types/src/page-runtime.d.ts +11 -0
- package/dist-types/src/page-runtime.d.ts.map +1 -1
- package/dist-types/src/pages.d.ts.map +1 -1
- package/dist-types/src/route-pattern.d.ts +10 -0
- package/dist-types/src/route-pattern.d.ts.map +1 -0
- package/dist-types/src/siteDiscoverability.d.ts +4 -0
- package/dist-types/src/siteDiscoverability.d.ts.map +1 -1
- package/dist-types/src/socialPreviewImages.d.ts +2 -1
- package/dist-types/src/socialPreviewImages.d.ts.map +1 -1
- package/dist-types/src/static-params.d.ts +33 -0
- package/dist-types/src/static-params.d.ts.map +1 -0
- package/dist-types/src/transform.d.ts +4 -0
- package/dist-types/src/transform.d.ts.map +1 -1
- package/dist-types/src/wds-plugin.d.ts.map +1 -1
- package/exports/layouts/atlasBlog.js +11 -0
- package/exports/types/rocket.test.ts +26 -0
- package/exports/types/rocket.ts +78 -2
- package/exports/types.ts +7 -0
- package/package.json +16 -32
- package/src/PageData.js +12 -2
- package/src/RocketJsDemo.js +1 -0
- package/src/SocialPreviewPlayground.js +26 -20
- package/src/asyncMessage.js +8 -2
- package/src/cli/RocketBuild.js +110 -13
- package/src/cli/RocketCli.js +11 -1
- package/src/cli/RocketInit.js +64 -12
- package/src/cli/RocketStart.js +60 -16
- package/src/cli/cli.js +10 -1
- package/src/component-hydration.js +23 -3
- package/src/components/RocketDrawer.js +44 -10
- package/src/components/RocketSocialLink.js +26 -2
- package/src/config.js +33 -4
- package/src/feeds.js +212 -0
- package/src/hydration/hydrationLoader.js +80 -48
- package/src/icons.js +5 -0
- package/src/layouts/atlas/atlasBlog.css +143 -0
- package/src/layouts/atlas/atlasBlogLayout.js +234 -0
- package/src/layouts/atlas/atlasDocLayout.js +10 -1
- package/src/layouts/atlas/atlasHeroLayout.js +10 -6
- package/src/layouts/atlas/atlasNotFoundLayout.js +12 -22
- package/src/layouts/layout-helper.js +5 -5
- package/src/layouts/layout.js +2 -2
- package/src/loaded-page-module.js +3 -1
- package/src/markdownCompiler.js +2 -2
- package/src/markdownHook.js +7 -2
- package/src/menu.js +25 -19
- package/src/menus/MainMenu.js +10 -4
- package/src/{static-page-module-loader.js → page-module-loader.js} +6 -1
- package/src/page-pagination.js +2 -2
- package/src/page-runtime.js +48 -3
- package/src/pages.js +47 -56
- package/src/publicAssets.js +2 -2
- package/src/route-pattern.js +21 -0
- package/src/siteDiscoverability.js +14 -1
- package/src/socialPreviewImages.js +80 -14
- package/src/standalone-demo-url.js +2 -2
- package/src/static-params.js +114 -0
- package/src/transform.js +89 -22
- package/src/wds-plugin.js +16 -3
- package/dist-types/src/development-page-module-loader.d.ts.map +0 -1
- package/dist-types/src/static-page-module-loader.d.ts +0 -15
- package/dist-types/src/static-page-module-loader.d.ts.map +0 -1
- 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
|
|
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
|
|
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
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
|
13
|
-
|
|
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
|
-
|
|
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('&', '&')
|
|
208
|
+
.replaceAll('"', '"')
|
|
209
|
+
.replaceAll("'", ''')
|
|
210
|
+
.replaceAll('<', '<')
|
|
211
|
+
.replaceAll('>', '>');
|
|
212
|
+
}
|
|
@@ -30,7 +30,7 @@ export class HydrationLoader {
|
|
|
30
30
|
/**
|
|
31
31
|
* @param {number} index
|
|
32
32
|
* @param {object} options
|
|
33
|
-
* @param {
|
|
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 {
|
|
127
|
+
* @param {{ composedPath?: EventTarget[]; event?: Event }} [options]
|
|
128
128
|
*/
|
|
129
129
|
async setResolveAbleOn({ target, strategyType, strategyOptions, resolveAble }, options = {}) {
|
|
130
130
|
let needsCleanup = false;
|
|
131
|
-
|
|
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
|
-
|
|
168
|
+
const result = await this.checkElement(i);
|
|
169
|
+
needsCleanup = needsCleanup || result.needsCleanup;
|
|
166
170
|
}
|
|
167
171
|
if (needsCleanup) {
|
|
168
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
190
|
+
queries.add(strategy.options);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
184
194
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
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
|
-
|
|
236
|
-
|
|
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
|
|
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
|
-
|
|
260
|
-
|
|
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) {
|