@janga/norna 0.7.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/LICENSE +674 -0
- package/README.md +109 -0
- package/astro.config.mjs +17 -0
- package/bin/norna.mjs +170 -0
- package/docs/README.md +48 -0
- package/docs/command-organization.md +402 -0
- package/docs/commands.md +152 -0
- package/docs/configuration.md +376 -0
- package/docs/content.md +384 -0
- package/docs/engine-development.md +164 -0
- package/docs/getting-started.md +96 -0
- package/docs/images-and-metadata.md +88 -0
- package/docs/local-development.md +61 -0
- package/docs/publishing.md +81 -0
- package/docs/site-examples-structure-note.md +105 -0
- package/docs/site-structure.md +81 -0
- package/fixtures/basic/site/.norna/generated-images.json +1 -0
- package/fixtures/basic/site/config.mjs +59 -0
- package/fixtures/basic/site/content.md +17 -0
- package/fixtures/basic/site/images/work/.gitkeep +1 -0
- package/fixtures/basic/site/public/robots.txt +2 -0
- package/fixtures/basic/site/theme.md +7 -0
- package/package.json +90 -0
- package/scripts/build-site.mjs +16 -0
- package/scripts/check-config.mjs +37 -0
- package/scripts/deploy-site.mjs +389 -0
- package/scripts/dev-local.mjs +313 -0
- package/scripts/doctor.mjs +38 -0
- package/scripts/engine-version.mjs +137 -0
- package/scripts/generate-images.mjs +369 -0
- package/scripts/init-site.mjs +249 -0
- package/scripts/lib/astro-command.mjs +34 -0
- package/scripts/lib/ci-lockfile.mjs +34 -0
- package/scripts/lib/image-dimensions.mjs +78 -0
- package/scripts/lib/presentation.mjs +72 -0
- package/scripts/lib/project-config.mjs +322 -0
- package/scripts/lib/run-command.mjs +21 -0
- package/scripts/lib/site-content.mjs +392 -0
- package/scripts/lib/site-paths.mjs +127 -0
- package/scripts/lib/typography.mjs +166 -0
- package/scripts/release.mjs +77 -0
- package/scripts/show-typography.mjs +210 -0
- package/scripts/sync-content-sections.mjs +610 -0
- package/scripts/sync-site-public.mjs +42 -0
- package/scripts/test-ci-lockfile.mjs +65 -0
- package/scripts/test-content-check.mjs +364 -0
- package/scripts/test-engine-commands.mjs +128 -0
- package/scripts/test-navigation-preview.mjs +108 -0
- package/scripts/test-navigation.mjs +116 -0
- package/scripts/test-package-check.mjs +394 -0
- package/scripts/test-site-public.mjs +85 -0
- package/scripts/test-temporary-visibility.mjs +99 -0
- package/scripts/update-engine.mjs +127 -0
- package/scripts/watch-pages-deploy.mjs +430 -0
- package/src/components/GalleryGrid.astro +221 -0
- package/src/components/SiteNavigation.astro +410 -0
- package/src/components/SitePage.astro +69 -0
- package/src/components/SiteSection.astro +174 -0
- package/src/content.config.ts +171 -0
- package/src/layouts/BaseLayout.astro +90 -0
- package/src/lib/generatedImages.ts +63 -0
- package/src/lib/sectionContent.ts +125 -0
- package/src/lib/sitePages.ts +80 -0
- package/src/lib/sitePublicAssets.ts +39 -0
- package/src/lib/visibility.ts +35 -0
- package/src/pages/[slug].astro +31 -0
- package/src/pages/index.astro +16 -0
- package/src/styles/global.css +872 -0
- package/starters/basic/.github/workflows/deploy.yml +65 -0
- package/starters/basic/README.md +55 -0
- package/starters/basic/package.json +35 -0
- package/starters/basic/site/config.mjs +60 -0
- package/starters/basic/site/content.md +21 -0
- package/starters/basic/site/images/work/.gitkeep +1 -0
- package/starters/basic/site/public/robots.txt +2 -0
- package/starters/basic/site/theme.md +53 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { CollectionEntry } from 'astro:content';
|
|
3
|
+
import projectConfig from '../../scripts/lib/project-config.mjs';
|
|
4
|
+
import { getGeneratedImage, getImageAttributes } from '../lib/generatedImages';
|
|
5
|
+
|
|
6
|
+
type SiteSection = CollectionEntry<'site'>['data']['sections'][number];
|
|
7
|
+
type GalleryItem = SiteSection['gallery'][number];
|
|
8
|
+
type GalleryImage = Extract<GalleryItem, { image: string }>;
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
title: string;
|
|
12
|
+
items: GalleryItem[];
|
|
13
|
+
priorityFirstImage?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const { title, items, priorityFirstImage = false } = Astro.props;
|
|
17
|
+
const galleryImageSizes = [
|
|
18
|
+
`(max-width: 700px) ${projectConfig.gallery.maxAvailableWidthPercent.mobile}vw`,
|
|
19
|
+
`${projectConfig.gallery.maxAvailableWidthPercent.desktop}vw`,
|
|
20
|
+
].join(', ');
|
|
21
|
+
const getImageClass = (image: GalleryImage) => {
|
|
22
|
+
const generatedImage = getGeneratedImage(image.image);
|
|
23
|
+
return generatedImage && generatedImage.width > generatedImage.height
|
|
24
|
+
? 'gallery-image-landscape'
|
|
25
|
+
: undefined;
|
|
26
|
+
};
|
|
27
|
+
const getImageLoadingProps = (isPriority: boolean) => ({
|
|
28
|
+
decoding: 'async' as const,
|
|
29
|
+
fetchpriority: isPriority ? 'high' as const : 'low' as const,
|
|
30
|
+
loading: isPriority ? 'eager' as const : 'lazy' as const,
|
|
31
|
+
});
|
|
32
|
+
const getCarouselAspectRatio = (images: GalleryImage[]) => {
|
|
33
|
+
const ratios = images
|
|
34
|
+
.map((image) => getGeneratedImage(image.image))
|
|
35
|
+
.filter((image): image is NonNullable<typeof image> => Boolean(image))
|
|
36
|
+
.map((image) => image.width / image.height);
|
|
37
|
+
|
|
38
|
+
return ratios.length > 0 ? Math.min(...ratios) : null;
|
|
39
|
+
};
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
<div class="gallery" aria-label={`${projectConfig.locale.labels.gallery}: ${title}`}>
|
|
43
|
+
{items.map((item, itemIndex) => {
|
|
44
|
+
const isPriorityItem = priorityFirstImage && itemIndex === 0;
|
|
45
|
+
|
|
46
|
+
if ('image' in item) {
|
|
47
|
+
return (
|
|
48
|
+
<figure class="gallery-item">
|
|
49
|
+
<div class="gallery-image-frame">
|
|
50
|
+
<img
|
|
51
|
+
{...getImageAttributes(item.image, galleryImageSizes)}
|
|
52
|
+
class:list={['gallery-image', getImageClass(item)]}
|
|
53
|
+
alt={item.alt}
|
|
54
|
+
{...getImageLoadingProps(isPriorityItem)}
|
|
55
|
+
/>
|
|
56
|
+
</div>
|
|
57
|
+
{item.caption && (
|
|
58
|
+
<figcaption class="gallery-meta">
|
|
59
|
+
<div class="gallery-details">
|
|
60
|
+
<span>{item.caption}</span>
|
|
61
|
+
</div>
|
|
62
|
+
</figcaption>
|
|
63
|
+
)}
|
|
64
|
+
</figure>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const carouselAspectRatio = getCarouselAspectRatio(item.carousel);
|
|
69
|
+
const carouselStyle = carouselAspectRatio
|
|
70
|
+
? `--gallery-carousel-aspect-ratio: ${carouselAspectRatio}`
|
|
71
|
+
: undefined;
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<section
|
|
75
|
+
class="gallery-item gallery-carousel"
|
|
76
|
+
data-carousel
|
|
77
|
+
tabindex="0"
|
|
78
|
+
role="group"
|
|
79
|
+
aria-roledescription="carousel"
|
|
80
|
+
aria-label={`${title}, bildkarusell`}
|
|
81
|
+
style={carouselStyle}
|
|
82
|
+
>
|
|
83
|
+
<div class="gallery-carousel-stage">
|
|
84
|
+
<div class="gallery-carousel-viewport" data-carousel-viewport>
|
|
85
|
+
<div class="gallery-carousel-slides">
|
|
86
|
+
{item.carousel.map((image, imageIndex) => (
|
|
87
|
+
<figure class="gallery-carousel-slide" data-carousel-slide>
|
|
88
|
+
<div class="gallery-carousel-image-frame">
|
|
89
|
+
<img
|
|
90
|
+
{...getImageAttributes(image.image, galleryImageSizes)}
|
|
91
|
+
class:list={['gallery-image', getImageClass(image)]}
|
|
92
|
+
alt={image.alt}
|
|
93
|
+
{...getImageLoadingProps(isPriorityItem && imageIndex === 0)}
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
</figure>
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
<button
|
|
101
|
+
class="gallery-carousel-button gallery-carousel-button-previous"
|
|
102
|
+
type="button"
|
|
103
|
+
data-carousel-previous
|
|
104
|
+
aria-label="Föregående bild"
|
|
105
|
+
title="Föregående bild"
|
|
106
|
+
>
|
|
107
|
+
<span aria-hidden="true">‹</span>
|
|
108
|
+
</button>
|
|
109
|
+
<button
|
|
110
|
+
class="gallery-carousel-button gallery-carousel-button-next"
|
|
111
|
+
type="button"
|
|
112
|
+
data-carousel-next
|
|
113
|
+
aria-label="Nästa bild"
|
|
114
|
+
title="Nästa bild"
|
|
115
|
+
>
|
|
116
|
+
<span aria-hidden="true">›</span>
|
|
117
|
+
</button>
|
|
118
|
+
<output class="gallery-carousel-position" data-carousel-position aria-live="polite">
|
|
119
|
+
1 / {item.carousel.length}
|
|
120
|
+
</output>
|
|
121
|
+
</div>
|
|
122
|
+
<div class="gallery-carousel-captions" data-carousel-captions>
|
|
123
|
+
{item.carousel.map((image, imageIndex) => (
|
|
124
|
+
image.caption && (
|
|
125
|
+
<p
|
|
126
|
+
class="gallery-meta gallery-carousel-caption"
|
|
127
|
+
data-carousel-caption
|
|
128
|
+
data-carousel-caption-index={imageIndex}
|
|
129
|
+
>
|
|
130
|
+
<span class="gallery-details">
|
|
131
|
+
<span>{image.caption}</span>
|
|
132
|
+
</span>
|
|
133
|
+
</p>
|
|
134
|
+
)
|
|
135
|
+
))}
|
|
136
|
+
</div>
|
|
137
|
+
</section>
|
|
138
|
+
);
|
|
139
|
+
})}
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<script>
|
|
143
|
+
import EmblaCarousel from 'embla-carousel';
|
|
144
|
+
|
|
145
|
+
const carouselSelector = '[data-carousel]';
|
|
146
|
+
|
|
147
|
+
const initializeCarousel = (carousel: HTMLElement) => {
|
|
148
|
+
const viewport = carousel.querySelector<HTMLElement>('[data-carousel-viewport]');
|
|
149
|
+
const previous = carousel.querySelector<HTMLButtonElement>('[data-carousel-previous]');
|
|
150
|
+
const next = carousel.querySelector<HTMLButtonElement>('[data-carousel-next]');
|
|
151
|
+
const position = carousel.querySelector<HTMLOutputElement>('[data-carousel-position]');
|
|
152
|
+
const captions = Array.from(carousel.querySelectorAll<HTMLElement>('[data-carousel-caption]'));
|
|
153
|
+
const slides = Array.from(carousel.querySelectorAll<HTMLElement>('[data-carousel-slide]'));
|
|
154
|
+
if (!viewport || !previous || !next || !position || slides.length < 2) return;
|
|
155
|
+
|
|
156
|
+
let controlsTimeout: number | undefined;
|
|
157
|
+
const showControls = () => {
|
|
158
|
+
carousel.classList.add('is-interacting');
|
|
159
|
+
window.clearTimeout(controlsTimeout);
|
|
160
|
+
controlsTimeout = window.setTimeout(() => {
|
|
161
|
+
carousel.classList.remove('is-interacting');
|
|
162
|
+
}, 1600);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
carousel.dataset.carouselReady = 'true';
|
|
166
|
+
|
|
167
|
+
const embla = EmblaCarousel(viewport, {
|
|
168
|
+
align: 'start',
|
|
169
|
+
containScroll: false,
|
|
170
|
+
loop: true,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const update = () => {
|
|
174
|
+
const activeIndex = embla.selectedScrollSnap();
|
|
175
|
+
slides.forEach((slide, index) => {
|
|
176
|
+
const isSelected = index === activeIndex;
|
|
177
|
+
slide.classList.toggle('is-active', isSelected);
|
|
178
|
+
slide.setAttribute('aria-hidden', String(!isSelected));
|
|
179
|
+
});
|
|
180
|
+
captions.forEach((caption) => {
|
|
181
|
+
const isSelected = Number(caption.dataset.carouselCaptionIndex) === activeIndex;
|
|
182
|
+
caption.classList.toggle('is-active', isSelected);
|
|
183
|
+
caption.setAttribute('aria-hidden', String(!isSelected));
|
|
184
|
+
});
|
|
185
|
+
previous.disabled = false;
|
|
186
|
+
next.disabled = false;
|
|
187
|
+
position.textContent = `${activeIndex + 1} / ${slides.length}`;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
previous.addEventListener('click', () => {
|
|
191
|
+
showControls();
|
|
192
|
+
embla.scrollPrev();
|
|
193
|
+
});
|
|
194
|
+
next.addEventListener('click', () => {
|
|
195
|
+
showControls();
|
|
196
|
+
embla.scrollNext();
|
|
197
|
+
});
|
|
198
|
+
carousel.addEventListener('focusin', showControls);
|
|
199
|
+
carousel.addEventListener('pointerdown', showControls);
|
|
200
|
+
carousel.addEventListener('keydown', (event) => {
|
|
201
|
+
if (event.key === 'ArrowLeft') {
|
|
202
|
+
event.preventDefault();
|
|
203
|
+
showControls();
|
|
204
|
+
embla.scrollPrev();
|
|
205
|
+
}
|
|
206
|
+
if (event.key === 'ArrowRight') {
|
|
207
|
+
event.preventDefault();
|
|
208
|
+
showControls();
|
|
209
|
+
embla.scrollNext();
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
embla.on('select', () => {
|
|
213
|
+
showControls();
|
|
214
|
+
update();
|
|
215
|
+
});
|
|
216
|
+
embla.on('reInit', update);
|
|
217
|
+
update();
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
document.querySelectorAll<HTMLElement>(carouselSelector).forEach(initializeCarousel);
|
|
221
|
+
</script>
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { CollectionEntry } from 'astro:content';
|
|
3
|
+
import projectConfig from '../../scripts/lib/project-config.mjs';
|
|
4
|
+
import type { SitePage } from '../lib/sitePages';
|
|
5
|
+
|
|
6
|
+
type SiteSection = CollectionEntry<'site'>['data']['sections'][number];
|
|
7
|
+
type ResolvedSection = SiteSection & { title: string };
|
|
8
|
+
type FrameColors = {
|
|
9
|
+
backgroundColor: string;
|
|
10
|
+
textColor: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
currentPage: SitePage;
|
|
15
|
+
pages: SitePage[];
|
|
16
|
+
sections: ResolvedSection[];
|
|
17
|
+
frameColors: FrameColors;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { currentPage, pages, sections, frameColors } = Astro.props;
|
|
21
|
+
const smoothScrollConfig = projectConfig.navigation.smoothScroll;
|
|
22
|
+
const navigationStyle = [
|
|
23
|
+
`--site-top-background-color: ${frameColors.backgroundColor}`,
|
|
24
|
+
`--site-top-text-color: ${frameColors.textColor}`,
|
|
25
|
+
].join('; ');
|
|
26
|
+
const showSiteNavigation = pages.length > 1;
|
|
27
|
+
const showPageNavigation = sections.length > 1;
|
|
28
|
+
const homePage = pages.find((page) => page.isHome) ?? currentPage;
|
|
29
|
+
const brandLabel = homePage.title;
|
|
30
|
+
const isCurrentPage = (page: SitePage) => page.pathname === currentPage.pathname;
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
<a class="skip-link" href="#main-content">{projectConfig.locale.labels.skipToContent}</a>
|
|
34
|
+
|
|
35
|
+
<header
|
|
36
|
+
class:list={['site-top', { 'site-top-has-site-nav': showSiteNavigation }]}
|
|
37
|
+
data-smooth-scroll={JSON.stringify(smoothScrollConfig)}
|
|
38
|
+
style={navigationStyle}
|
|
39
|
+
>
|
|
40
|
+
{showSiteNavigation && (
|
|
41
|
+
<div class="site-nav-row">
|
|
42
|
+
<a class="site-brand" href={homePage.pathname}>{brandLabel}</a>
|
|
43
|
+
|
|
44
|
+
<nav class="site-nav" aria-label={projectConfig.locale.labels.siteNavigation}>
|
|
45
|
+
<ul>
|
|
46
|
+
{pages.map((page) => (
|
|
47
|
+
<li>
|
|
48
|
+
<a href={page.pathname} aria-current={isCurrentPage(page) ? 'page' : undefined}>
|
|
49
|
+
{page.navigation.label}
|
|
50
|
+
</a>
|
|
51
|
+
</li>
|
|
52
|
+
))}
|
|
53
|
+
</ul>
|
|
54
|
+
</nav>
|
|
55
|
+
|
|
56
|
+
<details class="mobile-nav-menu">
|
|
57
|
+
<summary>
|
|
58
|
+
<span>{currentPage.navigation.label}</span>
|
|
59
|
+
</summary>
|
|
60
|
+
<div class="mobile-nav-panel">
|
|
61
|
+
<nav class="mobile-site-nav" aria-label={projectConfig.locale.labels.siteNavigation}>
|
|
62
|
+
<h2>{projectConfig.locale.labels.siteNavigation}</h2>
|
|
63
|
+
<ul>
|
|
64
|
+
{pages.map((page) => (
|
|
65
|
+
<li>
|
|
66
|
+
<a href={page.pathname} aria-current={isCurrentPage(page) ? 'page' : undefined}>
|
|
67
|
+
{page.navigation.label}
|
|
68
|
+
</a>
|
|
69
|
+
</li>
|
|
70
|
+
))}
|
|
71
|
+
</ul>
|
|
72
|
+
</nav>
|
|
73
|
+
|
|
74
|
+
{showPageNavigation && (
|
|
75
|
+
<nav class="mobile-page-nav" aria-label={projectConfig.locale.labels.pageNavigation}>
|
|
76
|
+
<h2>{projectConfig.locale.labels.pageNavigation}</h2>
|
|
77
|
+
<ul>
|
|
78
|
+
{sections.map((section, index) => (
|
|
79
|
+
<li>
|
|
80
|
+
<a href={`#${section.id}`} aria-current={index === 0 ? 'true' : undefined}>
|
|
81
|
+
{section.title}
|
|
82
|
+
</a>
|
|
83
|
+
</li>
|
|
84
|
+
))}
|
|
85
|
+
</ul>
|
|
86
|
+
</nav>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
</details>
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
92
|
+
|
|
93
|
+
{showPageNavigation && (
|
|
94
|
+
<nav class="page-nav" aria-label={projectConfig.locale.labels.pageNavigation}>
|
|
95
|
+
<ul>
|
|
96
|
+
{sections.map((section, index) => (
|
|
97
|
+
<li>
|
|
98
|
+
<a href={`#${section.id}`} aria-current={index === 0 ? 'true' : undefined}>
|
|
99
|
+
{section.title}
|
|
100
|
+
</a>
|
|
101
|
+
</li>
|
|
102
|
+
))}
|
|
103
|
+
</ul>
|
|
104
|
+
</nav>
|
|
105
|
+
)}
|
|
106
|
+
</header>
|
|
107
|
+
|
|
108
|
+
<script>
|
|
109
|
+
const siteTop = document.querySelector('.site-top');
|
|
110
|
+
const sections = Array.from(document.querySelectorAll('.site-section'));
|
|
111
|
+
const links = Array.from(document.querySelectorAll('.page-nav a, .mobile-page-nav a'));
|
|
112
|
+
const mobileMenu = document.querySelector('.mobile-nav-menu');
|
|
113
|
+
const smoothScrollConfig = JSON.parse(siteTop?.getAttribute('data-smooth-scroll') ?? '{}');
|
|
114
|
+
const anchorPadding = 0;
|
|
115
|
+
const anchorCorrectionDelays = [120, 320, 700, 1_000, 2_000];
|
|
116
|
+
const reduceMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
117
|
+
const minimumScrollDuration = smoothScrollConfig.minimumDurationMs;
|
|
118
|
+
const maximumScrollDuration = smoothScrollConfig.maximumDurationMs;
|
|
119
|
+
const scrollDurationPerPixel = smoothScrollConfig.durationPerPixelMs;
|
|
120
|
+
const scrollInterruptEvents = ['wheel', 'touchstart'];
|
|
121
|
+
const scrollInterruptKeys = new Set(['ArrowDown', 'ArrowUp', 'End', 'Home', 'PageDown', 'PageUp', ' ']);
|
|
122
|
+
let activeScrollAnimation = null;
|
|
123
|
+
let activeSectionId = null;
|
|
124
|
+
let anchorCorrectionToken = 0;
|
|
125
|
+
|
|
126
|
+
if ('scrollRestoration' in history) {
|
|
127
|
+
history.scrollRestoration = 'manual';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const setAnchorOffset = () => {
|
|
131
|
+
if (!(siteTop instanceof HTMLElement)) return;
|
|
132
|
+
|
|
133
|
+
const offset = Math.ceil(siteTop.getBoundingClientRect().height + anchorPadding);
|
|
134
|
+
document.documentElement.style.setProperty('--site-top-anchor-offset', `${offset}px`);
|
|
135
|
+
return offset;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const getSectionFromHash = (hash) => {
|
|
139
|
+
const id = hash.replace(/^#/, '');
|
|
140
|
+
return sections.find((section) => section.id === id);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const getSectionHeading = (section) => section?.querySelector('h1, h2');
|
|
144
|
+
|
|
145
|
+
const cancelActiveScroll = () => {
|
|
146
|
+
if (activeScrollAnimation) {
|
|
147
|
+
activeScrollAnimation.cancel();
|
|
148
|
+
activeScrollAnimation = null;
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const setActiveSection = (section) => {
|
|
153
|
+
if (!section) return;
|
|
154
|
+
if (activeSectionId === section.id) return;
|
|
155
|
+
|
|
156
|
+
activeSectionId = section.id;
|
|
157
|
+
|
|
158
|
+
links.forEach((link) => {
|
|
159
|
+
const isActive = link.getAttribute('href') === `#${section.id}`;
|
|
160
|
+
if (isActive) {
|
|
161
|
+
link.setAttribute('aria-current', 'true');
|
|
162
|
+
} else {
|
|
163
|
+
link.removeAttribute('aria-current');
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const getHashSection = () => getSectionFromHash(window.location.hash);
|
|
169
|
+
|
|
170
|
+
const setHash = (targetHash) => {
|
|
171
|
+
if (window.location.hash !== targetHash) {
|
|
172
|
+
history.pushState(null, '', targetHash);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const closeMobileMenu = () => {
|
|
177
|
+
if (mobileMenu instanceof HTMLDetailsElement) {
|
|
178
|
+
mobileMenu.open = false;
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const getSectionScrollTop = (section) => {
|
|
183
|
+
if (!(section instanceof HTMLElement)) return;
|
|
184
|
+
const heading = getSectionHeading(section);
|
|
185
|
+
if (!(heading instanceof HTMLElement)) return;
|
|
186
|
+
|
|
187
|
+
const offset = setAnchorOffset();
|
|
188
|
+
if (!offset) return;
|
|
189
|
+
|
|
190
|
+
return Math.max(0, heading.getBoundingClientRect().top + window.scrollY - offset);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const jumpTo = (top) => {
|
|
194
|
+
cancelActiveScroll();
|
|
195
|
+
|
|
196
|
+
const root = document.documentElement;
|
|
197
|
+
const previousScrollBehavior = root.style.scrollBehavior;
|
|
198
|
+
root.style.scrollBehavior = 'auto';
|
|
199
|
+
window.scrollTo(0, top);
|
|
200
|
+
root.style.scrollBehavior = previousScrollBehavior;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const getScrollDuration = (distance) => {
|
|
204
|
+
const distancePx = Math.abs(distance);
|
|
205
|
+
|
|
206
|
+
if (!smoothScrollConfig.enabled || reduceMotionQuery.matches || distancePx <= anchorPadding) return 0;
|
|
207
|
+
|
|
208
|
+
return Math.min(
|
|
209
|
+
maximumScrollDuration,
|
|
210
|
+
Math.max(minimumScrollDuration, distancePx * scrollDurationPerPixel),
|
|
211
|
+
);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const easeInOutCubic = (progress) => (
|
|
215
|
+
progress < 0.5
|
|
216
|
+
? 4 * progress * progress * progress
|
|
217
|
+
: 1 - Math.pow(-2 * progress + 2, 3) / 2
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
const animateScrollTo = (
|
|
221
|
+
target,
|
|
222
|
+
{ duration, onComplete } = {},
|
|
223
|
+
) => {
|
|
224
|
+
cancelActiveScroll();
|
|
225
|
+
|
|
226
|
+
const getTop = typeof target === 'function' ? target : () => target;
|
|
227
|
+
let latestTop = getTop();
|
|
228
|
+
if (latestTop === undefined) return 0;
|
|
229
|
+
|
|
230
|
+
const startY = window.scrollY;
|
|
231
|
+
const distance = latestTop - startY;
|
|
232
|
+
const scrollDuration = duration ?? getScrollDuration(distance);
|
|
233
|
+
|
|
234
|
+
if (scrollDuration <= 0) {
|
|
235
|
+
const top = getTop();
|
|
236
|
+
if (top === undefined) return 0;
|
|
237
|
+
|
|
238
|
+
jumpTo(top);
|
|
239
|
+
onComplete?.();
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const root = document.documentElement;
|
|
244
|
+
const previousScrollBehavior = root.style.scrollBehavior;
|
|
245
|
+
const startedAt = performance.now();
|
|
246
|
+
let frameId = 0;
|
|
247
|
+
|
|
248
|
+
const state = {
|
|
249
|
+
cancel: () => {},
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const cleanup = () => {
|
|
253
|
+
scrollInterruptEvents.forEach((eventName) => {
|
|
254
|
+
window.removeEventListener(eventName, interruptScroll);
|
|
255
|
+
});
|
|
256
|
+
window.removeEventListener('keydown', interruptScrollKey);
|
|
257
|
+
root.style.scrollBehavior = previousScrollBehavior;
|
|
258
|
+
|
|
259
|
+
if (activeScrollAnimation === state) {
|
|
260
|
+
activeScrollAnimation = null;
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const cancel = () => {
|
|
265
|
+
if (frameId) {
|
|
266
|
+
cancelAnimationFrame(frameId);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
cleanup();
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const interruptScroll = () => cancel();
|
|
273
|
+
const interruptScrollKey = (event) => {
|
|
274
|
+
if (scrollInterruptKeys.has(event.key)) {
|
|
275
|
+
interruptScroll();
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const step = (now) => {
|
|
280
|
+
const elapsed = now - startedAt;
|
|
281
|
+
const progress = Math.min(1, elapsed / scrollDuration);
|
|
282
|
+
const top = getTop();
|
|
283
|
+
if (top !== undefined) {
|
|
284
|
+
latestTop = top;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const nextY = startY + (latestTop - startY) * easeInOutCubic(progress);
|
|
288
|
+
|
|
289
|
+
window.scrollTo(0, nextY);
|
|
290
|
+
|
|
291
|
+
if (progress >= 1) {
|
|
292
|
+
window.scrollTo(0, latestTop);
|
|
293
|
+
cleanup();
|
|
294
|
+
onComplete?.();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
frameId = requestAnimationFrame(step);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
state.cancel = cancel;
|
|
302
|
+
activeScrollAnimation = state;
|
|
303
|
+
root.style.scrollBehavior = 'auto';
|
|
304
|
+
scrollInterruptEvents.forEach((eventName) => {
|
|
305
|
+
window.addEventListener(eventName, interruptScroll, { passive: true });
|
|
306
|
+
});
|
|
307
|
+
window.addEventListener('keydown', interruptScrollKey);
|
|
308
|
+
frameId = requestAnimationFrame(step);
|
|
309
|
+
|
|
310
|
+
return scrollDuration;
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const scrollToSection = (section, { behavior = 'smooth', duration, onComplete } = {}) => {
|
|
314
|
+
const top = getSectionScrollTop(section);
|
|
315
|
+
if (top === undefined) return 0;
|
|
316
|
+
|
|
317
|
+
const scrollDuration = behavior === 'smooth'
|
|
318
|
+
? animateScrollTo(() => getSectionScrollTop(section), { duration, onComplete })
|
|
319
|
+
: (jumpTo(top), onComplete?.(), 0);
|
|
320
|
+
|
|
321
|
+
setActiveSection(section);
|
|
322
|
+
|
|
323
|
+
return scrollDuration;
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const updateAnchorOffset = () => {
|
|
327
|
+
setAnchorOffset();
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const correctCurrentHashAnchor = () => {
|
|
331
|
+
if (activeScrollAnimation) return;
|
|
332
|
+
|
|
333
|
+
const section = getHashSection();
|
|
334
|
+
if (section instanceof HTMLElement) {
|
|
335
|
+
scrollToSection(section, { behavior: 'auto' });
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const scheduleHashAnchorCorrection = () => {
|
|
340
|
+
if (!window.location.hash) return;
|
|
341
|
+
|
|
342
|
+
anchorCorrectionToken += 1;
|
|
343
|
+
const token = anchorCorrectionToken;
|
|
344
|
+
|
|
345
|
+
anchorCorrectionDelays.forEach((delay) => {
|
|
346
|
+
window.setTimeout(() => {
|
|
347
|
+
if (token === anchorCorrectionToken) {
|
|
348
|
+
requestAnimationFrame(correctCurrentHashAnchor);
|
|
349
|
+
}
|
|
350
|
+
}, delay);
|
|
351
|
+
});
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const syncToLocation = ({ behavior = 'auto', scrollHashlessTop = false } = {}) => {
|
|
355
|
+
const section = getHashSection();
|
|
356
|
+
|
|
357
|
+
if (section instanceof HTMLElement) {
|
|
358
|
+
scrollToSection(section, { behavior });
|
|
359
|
+
scheduleHashAnchorCorrection();
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (scrollHashlessTop) {
|
|
364
|
+
jumpTo(0);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
setActiveSection(sections[0]);
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
links.forEach((link) => {
|
|
371
|
+
link.addEventListener('click', (event) => {
|
|
372
|
+
const section = getSectionFromHash(link.hash);
|
|
373
|
+
if (!(section instanceof HTMLElement)) return;
|
|
374
|
+
|
|
375
|
+
const top = getSectionScrollTop(section);
|
|
376
|
+
if (top === undefined) return;
|
|
377
|
+
|
|
378
|
+
event.preventDefault();
|
|
379
|
+
|
|
380
|
+
const targetHash = `#${section.id}`;
|
|
381
|
+
const duration = getScrollDuration(top - window.scrollY);
|
|
382
|
+
|
|
383
|
+
setHash(targetHash);
|
|
384
|
+
setActiveSection(section);
|
|
385
|
+
closeMobileMenu();
|
|
386
|
+
|
|
387
|
+
scrollToSection(section, {
|
|
388
|
+
behavior: 'smooth',
|
|
389
|
+
duration,
|
|
390
|
+
onComplete: scheduleHashAnchorCorrection,
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
updateAnchorOffset();
|
|
396
|
+
requestAnimationFrame(() => syncToLocation({ behavior: 'auto' }));
|
|
397
|
+
|
|
398
|
+
if (siteTop instanceof HTMLElement && 'ResizeObserver' in window) {
|
|
399
|
+
new ResizeObserver(updateAnchorOffset).observe(siteTop);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
window.addEventListener('resize', updateAnchorOffset);
|
|
403
|
+
window.addEventListener('load', scheduleHashAnchorCorrection);
|
|
404
|
+
window.addEventListener('hashchange', () => syncToLocation({ behavior: 'auto', scrollHashlessTop: true }));
|
|
405
|
+
window.addEventListener('popstate', () => syncToLocation({ behavior: 'auto', scrollHashlessTop: true }));
|
|
406
|
+
|
|
407
|
+
if (window.visualViewport) {
|
|
408
|
+
window.visualViewport.addEventListener('resize', updateAnchorOffset);
|
|
409
|
+
}
|
|
410
|
+
</script>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getCollection, type CollectionEntry } from 'astro:content';
|
|
3
|
+
import BaseLayout from '../layouts/BaseLayout.astro';
|
|
4
|
+
import SiteNavigation from './SiteNavigation.astro';
|
|
5
|
+
import SiteSection from './SiteSection.astro';
|
|
6
|
+
import { getSectionsContent } from '../lib/sectionContent';
|
|
7
|
+
import { getNavigationPages, getSitePage, getSitePages } from '../lib/sitePages';
|
|
8
|
+
import { getToday, isVisibleToday } from '../lib/visibility';
|
|
9
|
+
import { resolveFrameColors, resolvePagePresentation } from '../../scripts/lib/presentation.mjs';
|
|
10
|
+
|
|
11
|
+
type SiteEntry = CollectionEntry<'site'>;
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
entry: SiteEntry;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const { entry } = Astro.props;
|
|
18
|
+
const siteDirectory = (process.env.NORNA_SITE_DIR ?? 'site').trim();
|
|
19
|
+
const siteContentLabel = entry.id.includes('-route-')
|
|
20
|
+
? `${siteDirectory || 'site'}/routes/${entry.id.split('-route-').at(-1)}/route-content.md`
|
|
21
|
+
: `${siteDirectory || 'site'}/content.md`;
|
|
22
|
+
const [theme] = await getCollection('theme');
|
|
23
|
+
const allEntries = await getCollection('site');
|
|
24
|
+
const sitePages = getSitePages(allEntries);
|
|
25
|
+
const currentPage = getSitePage(entry);
|
|
26
|
+
const navigationPages = getNavigationPages(sitePages);
|
|
27
|
+
const themeData = theme?.data ?? {};
|
|
28
|
+
const pagePresentation = resolvePagePresentation(themeData.presentation, entry.data.presentation);
|
|
29
|
+
const frameColors = resolveFrameColors({
|
|
30
|
+
themePresentation: themeData.presentation,
|
|
31
|
+
themeFrame: themeData.frame,
|
|
32
|
+
pagePresentation,
|
|
33
|
+
pageFrame: entry.data.frame,
|
|
34
|
+
});
|
|
35
|
+
const sections = entry.data.sections;
|
|
36
|
+
const siteHtml = entry.rendered?.html ?? '';
|
|
37
|
+
const resolvedSections = getSectionsContent(siteHtml, sections, pagePresentation.inlineStyles);
|
|
38
|
+
const today = getToday();
|
|
39
|
+
const visibleSections = resolvedSections.filter((section) => isVisibleToday(section.visible, today));
|
|
40
|
+
|
|
41
|
+
if (visibleSections.length === 0) {
|
|
42
|
+
throw new Error(`No sections are visible for ${today}. Check sections[].visible in ${siteContentLabel}.`);
|
|
43
|
+
}
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
<BaseLayout
|
|
47
|
+
title={entry.data.title}
|
|
48
|
+
description={entry.data.description}
|
|
49
|
+
frameColors={frameColors}
|
|
50
|
+
pathname={currentPage.pathname}
|
|
51
|
+
>
|
|
52
|
+
<SiteNavigation
|
|
53
|
+
currentPage={currentPage}
|
|
54
|
+
pages={navigationPages}
|
|
55
|
+
sections={visibleSections}
|
|
56
|
+
frameColors={frameColors}
|
|
57
|
+
/>
|
|
58
|
+
|
|
59
|
+
<main id="main-content" class="site-content">
|
|
60
|
+
{visibleSections.map((section, index) => (
|
|
61
|
+
<SiteSection
|
|
62
|
+
section={section}
|
|
63
|
+
pagePresentation={pagePresentation}
|
|
64
|
+
headingLevel={index === 0 ? 1 : 2}
|
|
65
|
+
priorityGalleryImage={index === 0}
|
|
66
|
+
/>
|
|
67
|
+
))}
|
|
68
|
+
</main>
|
|
69
|
+
</BaseLayout>
|