@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,174 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { CollectionEntry } from 'astro:content';
|
|
3
|
+
import { resolveTypographyOverride } from '../../scripts/lib/typography.mjs';
|
|
4
|
+
import GalleryGrid from './GalleryGrid.astro';
|
|
5
|
+
|
|
6
|
+
type SiteSection = CollectionEntry<'site'>['data']['sections'][number];
|
|
7
|
+
type ResolvedSection = SiteSection & { title: string; contentHtml: string };
|
|
8
|
+
type ResponsiveValue<T> = { desktop: T; mobile: T };
|
|
9
|
+
type ResponsiveOverride<T> = { desktop?: T; mobile?: T };
|
|
10
|
+
type TextAlign = 'left' | 'center' | 'right';
|
|
11
|
+
type TextSize = 'small' | 'medium' | 'large' | 'xlarge';
|
|
12
|
+
type SectionPresentationOverride = {
|
|
13
|
+
backgroundColor?: string;
|
|
14
|
+
textColor?: string;
|
|
15
|
+
typography?: NonNullable<SiteSection['presentation']>['typography'];
|
|
16
|
+
};
|
|
17
|
+
type ResolvedPagePresentation = {
|
|
18
|
+
backgroundColor: string;
|
|
19
|
+
textColor: string;
|
|
20
|
+
typography: {
|
|
21
|
+
preset: string;
|
|
22
|
+
values: any;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
interface Props {
|
|
27
|
+
section: ResolvedSection;
|
|
28
|
+
pagePresentation: ResolvedPagePresentation;
|
|
29
|
+
headingLevel: 1 | 2;
|
|
30
|
+
priorityGalleryImage?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const { section, pagePresentation, headingLevel, priorityGalleryImage = false } = Astro.props;
|
|
34
|
+
const HeadingTag = headingLevel === 1 ? 'h1' : 'h2';
|
|
35
|
+
const headingSizeValues: Record<TextSize, ResponsiveValue<string>> = {
|
|
36
|
+
small: {
|
|
37
|
+
desktop: 'clamp(1.15rem, 2.2vw, 2rem)',
|
|
38
|
+
mobile: 'clamp(1.15rem, 5vw, 1.8rem)',
|
|
39
|
+
},
|
|
40
|
+
medium: {
|
|
41
|
+
desktop: 'clamp(1.4rem, 3.1vw, 3.2rem)',
|
|
42
|
+
mobile: 'clamp(1.4rem, 3.1vw, 3.2rem)',
|
|
43
|
+
},
|
|
44
|
+
large: {
|
|
45
|
+
desktop: 'clamp(1.65rem, 4.6vw, 5.65rem)',
|
|
46
|
+
mobile: 'clamp(1.9rem, 8vw, 3.2rem)',
|
|
47
|
+
},
|
|
48
|
+
xlarge: {
|
|
49
|
+
desktop: 'clamp(2rem, 5.6vw, 7rem)',
|
|
50
|
+
mobile: 'clamp(2.15rem, 9.4vw, 3.8rem)',
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
const bodySizeValues: Record<TextSize, ResponsiveValue<string>> = {
|
|
54
|
+
small: {
|
|
55
|
+
desktop: 'clamp(0.9rem, 0.86rem + 0.16vw, 1rem)',
|
|
56
|
+
mobile: 'clamp(0.9rem, 0.86rem + 0.16vw, 1rem)',
|
|
57
|
+
},
|
|
58
|
+
medium: {
|
|
59
|
+
desktop: 'clamp(1rem, 0.96rem + 0.18vw, 1.125rem)',
|
|
60
|
+
mobile: 'clamp(1rem, 0.96rem + 0.18vw, 1.125rem)',
|
|
61
|
+
},
|
|
62
|
+
large: {
|
|
63
|
+
desktop: 'clamp(1.1rem, 1.04rem + 0.24vw, 1.25rem)',
|
|
64
|
+
mobile: 'clamp(1.1rem, 1.04rem + 0.24vw, 1.25rem)',
|
|
65
|
+
},
|
|
66
|
+
xlarge: {
|
|
67
|
+
desktop: 'clamp(1.18rem, 1.08rem + 0.36vw, 1.42rem)',
|
|
68
|
+
mobile: 'clamp(1.18rem, 1.08rem + 0.36vw, 1.42rem)',
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
const captionSizeValues: Record<TextSize, ResponsiveValue<string>> = {
|
|
72
|
+
small: {
|
|
73
|
+
desktop: 'clamp(0.82rem, 0.78rem + 0.12vw, 0.92rem)',
|
|
74
|
+
mobile: 'clamp(0.82rem, 0.78rem + 0.12vw, 0.92rem)',
|
|
75
|
+
},
|
|
76
|
+
medium: {
|
|
77
|
+
desktop: 'clamp(0.9rem, 0.86rem + 0.16vw, 1rem)',
|
|
78
|
+
mobile: 'clamp(0.9rem, 0.86rem + 0.16vw, 1rem)',
|
|
79
|
+
},
|
|
80
|
+
large: {
|
|
81
|
+
desktop: 'clamp(1rem, 0.94rem + 0.2vw, 1.12rem)',
|
|
82
|
+
mobile: 'clamp(1rem, 0.94rem + 0.2vw, 1.12rem)',
|
|
83
|
+
},
|
|
84
|
+
xlarge: {
|
|
85
|
+
desktop: 'clamp(1.08rem, 1rem + 0.28vw, 1.25rem)',
|
|
86
|
+
mobile: 'clamp(1.08rem, 1rem + 0.28vw, 1.25rem)',
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
const sectionOverride = section.presentation as SectionPresentationOverride | undefined;
|
|
90
|
+
const getSizeValue = (
|
|
91
|
+
sizeValues: Record<TextSize, ResponsiveValue<string>>,
|
|
92
|
+
size: TextSize,
|
|
93
|
+
) => sizeValues[size];
|
|
94
|
+
const backgroundColor = sectionOverride?.backgroundColor
|
|
95
|
+
?? pagePresentation.backgroundColor;
|
|
96
|
+
const textColor = sectionOverride?.textColor
|
|
97
|
+
?? pagePresentation.textColor;
|
|
98
|
+
const resolvedTypography = resolveTypographyOverride(pagePresentation.typography, sectionOverride?.typography);
|
|
99
|
+
const typography = resolvedTypography.values;
|
|
100
|
+
const headingAlign = typography.heading.align as ResponsiveValue<TextAlign>;
|
|
101
|
+
const bodyAlign = typography.body.align as ResponsiveValue<TextAlign>;
|
|
102
|
+
const captionAlign = typography.caption.align as ResponsiveValue<TextAlign>;
|
|
103
|
+
const headingFontSize = getSizeValue(headingSizeValues, typography.heading.size as TextSize);
|
|
104
|
+
const bodyFontSize = getSizeValue(bodySizeValues, typography.body.size as TextSize);
|
|
105
|
+
const captionFontSize = getSizeValue(captionSizeValues, typography.caption.size as TextSize);
|
|
106
|
+
const justifyByAlign: Record<TextAlign, string> = {
|
|
107
|
+
left: 'flex-start',
|
|
108
|
+
center: 'center',
|
|
109
|
+
right: 'flex-end',
|
|
110
|
+
};
|
|
111
|
+
const getAlignedTextWidth = (
|
|
112
|
+
align: ResponsiveValue<TextAlign>,
|
|
113
|
+
centeredWidth: string,
|
|
114
|
+
): ResponsiveValue<string> => ({
|
|
115
|
+
desktop: align.desktop === 'center' ? centeredWidth : 'var(--gallery-layout-width)',
|
|
116
|
+
mobile: align.mobile === 'center' ? centeredWidth : 'var(--gallery-layout-width)',
|
|
117
|
+
});
|
|
118
|
+
const headingWidth = getAlignedTextWidth(headingAlign, '760px');
|
|
119
|
+
const bodyWidth = getAlignedTextWidth(bodyAlign, 'var(--text-width)');
|
|
120
|
+
const hasGallery = section.gallery.length > 0;
|
|
121
|
+
const sectionStyle = [
|
|
122
|
+
`--section-background-color: ${backgroundColor}`,
|
|
123
|
+
`--section-text-color: ${textColor}`,
|
|
124
|
+
`--section-heading-align-desktop: ${headingAlign.desktop}`,
|
|
125
|
+
`--section-heading-align-mobile: ${headingAlign.mobile}`,
|
|
126
|
+
`--section-heading-font-size-desktop: ${headingFontSize.desktop}`,
|
|
127
|
+
`--section-heading-font-size-mobile: ${headingFontSize.mobile}`,
|
|
128
|
+
`--section-heading-line-height: ${typography.heading.lineHeight}`,
|
|
129
|
+
`--section-heading-spacing: ${typography.heading.spacing}`,
|
|
130
|
+
`--section-heading-width-desktop: ${headingWidth.desktop}`,
|
|
131
|
+
`--section-heading-width-mobile: ${headingWidth.mobile}`,
|
|
132
|
+
`--section-body-align-desktop: ${bodyAlign.desktop}`,
|
|
133
|
+
`--section-body-align-mobile: ${bodyAlign.mobile}`,
|
|
134
|
+
`--section-body-font-size-desktop: ${bodyFontSize.desktop}`,
|
|
135
|
+
`--section-body-font-size-mobile: ${bodyFontSize.mobile}`,
|
|
136
|
+
`--section-body-line-height: ${typography.body.lineHeight}`,
|
|
137
|
+
`--section-body-paragraph-spacing: ${typography.body.paragraphSpacing}`,
|
|
138
|
+
`--section-body-width-desktop: ${bodyWidth.desktop}`,
|
|
139
|
+
`--section-body-width-mobile: ${bodyWidth.mobile}`,
|
|
140
|
+
`--section-caption-align-desktop: ${captionAlign.desktop}`,
|
|
141
|
+
`--section-caption-align-mobile: ${captionAlign.mobile}`,
|
|
142
|
+
`--section-caption-font-size-desktop: ${captionFontSize.desktop}`,
|
|
143
|
+
`--section-caption-font-size-mobile: ${captionFontSize.mobile}`,
|
|
144
|
+
`--section-caption-line-height: ${typography.caption.lineHeight}`,
|
|
145
|
+
`--section-caption-spacing: ${typography.caption.spacing}`,
|
|
146
|
+
`--section-caption-justify-desktop: ${justifyByAlign[captionAlign.desktop]}`,
|
|
147
|
+
`--section-caption-justify-mobile: ${justifyByAlign[captionAlign.mobile]}`,
|
|
148
|
+
].join('; ');
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
<section
|
|
152
|
+
id={section.id}
|
|
153
|
+
class:list={['site-section', { 'site-section-primary': headingLevel === 1 }]}
|
|
154
|
+
data-section-title={section.title}
|
|
155
|
+
style={sectionStyle}
|
|
156
|
+
>
|
|
157
|
+
<div class="section-card">
|
|
158
|
+
<header class="section-header">
|
|
159
|
+
<HeadingTag>{section.title}</HeadingTag>
|
|
160
|
+
</header>
|
|
161
|
+
|
|
162
|
+
<div class:list={['section-body', { 'section-body-has-gallery': hasGallery, 'section-body-text-only': !hasGallery }]}>
|
|
163
|
+
{section.contentHtml && <div class="section-markdown" set:html={section.contentHtml} />}
|
|
164
|
+
|
|
165
|
+
{hasGallery && (
|
|
166
|
+
<GalleryGrid
|
|
167
|
+
title={section.title}
|
|
168
|
+
items={section.gallery}
|
|
169
|
+
priorityFirstImage={priorityGalleryImage}
|
|
170
|
+
/>
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
</section>
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { defineCollection } from 'astro:content';
|
|
2
|
+
import { glob } from 'astro/loaders';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { z } from 'astro/zod';
|
|
5
|
+
import { siteDir, siteDirLabel } from '../scripts/lib/site-paths.mjs';
|
|
6
|
+
import { isDateOnly } from './lib/visibility';
|
|
7
|
+
|
|
8
|
+
const siteEntryId = `${siteDirLabel
|
|
9
|
+
.replace(/^[./\\]+/, '')
|
|
10
|
+
.replace(/[^a-zA-Z0-9-]+/g, '-')
|
|
11
|
+
.replace(/^-+|-+$/g, '') || 'site'}-content`;
|
|
12
|
+
const contentImageName = z.string().regex(/^[a-z0-9][a-z0-9.-]*\.(jpe?g|png)$/i);
|
|
13
|
+
const routeSlug = z.string().regex(/^[a-z0-9][a-z0-9-]*$/, 'Use lowercase letters, numbers, and hyphens.');
|
|
14
|
+
const colorValue = z.string().regex(
|
|
15
|
+
/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/,
|
|
16
|
+
'Use a hex color such as "#000000".',
|
|
17
|
+
);
|
|
18
|
+
const textAlign = z.enum(['left', 'center', 'right']);
|
|
19
|
+
const textSize = z.enum(['small', 'medium', 'large', 'xlarge']);
|
|
20
|
+
const typographyPreset = z.enum(['quiet-gallery', 'compact-gallery', 'text-forward', 'statement']);
|
|
21
|
+
const lineHeight = z.number()
|
|
22
|
+
.min(1, 'Use a unitless line height of at least 1.')
|
|
23
|
+
.max(3, 'Use a unitless line height of at most 3.');
|
|
24
|
+
const cssLength = z.string().regex(
|
|
25
|
+
/^(?:0|(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|ch|lh))$/,
|
|
26
|
+
'Use a CSS length such as "0", "0.8em", "1rem", or "12px".',
|
|
27
|
+
);
|
|
28
|
+
const inlineStyleName = z.string().regex(/^[a-z][a-z0-9-]*$/);
|
|
29
|
+
const dateOnly = z.string()
|
|
30
|
+
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Use YYYY-MM-DD format.')
|
|
31
|
+
.refine(isDateOnly, 'Use a real calendar date.');
|
|
32
|
+
const visibilityWindow = z.object({
|
|
33
|
+
from: dateOnly.optional(),
|
|
34
|
+
until: dateOnly.optional(),
|
|
35
|
+
}).strict().refine(
|
|
36
|
+
(value) => value.from !== undefined || value.until !== undefined,
|
|
37
|
+
'Specify from, until, or both.',
|
|
38
|
+
).refine(
|
|
39
|
+
(value) => value.from === undefined || value.until === undefined || value.from < value.until,
|
|
40
|
+
'visible.until must be later than visible.from.',
|
|
41
|
+
);
|
|
42
|
+
const overrideResponsiveTextAlign = z.object({
|
|
43
|
+
desktop: textAlign.optional(),
|
|
44
|
+
mobile: textAlign.optional(),
|
|
45
|
+
}).strict().refine(
|
|
46
|
+
(value) => value.desktop !== undefined || value.mobile !== undefined,
|
|
47
|
+
'Specify desktop, mobile, or both.',
|
|
48
|
+
);
|
|
49
|
+
const commonTextPresentationOverride = {
|
|
50
|
+
align: overrideResponsiveTextAlign.optional(),
|
|
51
|
+
size: textSize.optional(),
|
|
52
|
+
lineHeight: lineHeight.optional(),
|
|
53
|
+
};
|
|
54
|
+
const headingPresentationOverride = z.object({
|
|
55
|
+
...commonTextPresentationOverride,
|
|
56
|
+
spacing: cssLength.optional(),
|
|
57
|
+
}).strict();
|
|
58
|
+
const bodyPresentationOverride = z.object({
|
|
59
|
+
...commonTextPresentationOverride,
|
|
60
|
+
paragraphSpacing: cssLength.optional(),
|
|
61
|
+
}).strict();
|
|
62
|
+
const captionPresentationOverride = z.object({
|
|
63
|
+
...commonTextPresentationOverride,
|
|
64
|
+
spacing: cssLength.optional(),
|
|
65
|
+
}).strict();
|
|
66
|
+
const typographyOverrides = z.object({
|
|
67
|
+
heading: headingPresentationOverride.optional(),
|
|
68
|
+
body: bodyPresentationOverride.optional(),
|
|
69
|
+
caption: captionPresentationOverride.optional(),
|
|
70
|
+
}).strict();
|
|
71
|
+
const typography = z.object({
|
|
72
|
+
preset: typographyPreset.optional(),
|
|
73
|
+
overrides: typographyOverrides.optional(),
|
|
74
|
+
}).strict().refine(
|
|
75
|
+
(value) => value.preset !== undefined || value.overrides !== undefined,
|
|
76
|
+
'Specify preset, overrides, or both.',
|
|
77
|
+
);
|
|
78
|
+
const sectionPresentationOverride = z.object({
|
|
79
|
+
backgroundColor: colorValue.optional(),
|
|
80
|
+
textColor: colorValue.optional(),
|
|
81
|
+
typography: typography.optional(),
|
|
82
|
+
}).strict();
|
|
83
|
+
const themePresentation = z.object({
|
|
84
|
+
backgroundColor: colorValue.optional(),
|
|
85
|
+
textColor: colorValue.optional(),
|
|
86
|
+
inlineStyles: z.record(inlineStyleName, z.object({
|
|
87
|
+
color: colorValue,
|
|
88
|
+
}).strict()).optional(),
|
|
89
|
+
typography: typography.optional(),
|
|
90
|
+
}).strict();
|
|
91
|
+
const pagePresentation = z.object({
|
|
92
|
+
backgroundColor: colorValue.optional(),
|
|
93
|
+
textColor: colorValue.optional(),
|
|
94
|
+
typography: typography.optional(),
|
|
95
|
+
}).strict();
|
|
96
|
+
const frameColors = z.union([
|
|
97
|
+
z.enum(['theme', 'presentation']),
|
|
98
|
+
z.object({
|
|
99
|
+
backgroundColor: colorValue,
|
|
100
|
+
textColor: colorValue,
|
|
101
|
+
}).strict(),
|
|
102
|
+
]);
|
|
103
|
+
const frame = z.object({
|
|
104
|
+
colors: frameColors.optional(),
|
|
105
|
+
}).strict();
|
|
106
|
+
const pageNavigation = z.object({
|
|
107
|
+
include: z.boolean().optional(),
|
|
108
|
+
label: z.string().optional(),
|
|
109
|
+
order: z.number().int().optional(),
|
|
110
|
+
}).strict();
|
|
111
|
+
|
|
112
|
+
const galleryImage = z.object({
|
|
113
|
+
image: contentImageName,
|
|
114
|
+
alt: z.string(),
|
|
115
|
+
caption: z.string().optional(),
|
|
116
|
+
}).strict();
|
|
117
|
+
const galleryCarousel = z.object({
|
|
118
|
+
carousel: z.array(galleryImage).min(2, 'A carousel must contain at least two images.'),
|
|
119
|
+
}).strict();
|
|
120
|
+
const galleryItem = z.union([galleryImage, galleryCarousel]);
|
|
121
|
+
|
|
122
|
+
const siteSchema = z.object({
|
|
123
|
+
title: z.string(),
|
|
124
|
+
description: z.string(),
|
|
125
|
+
slug: routeSlug.optional(),
|
|
126
|
+
navigation: pageNavigation.optional(),
|
|
127
|
+
presentation: pagePresentation.optional(),
|
|
128
|
+
frame: frame.optional(),
|
|
129
|
+
sections: z.array(
|
|
130
|
+
z.object({
|
|
131
|
+
id: z.string().regex(/^[a-z0-9-]+$/),
|
|
132
|
+
visible: visibilityWindow.optional(),
|
|
133
|
+
presentation: sectionPresentationOverride.optional(),
|
|
134
|
+
gallery: z.array(galleryItem).optional().default([]),
|
|
135
|
+
}).strict(),
|
|
136
|
+
).min(1),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const themeSchema = z.object({
|
|
140
|
+
presentation: themePresentation.optional(),
|
|
141
|
+
frame: frame.optional(),
|
|
142
|
+
}).strict();
|
|
143
|
+
|
|
144
|
+
const site = defineCollection({
|
|
145
|
+
loader: glob({
|
|
146
|
+
pattern: ['content.md', 'routes/*/route-content.md'],
|
|
147
|
+
base: pathToFileURL(siteDir),
|
|
148
|
+
generateId: ({ entry }) => {
|
|
149
|
+
if (entry === 'content.md') {
|
|
150
|
+
return siteEntryId;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const routeFolder = entry.match(/^routes\/([^/]+)\/route-content\.md$/)?.[1];
|
|
154
|
+
return routeFolder
|
|
155
|
+
? `${siteEntryId.replace(/-content$/, '')}-route-${routeFolder}`
|
|
156
|
+
: entry.replace(/[^a-zA-Z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
157
|
+
},
|
|
158
|
+
}),
|
|
159
|
+
schema: siteSchema,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const theme = defineCollection({
|
|
163
|
+
loader: glob({
|
|
164
|
+
pattern: 'theme.md',
|
|
165
|
+
base: pathToFileURL(siteDir),
|
|
166
|
+
generateId: () => `${siteEntryId.replace(/-content$/, '')}-theme`,
|
|
167
|
+
}),
|
|
168
|
+
schema: themeSchema,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
export const collections = { site, theme };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
---
|
|
2
|
+
import projectConfig from '../../scripts/lib/project-config.mjs';
|
|
3
|
+
import { getIconLinks } from '../lib/sitePublicAssets';
|
|
4
|
+
import '../styles/global.css';
|
|
5
|
+
|
|
6
|
+
type FrameColors = {
|
|
7
|
+
backgroundColor: string;
|
|
8
|
+
textColor: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
title?: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
frameColors: FrameColors;
|
|
15
|
+
pathname?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const {
|
|
19
|
+
title = 'norna',
|
|
20
|
+
description = 'A norna site.',
|
|
21
|
+
frameColors,
|
|
22
|
+
pathname = '/',
|
|
23
|
+
} = Astro.props;
|
|
24
|
+
|
|
25
|
+
const documentTitle = title;
|
|
26
|
+
const canonicalUrl = new URL(pathname, projectConfig.site.url).href;
|
|
27
|
+
const iconLinks = getIconLinks();
|
|
28
|
+
const scrollBehavior = projectConfig.navigation.smoothScroll.enabled ? 'smooth' : 'auto';
|
|
29
|
+
const formatPercent = (value: number) => `${value}%`;
|
|
30
|
+
const formatViewportHeightPercent = (value: number) => `${value}svh`;
|
|
31
|
+
const documentStyle = [
|
|
32
|
+
`scroll-behavior: ${scrollBehavior}`,
|
|
33
|
+
`--font-sans: ${projectConfig.typography.fontFamily}`,
|
|
34
|
+
`--page-width: ${projectConfig.layout.pageWidth}`,
|
|
35
|
+
`--content-gutter-desktop: ${projectConfig.layout.gutter.desktop}`,
|
|
36
|
+
`--content-gutter-mobile: ${projectConfig.layout.gutter.mobile}`,
|
|
37
|
+
`--gallery-width: ${projectConfig.gallery.width}`,
|
|
38
|
+
`--gallery-max-available-width-desktop: ${formatPercent(projectConfig.gallery.maxAvailableWidthPercent.desktop)}`,
|
|
39
|
+
`--gallery-max-available-width-mobile: ${formatPercent(projectConfig.gallery.maxAvailableWidthPercent.mobile)}`,
|
|
40
|
+
`--gallery-max-image-height-desktop: ${formatViewportHeightPercent(projectConfig.gallery.maxAvailableHeightPercent.desktop)}`,
|
|
41
|
+
`--gallery-max-image-height-mobile: ${formatViewportHeightPercent(projectConfig.gallery.maxAvailableHeightPercent.mobile)}`,
|
|
42
|
+
].join('; ');
|
|
43
|
+
const { buildInfo, copyrightMessage } = projectConfig.footer;
|
|
44
|
+
const showBuildInfo = Boolean(buildInfo?.enabled);
|
|
45
|
+
const buildTime = showBuildInfo
|
|
46
|
+
? new Intl.DateTimeFormat(buildInfo.dateTimeFormat.locale, {
|
|
47
|
+
dateStyle: buildInfo.dateTimeFormat.dateStyle,
|
|
48
|
+
timeStyle: buildInfo.dateTimeFormat.timeStyle,
|
|
49
|
+
timeZone: buildInfo.dateTimeFormat.timeZone,
|
|
50
|
+
}).format(new Date())
|
|
51
|
+
: null;
|
|
52
|
+
const showFooter = Boolean(copyrightMessage || showBuildInfo);
|
|
53
|
+
const footerStyle = [
|
|
54
|
+
`--site-footer-background-color: ${frameColors.backgroundColor}`,
|
|
55
|
+
`--site-footer-text-color: ${frameColors.textColor}`,
|
|
56
|
+
].join('; ');
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
<!doctype html>
|
|
60
|
+
<html lang={projectConfig.locale.lang} style={documentStyle}>
|
|
61
|
+
<head>
|
|
62
|
+
<meta charset="utf-8" />
|
|
63
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
64
|
+
<meta name="format-detection" content="telephone=no" />
|
|
65
|
+
<meta name="description" content={description} />
|
|
66
|
+
<meta name="theme-color" content="#000000" />
|
|
67
|
+
<link rel="canonical" href={canonicalUrl} />
|
|
68
|
+
<meta name="generator" content={Astro.generator} />
|
|
69
|
+
{iconLinks.map((iconLink) => (
|
|
70
|
+
<link
|
|
71
|
+
rel={iconLink.rel}
|
|
72
|
+
type={iconLink.type}
|
|
73
|
+
sizes={iconLink.sizes}
|
|
74
|
+
href={iconLink.href}
|
|
75
|
+
/>
|
|
76
|
+
))}
|
|
77
|
+
<title>{documentTitle}</title>
|
|
78
|
+
</head>
|
|
79
|
+
<body>
|
|
80
|
+
<slot />
|
|
81
|
+
{showFooter && (
|
|
82
|
+
<footer class="site-footer" style={footerStyle}>
|
|
83
|
+
<p>
|
|
84
|
+
{copyrightMessage}
|
|
85
|
+
{showBuildInfo && <span>{buildInfo.text} {buildTime}</span>}
|
|
86
|
+
</p>
|
|
87
|
+
</footer>
|
|
88
|
+
)}
|
|
89
|
+
</body>
|
|
90
|
+
</html>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { generatedImagesManifestPath } from '../../scripts/lib/site-paths.mjs';
|
|
3
|
+
|
|
4
|
+
type GeneratedImage = {
|
|
5
|
+
outputVersion?: number;
|
|
6
|
+
sourceHash?: string;
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
variants: Array<{
|
|
10
|
+
src: string;
|
|
11
|
+
width: number;
|
|
12
|
+
}>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const readGeneratedImages = () => {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(readFileSync(generatedImagesManifestPath, 'utf8')) as Record<string, GeneratedImage | undefined>;
|
|
18
|
+
} catch {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const images = readGeneratedImages();
|
|
24
|
+
const maxDisplayImageWidth = 1920;
|
|
25
|
+
const fallbackDisplayImageWidth = 1440;
|
|
26
|
+
|
|
27
|
+
export const getGeneratedImage = (src: string) => images[src];
|
|
28
|
+
|
|
29
|
+
export const getLinkedImageSrc = (src: string) => getGeneratedImage(src)?.variants.at(-1)?.src ?? src;
|
|
30
|
+
|
|
31
|
+
const getDisplayVariants = (variants: GeneratedImage['variants']) => {
|
|
32
|
+
const sortedVariants = [...variants].sort((a, b) => a.width - b.width);
|
|
33
|
+
const displayVariants = sortedVariants.filter((variant) => variant.width <= maxDisplayImageWidth);
|
|
34
|
+
|
|
35
|
+
return displayVariants.length > 0 ? displayVariants : sortedVariants;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const getFallbackVariant = (variants: GeneratedImage['variants']) => (
|
|
39
|
+
variants.filter((variant) => variant.width <= fallbackDisplayImageWidth).at(-1) ?? variants[0]
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
export const getImageAttributes = (src: string, sizes: string) => {
|
|
43
|
+
const image = getGeneratedImage(src);
|
|
44
|
+
|
|
45
|
+
if (!image) {
|
|
46
|
+
return {
|
|
47
|
+
src,
|
|
48
|
+
sizes,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const displayVariants = getDisplayVariants(image.variants);
|
|
53
|
+
const fallbackVariant = getFallbackVariant(displayVariants);
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
src: fallbackVariant?.src ?? src,
|
|
57
|
+
srcset: displayVariants.map((variant) => `${variant.src} ${variant.width}w`).join(', '),
|
|
58
|
+
sizes,
|
|
59
|
+
style: `aspect-ratio: ${image.width} / ${image.height};`,
|
|
60
|
+
width: image.width,
|
|
61
|
+
height: image.height,
|
|
62
|
+
};
|
|
63
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { CollectionEntry } from 'astro:content';
|
|
2
|
+
|
|
3
|
+
type SiteSection = CollectionEntry<'site'>['data']['sections'][number];
|
|
4
|
+
type ThemePresentation = CollectionEntry<'theme'>['data']['presentation'];
|
|
5
|
+
type InlineStyles = NonNullable<NonNullable<ThemePresentation>['inlineStyles']>;
|
|
6
|
+
|
|
7
|
+
const headingRegex = /<h2\b([^>]*)>([\s\S]*?)<\/h2>/gi;
|
|
8
|
+
const explicitHeadingIdRegex = /\s*\{#([a-z0-9-]+)\}\s*$/;
|
|
9
|
+
const inlineStyleReferenceRegex = /\[([^\]<]+)\]\{\.([a-z][a-z0-9-]*)\}/g;
|
|
10
|
+
|
|
11
|
+
const stripTags = (html: string) => html.replace(/<[^>]*>/g, '');
|
|
12
|
+
|
|
13
|
+
const decodeHtmlEntities = (value: string) =>
|
|
14
|
+
value
|
|
15
|
+
.replace(/&/g, '&')
|
|
16
|
+
.replace(/</g, '<')
|
|
17
|
+
.replace(/>/g, '>')
|
|
18
|
+
.replace(/"/g, '"')
|
|
19
|
+
.replace(/'/g, "'");
|
|
20
|
+
|
|
21
|
+
const slugify = (value: string) =>
|
|
22
|
+
decodeHtmlEntities(stripTags(value))
|
|
23
|
+
.trim()
|
|
24
|
+
.toLowerCase()
|
|
25
|
+
.normalize('NFD')
|
|
26
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
27
|
+
.replace(/å/g, 'a')
|
|
28
|
+
.replace(/ä/g, 'a')
|
|
29
|
+
.replace(/ö/g, 'o')
|
|
30
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
31
|
+
.replace(/^-+|-+$/g, '');
|
|
32
|
+
|
|
33
|
+
const getHeadingId = (attributes: string, headingHtml: string) => {
|
|
34
|
+
const headingText = decodeHtmlEntities(stripTags(headingHtml)).trim();
|
|
35
|
+
const explicitId = headingText.match(explicitHeadingIdRegex)?.[1];
|
|
36
|
+
if (explicitId) return explicitId;
|
|
37
|
+
|
|
38
|
+
const id = attributes.match(/\sid=(["'])(.*?)\1/i)?.[2];
|
|
39
|
+
return id ? decodeHtmlEntities(id) : slugify(headingHtml);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const getExplicitHeadingId = (headingHtml: string) =>
|
|
43
|
+
decodeHtmlEntities(stripTags(headingHtml)).trim().match(explicitHeadingIdRegex)?.[1];
|
|
44
|
+
|
|
45
|
+
const getHeadingTitle = (headingHtml: string) =>
|
|
46
|
+
decodeHtmlEntities(stripTags(headingHtml)).replace(explicitHeadingIdRegex, '').trim();
|
|
47
|
+
|
|
48
|
+
const applyInlineStyles = (html: string, inlineStyles: InlineStyles | undefined) =>
|
|
49
|
+
html.replace(inlineStyleReferenceRegex, (_match, text: string, styleName: string) => {
|
|
50
|
+
const style = inlineStyles?.[styleName];
|
|
51
|
+
|
|
52
|
+
if (!style) {
|
|
53
|
+
throw new Error(`Markdown uses inline style ".${styleName}", but theme.md presentation.inlineStyles.${styleName} is not defined.`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return `<span class="inline-style inline-style-${styleName}" style="--inline-style-color: ${style.color}">${text}</span>`;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export const getSectionsContent = (
|
|
60
|
+
html: string,
|
|
61
|
+
sections: SiteSection[],
|
|
62
|
+
inlineStyles?: InlineStyles,
|
|
63
|
+
) => {
|
|
64
|
+
const matches = Array.from(html.matchAll(headingRegex));
|
|
65
|
+
const contentById = new Map<string, { title: string; contentHtml: string }>();
|
|
66
|
+
const sectionIds = new Set(sections.map((section) => section.id));
|
|
67
|
+
const markdownSectionIds: string[] = [];
|
|
68
|
+
|
|
69
|
+
for (let index = 0; index < matches.length; index += 1) {
|
|
70
|
+
const match = matches[index];
|
|
71
|
+
const attributes = match[1] ?? '';
|
|
72
|
+
const headingHtml = match[2] ?? '';
|
|
73
|
+
const explicitId = getExplicitHeadingId(headingHtml);
|
|
74
|
+
const id = getHeadingId(attributes, headingHtml);
|
|
75
|
+
const contentStart = (match.index ?? 0) + match[0].length;
|
|
76
|
+
const nextMatch = matches[index + 1];
|
|
77
|
+
const contentEnd = nextMatch?.index ?? html.length;
|
|
78
|
+
const content = html.slice(contentStart, contentEnd).trim();
|
|
79
|
+
const title = getHeadingTitle(headingHtml);
|
|
80
|
+
|
|
81
|
+
if (!explicitId) {
|
|
82
|
+
console.warn(`Markdown section is missing an explicit heading id: "${title}". Write for example "## ${title} {#${id}}".`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (contentById.has(id)) {
|
|
86
|
+
throw new Error(`Duplicate Markdown section heading id: ${id}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
contentById.set(id, {
|
|
90
|
+
title,
|
|
91
|
+
contentHtml: applyInlineStyles(content, inlineStyles),
|
|
92
|
+
});
|
|
93
|
+
markdownSectionIds.push(id);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const id of contentById.keys()) {
|
|
97
|
+
if (!sectionIds.has(id)) {
|
|
98
|
+
console.warn(`Markdown section exists but is not used in frontmatter: ${id}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const orderedMarkdownSectionIds = markdownSectionIds.filter((id) => sectionIds.has(id));
|
|
103
|
+
const frontmatterSectionIds = sections.map((section) => section.id);
|
|
104
|
+
const hasOrderMismatch = frontmatterSectionIds.some((id, index) => id !== orderedMarkdownSectionIds[index]);
|
|
105
|
+
|
|
106
|
+
if (hasOrderMismatch) {
|
|
107
|
+
console.warn('Markdown section order differs from frontmatter. Run npm run content:sync to sort Markdown according to frontmatter.');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return sections.map((section) => {
|
|
111
|
+
const content = contentById.get(section.id);
|
|
112
|
+
|
|
113
|
+
if (!content) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`Cannot find heading for "${section.id}". Each frontmatter section must have a matching level 2 Markdown heading, for example: ## Heading {#${section.id}}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
...section,
|
|
121
|
+
title: content.title,
|
|
122
|
+
contentHtml: content.contentHtml,
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
};
|