Removed the releases page’s runtime dependency on `fs` and `process.cwd()` by introducing a build-time manifest generator: release notes still live as markdown under `src/content/releases`, but a new script now parses their frontmatter/content, validates that each note has a release, title, and preview image (and that the image actually exists), sorts the notes, and emits a typed `generated-release-notes.ts` file that the app imports at runtime. Updated the releases loader to return that generated data, changed the menu releases preview and release JSON-LD to use explicit typed fields (`title`, `previewImage`) instead of scraping markdown with regex at runtime, wired the generator into Nx so it runs automatically before `dev`, `build`, and `typecheck`, and fixed two stale image references in the release MDX files that the new validation exposed. --------- Co-authored-by: prastoin <paul@twenty.com>
215 lines
5.3 KiB
JavaScript
215 lines
5.3 KiB
JavaScript
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
import matter from 'gray-matter';
|
|
|
|
const projectRoot = path.resolve(import.meta.dirname, '..');
|
|
const releasesDirectory = path.join(projectRoot, 'src', 'content', 'releases');
|
|
const outputPath = path.join(
|
|
projectRoot,
|
|
'src',
|
|
'lib',
|
|
'releases',
|
|
'generated-release-notes.ts',
|
|
);
|
|
|
|
const headingRegex = /^\s*#\s+(.+?)\s*$/m;
|
|
const imageRegex = /!\[[^\]]*\]\(([^)]+)\)/;
|
|
|
|
function normalizeFrontmatterDate(dateValue) {
|
|
if (typeof dateValue === 'string') {
|
|
return dateValue;
|
|
}
|
|
|
|
if (dateValue instanceof Date && !Number.isNaN(dateValue.getTime())) {
|
|
return dateValue.toISOString().slice(0, 10);
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function compareSemanticVersions(a, b) {
|
|
return compareParsed(parseSemver(a), parseSemver(b));
|
|
}
|
|
|
|
function parseSemver(version) {
|
|
const trimmed = version.trim().replace(/^v/i, '');
|
|
const withoutBuild = trimmed.split('+', 1)[0] ?? '';
|
|
const dashIndex = withoutBuild.indexOf('-');
|
|
const coreString =
|
|
dashIndex === -1 ? withoutBuild : withoutBuild.slice(0, dashIndex);
|
|
const preString = dashIndex === -1 ? null : withoutBuild.slice(dashIndex + 1);
|
|
const core = coreString.split('.').map((part) => {
|
|
const parsed = Number.parseInt(part, 10);
|
|
return Number.isFinite(parsed) ? parsed : 0;
|
|
});
|
|
|
|
return {
|
|
core,
|
|
pre: preString === null || preString === '' ? null : preString.split('.'),
|
|
};
|
|
}
|
|
|
|
function compareParsed(a, b) {
|
|
const length = Math.max(a.core.length, b.core.length);
|
|
|
|
for (let index = 0; index < length; index += 1) {
|
|
const left = a.core[index] ?? 0;
|
|
const right = b.core[index] ?? 0;
|
|
|
|
if (left !== right) {
|
|
return left < right ? -1 : 1;
|
|
}
|
|
}
|
|
|
|
if (a.pre === null && b.pre === null) {
|
|
return 0;
|
|
}
|
|
|
|
if (a.pre === null) {
|
|
return 1;
|
|
}
|
|
|
|
if (b.pre === null) {
|
|
return -1;
|
|
}
|
|
|
|
const prereleaseLength = Math.min(a.pre.length, b.pre.length);
|
|
|
|
for (let index = 0; index < prereleaseLength; index += 1) {
|
|
const left = a.pre[index] ?? '';
|
|
const right = b.pre[index] ?? '';
|
|
const leftIsNumeric = /^\d+$/.test(left);
|
|
const rightIsNumeric = /^\d+$/.test(right);
|
|
|
|
if (leftIsNumeric && rightIsNumeric) {
|
|
const leftNumber = Number.parseInt(left, 10);
|
|
const rightNumber = Number.parseInt(right, 10);
|
|
|
|
if (leftNumber !== rightNumber) {
|
|
return leftNumber < rightNumber ? -1 : 1;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if (leftIsNumeric) {
|
|
return -1;
|
|
}
|
|
|
|
if (rightIsNumeric) {
|
|
return 1;
|
|
}
|
|
|
|
if (left !== right) {
|
|
return left < right ? -1 : 1;
|
|
}
|
|
}
|
|
|
|
if (a.pre.length === b.pre.length) {
|
|
return 0;
|
|
}
|
|
|
|
return a.pre.length < b.pre.length ? -1 : 1;
|
|
}
|
|
|
|
function inferTitle(content) {
|
|
const match = content.match(headingRegex);
|
|
return match?.[1]?.trim() ?? '';
|
|
}
|
|
|
|
function inferPreviewImage(content) {
|
|
const match = content.match(imageRegex);
|
|
return match?.[1]?.trim() ?? '';
|
|
}
|
|
|
|
function readReleaseNotes() {
|
|
const fileNames = fs
|
|
.readdirSync(releasesDirectory)
|
|
.filter(
|
|
(fileName) => fileName.endsWith('.md') || fileName.endsWith('.mdx'),
|
|
);
|
|
|
|
const notes = fileNames.map((fileName) => {
|
|
const fullPath = path.join(releasesDirectory, fileName);
|
|
const raw = fs.readFileSync(fullPath, 'utf8');
|
|
const { data, content } = matter(raw);
|
|
const release = typeof data.release === 'string' ? data.release.trim() : '';
|
|
const date = normalizeFrontmatterDate(data.Date ?? data.date);
|
|
const title =
|
|
typeof data.title === 'string' && data.title.trim()
|
|
? data.title.trim()
|
|
: inferTitle(content);
|
|
const previewImage =
|
|
typeof data.previewImage === 'string' && data.previewImage.trim()
|
|
? data.previewImage.trim()
|
|
: inferPreviewImage(content);
|
|
|
|
if (!release) {
|
|
throw new Error(`Missing "release" frontmatter in ${fileName}`);
|
|
}
|
|
|
|
if (!title) {
|
|
throw new Error(
|
|
`Missing release title in ${fileName}. Add "title" frontmatter or a top-level markdown heading.`,
|
|
);
|
|
}
|
|
|
|
if (!previewImage) {
|
|
throw new Error(
|
|
`Missing preview image in ${fileName}. Add "previewImage" frontmatter or a markdown image.`,
|
|
);
|
|
}
|
|
|
|
const imagePath = path.join(
|
|
projectRoot,
|
|
'public',
|
|
previewImage.replace(/^\//, ''),
|
|
);
|
|
|
|
if (!fs.existsSync(imagePath)) {
|
|
throw new Error(
|
|
`Preview image "${previewImage}" referenced by ${fileName} was not found in public/.`,
|
|
);
|
|
}
|
|
|
|
return {
|
|
slug: fileName.replace(/\.mdx?$/i, ''),
|
|
date,
|
|
release,
|
|
title,
|
|
previewImage,
|
|
content,
|
|
};
|
|
});
|
|
|
|
notes.sort((left, right) =>
|
|
compareSemanticVersions(right.release, left.release),
|
|
);
|
|
|
|
return notes;
|
|
}
|
|
|
|
function buildFileContent(notes) {
|
|
const serializedNotes = JSON.stringify(notes, null, 2);
|
|
|
|
return `/* eslint-disable */
|
|
// This file is auto-generated by scripts/generate-release-notes-manifest.mjs.
|
|
// Do not edit it manually.
|
|
|
|
import type { LocalReleaseNote } from './types';
|
|
|
|
export const GENERATED_RELEASE_NOTES: LocalReleaseNote[] = ${serializedNotes} as LocalReleaseNote[];
|
|
`;
|
|
}
|
|
|
|
function main() {
|
|
const notes = readReleaseNotes();
|
|
fs.writeFileSync(outputPath, buildFileContent(notes));
|
|
process.stdout.write(
|
|
`Generated release manifest with ${notes.length} entries at ${path.relative(projectRoot, outputPath)}\n`,
|
|
);
|
|
}
|
|
|
|
main();
|