@master/internal-site 0.2.2 → 0.2.4

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/dist/astro.mjs CHANGED
@@ -250,7 +250,7 @@ async function inlineGeneratedStyles(file, ancestors = []) {
250
250
  }
251
251
  /** Preserve HTML link order and each reusable entry's cache identity. Originals
252
252
  * remain available to any JS/CSS references; no client loading logic is added. */
253
- async function compactStyleAssets(output) {
253
+ async function compactStyleAssets(output, transformPage = (html) => html) {
254
254
  const root = resolve(output), replacements = /* @__PURE__ */ new Map();
255
255
  const documents = /* @__PURE__ */ new Map(), users = /* @__PURE__ */ new Map();
256
256
  const boundaries = /* @__PURE__ */ new Set();
@@ -322,9 +322,32 @@ async function compactStyleAssets(output) {
322
322
  await flush();
323
323
  html = html.replace(run[0], replacement);
324
324
  }
325
- await writeFile(file, html);
325
+ await writeFile(file, transformPage(html));
326
326
  }
327
327
  }
328
+ // src/astro/island-scripts.ts
329
+ const islandScriptSlot = "<!--master-site:island-scripts-->";
330
+ const islandStyles = "<style>astro-island,astro-slot,astro-static-slot{display:contents}</style>";
331
+ /** Move Astro's first client:load preamble ahead of the stylesheets. A classic
332
+ * script after a stylesheet pauses HTML parsing until that stylesheet arrives. */
333
+ function hoistIslandScripts(html) {
334
+ const slot = html.indexOf(islandScriptSlot);
335
+ if (slot < 0) return html;
336
+ const unchanged = () => html.replace(islandScriptSlot, "");
337
+ const headEnd = html.indexOf("</head>", slot);
338
+ const body = html.indexOf("<body", headEnd);
339
+ if (headEnd < 0 || body < 0) return unchanged();
340
+ const island = html.indexOf("<astro-island ", body);
341
+ const styles = html.indexOf(islandStyles, body);
342
+ if (island < 0 || styles < 0 || styles > island) return unchanged();
343
+ const start = styles + 74;
344
+ const preamble = html.slice(start, island);
345
+ const scripts = preamble.match(/<script>[\s\S]*?<\/script>/g);
346
+ if (!scripts || scripts.length !== 2 || preamble.trim() !== scripts.join("")) return unchanged();
347
+ const [directive, runtime] = scripts;
348
+ if (!directive.includes("astro:load") || !directive.includes("self.Astro") || !runtime.includes("customElements.define(") || !runtime.includes("astro-island")) return unchanged();
349
+ return (html.slice(0, start) + html.slice(island)).replace(islandScriptSlot, directive + runtime);
350
+ }
328
351
  // src/docs-shell/remark/slug-and-toc.js
329
352
  function remarkSlugAndTOC() {
330
353
  return (tree) => {
@@ -688,7 +711,7 @@ function masterSite(site) {
688
711
  recursive: true,
689
712
  force: false
690
713
  });
691
- await compactStyleAssets(fileURLToPath(destination));
714
+ await compactStyleAssets(fileURLToPath(destination), hoistIslandScripts);
692
715
  }
693
716
  }
694
717
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@master/internal-site",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
5
  "description": "First-party Astro and Svelte website framework for Master sites.",
6
6
  "engines": {
@@ -17,6 +17,7 @@ import serverStyles from './server-styles'
17
17
  import svgMarkup from './svg-markup'
18
18
  import localizedMdx from './localized-mdx'
19
19
  import { compactStyleAssets } from './style-assets'
20
+ import { hoistIslandScripts } from './island-scripts'
20
21
  import remarkSlugAndTOC from '../docs-shell/remark/slug-and-toc.js'
21
22
  import remarkMarkdownComponents from '../docs-shell/remark/markdown-components.js'
22
23
  import svgoConfig from '../docs-shell/common/svgo.config.js'
@@ -123,7 +124,7 @@ export default function masterSite(site: SiteConfig): AstroIntegration {
123
124
  const destination = output
124
125
  await mkdir(destination, { recursive: true })
125
126
  await cp(new URL('../public/', import.meta.url), destination, { recursive: true, force: false })
126
- await compactStyleAssets(fileURLToPath(destination))
127
+ await compactStyleAssets(fileURLToPath(destination), hoistIslandScripts)
127
128
  }
128
129
  }
