@pagenary/publisher 2026.6.14 → 2026.6.16
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/examples/page-effects/config.json +2 -1
- package/examples/page-effects/content/disclosure.md +40 -0
- package/examples/page-effects/content/index.md +28 -0
- package/examples/page-effects/content/parallax-and-sticky.md +13 -0
- package/examples/page-effects/content/scroll-snap.md +29 -0
- package/examples/page-effects/content/scrollytelling.md +35 -0
- package/examples/page-effects/manifest.json +16 -4
- package/package.json +1 -1
- package/scripts/build-tenants.js +148 -20
- package/site/{app.2a8fb673db91.js → app.b987cce735d6.js} +1 -1
- package/site/app.js +1 -1
- package/site/assets/images/pipeline.e57f0dbfd05a.svg +33 -0
- package/site/assets/images/pipeline.svg +33 -0
- package/site/index.html +4 -4
- package/site/lib/page-effects.2131b53bea6b.js +1 -0
- package/site/lib/page-effects.js +1 -1
- package/site/llms.txt +11 -6
- package/site/{manifest.a870df3a026b.js → manifest.76e14cbcc513.js} +51 -46
- package/site/manifest.js +51 -46
- package/site/pages/accessible-authoring.html +4 -4
- package/site/pages/api.html +2 -2
- package/site/pages/architecture.html +2 -2
- package/site/pages/blog-layout.html +3 -3
- package/site/pages/deployment.html +2 -2
- package/site/pages/developer-guide.html +2 -2
- package/site/pages/extending.html +2 -2
- package/site/pages/overview.html +189 -0
- package/site/pages/page-effects.html +265 -0
- package/site/pages/publishing.html +258 -0
- package/site/pages/quickstart.html +2 -2
- package/site/pages/search-and-data.html +2 -2
- package/site/pages/seo-strategy.html +2 -2
- package/site/pages/showcase-gallery.html +184 -0
- package/site/pages/showcase-story.html +130 -0
- package/site/pages/tenant-config.html +12 -14
- package/site/pages/theming-recipes.html +2 -2
- package/site/pages/welcome.html +2 -2
- package/site/robots.txt +1 -1
- package/site/search-index/manifest.json +36 -38
- package/site/search-index/metadata.json +706 -355
- package/site/search-index/{part-0000.c67bff0d76c7.json → part-0000.510602c32169.json} +708 -355
- package/site/search-index/part-0000.json +708 -355
- package/site/sections/{blog-layout.f34ad33cdde0.js → blog-layout.3e80561d6838.js} +1 -1
- package/site/sections/blog-layout.js +1 -1
- package/site/sections/overview.5a483987fb9d.js +3 -0
- package/site/sections/overview.js +3 -0
- package/site/sections/page-effects.a38e5a7715d4.js +3 -0
- package/site/sections/page-effects.js +3 -0
- package/site/sections/publishing.32bf1d55b285.js +3 -0
- package/site/sections/publishing.js +3 -0
- package/site/sections/showcase-gallery.cd729f94752d.js +3 -0
- package/site/sections/showcase-gallery.js +3 -0
- package/site/sections/showcase-story.79311d1302c8.js +3 -0
- package/site/sections/showcase-story.js +3 -0
- package/site/sections/tenant-config.532868a466d7.js +3 -0
- package/site/sections/tenant-config.js +1 -1
- package/site/sitemap.xml +38 -26
- package/site/{styles.d957d4c1c1af.css → styles.bdb30ba34de5.css} +326 -8
- package/site/styles.css +326 -8
- package/src/app.js +35 -28
- package/src/lib/page-effects.js +410 -23
- package/src/styles.css +324 -6
- package/examples/page-effects/content/effects-spike.md +0 -212
- package/site/lib/page-effects.2e4e21674b13.js +0 -1
- package/site/pages/managed-hosting.html +0 -298
- package/site/pages/page-effects-spike.html +0 -152
- package/site/pages/theme-token-audit.html +0 -158
- package/site/sections/managed-hosting.e707893d4520.js +0 -3
- package/site/sections/managed-hosting.js +0 -3
- package/site/sections/page-effects-spike.d18d9601f432.js +0 -3
- package/site/sections/page-effects-spike.js +0 -3
- package/site/sections/tenant-config.e99335c66502.js +0 -3
- package/site/sections/theme-token-audit.182808e9139e.js +0 -3
- package/site/sections/theme-token-audit.js +0 -3
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Disclosure (accordion)
|
|
3
|
+
summary: Production accordion built on native <details> — works with zero JS, keyboard-operable, single-open optional.
|
|
4
|
+
hero:
|
|
5
|
+
eyebrow: Primitive
|
|
6
|
+
title: Disclosure
|
|
7
|
+
subtitle: An accordion that's just native <details> — accessible and complete with JavaScript off.
|
|
8
|
+
align: start
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Disclosure (accordion)
|
|
12
|
+
|
|
13
|
+
`.pe-accordion` styles native `<details>/<summary>`, so each panel opens, closes,
|
|
14
|
+
and takes keyboard focus with **zero JavaScript**. Add `data-pe-single` to make it
|
|
15
|
+
single-open — opening one panel closes its siblings — which is a pure enhancement:
|
|
16
|
+
with JS off, every panel still works independently and no content is ever hidden
|
|
17
|
+
behind the script.
|
|
18
|
+
|
|
19
|
+
```html
|
|
20
|
+
<div class="pe-accordion" data-pe-single>
|
|
21
|
+
<details open>
|
|
22
|
+
<summary>What is a page effect?</summary>
|
|
23
|
+
<p>An opt-in, accessible enhancement — heroes, reveal-on-scroll, living scroll,
|
|
24
|
+
disclosure — that degrades to plain content when JavaScript or motion is off.</p>
|
|
25
|
+
</details>
|
|
26
|
+
<details>
|
|
27
|
+
<summary>Does it work without JavaScript?</summary>
|
|
28
|
+
<p>Yes. The accordion is native <code><details></code>. The only thing JS
|
|
29
|
+
adds is the single-open grouping, and that's optional.</p>
|
|
30
|
+
</details>
|
|
31
|
+
<details>
|
|
32
|
+
<summary>Is it keyboard accessible?</summary>
|
|
33
|
+
<p>Native disclosure is operable with Enter/Space and shows a visible focus
|
|
34
|
+
ring. The open/closed state uses a +/− marker, not color alone.</p>
|
|
35
|
+
</details>
|
|
36
|
+
</div>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Drop `data-pe-single` to let multiple panels stay open at once. Style is theme-token
|
|
40
|
+
aware, so it inherits each tenant's palette automatically.
|
|
@@ -81,6 +81,34 @@ the viewport, and appear instantly under reduced-motion.
|
|
|
81
81
|
</div>
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
+
## Staggered reveal
|
|
85
|
+
|
|
86
|
+
The cards below arrive in a wave — `data-reveal-stagger` on the grid sequences
|
|
87
|
+
each child's entrance as it scrolls into view (JS-off and reduced-motion show all
|
|
88
|
+
at once).
|
|
89
|
+
|
|
90
|
+
```html
|
|
91
|
+
<div class="pe-card-grid" data-reveal-stagger="90">
|
|
92
|
+
<section class="pe-demo-card"><h3>One</h3><p>Arrives first.</p></section>
|
|
93
|
+
<section class="pe-demo-card"><h3>Two</h3><p>A beat later.</p></section>
|
|
94
|
+
<section class="pe-demo-card"><h3>Three</h3><p>Then this one.</p></section>
|
|
95
|
+
<section class="pe-demo-card"><h3>Four</h3><p>And finally this.</p></section>
|
|
96
|
+
</div>
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Figure zoom
|
|
100
|
+
|
|
101
|
+
The figure below shows its image normally; click it (or press Enter when focused)
|
|
102
|
+
to enlarge it in a modal. With JavaScript off, the image is just visible at its
|
|
103
|
+
normal size.
|
|
104
|
+
|
|
105
|
+
```html
|
|
106
|
+
<figure class="pe-figure" data-pe-zoom>
|
|
107
|
+
<img src="images/ridge.svg" alt="Layered ridge illustration" />
|
|
108
|
+
<figcaption>Click to enlarge — opens a native dialog with focus trap and Esc-to-close.</figcaption>
|
|
109
|
+
</figure>
|
|
110
|
+
```
|
|
111
|
+
|
|
84
112
|
## Authoring paths
|
|
85
113
|
|
|
86
114
|
You have four ways to add a hero, from least to most control:
|
|
@@ -61,3 +61,16 @@ The CTA band primitive is just as portable:
|
|
|
61
61
|
```
|
|
62
62
|
|
|
63
63
|
Keep scrolling to confirm the sticky hero releases at the end of the page.
|
|
64
|
+
|
|
65
|
+
## Parallax on any element
|
|
66
|
+
|
|
67
|
+
The card below isn't a hero — it drifts on its own with `data-pe-parallax="0.35"`.
|
|
68
|
+
Any element can; the speed is clamped so it never wanders far, and it's skipped
|
|
69
|
+
entirely under reduced motion.
|
|
70
|
+
|
|
71
|
+
```html
|
|
72
|
+
<div class="pe-demo-card" data-pe-parallax="0.35" style="max-width: 20rem;">
|
|
73
|
+
<h3>Parallax aside</h3>
|
|
74
|
+
<p>Drifts gently against the scroll — a non-hero element with its own speed.</p>
|
|
75
|
+
</div>
|
|
76
|
+
```
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Scroll-snap sections
|
|
3
|
+
summary: A CSS-only .pe-snap region whose panels snap as you scroll — opt-in, no runtime, never traps the keyboard.
|
|
4
|
+
hero:
|
|
5
|
+
eyebrow: Primitive
|
|
6
|
+
title: Scroll-snap sections
|
|
7
|
+
subtitle: Bounded snap panels for landing pages — pure CSS, and the keyboard is never trapped.
|
|
8
|
+
align: start
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Scroll-snap sections
|
|
12
|
+
|
|
13
|
+
`.pe-snap` is a **self-contained, opt-in** scroll region whose `.pe-snap__panel`
|
|
14
|
+
children snap into place as you scroll it. It's pure CSS — no runtime — and uses
|
|
15
|
+
`scroll-snap-type: y proximity` (not `mandatory`) inside a bounded height, so it
|
|
16
|
+
never takes over the page scroll and never traps keyboard users.
|
|
17
|
+
|
|
18
|
+
```html
|
|
19
|
+
<div class="pe-snap">
|
|
20
|
+
<section class="pe-snap__panel"><h2>Capture</h2><p>Scroll inside this region — each panel snaps to the top.</p></section>
|
|
21
|
+
<section class="pe-snap__panel"><h2>Compose</h2><p>Proximity snapping, so you can still stop between panels.</p></section>
|
|
22
|
+
<section class="pe-snap__panel"><h2>Publish</h2><p>Tab through; the keyboard is never trapped.</p></section>
|
|
23
|
+
</div>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Smooth glide to the snap points is gated under `prefers-reduced-motion:
|
|
27
|
+
no-preference`; reduced-motion readers get instant positioning. The panels are
|
|
28
|
+
ordinary sections, so the content is complete and reachable with or without
|
|
29
|
+
snapping.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Scrollytelling
|
|
3
|
+
summary: A sticky stage beside content steps — the stage updates as each step scrolls into view. JS-off shows the steps as plain content.
|
|
4
|
+
hero:
|
|
5
|
+
eyebrow: Primitive
|
|
6
|
+
title: Scrollytelling
|
|
7
|
+
subtitle: A sticky stage that reacts as you read past each step — built from sticky layout + scroll position, no animation engine.
|
|
8
|
+
align: start
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Scrollytelling
|
|
12
|
+
|
|
13
|
+
A `.pe-scrolly` block pairs a **sticky stage** with a column of `[data-pe-step]`
|
|
14
|
+
content steps. As each step scrolls into view, the stage's matching layer
|
|
15
|
+
crossfades in. With JavaScript off, the steps are ordinary readable content and
|
|
16
|
+
the stage shows its layers statically — the swap is a pure enhancement.
|
|
17
|
+
|
|
18
|
+
```html
|
|
19
|
+
<div class="pe-scrolly">
|
|
20
|
+
<div class="pe-scrolly__stage">
|
|
21
|
+
<div data-pe-step="capture"><h2>Capture</h2></div>
|
|
22
|
+
<div data-pe-step="compose"><h2>Compose</h2></div>
|
|
23
|
+
<div data-pe-step="publish"><h2>Publish</h2></div>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="pe-scrolly__steps">
|
|
26
|
+
<section data-pe-step="capture"><p>Write your content as Markdown, HTML, or a JS module.</p></section>
|
|
27
|
+
<section data-pe-step="compose"><p>Shared templates turn it into a branded, tenant-specific site.</p></section>
|
|
28
|
+
<section data-pe-step="publish"><p>Build emits static files — host them anywhere.</p></section>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The active step is the last one to scroll past the upper-middle of the viewport —
|
|
34
|
+
the same rect-based detection scroll-spy uses, so no bespoke animation engine is
|
|
35
|
+
needed. The crossfade is gated under `prefers-reduced-motion: no-preference`.
|
|
@@ -14,10 +14,22 @@
|
|
|
14
14
|
"file": "parallax-and-sticky.md"
|
|
15
15
|
},
|
|
16
16
|
{
|
|
17
|
-
"id": "
|
|
18
|
-
"title": "
|
|
19
|
-
"summary": "
|
|
20
|
-
"file": "
|
|
17
|
+
"id": "disclosure",
|
|
18
|
+
"title": "Disclosure",
|
|
19
|
+
"summary": "Production accordion on native <details> — zero-JS, keyboard-operable, single-open optional.",
|
|
20
|
+
"file": "disclosure.md"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"id": "scroll-snap",
|
|
24
|
+
"title": "Scroll-snap",
|
|
25
|
+
"summary": "CSS-only .pe-snap region — bounded, opt-in snap panels that never trap the keyboard.",
|
|
26
|
+
"file": "scroll-snap.md"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"id": "scrollytelling",
|
|
30
|
+
"title": "Scrollytelling",
|
|
31
|
+
"summary": "A sticky stage that reacts as you read past each step — sticky layout + scroll position, no animation engine.",
|
|
32
|
+
"file": "scrollytelling.md"
|
|
21
33
|
}
|
|
22
34
|
]
|
|
23
35
|
}
|
package/package.json
CHANGED
package/scripts/build-tenants.js
CHANGED
|
@@ -277,6 +277,11 @@ function isGitAvailable() {
|
|
|
277
277
|
* Execute a command with timeout and retry
|
|
278
278
|
*/
|
|
279
279
|
async function execWithRetry(command, options = {}) {
|
|
280
|
+
const commandSegments = Array.isArray(command) ? command : [];
|
|
281
|
+
if (commandSegments.length === 0) {
|
|
282
|
+
throw new Error('execWithRetry expected an argument array');
|
|
283
|
+
}
|
|
284
|
+
|
|
280
285
|
const {
|
|
281
286
|
cwd = root,
|
|
282
287
|
timeout = DEFAULT_GIT_TIMEOUT,
|
|
@@ -288,10 +293,13 @@ async function execWithRetry(command, options = {}) {
|
|
|
288
293
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
289
294
|
try {
|
|
290
295
|
return await new Promise((resolve, reject) => {
|
|
291
|
-
const proc = spawn(
|
|
296
|
+
const proc = spawn(commandSegments[0], commandSegments.slice(1), {
|
|
292
297
|
cwd,
|
|
293
298
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
294
|
-
env: {
|
|
299
|
+
env: {
|
|
300
|
+
...env,
|
|
301
|
+
GIT_TERMINAL_PROMPT: '0'
|
|
302
|
+
},
|
|
295
303
|
timeout
|
|
296
304
|
});
|
|
297
305
|
|
|
@@ -305,7 +313,10 @@ async function execWithRetry(command, options = {}) {
|
|
|
305
313
|
if (code === 0) {
|
|
306
314
|
resolve({ stdout, stderr });
|
|
307
315
|
} else {
|
|
308
|
-
|
|
316
|
+
const prettyCommand = commandSegments
|
|
317
|
+
.map((segment) => maskAuthSegment(String(segment)))
|
|
318
|
+
.join(' ');
|
|
319
|
+
reject(new Error(`Command failed (exit ${code}): ${stderr || stdout} (command: ${prettyCommand})`));
|
|
309
320
|
}
|
|
310
321
|
});
|
|
311
322
|
|
|
@@ -334,6 +345,34 @@ async function execWithRetry(command, options = {}) {
|
|
|
334
345
|
throw lastError;
|
|
335
346
|
}
|
|
336
347
|
|
|
348
|
+
function maskAuthSegment(value) {
|
|
349
|
+
return value.replace(/\/\/[^@]+@/, '//***@');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Build an authenticated git URL when GIT_CREDENTIALS is available.
|
|
354
|
+
*/
|
|
355
|
+
function withGitCredentials(rawUrl) {
|
|
356
|
+
const credentials = process.env.GIT_CREDENTIALS || '';
|
|
357
|
+
const [username, password] = credentials.split(':', 2);
|
|
358
|
+
if (!credentials || !username || password === undefined || !rawUrl) return rawUrl;
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
const source = new URL(rawUrl);
|
|
362
|
+
if (!['https:', 'http:'].includes(source.protocol)) {
|
|
363
|
+
return rawUrl;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (source.username) {
|
|
367
|
+
return rawUrl;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return `${source.protocol}//${encodeURIComponent(username)}:${encodeURIComponent(password)}@${source.host}${source.pathname}${source.search}${source.hash}`;
|
|
371
|
+
} catch {
|
|
372
|
+
return rawUrl;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
337
376
|
/**
|
|
338
377
|
* Generate cache key for git source
|
|
339
378
|
*/
|
|
@@ -364,7 +403,10 @@ function isImmutableRef(ref) {
|
|
|
364
403
|
*/
|
|
365
404
|
async function getHeadCommit(repoDir) {
|
|
366
405
|
try {
|
|
367
|
-
const { stdout } = await execWithRetry(
|
|
406
|
+
const { stdout } = await execWithRetry(['git', '-C', repoDir, 'rev-parse', 'HEAD'], {
|
|
407
|
+
cwd: root,
|
|
408
|
+
retries: 1
|
|
409
|
+
});
|
|
368
410
|
return stdout.trim();
|
|
369
411
|
} catch {
|
|
370
412
|
return null;
|
|
@@ -385,7 +427,7 @@ async function getChangedFiles(repoDir, oldCommit, newCommit, subPath = '.') {
|
|
|
385
427
|
try {
|
|
386
428
|
// Use --name-status to get file status (A=added, M=modified, D=deleted)
|
|
387
429
|
const { stdout } = await execWithRetry(
|
|
388
|
-
|
|
430
|
+
['git', '-C', repoDir, 'diff', '--name-status', oldCommit, newCommit],
|
|
389
431
|
{ cwd: root, retries: 1 }
|
|
390
432
|
);
|
|
391
433
|
|
|
@@ -461,6 +503,7 @@ async function cloneGitSource(source, cacheDir, options = {}) {
|
|
|
461
503
|
|
|
462
504
|
// Sanitize URL for logging (hide credentials)
|
|
463
505
|
const safeUrl = url.replace(/\/\/[^@]+@/, '//***@');
|
|
506
|
+
const authUrl = withGitCredentials(url);
|
|
464
507
|
|
|
465
508
|
console.log(` ↳ git source: ${safeUrl}`);
|
|
466
509
|
console.log(` ↳ ref: ${ref}, path: ${subPath || '(root)'}`);
|
|
@@ -494,8 +537,8 @@ async function cloneGitSource(source, cacheDir, options = {}) {
|
|
|
494
537
|
console.log(` ↳ current HEAD: ${oldCommit ? oldCommit.slice(0, 7) : 'unknown'}`);
|
|
495
538
|
|
|
496
539
|
try {
|
|
497
|
-
await execWithRetry(
|
|
498
|
-
await execWithRetry(
|
|
540
|
+
await execWithRetry(['git', '-C', cloneDir, 'fetch', 'origin', ref, '--depth', String(effectiveDepth)], { cwd: root });
|
|
541
|
+
await execWithRetry(['git', '-C', cloneDir, 'checkout', 'FETCH_HEAD'], { cwd: root });
|
|
499
542
|
newCommit = await getHeadCommit(cloneDir);
|
|
500
543
|
console.log(` ↳ updated HEAD: ${newCommit ? newCommit.slice(0, 7) : 'unknown'}`);
|
|
501
544
|
} catch (err) {
|
|
@@ -513,33 +556,33 @@ async function cloneGitSource(source, cacheDir, options = {}) {
|
|
|
513
556
|
wasCloned = true;
|
|
514
557
|
|
|
515
558
|
// Build clone command
|
|
516
|
-
|
|
559
|
+
const cloneArgs = ['git', 'clone', '--depth', String(effectiveDepth)];
|
|
517
560
|
|
|
518
561
|
// Add branch/ref
|
|
519
562
|
// For commits, we can't use --branch, need to fetch after
|
|
520
563
|
if (!isImmutableRef(ref) || /^v?\d+\.\d+/.test(ref)) {
|
|
521
|
-
|
|
564
|
+
cloneArgs.push('--branch', ref);
|
|
522
565
|
}
|
|
523
566
|
|
|
524
567
|
// Sparse checkout preparation
|
|
525
568
|
if (effectiveSparse) {
|
|
526
|
-
|
|
569
|
+
cloneArgs.push('--filter=blob:none', '--sparse');
|
|
527
570
|
}
|
|
528
571
|
|
|
529
|
-
|
|
572
|
+
cloneArgs.push(authUrl, cloneDir);
|
|
530
573
|
|
|
531
574
|
try {
|
|
532
|
-
await execWithRetry(
|
|
575
|
+
await execWithRetry(cloneArgs, { cwd: root });
|
|
533
576
|
|
|
534
577
|
// If ref is a commit SHA, checkout after clone
|
|
535
578
|
if (/^[0-9a-f]{7,40}$/i.test(ref) && !/^v?\d+\.\d+/.test(ref)) {
|
|
536
|
-
await execWithRetry(
|
|
537
|
-
await execWithRetry(
|
|
579
|
+
await execWithRetry(['git', '-C', cloneDir, 'fetch', '--depth', String(effectiveDepth), 'origin', ref], { cwd: root });
|
|
580
|
+
await execWithRetry(['git', '-C', cloneDir, 'checkout', ref], { cwd: root });
|
|
538
581
|
}
|
|
539
582
|
|
|
540
583
|
// Set up sparse checkout if needed
|
|
541
584
|
if (effectiveSparse) {
|
|
542
|
-
await execWithRetry(
|
|
585
|
+
await execWithRetry(['git', '-C', cloneDir, 'sparse-checkout', 'set', subPath], { cwd: root });
|
|
543
586
|
}
|
|
544
587
|
|
|
545
588
|
newCommit = await getHeadCommit(cloneDir);
|
|
@@ -1670,6 +1713,83 @@ async function applyReadingProgressConfig(distDir, config, tenantId) {
|
|
|
1670
1713
|
console.log(` ↳ enabled reading progress for ${tenantId}`);
|
|
1671
1714
|
}
|
|
1672
1715
|
|
|
1716
|
+
/**
|
|
1717
|
+
* Living scroll on any layout (docs included). Blog tenants opt in via
|
|
1718
|
+
* `blog.livingScroll` (handled in applyBlogLayout); this is the general path,
|
|
1719
|
+
* driven by a top-level `livingScroll: true` (or `reader.livingScroll`). It sets
|
|
1720
|
+
* the layout-agnostic body flag plus a reading-progress bar. The hidden base
|
|
1721
|
+
* state, reduced-motion fallback, and JS-off completeness all live in
|
|
1722
|
+
* page-effects.js + styles.css — the build only sets the hooks.
|
|
1723
|
+
*/
|
|
1724
|
+
async function applyLivingScrollConfig(distDir, config, tenantId) {
|
|
1725
|
+
const reader = (config.reader && typeof config.reader === 'object') ? config.reader : {};
|
|
1726
|
+
if (config.livingScroll !== true && reader.livingScroll !== true) return;
|
|
1727
|
+
|
|
1728
|
+
const indexPath = path.join(distDir, 'index.html');
|
|
1729
|
+
if (!(await pathExists(indexPath))) return;
|
|
1730
|
+
|
|
1731
|
+
let html = await fsp.readFile(indexPath, 'utf8');
|
|
1732
|
+
// Idempotent, and don't fight applyBlogLayout if it already set the blog alias.
|
|
1733
|
+
if (/<body[^>]*data-(?:blog-)?living-scroll(?:[\s=>])/.test(html)) return;
|
|
1734
|
+
const attrs = ['data-living-scroll'];
|
|
1735
|
+
// Living scroll pairs with a progress bar; only add it if not already set
|
|
1736
|
+
// (applyReadingProgressConfig runs first and may have added it).
|
|
1737
|
+
if (!/<body[^>]*data-reading-progress(?:[\s=>])/.test(html)) {
|
|
1738
|
+
attrs.push('data-reading-progress');
|
|
1739
|
+
}
|
|
1740
|
+
html = html.replace(/<body(?=[\s>])/, `<body ${attrs.join(' ')}`);
|
|
1741
|
+
await fsp.writeFile(indexPath, html, 'utf8');
|
|
1742
|
+
console.log(` ↳ enabled living scroll for ${tenantId}`);
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
/**
|
|
1746
|
+
* On-this-page TOC + scroll-spy (#73). Opt-in via a top-level `pageToc`:
|
|
1747
|
+
* pageToc: true → rail placement, default min
|
|
1748
|
+
* pageToc: { placement: "rail"|"top"|"off", minHeadings: N }
|
|
1749
|
+
* Sets the body hooks the pageToc page-effect reads; the effect generates the nav
|
|
1750
|
+
* client-side from the page's headings.
|
|
1751
|
+
*/
|
|
1752
|
+
async function applyPageTocConfig(distDir, config, tenantId) {
|
|
1753
|
+
const toc = config.pageToc;
|
|
1754
|
+
if (toc !== true && (!toc || typeof toc !== 'object')) return;
|
|
1755
|
+
const placementRaw = toc && typeof toc === 'object' ? toc.placement : undefined;
|
|
1756
|
+
if (placementRaw === 'off' || (toc && typeof toc === 'object' && toc.enabled === false)) return;
|
|
1757
|
+
const placement = placementRaw === 'top' ? 'top' : 'rail';
|
|
1758
|
+
const min = toc && typeof toc === 'object' && Number.isInteger(toc.minHeadings) && toc.minHeadings > 0
|
|
1759
|
+
? toc.minHeadings : null;
|
|
1760
|
+
|
|
1761
|
+
const indexPath = path.join(distDir, 'index.html');
|
|
1762
|
+
if (!(await pathExists(indexPath))) return;
|
|
1763
|
+
|
|
1764
|
+
let html = await fsp.readFile(indexPath, 'utf8');
|
|
1765
|
+
if (/<body[^>]*data-page-toc(?:[\s=>])/.test(html)) return;
|
|
1766
|
+
const attrs = [`data-page-toc="${placement}"`];
|
|
1767
|
+
if (min) attrs.push(`data-page-toc-min="${min}"`);
|
|
1768
|
+
html = html.replace(/<body(?=[\s>])/, `<body ${attrs.join(' ')}`);
|
|
1769
|
+
await fsp.writeFile(indexPath, html, 'utf8');
|
|
1770
|
+
console.log(` ↳ enabled on-this-page TOC (${placement}) for ${tenantId}`);
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
/**
|
|
1774
|
+
* Sidebar nav collapse behavior. Configurable via `navCollapse`:
|
|
1775
|
+
* "overlay" (default) — drawer hidden by default; hamburger slides it over the content
|
|
1776
|
+
* "push" — nav visible; collapse slides it out and reflows the content
|
|
1777
|
+
* "instant" — nav visible; collapse instantly hides the column
|
|
1778
|
+
* Writes data-nav-collapse so styles.css can switch modes (drawer modes apply only
|
|
1779
|
+
* to the default left-sidebar layout; positioned-nav demos are unaffected).
|
|
1780
|
+
*/
|
|
1781
|
+
async function applyNavCollapseConfig(distDir, config, tenantId) {
|
|
1782
|
+
const raw = typeof config.navCollapse === 'string' ? config.navCollapse.toLowerCase() : '';
|
|
1783
|
+
const mode = raw === 'push' || raw === 'instant' ? raw : 'overlay';
|
|
1784
|
+
const indexPath = path.join(distDir, 'index.html');
|
|
1785
|
+
if (!(await pathExists(indexPath))) return;
|
|
1786
|
+
let html = await fsp.readFile(indexPath, 'utf8');
|
|
1787
|
+
if (/<body[^>]*data-nav-collapse(?:[\s=>])/.test(html)) return;
|
|
1788
|
+
html = html.replace(/<body(?=[\s>])/, `<body data-nav-collapse="${mode}"`);
|
|
1789
|
+
await fsp.writeFile(indexPath, html, 'utf8');
|
|
1790
|
+
console.log(` ↳ nav collapse mode: ${mode} for ${tenantId}`);
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1673
1793
|
/**
|
|
1674
1794
|
* Write the synthetic blog-index section module and register it in manifest.js.
|
|
1675
1795
|
* Mirrors applyDocsMap's injection (append + idempotent).
|
|
@@ -3822,8 +3942,11 @@ async function processNestedContent(sourceDir, distDir, tenantId, contentRoot, o
|
|
|
3822
3942
|
const layout = typeof config.layout === 'string' ? config.layout.toLowerCase() : 'docs';
|
|
3823
3943
|
const blogCfg = (config.blog && typeof config.blog === 'object') ? config.blog : {};
|
|
3824
3944
|
const siteConfig = {
|
|
3825
|
-
|
|
3826
|
-
|
|
3945
|
+
// Prev/next article nav at the bottom of each page is a generic docs feature,
|
|
3946
|
+
// visible on all screens by default. Honor config.json or the root manifest;
|
|
3947
|
+
// a tenant can scope it down with "mobile" or turn it off with "never".
|
|
3948
|
+
bottomNav: config.bottomNav || rootManifest?.bottomNav || 'always',
|
|
3949
|
+
bottomNavSections: config.bottomNavSections || rootManifest?.bottomNavSections || [],
|
|
3827
3950
|
// Pass SEO-relevant config to SPA for dynamic meta tag updates.
|
|
3828
3951
|
// siteUrl falls back to `domain` (#15); ogImage drives social cards (#16).
|
|
3829
3952
|
siteTitle: config.title || '',
|
|
@@ -4022,7 +4145,7 @@ function buildManifestModuleSource(manifestEntries, defaultSection, siteConfig =
|
|
|
4022
4145
|
const manifestJson = JSON.stringify(manifestEntries, null, 2);
|
|
4023
4146
|
const defaultJson = JSON.stringify(defaultSection || null);
|
|
4024
4147
|
const configJson = JSON.stringify({
|
|
4025
|
-
bottomNav: siteConfig.bottomNav || '
|
|
4148
|
+
bottomNav: siteConfig.bottomNav || 'always', // 'always' | 'mobile' | 'never'
|
|
4026
4149
|
bottomNavSections: siteConfig.bottomNavSections || [], // Section prefixes to always show nav
|
|
4027
4150
|
...siteConfig
|
|
4028
4151
|
}, null, 2);
|
|
@@ -4215,8 +4338,10 @@ async function processTenantManifestLegacy(sourceDir, distDir, tenantId, options
|
|
|
4215
4338
|
|
|
4216
4339
|
// Extract site configuration from manifest
|
|
4217
4340
|
const siteConfig = {
|
|
4218
|
-
|
|
4219
|
-
|
|
4341
|
+
// Prev/next article nav is a generic docs feature, visible on all screens by
|
|
4342
|
+
// default; honor config.json or the manifest, scope down with "mobile"/"never".
|
|
4343
|
+
bottomNav: config.bottomNav || manifestData.bottomNav || 'always',
|
|
4344
|
+
bottomNavSections: config.bottomNavSections || manifestData.bottomNavSections || [],
|
|
4220
4345
|
// SEO-relevant config for runtime meta updates — parity with the nested path
|
|
4221
4346
|
// so the runtime <title> brand matches config.title, not a generic fallback (#29).
|
|
4222
4347
|
siteTitle: config.title || manifestData.title || '',
|
|
@@ -4776,6 +4901,9 @@ async function buildTenant(tenant, targetOverride, cacheDir, buildOptions) {
|
|
|
4776
4901
|
await applyNavAlignment(distDir, config, tenantId);
|
|
4777
4902
|
await applyBlogLayout(distDir, config, tenantId);
|
|
4778
4903
|
await applyReadingProgressConfig(distDir, config, tenantId);
|
|
4904
|
+
await applyLivingScrollConfig(distDir, config, tenantId);
|
|
4905
|
+
await applyPageTocConfig(distDir, config, tenantId);
|
|
4906
|
+
await applyNavCollapseConfig(distDir, config, tenantId);
|
|
4779
4907
|
await applyDocsMap(distDir, config, tenantId);
|
|
4780
4908
|
await applyWelcome(distDir, config, tenantId);
|
|
4781
4909
|
}
|