edgepress 2026.10.12 → 2026.10.13

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 (57) hide show
  1. package/README.md +12 -2
  2. package/config.yml +171 -171
  3. package/content/assets/edgepress/brand/sources/export-manifest.json +181 -181
  4. package/content/assets/edgepress-markdown-guide.svg +21 -21
  5. package/content/guides/markdown-media.md +38 -0
  6. package/content/guides/product-demos.md +30 -30
  7. package/content/pages/deployment/en.json +1 -1
  8. package/content/posts/2026-09-24-edgepress-release-2026-9-1/en.md +1 -1
  9. package/content/posts/2026-09-24-edgepress-release-2026-9-1/zh-cn.md +1 -1
  10. package/languages/base/en.json +98 -98
  11. package/languages/packs/zh-CN.json +98 -98
  12. package/package.json +90 -90
  13. package/src/assets.js +187 -186
  14. package/src/browser-audit.js +158 -158
  15. package/src/build.js +293 -291
  16. package/src/cli.js +364 -364
  17. package/src/content.js +233 -233
  18. package/src/data-saver.js +3 -3
  19. package/src/generators.js +414 -420
  20. package/src/i18n.js +86 -86
  21. package/src/markdown.js +195 -160
  22. package/src/page-check.js +1 -1
  23. package/src/plugin-api.js +77 -77
  24. package/src/report-visuals.js +642 -642
  25. package/src/revision-cards.js +30 -30
  26. package/src/rss.js +100 -0
  27. package/src/theme.js +4 -4
  28. package/src/video-metadata.js +36 -0
  29. package/static/edgepress/fonts/faces.css +2217 -2217
  30. package/static/edgepress/media-viewer.js +67 -10
  31. package/static/edgepress/project-demo.js +83 -83
  32. package/static/edgepress/search.js +84 -84
  33. package/tests/assets-runtime.test.mjs +26 -26
  34. package/tests/audit.test.mjs +142 -142
  35. package/tests/brand-regression.test.mjs +110 -110
  36. package/tests/document-package.test.mjs +2 -2
  37. package/tests/embed-syntax.test.mjs +68 -68
  38. package/tests/image-viewer.test.mjs +36 -36
  39. package/tests/markdown-media.test.mjs +119 -0
  40. package/tests/oembed-layout.test.mjs +6 -1
  41. package/tests/project-demo.test.mjs +73 -73
  42. package/tests/release-version.test.mjs +28 -0
  43. package/tests/rss.test.mjs +54 -0
  44. package/tests/video-metadata.test.mjs +28 -0
  45. package/tests/writing.test.mjs +83 -83
  46. package/themes/atelier/assets/style.css +340 -340
  47. package/themes/default/assets/style.css +371 -371
  48. package/themes/default/layouts/partials/head.html +1 -1
  49. package/themes/folio/assets/style.css +515 -515
  50. package/themes/folio/theme.json +5 -5
  51. package/themes/signal/assets/style.css +332 -332
  52. package/tools/README.md +10 -10
  53. package/tools/assets/generate-brand-assets.py +160 -160
  54. package/tools/assets/video-preview.mjs +15 -0
  55. package/tools/recordings/demo-workbench.mjs +53 -53
  56. package/tools/recordings/desktop-recording.mjs +70 -70
  57. package/tools/recordings/vm-desktop.mjs +59 -59