129
130
  }
@@ -0,0 +1,31 @@
1
+ export const islandScriptSlot = '<!--master-site:island-scripts-->'
2
+
3
+ const islandStyles = '<style>astro-island,astro-slot,astro-static-slot{display:contents}</style>'
4
+
5
+ /** Move Astro's first client:load preamble ahead of the stylesheets. A classic
6
+ * script after a stylesheet pauses HTML parsing until that stylesheet arrives. */
7
+ export function hoistIslandScripts(html: string) {
8
+ const slot = html.indexOf(islandScriptSlot)
9
+ if (slot < 0) return html
10
+ const unchanged = () => html.replace(islandScriptSlot, '')
11
+
12
+ const headEnd = html.indexOf('</head>', slot)
13
+ const body = html.indexOf('<body', headEnd)
14
+ if (headEnd < 0 || body < 0) return unchanged()
15
+
16
+ const island = html.indexOf('<astro-island ', body)
17
+ const styles = html.indexOf(islandStyles, body)
18
+ if (island < 0 || styles < 0 || styles > island) return unchanged()
19
+
20
+ const start = styles + islandStyles.length
21
+ const preamble = html.slice(start, island)
22
+ const scripts = preamble.match(/<script>[\s\S]*?<\/script>/g)
23
+ if (!scripts || scripts.length !== 2 || preamble.trim() !== scripts.join('')) return unchanged()
24
+ const [directive, runtime] = scripts
25
+ // Leave unfamiliar Astro output alone. Only the shell's known first island
26
+ // can supply this preamble. Author scripts and later directives stay put.
27
+ if (!directive.includes('astro:load') || !directive.includes('self.Astro')
28
+ || !runtime.includes('customElements.define(') || !runtime.includes('astro-island')) return unchanged()
29
+
30
+ return (html.slice(0, start) + html.slice(island)).replace(islandScriptSlot, directive + runtime)
31
+ }
@@ -30,7 +30,7 @@ export async function inlineGeneratedStyles(file: string, ancestors: string[] =
30
30
 
31
31
  /** Preserve HTML link order and each reusable entry's cache identity. Originals
32
32
  * remain available to any JS/CSS references; no client loading logic is added. */
33
- export async function compactStyleAssets(output: string) {
33
+ export async function compactStyleAssets(output: string, transformPage = (html: string) => html) {
34
34
  const root = resolve(output), replacements = new Map<string, string>()
35
35
  const documents = new Map<string, string>(), users = new Map<string, string[]>()
36
36
  const boundaries = new Set<string>()
@@ -102,6 +102,6 @@ export async function compactStyleAssets(output: string) {
102
102
  await flush()
103
103
  html = html.replace(run[0], replacement)
104
104
  }
105
- await writeFile(file, html)
105
+ await writeFile(file, transformPage(html))
106
106
  }
107
107
  }
@@ -8,7 +8,6 @@
8
8
  <script lang="ts">
9
9
  import '@master/internal-site/styles/system/toc.css'
10
10
 
11
- import Link from './Link.svelte'
12
11
  import PageContentTitle from './PageContentTitle.svelte'
13
12
  import { usePageContent, type PageContentItem } from './page-content.svelte'
14
13
  import groupPageContent from '../utils/group-page-content'
@@ -57,9 +56,9 @@
57
56
 
58
57
  <!-- The bootstrap script marks the live entry before hydration, so its attribute may differ from the first render. -->
59
58
  {#snippet nav({ id, title, level }: PageContentItem)}
60
- <Link href={'#' + id} aria-current={pageContent.currentId === id ? 'location' : undefined} data-page-content-nav-id={id} data-level={level} lang={langOf(title)}>
59
+ <a href={'#' + id} aria-current={pageContent.currentId === id ? 'location' : undefined} data-page-content-nav-id={id} data-level={level} lang={langOf(title)}>
61
60
  <PageContentTitle {title} translate={translate ? t : undefined} />
62
- </Link>
61
+ </a>
63
62
  {/snippet}
64
63
 
65
64
  <ol bind:this={element} {lang} data-fold={fold ? '' : undefined}>
@@ -96,6 +96,10 @@ export function providePageContent(getItems: () => PageContentItem[]): PageConte
96
96
 
97
97
  updateCurrentId()
98
98
  initialUpdateTimeout = window.setTimeout(requestUpdate, 100)
99
+ // The rail can hydrate before a streamed article has delivered its headings.
100
+ if (document.readyState === 'loading') {
101
+ document.addEventListener('DOMContentLoaded', requestUpdate, { once: true })
102
+ }
99
103
  document.addEventListener('scroll', requestUpdate, {
100
104
  capture: true,
101
105
  passive: true
@@ -103,6 +107,7 @@ export function providePageContent(getItems: () => PageContentItem[]): PageConte
103
107
  window.addEventListener('resize', requestUpdate, { passive: true })
104
108
 
105
109
  return () => {
110
+ document.removeEventListener('DOMContentLoaded', requestUpdate)
106
111
  document.removeEventListener('scroll', requestUpdate, true)
107
112
  window.removeEventListener('resize', requestUpdate)
108
113
  window.clearTimeout(initialUpdateTimeout)
@@ -26,16 +26,12 @@ const pageContent = Array.isArray(toc) && toc.length > 0 ? toc : null
26
26
  ---
27
27
 
28
28
  <DocMain class={props.class} footerProps={footerProps}>
29
+ {/* Stream the rail before the article so both columns are present when its title first paints. */}
30
+ {pageContent && <PageContent client:load items={pageContent.map(({ id, title, level }) => ({ id, title, level }))} metadata={{ sourcePath: metadata.sourcePath, title: metadata.title }} />}
29
31
  <DocArticle aside={Boolean(pageContent)}>
30
32
  <ArticleHeader {...props} locale={locale} metadata={metadata} dictionaries={dictionaries} toc={pageContent} />
31
33
  <slot />
32
34
  <PageNavs locale={locale} metadata={metadata} pageCategories={pageCategories} dictionaries={dictionaries} />
33
35
  </DocArticle>
34
- {pageContent && (
35
- <Fragment>
36
- {/* The rail's island serializes only the fields it renders. */}
37
- <PageContent client:load items={pageContent.map(({ id, title, level }) => ({ id, title, level }))} metadata={{ sourcePath: metadata.sourcePath, title: metadata.title }} />
38
- <PageContentBootstrap items={pageContent} />
39
- </Fragment>
40
- )}
36
+ {pageContent && <PageContentBootstrap items={pageContent} />}
41
37
  </DocMain>
@@ -9,6 +9,8 @@ import { localizePathname } from '../astro/route-paths'
9
9
  import { siteContextElementId } from '../docs-shell/site'
10
10
  import type { Metadata } from '../docs-shell/types/Metadata'
11
11
  import type { PageAssets } from '../astro/propagated-assets'
12
+ import { islandScriptSlot } from '../astro/island-scripts'
13
+ import NavigationTransition from './NavigationTransition.astro'
12
14
 
13
15
  interface Props {
14
16
  metadata: Partial<Metadata>
@@ -45,17 +47,19 @@ if (['dark', 'light'].includes(value)) document.documentElement.style.colorSchem
45
47
  <head>
46
48
  <meta charset="utf-8" />
47
49
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
50
+ {!bare && <NavigationTransition />}
48
51
  <link rel="icon" href="/favicon.ico" type="image/x-icon" sizes="any" />
49
52
  <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
50
53
  <script is:inline blocking="render" set:html={themeScript}></script>
54
+ {!bare && import.meta.env.PROD && <Fragment set:html={islandScriptSlot} />}
51
55
  <link blocking="render" rel="preload" as="font" href="/fonts/GeistVariable.woff2" crossorigin="anonymous" />
52
56
  <link blocking="render" rel="preload" as="font" href="/fonts/IBMPlexMono-Regular.woff2" crossorigin="anonymous" />
53
57
  {(site.locale !== 'en' || basePathname === '/brand') && <link href={`https://fonts.googleapis.com/css2?family=Noto+Sans+${({ ja: 'JP', ko: 'KR', cn: 'SC' } as Record<string, string>)[site.locale] ?? 'TC'}:wght@100..900&display=swap`} rel="stylesheet" />}
54
58
  <style is:inline blocking="render" set:html={fontCSS}></style>
59
+ {assets.links.map((href) => <link rel="stylesheet" href={href} />)}
55
60
  {/* client:load islands can hydrate while a streamed body is still parsing.
56
- Their request-local context must already exist, even with cached CSS/JS. */}
61
+ Keep their context in the head, after stylesheet discovery. */}
57
62
  <script is:inline type="application/json" id={siteContextElementId} set:html={siteContextJSON}></script>
58
- {assets.links.map((href) => <link rel="stylesheet" href={href} />)}
59
63
  {/* Without the dev id, Vite appends a second copy when an island imports the same CSS, reordering the cascade. */}
60
64
  {assets.styles.map(({ id, content }) => <style is:inline data-vite-dev-id={id} set:html={content}></style>)}
61
65
  <title>{head.title}</title>
@@ -0,0 +1,58 @@
1
+ <style is:inline>
2
+ @media (prefers-reduced-motion: no-preference) and (scripting: enabled) {
3
+ @view-transition { navigation: auto; types: master-navigation; }
4
+ }
5
+ </style>
6
+ <script is:inline>
7
+ (() => {
8
+ if (!('onpagereveal' in window)) return
9
+
10
+ const hasTitle = () => Boolean(document.querySelector('main h1')?.textContent?.trim())
11
+ const hasFragmentTarget = () => {
12
+ if (!location.hash) return true
13
+ try {
14
+ return Boolean(document.getElementById(decodeURIComponent(location.hash.slice(1))))
15
+ } catch {
16
+ return false
17
+ }
18
+ }
19
+
20
+ addEventListener('pageswap', ({ viewTransition, activation }) => {
21
+ if (!viewTransition) return
22
+ void viewTransition.ready.catch(() => {})
23
+ // The standalone examples own their transitions and do not opt in to the site capture.
24
+ const destination = activation?.entry?.url
25
+ if (destination && new URL(destination).pathname.startsWith('/examples/')) viewTransition.skipTransition()
26
+ })
27
+ addEventListener('pagereveal', ({ viewTransition }) => {
28
+ if (!viewTransition) return
29
+ void viewTransition.ready.catch(() => {})
30
+ if (!hasTitle() || !hasFragmentTarget()) viewTransition.skipTransition()
31
+ })
32
+
33
+ const activation = window.navigation?.activation
34
+ const source = activation ? activation.from?.url : document.referrer
35
+ const navigation = performance.getEntriesByType('navigation')[0]
36
+ if (!source || new URL(source).origin !== location.origin || navigation?.type === 'reload'
37
+ || document.visibilityState !== 'visible' || !matchMedia('(prefers-reduced-motion: no-preference)').matches) return
38
+
39
+ // The browser may reveal a buffered response before parsing its title. Release this short
40
+ // wait as soon as the title arrives. If the stream is slower, pagereveal skips the capture.
41
+ const blocker = document.createElement('link')
42
+ if (!('blocking' in blocker)) return
43
+ blocker.rel = 'expect'
44
+ blocker.href = '#master-navigation-ready'
45
+ blocker.setAttribute('blocking', 'render')
46
+ const release = () => {
47
+ observer.disconnect()
48
+ clearTimeout(timeout)
49
+ blocker.remove()
50
+ }
51
+ const observer = new MutationObserver(() => {
52
+ if (hasTitle()) release()
53
+ })
54
+ const timeout = setTimeout(release, 120)
55
+ observer.observe(document.documentElement, { childList: true, subtree: true, characterData: true })
56
+ document.head.append(blocker)
57
+ })()
58
+ </script>
@@ -63,6 +63,7 @@
63
63
 
64
64
  /* The contents rail stands still beside the article; a long list scrolls inside it. */
65
65
  :is(.ui-docs-body > .ui-toc, .ui-docs-body > astro-island > .ui-toc) {
66
+ order: 1;
66
67
  position: sticky; top: var(--docs-header); display: flex; flex: none; flex-direction: column; align-self: start; width: var(--docs-rail); max-height: calc(100dvh - var(--docs-header));
67
68
  padding: 3.5rem var(--ui-space-lg) var(--ui-space-xl) 0;
68
69
  }
@@ -1,4 +1,6 @@
1
1
  /* header-core: owned component styles; shared definitions come from the foundation. */
2
+ @import './navigation-transitions.css';
3
+
2
4
  @layer components {
3
5
  .ui-app { min-height: 100dvh; background: var(--ui-surface-canvas); color: var(--ui-text-body); }
4
6
 
@@ -0,0 +1,61 @@
1
+ /* Cross-document captures only. The homepage and blog overlay headers travel with their page. */
2
+ :root:active-view-transition-type(master-navigation) .ui-header:not([data-variant="stickable"]) {
3
+ view-transition-name: master-header;
4
+ view-transition-class: master-navigation-chrome;
5
+ }
6
+
7
+ :root:active-view-transition-type(master-navigation) #sidebar.ui-sidebar {
8
+ view-transition-name: master-sidebar;
9
+ view-transition-class: master-navigation-chrome;
10
+ }
11
+
12
+ :root:active-view-transition-type(master-navigation) .ui-docs-body .ui-toc[data-rail] {
13
+ view-transition-name: master-contents;
14
+ view-transition-class: master-navigation-chrome;
15
+ }
16
+
17
+ :root:active-view-transition-type(master-navigation)::view-transition {
18
+ pointer-events: none;
19
+ }
20
+
21
+ :root:active-view-transition-type(master-navigation)::view-transition-group(root) {
22
+ animation-duration: var(--ui-duration-quick);
23
+ }
24
+
25
+ /* Replace matching navigation in place while the page underneath crossfades. */
26
+ :root:active-view-transition-type(master-navigation)::view-transition-group(.master-navigation-chrome) {
27
+ animation: none;
28
+ }
29
+
30
+ :root:active-view-transition-type(master-navigation)::view-transition-old(.master-navigation-chrome) {
31
+ animation: none;
32
+ opacity: 0;
33
+ }
34
+
35
+ :root:active-view-transition-type(master-navigation)::view-transition-new(.master-navigation-chrome) {
36
+ animation: none;
37
+ mix-blend-mode: normal;
38
+ }
39
+
40
+ /* If a layout gains or loses navigation, fade that capture with the page. */
41
+ :root:active-view-transition-type(master-navigation)::view-transition-old(.master-navigation-chrome):only-child {
42
+ animation-name: master-navigation-chrome-out;
43
+ animation-duration: var(--ui-duration-quick);
44
+ animation-fill-mode: both;
45
+ }
46
+
47
+ :root:active-view-transition-type(master-navigation)::view-transition-new(.master-navigation-chrome):only-child {
48
+ animation-name: master-navigation-chrome-in;
49
+ animation-duration: var(--ui-duration-quick);
50
+ animation-fill-mode: both;
51
+ }
52
+
53
+ @keyframes master-navigation-chrome-in {
54
+ from { opacity: 0; }
55
+ to { opacity: 1; }
56
+ }
57
+
58
+ @keyframes master-navigation-chrome-out {
59
+ from { opacity: 1; }
60
+ to { opacity: 0; }
61
+ }