@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.
Files changed (77) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +109 -0
  3. package/astro.config.mjs +17 -0
  4. package/bin/norna.mjs +170 -0
  5. package/docs/README.md +48 -0
  6. package/docs/command-organization.md +402 -0
  7. package/docs/commands.md +152 -0
  8. package/docs/configuration.md +376 -0
  9. package/docs/content.md +384 -0
  10. package/docs/engine-development.md +164 -0
  11. package/docs/getting-started.md +96 -0
  12. package/docs/images-and-metadata.md +88 -0
  13. package/docs/local-development.md +61 -0
  14. package/docs/publishing.md +81 -0
  15. package/docs/site-examples-structure-note.md +105 -0
  16. package/docs/site-structure.md +81 -0
  17. package/fixtures/basic/site/.norna/generated-images.json +1 -0
  18. package/fixtures/basic/site/config.mjs +59 -0
  19. package/fixtures/basic/site/content.md +17 -0
  20. package/fixtures/basic/site/images/work/.gitkeep +1 -0
  21. package/fixtures/basic/site/public/robots.txt +2 -0
  22. package/fixtures/basic/site/theme.md +7 -0
  23. package/package.json +90 -0
  24. package/scripts/build-site.mjs +16 -0
  25. package/scripts/check-config.mjs +37 -0
  26. package/scripts/deploy-site.mjs +389 -0
  27. package/scripts/dev-local.mjs +313 -0
  28. package/scripts/doctor.mjs +38 -0
  29. package/scripts/engine-version.mjs +137 -0
  30. package/scripts/generate-images.mjs +369 -0
  31. package/scripts/init-site.mjs +249 -0
  32. package/scripts/lib/astro-command.mjs +34 -0
  33. package/scripts/lib/ci-lockfile.mjs +34 -0
  34. package/scripts/lib/image-dimensions.mjs +78 -0
  35. package/scripts/lib/presentation.mjs +72 -0
  36. package/scripts/lib/project-config.mjs +322 -0
  37. package/scripts/lib/run-command.mjs +21 -0
  38. package/scripts/lib/site-content.mjs +392 -0
  39. package/scripts/lib/site-paths.mjs +127 -0
  40. package/scripts/lib/typography.mjs +166 -0
  41. package/scripts/release.mjs +77 -0
  42. package/scripts/show-typography.mjs +210 -0
  43. package/scripts/sync-content-sections.mjs +610 -0
  44. package/scripts/sync-site-public.mjs +42 -0
  45. package/scripts/test-ci-lockfile.mjs +65 -0
  46. package/scripts/test-content-check.mjs +364 -0
  47. package/scripts/test-engine-commands.mjs +128 -0
  48. package/scripts/test-navigation-preview.mjs +108 -0
  49. package/scripts/test-navigation.mjs +116 -0
  50. package/scripts/test-package-check.mjs +394 -0
  51. package/scripts/test-site-public.mjs +85 -0
  52. package/scripts/test-temporary-visibility.mjs +99 -0
  53. package/scripts/update-engine.mjs +127 -0
  54. package/scripts/watch-pages-deploy.mjs +430 -0
  55. package/src/components/GalleryGrid.astro +221 -0
  56. package/src/components/SiteNavigation.astro +410 -0
  57. package/src/components/SitePage.astro +69 -0
  58. package/src/components/SiteSection.astro +174 -0
  59. package/src/content.config.ts +171 -0
  60. package/src/layouts/BaseLayout.astro +90 -0
  61. package/src/lib/generatedImages.ts +63 -0
  62. package/src/lib/sectionContent.ts +125 -0
  63. package/src/lib/sitePages.ts +80 -0
  64. package/src/lib/sitePublicAssets.ts +39 -0
  65. package/src/lib/visibility.ts +35 -0
  66. package/src/pages/[slug].astro +31 -0
  67. package/src/pages/index.astro +16 -0
  68. package/src/styles/global.css +872 -0
  69. package/starters/basic/.github/workflows/deploy.yml +65 -0
  70. package/starters/basic/README.md +55 -0
  71. package/starters/basic/package.json +35 -0
  72. package/starters/basic/site/config.mjs +60 -0
  73. package/starters/basic/site/content.md +21 -0
  74. package/starters/basic/site/images/work/.gitkeep +1 -0
  75. package/starters/basic/site/public/robots.txt +2 -0
  76. package/starters/basic/site/theme.md +53 -0
  77. 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>