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.
- package/README.md +12 -2
- package/config.yml +171 -171
- package/content/assets/edgepress/brand/sources/export-manifest.json +181 -181
- package/content/assets/edgepress-markdown-guide.svg +21 -21
- package/content/guides/markdown-media.md +38 -0
- package/content/guides/product-demos.md +30 -30
- package/content/pages/deployment/en.json +1 -1
- package/content/posts/2026-09-24-edgepress-release-2026-9-1/en.md +1 -1
- package/content/posts/2026-09-24-edgepress-release-2026-9-1/zh-cn.md +1 -1
- package/languages/base/en.json +98 -98
- package/languages/packs/zh-CN.json +98 -98
- package/package.json +90 -90
- package/src/assets.js +187 -186
- package/src/browser-audit.js +158 -158
- package/src/build.js +293 -291
- package/src/cli.js +364 -364
- package/src/content.js +233 -233
- package/src/data-saver.js +3 -3
- package/src/generators.js +414 -420
- package/src/i18n.js +86 -86
- package/src/markdown.js +195 -160
- package/src/page-check.js +1 -1
- package/src/plugin-api.js +77 -77
- package/src/report-visuals.js +642 -642
- package/src/revision-cards.js +30 -30
- package/src/rss.js +100 -0
- package/src/theme.js +4 -4
- package/src/video-metadata.js +36 -0
- package/static/edgepress/fonts/faces.css +2217 -2217
- package/static/edgepress/media-viewer.js +67 -10
- package/static/edgepress/project-demo.js +83 -83
- package/static/edgepress/search.js +84 -84
- package/tests/assets-runtime.test.mjs +26 -26
- package/tests/audit.test.mjs +142 -142
- package/tests/brand-regression.test.mjs +110 -110
- package/tests/document-package.test.mjs +2 -2
- package/tests/embed-syntax.test.mjs +68 -68
- package/tests/image-viewer.test.mjs +36 -36
- package/tests/markdown-media.test.mjs +119 -0
- package/tests/oembed-layout.test.mjs +6 -1
- package/tests/project-demo.test.mjs +73 -73
- package/tests/release-version.test.mjs +28 -0
- package/tests/rss.test.mjs +54 -0
- package/tests/video-metadata.test.mjs +28 -0
- package/tests/writing.test.mjs +83 -83
- package/themes/atelier/assets/style.css +340 -340
- package/themes/default/assets/style.css +371 -371
- package/themes/default/layouts/partials/head.html +1 -1
- package/themes/folio/assets/style.css +515 -515
- package/themes/folio/theme.json +5 -5
- package/themes/signal/assets/style.css +332 -332
- package/tools/README.md +10 -10
- package/tools/assets/generate-brand-assets.py +160 -160
- package/tools/assets/video-preview.mjs +15 -0
- package/tools/recordings/demo-workbench.mjs +53 -53
- package/tools/recordings/desktop-recording.mjs +70 -70
- 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));
|