@janga/norna 0.7.6 → 0.7.7
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/README.md +5 -5
- package/astro.config.mjs +76 -0
- package/bin/norna-cli.mjs +2 -2
- package/docs/README.md +3 -3
- package/docs/commands.md +6 -6
- package/docs/configuration.md +3 -2
- package/docs/content.md +152 -91
- package/docs/design/site-examples-structure.md +1 -1
- package/docs/engine-development.md +3 -3
- package/docs/getting-started.md +4 -3
- package/docs/images-and-metadata.md +112 -20
- package/docs/routes.md +9 -12
- package/docs/site-structure.md +4 -3
- package/docs/theme.md +8 -7
- package/docs/typography.md +5 -5
- package/examples/dog-gallery/site/content.md +41 -45
- package/examples/dog-gallery/site/routes/010-dog-care/route-content.md +1 -3
- package/fixtures/basic/site/content.md +2 -4
- package/fixtures/content-model-v2/.astro/collections/site.schema.json +776 -0
- package/fixtures/content-model-v2/.astro/collections/theme.schema.json +668 -0
- package/fixtures/content-model-v2/.astro/content-assets.mjs +4 -0
- package/fixtures/content-model-v2/.astro/content-modules.mjs +1 -0
- package/fixtures/content-model-v2/.astro/content.d.ts +175 -0
- package/fixtures/content-model-v2/.astro/data-store.json +1 -0
- package/fixtures/content-model-v2/.astro/dev-local.json +8 -0
- package/fixtures/content-model-v2/.astro/dev.json +13 -0
- package/fixtures/content-model-v2/.astro/dev.log +231 -0
- package/fixtures/content-model-v2/.astro/settings.json +5 -0
- package/fixtures/content-model-v2/.astro/types.d.ts +2 -0
- package/fixtures/content-model-v2/dist/_astro/GalleryGrid.astro_astro_type_script_index_0_lang.B0kxDyGz.js +1 -0
- package/fixtures/content-model-v2/dist/_astro/SitePage.BbfZub4g.css +1 -0
- package/fixtures/content-model-v2/dist/guide/index.html +3 -0
- package/fixtures/content-model-v2/dist/images/generated/intro/detail-27930b14-480.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/intro/detail-27930b14-768.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/intro/detail-27930b14-971.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/intro/duplicate-49c98f6c-480.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/intro/duplicate-49c98f6c-768.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/intro/duplicate-49c98f6c-971.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/intro/hero-6d588a83-480.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/intro/hero-6d588a83-768.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/intro/hero-6d588a83-971.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-1080.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-1440.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-1600.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-480.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-768.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/routes/010-guide/images/intro/duplicate-5cf614d2-480.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/routes/010-guide/images/intro/duplicate-5cf614d2-768.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/routes/010-guide/images/intro/duplicate-5cf614d2-960.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-1080.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-1440.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-1600.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-480.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-768.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-1080.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-1440.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-1600.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-480.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-768.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-1080.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-1440.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-1600.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-480.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-768.webp +0 -0
- package/fixtures/content-model-v2/dist/index.html +8 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/_metadata.json +80 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/astro-D_GIpJIE.js +434 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/astro-D_GIpJIE.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_aria-query.js +6390 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_aria-query.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_axobject-query.js +2702 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_axobject-query.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_html-escaper.js +65 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_html-escaper.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js +455 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/audit-DM9Hkl5B.js +1534 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/audit-DM9Hkl5B.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/embla-carousel.js +1389 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/embla-carousel.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/highlight-DnZ1ULOL.js +48 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/highlight-DnZ1ULOL.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/icons-RyebKi45.js +42 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/icons-RyebKi45.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/package.json +3 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/rolldown-runtime-BvCyGRYZ.js +4 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/settings-DaU-gNnK.js +190 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/settings-DaU-gNnK.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/toolbar-63PA619H.js +458 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/toolbar-63PA619H.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/ui-library--bkcNSYz.js +1016 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/ui-library--bkcNSYz.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/window-jPkpKVcy.js +32 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/window-jPkpKVcy.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/xray-BXCHpEJP.js +111 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/xray-BXCHpEJP.js.map +1 -0
- package/fixtures/content-model-v2/site/.norna/generated-images.json +210 -0
- package/fixtures/content-model-v2/site/config.mjs +11 -0
- package/fixtures/content-model-v2/site/content.md +84 -0
- package/fixtures/content-model-v2/site/images/card-section/adopt.svg +9 -0
- package/fixtures/content-model-v2/site/images/card-section/foster.svg +12 -0
- package/fixtures/content-model-v2/site/images/intro/detail.jpg +0 -0
- package/fixtures/content-model-v2/site/images/intro/duplicate.jpg +0 -0
- package/fixtures/content-model-v2/site/images/intro/hero.jpg +0 -0
- package/fixtures/content-model-v2/site/images/plain/image.jpg +0 -0
- package/fixtures/content-model-v2/site/images/timed/slide-one.jpg +0 -0
- package/fixtures/content-model-v2/site/images/timed/slide-three.jpg +0 -0
- package/fixtures/content-model-v2/site/images/timed/slide-two.jpg +0 -0
- package/fixtures/content-model-v2/site/routes/010-guide/images/intro/duplicate.jpg +0 -0
- package/fixtures/content-model-v2/site/routes/010-guide/route-content.md +17 -0
- package/fixtures/content-model-v2/site/theme.md +9 -0
- package/package.json +3 -2
- package/scripts/deploy-site.mjs +11 -4
- package/scripts/dev-local.mjs +6 -0
- package/scripts/generate-images.mjs +132 -56
- package/scripts/lib/image-dimensions.mjs +26 -0
- package/scripts/lib/norna-markdown-blocks.mjs +643 -0
- package/scripts/lib/site-content.mjs +31 -29
- package/scripts/show-typography.mjs +17 -9
- package/scripts/sync-content-sections.mjs +280 -411
- package/scripts/test-content-check.mjs +59 -45
- package/scripts/test-content-model-v2.mjs +998 -0
- package/scripts/test-engine-commands.mjs +1 -1
- package/scripts/test-package-check.mjs +36 -8
- package/scripts/test-temporary-visibility.mjs +3 -6
- package/src/components/CardList.astro +77 -0
- package/src/components/GalleryGrid.astro +22 -12
- package/src/components/SiteNavigation.astro +5 -4
- package/src/components/SitePage.astro +9 -2
- package/src/components/SiteSection.astro +35 -13
- package/src/content.config.ts +4 -17
- package/src/lib/generatedImages.ts +50 -10
- package/src/lib/sectionContent.ts +130 -35
- package/src/styles/global.css +187 -0
- package/starters/basic/site/content.md +10 -6
- package/starters/project/site/content.md +0 -9
- package/starters/project/site/routes/010-guide/route-content.md +0 -5
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
|
|
3
|
+
export const nornaBlockTypes = new Set(['norna-image-stack', 'norna-image-carousel', 'norna-card-list']);
|
|
4
|
+
|
|
5
|
+
const blockTypeLabels = {
|
|
6
|
+
'norna-image-stack': 'norna-image-stack',
|
|
7
|
+
'norna-image-carousel': 'norna-image-carousel',
|
|
8
|
+
'norna-card-list': 'norna-card-list',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const knownBlockTypeList = Array.from(nornaBlockTypes).join(', ');
|
|
12
|
+
const imageNameRegex = /^[a-z0-9][a-z0-9.-]*\.(jpe?g|png|svg)$/i;
|
|
13
|
+
const markerTagName = 'norna-media-block';
|
|
14
|
+
const imageStackExample = [
|
|
15
|
+
'```norna-image-stack',
|
|
16
|
+
'- image: filename.jpg',
|
|
17
|
+
'```',
|
|
18
|
+
].join('\n');
|
|
19
|
+
const cardListExample = [
|
|
20
|
+
'```norna-card-list',
|
|
21
|
+
'layout: image-top',
|
|
22
|
+
'flow: grid',
|
|
23
|
+
'size: m',
|
|
24
|
+
'width: normal',
|
|
25
|
+
'',
|
|
26
|
+
'- title: Adopt',
|
|
27
|
+
' text: Give a dog a new home.',
|
|
28
|
+
' image: adopt.jpg',
|
|
29
|
+
' link: /adopt/',
|
|
30
|
+
' badge-text: Recommended',
|
|
31
|
+
'```',
|
|
32
|
+
].join('\n');
|
|
33
|
+
const cardListLayouts = new Set(['image-top', 'image-left', 'image-right']);
|
|
34
|
+
const cardListFlows = new Set(['grid', 'stack']);
|
|
35
|
+
const cardListSizes = new Set(['s', 'm', 'l', 'xl']);
|
|
36
|
+
const cardListWidths = new Set(['text', 'narrow', 'normal', 'wide']);
|
|
37
|
+
|
|
38
|
+
const formatLocation = ({ label, line } = {}) => [
|
|
39
|
+
label,
|
|
40
|
+
line ? `line ${line}` : null,
|
|
41
|
+
].filter(Boolean).join(' ');
|
|
42
|
+
|
|
43
|
+
const fail = (message, options) => {
|
|
44
|
+
const location = formatLocation(options);
|
|
45
|
+
throw new Error(location ? `${location}: ${message}` : message);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const getUnknownNornaBlockMessage = (type) => [
|
|
49
|
+
`Unknown Norna block "${type}". Use one of: ${knownBlockTypeList}.`,
|
|
50
|
+
type === 'norna-gallery-stack'
|
|
51
|
+
? 'Use norna-image-stack for one or more stacked images.'
|
|
52
|
+
: null,
|
|
53
|
+
type === 'norna-carousel'
|
|
54
|
+
? 'Use norna-image-carousel for an image carousel.'
|
|
55
|
+
: null,
|
|
56
|
+
type === 'norna-image'
|
|
57
|
+
? 'Use norna-image-stack for a single image or a stacked list of images.'
|
|
58
|
+
: null,
|
|
59
|
+
'Example:',
|
|
60
|
+
imageStackExample,
|
|
61
|
+
].filter(Boolean).join(' ');
|
|
62
|
+
|
|
63
|
+
const decodeScalar = (value) => {
|
|
64
|
+
const trimmed = value.trim();
|
|
65
|
+
const quoted = trimmed.match(/^(['"])([\s\S]*)\1$/);
|
|
66
|
+
return quoted ? quoted[2] : trimmed;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const parseKeyValue = (line, options) => {
|
|
70
|
+
const match = line.match(/^(\s*)([a-z][a-z0-9-]*):\s*(.*?)\s*$/);
|
|
71
|
+
if (!match) {
|
|
72
|
+
fail(`Invalid ${options.type} entry "${line.trim()}". Use "key: value" lines.`, options);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
indent: match[1].length,
|
|
77
|
+
key: match[2],
|
|
78
|
+
value: decodeScalar(match[3]),
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const validateImage = (image, options) => {
|
|
83
|
+
if (!image.image) {
|
|
84
|
+
fail(`${options.type} image is missing "image".`, options);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!imageNameRegex.test(image.image)) {
|
|
88
|
+
if (/\.(?:jpe?g|png|svg)\s+[a-z][a-z0-9-]*:/i.test(image.image)) {
|
|
89
|
+
fail(`Image entry "${image.image}" looks like multiple fields on one line. Put image, alt and caption on separate lines. Example:\n${imageStackExample}`, options);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
fail(`Image reference "${image.image}" must be a filename ending in jpg, jpeg, png, or svg.`, options);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const validateOptionalImage = (image, options) => {
|
|
97
|
+
if (!image) return;
|
|
98
|
+
validateImage({ image }, options);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const normalizeLines = (source) => source.replace(/\r\n?/g, '\n').split('\n');
|
|
102
|
+
|
|
103
|
+
const getFenceInfo = (line) => {
|
|
104
|
+
const match = line.match(/^ {0,3}(`+|~+)([^\r\n]*)$/);
|
|
105
|
+
if (!match) return null;
|
|
106
|
+
|
|
107
|
+
const marker = match[1];
|
|
108
|
+
const markerCharacter = marker[0];
|
|
109
|
+
const info = match[2].trim();
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
info,
|
|
113
|
+
length: marker.length,
|
|
114
|
+
marker,
|
|
115
|
+
markerCharacter,
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const getFenceCloseInfo = (line, opening) => {
|
|
120
|
+
const fence = getFenceInfo(line);
|
|
121
|
+
if (!fence) return null;
|
|
122
|
+
if (fence.markerCharacter !== opening.markerCharacter) return null;
|
|
123
|
+
if (fence.length < opening.length) return null;
|
|
124
|
+
if (fence.info) return null;
|
|
125
|
+
return fence;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const getNornaLineAttempt = (line) => {
|
|
129
|
+
const match = line.match(/^ {0,3}([`~]{0,2})(norna-[a-z0-9-]+)\s*$/);
|
|
130
|
+
if (!match) return null;
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
marker: match[1],
|
|
134
|
+
type: match[2],
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const getNornaFenceStartMessage = (type, marker = '') => {
|
|
139
|
+
const example = type === 'norna-card-list' ? cardListExample : `\`\`\`${type}\n- image: filename.jpg\n\`\`\``;
|
|
140
|
+
if (marker) {
|
|
141
|
+
return `Invalid Norna block start for "${type}". Use three backticks or three tildes. Example:\n${example}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return `Found "${type}" outside a code block. Start the block like this:\n${example}`;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const getUnclosedNornaBlockMessage = (opening) =>
|
|
148
|
+
`This Norna block was started on line ${opening.line} but not closed. Add a closing ${opening.markerCharacter.repeat(opening.length)} line after the last entry.`;
|
|
149
|
+
|
|
150
|
+
const scanMarkdownFencedBlocks = (markdown, options = {}) => {
|
|
151
|
+
const lines = normalizeLines(markdown);
|
|
152
|
+
const lineOffset = options.lineOffset ?? 0;
|
|
153
|
+
const blocks = [];
|
|
154
|
+
const errors = [];
|
|
155
|
+
let index = 0;
|
|
156
|
+
|
|
157
|
+
while (index < lines.length) {
|
|
158
|
+
const line = lines[index];
|
|
159
|
+
const fence = getFenceInfo(line);
|
|
160
|
+
const lineNumber = lineOffset + index + 1;
|
|
161
|
+
|
|
162
|
+
if (!fence) {
|
|
163
|
+
const attempt = getNornaLineAttempt(line);
|
|
164
|
+
if (attempt) {
|
|
165
|
+
errors.push({
|
|
166
|
+
blockType: attempt.type,
|
|
167
|
+
line: lineNumber,
|
|
168
|
+
source: '',
|
|
169
|
+
message: failMessage(getNornaFenceStartMessage(attempt.type, attempt.marker), { ...options, line: lineNumber }),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
index += 1;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const attempt = getNornaLineAttempt(line);
|
|
178
|
+
if (fence.length < 3) {
|
|
179
|
+
if (attempt) {
|
|
180
|
+
errors.push({
|
|
181
|
+
blockType: attempt.type,
|
|
182
|
+
line: lineNumber,
|
|
183
|
+
source: '',
|
|
184
|
+
message: failMessage(getNornaFenceStartMessage(attempt.type, attempt.marker), { ...options, line: lineNumber }),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
index += 1;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const type = fence.info.split(/\s+/)[0] ?? '';
|
|
193
|
+
const opening = {
|
|
194
|
+
...fence,
|
|
195
|
+
line: lineNumber,
|
|
196
|
+
type,
|
|
197
|
+
};
|
|
198
|
+
const bodyStartIndex = index + 1;
|
|
199
|
+
let closingIndex = -1;
|
|
200
|
+
|
|
201
|
+
for (let candidateIndex = bodyStartIndex; candidateIndex < lines.length; candidateIndex += 1) {
|
|
202
|
+
if (getFenceCloseInfo(lines[candidateIndex], opening)) {
|
|
203
|
+
closingIndex = candidateIndex;
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const isNornaLike = type.startsWith('norna-');
|
|
209
|
+
if (closingIndex === -1) {
|
|
210
|
+
if (isNornaLike) {
|
|
211
|
+
errors.push({
|
|
212
|
+
blockType: type,
|
|
213
|
+
line: lineNumber,
|
|
214
|
+
source: lines.slice(bodyStartIndex).join('\n'),
|
|
215
|
+
message: failMessage(getUnclosedNornaBlockMessage(opening), { ...options, line: lineNumber }),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const source = lines.slice(bodyStartIndex, closingIndex).join('\n');
|
|
222
|
+
if (isNornaLike) {
|
|
223
|
+
if (nornaBlockTypes.has(type)) {
|
|
224
|
+
blocks.push({
|
|
225
|
+
blockType: type,
|
|
226
|
+
line: lineNumber,
|
|
227
|
+
source,
|
|
228
|
+
sourceLine: lineNumber + 1,
|
|
229
|
+
});
|
|
230
|
+
} else {
|
|
231
|
+
errors.push({
|
|
232
|
+
blockType: type,
|
|
233
|
+
line: lineNumber,
|
|
234
|
+
source,
|
|
235
|
+
message: failMessage(getUnknownNornaBlockMessage(type), { ...options, line: lineNumber }),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
index = closingIndex + 1;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { blocks, errors };
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const failMessage = (message, options) => {
|
|
247
|
+
const location = formatLocation(options);
|
|
248
|
+
return location ? `${location}: ${message}` : message;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const parseImageListBlock = (source, options = {}) => {
|
|
252
|
+
const images = [];
|
|
253
|
+
const allowedKeys = new Set(['alt', 'caption']);
|
|
254
|
+
let current = null;
|
|
255
|
+
|
|
256
|
+
for (const [index, line] of normalizeLines(source).entries()) {
|
|
257
|
+
if (!line.trim()) continue;
|
|
258
|
+
|
|
259
|
+
const lineNumber = (options.line ?? 1) + index;
|
|
260
|
+
const itemMatch = line.match(/^(\s*)-\s+image:\s*(.*?)\s*$/);
|
|
261
|
+
if (itemMatch) {
|
|
262
|
+
current = { image: decodeScalar(itemMatch[2]) };
|
|
263
|
+
images.push(current);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (!current) {
|
|
268
|
+
fail(`Invalid ${options.type} entry "${line.trim()}". Start each image with "- image: filename.jpg". Example:\n${imageStackExample}`, { ...options, line: lineNumber });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const entry = parseKeyValue(line, { ...options, line: lineNumber });
|
|
272
|
+
if (entry.indent !== 2) {
|
|
273
|
+
fail(`Invalid indentation in ${options.type}. Use two spaces before optional image fields such as alt and caption. Example:\n${imageStackExample}`, { ...options, line: lineNumber });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (!allowedKeys.has(entry.key)) {
|
|
277
|
+
fail(`Unknown ${options.type} field "${entry.key}".`, { ...options, line: lineNumber });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
current[entry.key] = entry.value;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
for (const image of images) {
|
|
284
|
+
validateImage(image, options);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (images.length === 0) {
|
|
288
|
+
fail(`${options.type} must contain at least one image. Example:\n${imageStackExample}`, options);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return { type: options.type === 'norna-image-carousel' ? 'image-carousel' : 'image-stack', images };
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const parseCardListBlock = (source, options = {}) => {
|
|
295
|
+
const cards = [];
|
|
296
|
+
const allowedKeys = new Set(['text', 'image', 'link', 'badge-text']);
|
|
297
|
+
let layout = 'image-top';
|
|
298
|
+
let flow = 'grid';
|
|
299
|
+
let size = 'm';
|
|
300
|
+
let width = 'normal';
|
|
301
|
+
let current = null;
|
|
302
|
+
|
|
303
|
+
for (const [index, line] of normalizeLines(source).entries()) {
|
|
304
|
+
if (!line.trim()) continue;
|
|
305
|
+
|
|
306
|
+
const lineNumber = (options.line ?? 1) + index;
|
|
307
|
+
const itemMatch = line.match(/^(\s*)-\s+title:\s*(.*?)\s*$/);
|
|
308
|
+
if (itemMatch) {
|
|
309
|
+
current = { title: decodeScalar(itemMatch[2]) };
|
|
310
|
+
cards.push(current);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (!current) {
|
|
315
|
+
if (/^\s*-\s+/.test(line)) {
|
|
316
|
+
fail(`Invalid ${options.type} entry "${line.trim()}". Start each card with "- title: Card title". Example:\n${cardListExample}`, { ...options, line: lineNumber });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const optionEntry = parseKeyValue(line, { ...options, line: lineNumber });
|
|
320
|
+
if (optionEntry.indent !== 0) {
|
|
321
|
+
fail(`Invalid ${options.type} entry "${line.trim()}". Start each card with "- title: Card title". Example:\n${cardListExample}`, { ...options, line: lineNumber });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (optionEntry.key === 'layout') {
|
|
325
|
+
if (!cardListLayouts.has(optionEntry.value)) {
|
|
326
|
+
fail(`Invalid ${options.type} layout "${optionEntry.value}". Use one of: ${Array.from(cardListLayouts).join(', ')}.`, { ...options, line: lineNumber });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
layout = optionEntry.value;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (optionEntry.key === 'flow') {
|
|
334
|
+
if (!cardListFlows.has(optionEntry.value)) {
|
|
335
|
+
fail(`Invalid ${options.type} flow "${optionEntry.value}". Use one of: ${Array.from(cardListFlows).join(', ')}.`, { ...options, line: lineNumber });
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
flow = optionEntry.value;
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (optionEntry.key === 'size') {
|
|
343
|
+
if (!cardListSizes.has(optionEntry.value)) {
|
|
344
|
+
fail(`Invalid ${options.type} size "${optionEntry.value}". Use one of: ${Array.from(cardListSizes).join(', ')}.`, { ...options, line: lineNumber });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
size = optionEntry.value;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (optionEntry.key === 'width') {
|
|
352
|
+
if (!cardListWidths.has(optionEntry.value)) {
|
|
353
|
+
fail(`Invalid ${options.type} width "${optionEntry.value}". Use one of: ${Array.from(cardListWidths).join(', ')}.`, { ...options, line: lineNumber });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
width = optionEntry.value;
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
fail(`Unknown ${options.type} option "${optionEntry.key}". Use layout, flow, size, width, or start the first card with "- title: Card title".`, { ...options, line: lineNumber });
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const entry = parseKeyValue(line, { ...options, line: lineNumber });
|
|
364
|
+
if (entry.indent === 0 && (entry.key === 'layout' || entry.key === 'flow' || entry.key === 'size' || entry.key === 'width')) {
|
|
365
|
+
fail(`${options.type} option "${entry.key}" must appear before the first card.`, { ...options, line: lineNumber });
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (entry.indent !== 2) {
|
|
369
|
+
fail(`Invalid indentation in ${options.type}. Use two spaces before card fields such as text, image, link and badge-text. Example:\n${cardListExample}`, { ...options, line: lineNumber });
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (!allowedKeys.has(entry.key)) {
|
|
373
|
+
fail(`Unknown ${options.type} field "${entry.key}". Use title, text, image, link, or badge-text.`, { ...options, line: lineNumber });
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
current[entry.key] = entry.value;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
for (const card of cards) {
|
|
380
|
+
if (!card.title) {
|
|
381
|
+
fail(`${options.type} card is missing "title".`, options);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (!card.text && !card.image && !card.link) {
|
|
385
|
+
fail(`${options.type} card "${card.title}" must specify at least one of text, image, or link.`, options);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
validateOptionalImage(card.image, options);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (cards.length === 0) {
|
|
392
|
+
fail(`${options.type} must contain at least one card. Example:\n${cardListExample}`, options);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return { type: 'card-list', layout, flow, size, width, cards };
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
export const parseNornaMarkdownBlock = (type, source, options = {}) => {
|
|
399
|
+
if (!nornaBlockTypes.has(type)) {
|
|
400
|
+
fail(getUnknownNornaBlockMessage(type), options);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const parseOptions = { ...options, type: blockTypeLabels[type] };
|
|
404
|
+
return type === 'norna-card-list'
|
|
405
|
+
? parseCardListBlock(source, parseOptions)
|
|
406
|
+
: parseImageListBlock(source, parseOptions);
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const getLineNumber = (source, index) => source.slice(0, index).split(/\r?\n/).length;
|
|
410
|
+
|
|
411
|
+
export const extractNornaMarkdownBlocks = (markdown, options = {}) => {
|
|
412
|
+
const blocks = [];
|
|
413
|
+
const { blocks: scannedBlocks, errors } = scanMarkdownFencedBlocks(markdown, options);
|
|
414
|
+
|
|
415
|
+
if (errors.length > 0) {
|
|
416
|
+
throw new Error(errors[0].message);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
for (const block of scannedBlocks) {
|
|
420
|
+
blocks.push({
|
|
421
|
+
...parseNornaMarkdownBlock(block.blockType, block.source, { ...options, line: block.sourceLine }),
|
|
422
|
+
blockType: block.blockType,
|
|
423
|
+
line: block.line,
|
|
424
|
+
source: block.source,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return blocks;
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
export const extractNornaMarkdownBlockDiagnostics = (markdown, options = {}) => {
|
|
432
|
+
const blocks = [];
|
|
433
|
+
const { blocks: scannedBlocks, errors } = scanMarkdownFencedBlocks(markdown, options);
|
|
434
|
+
|
|
435
|
+
for (const block of scannedBlocks) {
|
|
436
|
+
try {
|
|
437
|
+
blocks.push({
|
|
438
|
+
...parseNornaMarkdownBlock(block.blockType, block.source, { ...options, line: block.sourceLine }),
|
|
439
|
+
blockType: block.blockType,
|
|
440
|
+
line: block.line,
|
|
441
|
+
source: block.source,
|
|
442
|
+
});
|
|
443
|
+
} catch (error) {
|
|
444
|
+
errors.push({
|
|
445
|
+
blockType: block.blockType,
|
|
446
|
+
line: block.line,
|
|
447
|
+
source: block.source,
|
|
448
|
+
message: error instanceof Error ? error.message : String(error),
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return { blocks, errors };
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const maskFencedCodeBlocks = (markdown) => {
|
|
457
|
+
const lines = normalizeLines(markdown);
|
|
458
|
+
const maskedLines = [];
|
|
459
|
+
let index = 0;
|
|
460
|
+
|
|
461
|
+
while (index < lines.length) {
|
|
462
|
+
const fence = getFenceInfo(lines[index]);
|
|
463
|
+
if (!fence || fence.length < 3) {
|
|
464
|
+
maskedLines.push(lines[index]);
|
|
465
|
+
index += 1;
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
let closingIndex = -1;
|
|
470
|
+
for (let candidateIndex = index + 1; candidateIndex < lines.length; candidateIndex += 1) {
|
|
471
|
+
if (getFenceCloseInfo(lines[candidateIndex], fence)) {
|
|
472
|
+
closingIndex = candidateIndex;
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (closingIndex === -1) {
|
|
478
|
+
maskedLines.push(lines[index]);
|
|
479
|
+
index += 1;
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
for (let maskedIndex = index; maskedIndex <= closingIndex; maskedIndex += 1) {
|
|
484
|
+
maskedLines.push(lines[maskedIndex].replace(/[^\r\n]/g, ' '));
|
|
485
|
+
}
|
|
486
|
+
index = closingIndex + 1;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return maskedLines.join('\n');
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
const isExternalImageTarget = (target) => /^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith('//');
|
|
493
|
+
const isRootRelativePublicTarget = (target) => target.startsWith('/');
|
|
494
|
+
|
|
495
|
+
const stripTags = (value) => value.replace(/<[^>]*>/g, '');
|
|
496
|
+
|
|
497
|
+
const decodeHtmlEntities = (value) =>
|
|
498
|
+
value
|
|
499
|
+
.replace(/&/g, '&')
|
|
500
|
+
.replace(/</g, '<')
|
|
501
|
+
.replace(/>/g, '>')
|
|
502
|
+
.replace(/"/g, '"')
|
|
503
|
+
.replace(/'/g, "'")
|
|
504
|
+
.replace(/&#x([0-9a-f]+);/gi, (_match, hex) => String.fromCodePoint(Number.parseInt(hex, 16)))
|
|
505
|
+
.replace(/&#(\d+);/g, (_match, decimal) => String.fromCodePoint(Number.parseInt(decimal, 10)));
|
|
506
|
+
|
|
507
|
+
export const extractMarkdownImageReferences = (markdown) => {
|
|
508
|
+
const masked = maskFencedCodeBlocks(markdown);
|
|
509
|
+
const references = [];
|
|
510
|
+
const imageRegex = /!\[[^\]\n]*\]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g;
|
|
511
|
+
|
|
512
|
+
for (const match of masked.matchAll(imageRegex)) {
|
|
513
|
+
const target = match[1] ?? '';
|
|
514
|
+
if (!target || target.startsWith('#') || isExternalImageTarget(target) || isRootRelativePublicTarget(target)) continue;
|
|
515
|
+
|
|
516
|
+
references.push({
|
|
517
|
+
target,
|
|
518
|
+
line: getLineNumber(markdown, match.index ?? 0),
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return references;
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
export const getNornaBlockImageReferences = (blocks) =>
|
|
526
|
+
blocks.flatMap((block) => {
|
|
527
|
+
if (block.type === 'card-list') {
|
|
528
|
+
return block.cards
|
|
529
|
+
.filter((card) => card.image)
|
|
530
|
+
.map((card) => ({
|
|
531
|
+
image: card.image,
|
|
532
|
+
alt: card.title,
|
|
533
|
+
blockType: block.blockType,
|
|
534
|
+
blockDisplayType: block.type,
|
|
535
|
+
line: block.line,
|
|
536
|
+
}));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return block.images.map((image) => ({
|
|
540
|
+
...image,
|
|
541
|
+
blockType: block.blockType,
|
|
542
|
+
blockDisplayType: block.type,
|
|
543
|
+
line: block.line,
|
|
544
|
+
}));
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
export const createNornaMarkdownBlockMarker = (type, source) => {
|
|
548
|
+
const encodedSource = Buffer.from(source, 'utf8').toString('base64');
|
|
549
|
+
return `<${markerTagName} data-type="${type}" data-source="${encodedSource}"></${markerTagName}>`;
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
export const splitNornaMarkdownBlockMarkers = (html, options = {}) => {
|
|
553
|
+
const blocks = [];
|
|
554
|
+
const markerRegex = /<norna-media-block\s+data-type="([^"]+)"\s+data-source="([^"]*)"\s*><\/norna-media-block>|<pre\b([^>]*)>\s*<code\b([^>]*)>([\s\S]*?)<\/code>\s*<\/pre>/g;
|
|
555
|
+
let cursor = 0;
|
|
556
|
+
|
|
557
|
+
for (const match of html.matchAll(markerRegex)) {
|
|
558
|
+
const start = match.index ?? 0;
|
|
559
|
+
if (start > cursor) {
|
|
560
|
+
blocks.push({ type: 'html', html: html.slice(cursor, start) });
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (match[1]) {
|
|
564
|
+
const blockType = match[1];
|
|
565
|
+
const source = Buffer.from(match[2] ?? '', 'base64').toString('utf8');
|
|
566
|
+
blocks.push(parseNornaMarkdownBlock(blockType, source, options));
|
|
567
|
+
} else {
|
|
568
|
+
const attributes = `${match[3] ?? ''} ${match[4] ?? ''}`;
|
|
569
|
+
const blockType = attributes.match(/\blanguage-(norna-[a-z0-9-]+)\b/)?.[1]
|
|
570
|
+
?? attributes.match(/\bdata-language="(norna-[a-z0-9-]+)"/)?.[1];
|
|
571
|
+
if (!nornaBlockTypes.has(blockType)) {
|
|
572
|
+
blocks.push({ type: 'html', html: match[0] });
|
|
573
|
+
} else {
|
|
574
|
+
const source = decodeHtmlEntities(stripTags(match[5] ?? ''));
|
|
575
|
+
blocks.push(parseNornaMarkdownBlock(blockType, source, options));
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
cursor = start + match[0].length;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (cursor < html.length) {
|
|
583
|
+
blocks.push({ type: 'html', html: html.slice(cursor) });
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return blocks.filter((block) => block.type !== 'html' || block.html.trim());
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
const normalizeBlockSource = (source) => source.replace(/\r\n?/g, '\n').trim();
|
|
590
|
+
|
|
591
|
+
export const splitNornaRenderedCodeBlocks = (html, rawBlocks, options = {}) => {
|
|
592
|
+
const blocks = [];
|
|
593
|
+
const renderedCodeRegex = /<pre\b[^>]*>\s*<code\b[^>]*>([\s\S]*?)<\/code>\s*<\/pre>/g;
|
|
594
|
+
let cursor = 0;
|
|
595
|
+
let rawIndex = 0;
|
|
596
|
+
|
|
597
|
+
for (const match of html.matchAll(renderedCodeRegex)) {
|
|
598
|
+
const start = match.index ?? 0;
|
|
599
|
+
if (start > cursor) {
|
|
600
|
+
blocks.push({ type: 'html', html: html.slice(cursor, start) });
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const rawBlock = rawBlocks[rawIndex];
|
|
604
|
+
const renderedSource = normalizeBlockSource(decodeHtmlEntities(stripTags(match[1] ?? '')));
|
|
605
|
+
const rawSource = normalizeBlockSource(rawBlock?.source ?? '');
|
|
606
|
+
|
|
607
|
+
if (rawBlock && renderedSource === rawSource) {
|
|
608
|
+
blocks.push(parseNornaMarkdownBlock(rawBlock.blockType, rawBlock.source, options));
|
|
609
|
+
rawIndex += 1;
|
|
610
|
+
} else {
|
|
611
|
+
blocks.push({ type: 'html', html: match[0] });
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
cursor = start + match[0].length;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (cursor < html.length) {
|
|
618
|
+
blocks.push({ type: 'html', html: html.slice(cursor) });
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
return blocks.filter((block) => block.type !== 'html' || block.html.trim());
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
const visitCodeBlocks = (node) => {
|
|
625
|
+
if (!node || typeof node !== 'object') return;
|
|
626
|
+
|
|
627
|
+
if (Array.isArray(node.children)) {
|
|
628
|
+
for (const child of node.children) {
|
|
629
|
+
visitCodeBlocks(child);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (node.type !== 'code' || !nornaBlockTypes.has(node.lang)) return;
|
|
634
|
+
|
|
635
|
+
node.type = 'html';
|
|
636
|
+
node.value = createNornaMarkdownBlockMarker(node.lang, node.value ?? '');
|
|
637
|
+
delete node.lang;
|
|
638
|
+
delete node.meta;
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
export const nornaMarkdownBlocksRemarkPlugin = () => (tree) => {
|
|
642
|
+
visitCodeBlocks(tree);
|
|
643
|
+
};
|