package/src/content.js CHANGED
@@ -1,239 +1,239 @@
1
- import {parsePublicationDate,publicationCalendar} from './publication-time.js';
2
- import { readPostRevision } from './revisions.js';
3
- import { normalizeCategory, safeAvatar, readingMinutes } from './post-details.js';
4
- import { readdir, readFile } from 'node:fs/promises';
5
- import { basename, dirname, extname, relative, resolve, sep } from 'node:path';
6
- import { parse as parseYaml } from 'yaml';
7
- import { mapLimit } from './concurrency.js';
8
-
9
- export function sortPostsNewest(posts) {
10
- return [...posts].sort((left, right) => {
11
- const dateOrder = right.date - left.date;
12
- if (dateOrder) return dateOrder;
13
- const leftPath = String(left.path);
14
- const rightPath = String(right.path);
15
- return rightPath.localeCompare(leftPath, undefined, { numeric: true, sensitivity: 'base' });
16
- });
17
- }
18
-
19
- export function postsForLocale(posts, locale, defaultLocale) {
20
- const byBundle = new Map();
21
- for (const post of posts) {
22
- const current = byBundle.get(post.bundlePath);
23
- if (!current || post.locale === locale || (current.locale !== locale && post.locale === defaultLocale) ||
24
- (current.locale !== locale && current.locale !== defaultLocale && post.locale !== defaultLocale && post.locale.localeCompare(current.locale) < 0)) {
25
- byBundle.set(post.bundlePath, post);
26
- }
27
- }
28
- return sortPostsNewest([...byBundle.values()]);
29
- }
30
-
31
- async function walkContent(directory, kind) {
32
- let entries;
33
- try {
34
- entries = await readdir(directory, { withFileTypes: true });
35
- } catch (error) {
36
- if (error.code === 'ENOENT') return [];
37
- throw error;
38
- }
39
- entries.sort((a, b) => a.name.localeCompare(b.name));
40
- const found = [];
41
- for (const entry of entries) {
42
- const fullPath = resolve(directory, entry.name);
43
- if (entry.isDirectory()) found.push(...await walkContent(fullPath, kind));
44
- else if (entry.isFile() && (extname(entry.name).toLowerCase() === '.md' || (kind === 'pages' && extname(entry.name).toLowerCase() === '.json'))) found.push(fullPath);
45
- }
46
- return found;
47
- }
48
-
49
- function splitFrontmatter(source, file) {
50
- const lines = source.replace(/^\uFEFF/, '').split(/\r?\n/);
51
- if (lines[0] !== '---') return { metadata: {}, markdown: source };
52
- const end = lines.findIndex((line, index) => index > 0 && (line === '---' || line === '...'));
53
- if (end < 0) throw new Error('Unclosed YAML front matter in ' + file);
54
- const metadata = parseYaml(lines.slice(1, end).join('\n')) ?? {};
55
- if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
56
- throw new Error('Front matter must be a YAML object in ' + file);
57
- }
58
- return { metadata, markdown: lines.slice(end + 1).join('\n') };
59
- }
60
-
61
- export function parseDocumentSource(source, file) {
62
- if (extname(file).toLowerCase() !== '.json') return splitFrontmatter(source, file);
63
- let metadata;
64
- try { metadata = JSON.parse(source.replace(/^\uFEFF/, '')); }
65
- catch (error) { throw new Error('Invalid page JSON in ' + file + ': ' + error.message, {cause: error}); }
66
- if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) throw new Error('Page JSON must be an object in ' + file);
67
- return {metadata, markdown: ''};
68
- }
69
-
70
- export function slugify(value) {
71
- const slug = String(value)
72
- .normalize('NFKC')
73
- .toLowerCase()
74
- .replace(/[^\p{L}\p{N}]+/gu, '-')
75
- .replace(/^-+|-+$/g, '');
76
- return slug || 'post';
77
- }
78
-
79
- function normalizeTags(value) {
80
- if (value == null || value === '') return [];
81
- return (Array.isArray(value) ? value : [value]).map(String).map((tag) => tag.trim()).filter(Boolean);
82
- }
83
-
84
- function normalizeAuthor(value, file) {
85
- if (value && typeof value === 'object' && !Array.isArray(value)) {
86
- if (Object.keys(value).some(key => !['name', 'avatar'].includes(key))) throw new Error('Unsupported author field in ' + file);
87
- value = value.name;
88
- }
89
- if (value === undefined || value === null || value === '') return '';
90
- if (typeof value !== 'string' || !value.trim() || value.length > 160) {
91
- throw new Error('author must be a non-empty string of at most 160 characters in ' + file);
92
- }
93
- return value.trim();
94
- }
95
-
96
- function normalizeVideo(value, file) {
97
- if (value === undefined) return null;
98
- if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('video must be an object in ' + file);
99
- for (const key of Object.keys(value)) {
100
- if (!['src', 'captions', 'captionLanguage', 'captionLabel', 'poster', 'alt', 'title', 'description'].includes(key)) {
101
- throw new Error('Unsupported video field in ' + file + ': ' + key);
102
- }
103
- }
104
- const localAsset = /^\/edgepress\/media\/[a-z0-9-]+\.(?:mp4|vtt|jpe?g)$/i;
105
- for (const field of ['src', 'captions', 'poster']) {
106
- if (typeof value[field] !== 'string' || !localAsset.test(value[field])) {
107
- throw new Error('video.' + field + ' must reference a local /edgepress/media asset in ' + file);
108
- }
109
- }
110
- if (typeof value.captionLanguage !== 'string' || !/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(value.captionLanguage) ||
111
- typeof value.captionLabel !== 'string' || !value.captionLabel.trim() || typeof value.alt !== 'string' || !value.alt.trim() ||
112
- typeof value.title !== 'string' || !value.title.trim() || typeof value.description !== 'string' || !value.description.trim()) {
113
- throw new Error('video needs a language, labels, accessible description, title, and summary in ' + file);
114
- }
115
- return value;
116
- }
117
-
118
- function normalizeDate(value, filename, kind, file, timeZone) {
119
- const fallback = filename.match(/^(\d{4}-\d{2}-\d{2})-/)?.[1];
120
- if (value == null && !fallback) {
121
- if (kind === 'posts') throw new Error('Posts need a valid date in front matter or a YYYY-MM-DD filename: ' + file);
122
- return new Date(0);
123
- }
124
- try { return parsePublicationDate(value ?? fallback, timeZone); }
125
- catch (error) { throw new Error(error.message + ' in ' + file); }
126
- }
127
-
128
- function normalizePagePath(value, file) {
129
- if (typeof value !== 'string' || !value.trim()) throw new Error('Page folder or slug must be a non-empty path: ' + file);
130
- const source = value.trim();
131
- if (source.startsWith('/') || source.includes('\\') || /[%?#\0]/.test(source)) throw new Error('Page path contains unsafe URL characters: ' + file);
132
- const segments = source.split('/');
133
- if (segments.some((part) => !part || part === '.' || part === '..')) throw new Error('Page path contains an empty or traversal segment: ' + file);
134
- const parts = segments.map((part) => slugify(part));
135
- if (parts.some((part) => !part)) throw new Error('Page path contains an invalid segment: ' + file);
136
- return parts.join('/');
137
- }
138
-
139
- export async function readDocuments(config) {
140
- const root = config.resolvedPaths.content;
141
- const groups = [['posts', resolve(root, 'posts')], ['pages', resolve(root, 'pages')]];
142
- const files = [];
143
- for (const [kind, directory] of groups) {
144
- const identities = new Set();
145
- for (const file of await walkContent(directory, kind)) {
146
- const identity = resolve(dirname(file), basename(file, extname(file)).toLowerCase());
147
- if (identities.has(identity)) throw new Error('Duplicate language document; keep only one .json or .md file per page locale: ' + file);
148
- identities.add(identity);
149
- files.push({ kind, file });
150
- }
151
- }
152
- return await mapLimit(files, config.concurrency, async ({ kind, file }) => {
153
- const source = await readFile(file, 'utf8');
154
- const { metadata, markdown } = parseDocumentSource(source, file);
155
- const contentRoot = resolve(root, kind);
156
- const relativePath = relative(contentRoot, file).split(sep).join('/');
157
- const bundleDirectory = dirname(file);
158
- const bundlePath = relative(contentRoot, bundleDirectory).split(sep).join('/');
159
- if (!bundlePath || bundlePath === '.' || bundlePath.startsWith('..')) {
160
- throw new Error('Each content document must have its own folder with one file per language: ' + file);
161
- }
162
- const bundleName = basename(bundleDirectory);
163
- const languageFileName = basename(file, extname(file));
164
- const languageFile = languageFileName.toLowerCase();
165
- const fileLocale = config.i18n.locales.find((locale) => locale.toLowerCase() === languageFile);
166
- if (!fileLocale || languageFileName !== languageFile) {
167
- throw new Error('Content language files must be named after a configured locale, such as en.json or zh-cn.md: ' + file);
168
- }
169
- const declaredLocale = metadata.lang === undefined ? fileLocale :
170
- config.i18n.locales.find((locale) => locale.toLowerCase() === String(metadata.lang).toLowerCase());
171
- if (!declaredLocale) throw new Error('Unsupported lang value in ' + file + '; use the language filename and configure its language pack');
172
- if (declaredLocale !== fileLocale) throw new Error('Content lang must match the content filename locale in ' + file);
173
- const locale = fileLocale;
174
- const fallbackSlug = bundlePath.replace(/\\/g, '/');
175
- const fallbackName = bundleName.replace(/^\d{4}-\d{2}-\d{2}-/, '');
176
- const fallbackTitle = fallbackName.replace(/[-_]/g, ' ');
177
- const title = String(metadata.title ?? fallbackTitle).trim();
178
- if (!title) throw new Error('Document needs a non-empty title: ' + file);
179
- if (metadata.homepage !== undefined && typeof metadata.homepage !== 'boolean') {
180
- throw new Error('homepage must be a boolean in ' + file);
181
- }
182
- if (metadata.homepage === true && kind !== 'pages') throw new Error('Only pages can be marked as the homepage: ' + file);
183
- if (metadata.blocks !== undefined && (kind !== 'pages' || !Array.isArray(metadata.blocks) || metadata.blocks.length > 100)) {
184
- throw new Error('blocks must be an array of at most 100 items on a page: ' + file);
185
- }
186
- if (kind === 'pages' && (!Array.isArray(metadata.blocks) || metadata.blocks.length === 0)) {
187
- throw new Error('Pages need an ordered blocks array; use Markdown only for posts: ' + file);
188
- }
189
- if (kind === 'pages' && markdown.trim()) {
190
- throw new Error('Page body must be empty; move content into page blocks: ' + file);
191
- }
1
+ import {parsePublicationDate,publicationCalendar} from './publication-time.js';
2
+ import { readPostRevision } from './revisions.js';
3
+ import { normalizeCategory, safeAvatar, readingMinutes } from './post-details.js';
4
+ import { readdir, readFile } from 'node:fs/promises';
5
+ import { basename, dirname, extname, relative, resolve, sep } from 'node:path';
6
+ import { parse as parseYaml } from 'yaml';
7
+ import { mapLimit } from './concurrency.js';
8
+
9
+ export function sortPostsNewest(posts) {
10
+ return [...posts].sort((left, right) => {
11
+ const dateOrder = right.date - left.date;
12
+ if (dateOrder) return dateOrder;
13
+ const leftPath = String(left.path);
14
+ const rightPath = String(right.path);
15
+ return rightPath.localeCompare(leftPath, undefined, { numeric: true, sensitivity: 'base' });
16
+ });
17
+ }
18
+
19
+ export function postsForLocale(posts, locale, defaultLocale) {
20
+ const byBundle = new Map();
21
+ for (const post of posts) {
22
+ const current = byBundle.get(post.bundlePath);
23
+ if (!current || post.locale === locale || (current.locale !== locale && post.locale === defaultLocale) ||
24
+ (current.locale !== locale && current.locale !== defaultLocale && post.locale !== defaultLocale && post.locale.localeCompare(current.locale) < 0)) {
25
+ byBundle.set(post.bundlePath, post);
26
+ }
27
+ }
28
+ return sortPostsNewest([...byBundle.values()]);
29
+ }
30
+
31
+ async function walkContent(directory, kind) {
32
+ let entries;
33
+ try {
34
+ entries = await readdir(directory, { withFileTypes: true });
35
+ } catch (error) {
36
+ if (error.code === 'ENOENT') return [];
37
+ throw error;
38
+ }
39
+ entries.sort((a, b) => a.name.localeCompare(b.name));
40
+ const found = [];
41
+ for (const entry of entries) {
42
+ const fullPath = resolve(directory, entry.name);
43
+ if (entry.isDirectory()) found.push(...await walkContent(fullPath, kind));
44
+ else if (entry.isFile() && (extname(entry.name).toLowerCase() === '.md' || (kind === 'pages' && extname(entry.name).toLowerCase() === '.json'))) found.push(fullPath);
45
+ }
46
+ return found;
47
+ }
48
+
49
+ function splitFrontmatter(source, file) {
50
+ const lines = source.replace(/^\uFEFF/, '').split(/\r?\n/);
51
+ if (lines[0] !== '---') return { metadata: {}, markdown: source };
52
+ const end = lines.findIndex((line, index) => index > 0 && (line === '---' || line === '...'));
53
+ if (end < 0) throw new Error('Unclosed YAML front matter in ' + file);
54
+ const metadata = parseYaml(lines.slice(1, end).join('\n')) ?? {};
55
+ if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
56
+ throw new Error('Front matter must be a YAML object in ' + file);
57
+ }
58
+ return { metadata, markdown: lines.slice(end + 1).join('\n') };
59
+ }
60
+
61
+ export function parseDocumentSource(source, file) {
62
+ if (extname(file).toLowerCase() !== '.json') return splitFrontmatter(source, file);
63
+ let metadata;
64
+ try { metadata = JSON.parse(source.replace(/^\uFEFF/, '')); }
65
+ catch (error) { throw new Error('Invalid page JSON in ' + file + ': ' + error.message, {cause: error}); }
66
+ if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) throw new Error('Page JSON must be an object in ' + file);
67
+ return {metadata, markdown: ''};
68
+ }
69
+
70
+ export function slugify(value) {
71
+ const slug = String(value)
72
+ .normalize('NFKC')
73
+ .toLowerCase()
74
+ .replace(/[^\p{L}\p{N}]+/gu, '-')
75
+ .replace(/^-+|-+$/g, '');
76
+ return slug || 'post';
77
+ }
78
+
79
+ function normalizeTags(value) {
80
+ if (value == null || value === '') return [];
81
+ return (Array.isArray(value) ? value : [value]).map(String).map((tag) => tag.trim()).filter(Boolean);
82
+ }
83
+
84
+ function normalizeAuthor(value, file) {
85
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
86
+ if (Object.keys(value).some(key => !['name', 'avatar'].includes(key))) throw new Error('Unsupported author field in ' + file);
87
+ value = value.name;
88
+ }
89
+ if (value === undefined || value === null || value === '') return '';
90
+ if (typeof value !== 'string' || !value.trim() || value.length > 160) {
91
+ throw new Error('author must be a non-empty string of at most 160 characters in ' + file);
92
+ }
93
+ return value.trim();
94
+ }
95
+
96
+ function normalizeVideo(value, file) {
97
+ if (value === undefined) return null;
98
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('video must be an object in ' + file);
99
+ for (const key of Object.keys(value)) {
100
+ if (!['src', 'captions', 'captionLanguage', 'captionLabel', 'poster', 'alt', 'title', 'description'].includes(key)) {
101
+ throw new Error('Unsupported video field in ' + file + ': ' + key);
102
+ }
103
+ }
104
+ const localAsset = /^\/edgepress\/media\/[a-z0-9-]+\.(?:mp4|vtt|jpe?g)$/i;
105
+ for (const field of ['src', 'captions', 'poster']) {
106
+ if (typeof value[field] !== 'string' || !localAsset.test(value[field])) {
107
+ throw new Error('video.' + field + ' must reference a local /edgepress/media asset in ' + file);
108
+ }
109
+ }
110
+ if (typeof value.captionLanguage !== 'string' || !/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(value.captionLanguage) ||
111
+ typeof value.captionLabel !== 'string' || !value.captionLabel.trim() || typeof value.alt !== 'string' || !value.alt.trim() ||
112
+ typeof value.title !== 'string' || !value.title.trim() || typeof value.description !== 'string' || !value.description.trim()) {
113
+ throw new Error('video needs a language, labels, accessible description, title, and summary in ' + file);
114
+ }
115
+ return value;
116
+ }
117
+
118
+ function normalizeDate(value, filename, kind, file, timeZone) {
119
+ const fallback = filename.match(/^(\d{4}-\d{2}-\d{2})-/)?.[1];
120
+ if (value == null && !fallback) {
121
+ if (kind === 'posts') throw new Error('Posts need a valid date in front matter or a YYYY-MM-DD filename: ' + file);
122
+ return new Date(0);
123
+ }
124
+ try { return parsePublicationDate(value ?? fallback, timeZone); }
125
+ catch (error) { throw new Error(error.message + ' in ' + file); }
126
+ }
127
+
128
+ function normalizePagePath(value, file) {
129
+ if (typeof value !== 'string' || !value.trim()) throw new Error('Page folder or slug must be a non-empty path: ' + file);
130
+ const source = value.trim();
131
+ if (source.startsWith('/') || source.includes('\\') || /[%?#\0]/.test(source)) throw new Error('Page path contains unsafe URL characters: ' + file);
132
+ const segments = source.split('/');
133
+ if (segments.some((part) => !part || part === '.' || part === '..')) throw new Error('Page path contains an empty or traversal segment: ' + file);
134
+ const parts = segments.map((part) => slugify(part));
135
+ if (parts.some((part) => !part)) throw new Error('Page path contains an invalid segment: ' + file);
136
+ return parts.join('/');
137
+ }
138
+
139
+ export async function readDocuments(config) {
140
+ const root = config.resolvedPaths.content;
141
+ const groups = [['posts', resolve(root, 'posts')], ['pages', resolve(root, 'pages')]];
142
+ const files = [];
143
+ for (const [kind, directory] of groups) {
144
+ const identities = new Set();
145
+ for (const file of await walkContent(directory, kind)) {
146
+ const identity = resolve(dirname(file), basename(file, extname(file)).toLowerCase());
147
+ if (identities.has(identity)) throw new Error('Duplicate language document; keep only one .json or .md file per page locale: ' + file);
148
+ identities.add(identity);
149
+ files.push({ kind, file });
150
+ }
151
+ }
152
+ return await mapLimit(files, config.concurrency, async ({ kind, file }) => {
153
+ const source = await readFile(file, 'utf8');
154
+ const { metadata, markdown } = parseDocumentSource(source, file);
155
+ const contentRoot = resolve(root, kind);
156
+ const relativePath = relative(contentRoot, file).split(sep).join('/');
157
+ const bundleDirectory = dirname(file);
158
+ const bundlePath = relative(contentRoot, bundleDirectory).split(sep).join('/');
159
+ if (!bundlePath || bundlePath === '.' || bundlePath.startsWith('..')) {
160
+ throw new Error('Each content document must have its own folder with one file per language: ' + file);
161
+ }
162
+ const bundleName = basename(bundleDirectory);
163
+ const languageFileName = basename(file, extname(file));
164
+ const languageFile = languageFileName.toLowerCase();
165
+ const fileLocale = config.i18n.locales.find((locale) => locale.toLowerCase() === languageFile);
166
+ if (!fileLocale || languageFileName !== languageFile) {
167
+ throw new Error('Content language files must be named after a configured locale, such as en.json or zh-cn.md: ' + file);
168
+ }
169
+ const declaredLocale = metadata.lang === undefined ? fileLocale :
170
+ config.i18n.locales.find((locale) => locale.toLowerCase() === String(metadata.lang).toLowerCase());
171
+ if (!declaredLocale) throw new Error('Unsupported lang value in ' + file + '; use the language filename and configure its language pack');
172
+ if (declaredLocale !== fileLocale) throw new Error('Content lang must match the content filename locale in ' + file);
173
+ const locale = fileLocale;
174
+ const fallbackSlug = bundlePath.replace(/\\/g, '/');
175
+ const fallbackName = bundleName.replace(/^\d{4}-\d{2}-\d{2}-/, '');
176
+ const fallbackTitle = fallbackName.replace(/[-_]/g, ' ');
177
+ const title = String(metadata.title ?? fallbackTitle).trim();
178
+ if (!title) throw new Error('Document needs a non-empty title: ' + file);
179
+ if (metadata.homepage !== undefined && typeof metadata.homepage !== 'boolean') {
180
+ throw new Error('homepage must be a boolean in ' + file);
181
+ }
182
+ if (metadata.homepage === true && kind !== 'pages') throw new Error('Only pages can be marked as the homepage: ' + file);
183
+ if (metadata.blocks !== undefined && (kind !== 'pages' || !Array.isArray(metadata.blocks) || metadata.blocks.length > 100)) {
184
+ throw new Error('blocks must be an array of at most 100 items on a page: ' + file);
185
+ }
186
+ if (kind === 'pages' && (!Array.isArray(metadata.blocks) || metadata.blocks.length === 0)) {
187
+ throw new Error('Pages need an ordered blocks array; use Markdown only for posts: ' + file);
188
+ }
189
+ if (kind === 'pages' && markdown.trim()) {
190
+ throw new Error('Page body must be empty; move content into page blocks: ' + file);
191
+ }
192
192
  if (metadata.pinned !== undefined && typeof metadata.pinned !== 'boolean') throw new Error('pinned must be a boolean in ' + file);
193
193
  if (metadata.embeds !== undefined) throw new Error('Use !embed[service](URL) in the content instead of front-matter embeds: ' + file);
194
- const rawSlug = metadata.slug ?? (kind === 'posts' ? fallbackName : fallbackSlug);
195
- const normalizedPagePath = kind === 'pages' && metadata.homepage !== true ? normalizePagePath(rawSlug, file) : '';
196
- const slug = slugify(String(rawSlug).split('/').pop());
197
- const date = normalizeDate(metadata.date, bundleName, kind, file, config.site?.timeZone);
198
- const calendar = metadata.date == null ? (bundleName.match(/^(\d{4}-\d{2}-\d{2})-/)?.[1] || '1970-01-01') : publicationCalendar(metadata.date,date,config.site?.timeZone);
199
- if (metadata.showChanges !== undefined && typeof metadata.showChanges !== 'boolean') throw new Error('showChanges must be a boolean in ' + file);
194
+ const rawSlug = metadata.slug ?? (kind === 'posts' ? fallbackName : fallbackSlug);
195
+ const normalizedPagePath = kind === 'pages' && metadata.homepage !== true ? normalizePagePath(rawSlug, file) : '';
196
+ const slug = slugify(String(rawSlug).split('/').pop());
197
+ const date = normalizeDate(metadata.date, bundleName, kind, file, config.site?.timeZone);
198
+ const calendar = metadata.date == null ? (bundleName.match(/^(\d{4}-\d{2}-\d{2})-/)?.[1] || '1970-01-01') : publicationCalendar(metadata.date,date,config.site?.timeZone);
199
+ if (metadata.showChanges !== undefined && typeof metadata.showChanges !== 'boolean') throw new Error('showChanges must be a boolean in ' + file);
200
200
  const revision = kind === 'posts' ? await readPostRevision(file, source, metadata.showChanges !== false, resolve(root,'assets')) : {updated:null,changes:''};
201
- let path;
202
- if (kind === 'posts') {
203
- path = config.permalink
204
- .replace(/:year/g, calendar.slice(0,4))
205
- .replace(/:month/g, calendar.slice(5,7))
206
- .replace(/:day/g, calendar.slice(8,10))
207
- .replace(/:slug/g, slug)
208
- .replace(/:title/g, slug)
209
- .replace(/^\/+|\/+$/g, '') + '/';
210
- } else {
211
- const localePrefix = locale === config.i18n.defaultLocale ? '' : locale + '/';
212
- path = metadata.homepage === true ? localePrefix : normalizedPagePath + '/';
213
- }
214
- if (locale !== config.i18n.defaultLocale && metadata.homepage !== true) path = locale + '/' + path;
215
- return {
216
- kind, file, relativePath, bundlePath, title, slug, date, locale,
217
- dateOnly: metadata.date == null || /^\d{4}-\d{2}-\d{2}$/.test(String(metadata.date)),
201
+ let path;
202
+ if (kind === 'posts') {
203
+ path = config.permalink
204
+ .replace(/:year/g, calendar.slice(0,4))
205
+ .replace(/:month/g, calendar.slice(5,7))
206
+ .replace(/:day/g, calendar.slice(8,10))
207
+ .replace(/:slug/g, slug)
208
+ .replace(/:title/g, slug)
209
+ .replace(/^\/+|\/+$/g, '') + '/';
210
+ } else {
211
+ const localePrefix = locale === config.i18n.defaultLocale ? '' : locale + '/';
212
+ path = metadata.homepage === true ? localePrefix : normalizedPagePath + '/';
213
+ }
214
+ if (locale !== config.i18n.defaultLocale && metadata.homepage !== true) path = locale + '/' + path;
215
+ return {
216
+ kind, file, relativePath, bundlePath, title, slug, date, locale,
217
+ dateOnly: metadata.date == null || /^\d{4}-\d{2}-\d{2}$/.test(String(metadata.date)),
218
218
  updated: revision.updated, changes: revision.changes, showChanges: metadata.showChanges !== false,
219
219
  revisionImages: revision.revisionImages, revisionAssets: revision.revisionAssets??[],
220
- author: kind === 'posts' ? normalizeAuthor(metadata.author, file) : '',
221
- authorAvatar: safeAvatar(metadata.authorAvatar ?? (typeof metadata.author === 'object' && metadata.author !== null ? metadata.author.avatar : undefined), 'author avatar in ' + file),
222
- category: normalizeCategory(metadata.category),
220
+ author: kind === 'posts' ? normalizeAuthor(metadata.author, file) : '',
221
+ authorAvatar: safeAvatar(metadata.authorAvatar ?? (typeof metadata.author === 'object' && metadata.author !== null ? metadata.author.avatar : undefined), 'author avatar in ' + file),
222
+ category: normalizeCategory(metadata.category),
223
223
  pinned: kind === 'posts' && metadata.pinned === true,
224
- readingMinutes: kind === 'posts' ? readingMinutes(markdown) : 0,
225
- videoId: (kind === 'posts' ? 'post-' : 'page-') + slugify(bundlePath + '-' + locale.toLowerCase()),
226
- video: kind === 'posts' ? normalizeVideo(metadata.video, file) : null,
227
- tags: normalizeTags(metadata.tags),
228
- description: String(metadata.description ?? ''),
229
- keywords: normalizeTags(metadata.keywords),
230
- robots: typeof metadata.robots === 'string' ? metadata.robots : '',
231
- image: typeof metadata.image === 'string' ? metadata.image : '',
232
- homepage: metadata.homepage === true,
233
- blocks: metadata.blocks ?? [],
234
- draft: metadata.draft === true,
235
- path,
236
- markdown: markdown.trim()
237
- };
238
- });
239
- }
224
+ readingMinutes: kind === 'posts' ? readingMinutes(markdown) : 0,
225
+ videoId: (kind === 'posts' ? 'post-' : 'page-') + slugify(bundlePath + '-' + locale.toLowerCase()),
226
+ video: kind === 'posts' ? normalizeVideo(metadata.video, file) : null,
227
+ tags: normalizeTags(metadata.tags),
228
+ description: String(metadata.description ?? ''),
229
+ keywords: normalizeTags(metadata.keywords),
230
+ robots: typeof metadata.robots === 'string' ? metadata.robots : '',
231
+ image: typeof metadata.image === 'string' ? metadata.image : '',
232
+ homepage: metadata.homepage === true,
233
+ blocks: metadata.blocks ?? [],
234
+ draft: metadata.draft === true,
235
+ path,
236
+ markdown: markdown.trim()
237
+ };
238
+ });
239
+ }
package/src/data-saver.js CHANGED
@@ -84,7 +84,7 @@ function featureFor(source) {
84
84
 
85
85
  function mediaLabel(node) {
86
86
  const attributes = node.attribs || {};
87
- const label = attributes.alt || attributes.title || attributes['data-document-title'];
87
+ const label = attributes.alt || attributes.title || attributes['aria-label'] || attributes['data-document-title'];
88
88
  if (label) return label;
89
89
  for (const child of node.children || []) {
90
90
  if (['template', 'noscript'].includes(child.name)) continue;
@@ -146,7 +146,7 @@ export function dataSaverHtml(html, config, manifest = {}) {
146
146
  patches.push({ start: node.startIndex, end: node.endIndex + 1, text: '<template data-edgepress-data-resource="' + resource + '" data-data-feature="' + (core ? 'core' : featureFor(attributes.src || attributes.href || '')) + '">' + original + '</template><noscript>' + original + '</noscript>' });
147
147
  return;
148
148
  }
149
- if (['img', 'picture', 'video', 'audio', 'iframe'].includes(name) || attributes['data-edgepress-document'] !== undefined || attributes['data-edgepress-media-viewer'] !== undefined || attributes['data-project-demo'] !== undefined) {
149
+ if (['img', 'picture', 'video', 'audio', 'iframe'].includes(name) || attributes['data-edgepress-document'] !== undefined || attributes['data-edgepress-media-viewer'] !== undefined || attributes['data-edgepress-video'] !== undefined || attributes['data-project-demo'] !== undefined) {
150
150
  const candidate = name === 'picture' ? node.children?.find(child => child.name === 'img')?.attribs : name === 'img' ? attributes : null;
151
151
  if (!firstImage && candidate?.fetchpriority === 'high') {
152
152
  const source = name === 'picture' ? node.children?.find(child => child.name === 'source')?.attribs : null;
@@ -157,7 +157,7 @@ export function dataSaverHtml(html, config, manifest = {}) {
157
157
  }
158
158
  const label = mediaLabel(node);
159
159
  const tag = name === 'img' || name === 'picture' ? 'span' : 'div';
160
- const feature = attributes['data-edgepress-document'] !== undefined ? 'document' : attributes['data-edgepress-media-viewer'] !== undefined ? 'gallery' : attributes['data-project-demo'] !== undefined ? 'demo' : name === 'img' || name === 'picture' ? 'image' : 'media';
160
+ const feature = attributes['data-edgepress-document'] !== undefined ? 'document' : attributes['data-edgepress-media-viewer'] !== undefined || attributes['data-edgepress-video'] !== undefined ? 'gallery' : attributes['data-project-demo'] !== undefined ? 'demo' : name === 'img' || name === 'picture' ? 'image' : 'media';
161
161
  const classes = attributes.class || '';
162
162
  const pictureImage = name === 'picture' ? node.children?.find(child => child.name === 'img')?.attribs : null;
163
163
  const decorative = feature === 'image' && (attributes.alt === '' || pictureImage?.alt === '' || attributes['aria-hidden'] === 'true' || ['none', 'presentation'].includes(attributes.role) || /(?:^|\s)(?:navigation-icon|icon-library|brand-mark)(?:\s|$)/.test(classes));