[Website] Generate release notes manifest at build time (#20913)

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>
This commit is contained in:
Abdullah.
2026-05-26 19:46:11 +05:00
committed by GitHub
parent 53392f9a16
commit 53d4e92dda
11 changed files with 692 additions and 112 deletions
+1
View File
@@ -3,6 +3,7 @@
"private": true,
"scripts": {
"nx": "NX_DEFAULT_PROJECT=twenty-website node ../../node_modules/nx/bin/nx.js",
"generate:releases": "node ./scripts/generate-release-notes-manifest.mjs",
"dev": "npx next dev --port 3002",
"build": "npx next build",
"start": "npx next start --port 3002",
+31 -3
View File
@@ -5,6 +5,20 @@
"projectType": "application",
"tags": ["scope:website"],
"targets": {
"generate-release-manifest": {
"executor": "nx:run-commands",
"cache": true,
"inputs": [
"{projectRoot}/src/content/releases/**/*",
"{projectRoot}/public/images/releases/**/*",
"{projectRoot}/scripts/generate-release-notes-manifest.mjs"
],
"outputs": ["{projectRoot}/src/lib/releases/generated-release-notes.ts"],
"options": {
"cwd": "{projectRoot}",
"command": "node scripts/generate-release-notes-manifest.mjs"
}
},
"build": {
"executor": "nx:run-commands",
"cache": true,
@@ -14,11 +28,12 @@
"cwd": "{projectRoot}",
"command": "npx next build"
},
"dependsOn": ["^build"]
"dependsOn": ["generate-release-manifest", "^build"]
},
"dev": {
"executor": "nx:run-commands",
"cache": false,
"dependsOn": ["generate-release-manifest"],
"options": {
"cwd": "{projectRoot}",
"command": "npx next dev --port 3002"
@@ -71,13 +86,24 @@
}
},
"lint": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": [
"check-boundaries",
"check-section-shape",
"check-lottie-frames",
"^build",
"twenty-oxlint-rules:build"
]
],
"options": {
"cwd": "{projectRoot}",
"command": "npx oxlint -c .oxlintrc.json . && (npx oxfmt --check . || (echo 'ERROR: oxfmt formatting check failed! Fix with: npx nx run twenty-website:lint --configuration=fix' && false))"
},
"configurations": {
"fix": {
"command": "npx oxlint -c .oxlintrc.json . && npx oxfmt ."
}
}
},
"lint:diff-with-main": {
"dependsOn": [
@@ -86,7 +112,9 @@
"check-lottie-frames"
]
},
"typecheck": {},
"typecheck": {
"dependsOn": ["generate-release-manifest"]
},
"test": {},
"fmt": {
"options": {
@@ -0,0 +1,214 @@
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();
@@ -7,4 +7,4 @@ Date: 2025-12-17
Take control of your running workflows with the new Stop Workflow button. When a workflow is in progress, you can now immediately halt its execution, giving you the flexibility to cancel operations that are no longer needed or troubleshoot issues in real-time.
![](/images/releases/1.13/1.13.0-stop-workflow-button.png)
![](/images/releases/1.13/1.13.0-stop-workflow-button.webp)
@@ -7,4 +7,4 @@ Date: 2025-12-20
You can now resize the side panel and navigation menu to view content more easily. This is especially useful on record pages with long content.
![](/images/releases/1.14/1.14.0-resize-navbar-and-side-panel.png)
![](/images/releases/1.14/1.14.0-resize-navbar-and-side-panel.webp)
@@ -0,0 +1,424 @@
/* 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[] = [
{
"slug": "2.0.0",
"date": "2026-04-21",
"release": "2.0.0",
"title": "Build an app",
"previewImage": "/images/releases/2.0/2.0.0-build-anything.webp",
"content": "\n# Build an app\n\nModel your data, add business logic, and design layouts — everything you need to ship an app on top of Twenty, in one place.\n\n![](/images/releases/2.0/2.0.0-build-anything.webp)\n\n# Version control\n\nYour workspace is backed by a git repo, so you can branch, review in pull requests, and roll back changes the same way you ship code.\n\n![](/images/releases/2.0/2.0.0-version-control.webp)\n\n# Build with your favorite tools\n\nScaffold components, workflows, and skills directly from Claude Code, Cursor, or the CLI, and commit them back to your workspace.\n\n![](/images/releases/2.0/2.0.0-build-with-tools.webp)\n\n# Custom layouts\n\nDrag and drop widgets to build record pages, dashboards, and layout pages that match the way your team actually works.\n\n![](/images/releases/2.0/2.0.0-custom-layouts.webp)\n\n# AI agents, chats, and MCP\n\nPick your model, build agents that write emails or enrich records inside workflows, and expose your workspace to any MCP-compatible client.\n\n![](/images/releases/2.0/2.0.0-ai.webp)\n"
},
{
"slug": "1.23.0",
"date": "2026-04-17",
"release": "1.23.0",
"title": "Easier layouts",
"previewImage": "/images/releases/1.23/1.23.0-easier-layouts.webp",
"content": "\n# Easier layouts\n\nReset layouts to default, pick tab icons, and connect shortcuts to specific page layouts.\n\n![](/images/releases/1.23/1.23.0-easier-layouts.webp)\n\n# Faster field setup\n\nSearch in field pickers and add-column menus makes table and layout customization faster.\n"
},
{
"slug": "1.22.0",
"date": "2026-04-11",
"release": "1.22.0",
"title": "Rich text layouts",
"previewImage": "/images/releases/1.22/1.22.0-rich-text-layouts.webp",
"content": "\n# Rich text layouts\n\nAdd rich text directly inside layouts with the new dedicated field widget.\n\n![](/images/releases/1.22/1.22.0-rich-text-layouts.webp)\n\n# More field relations\n\nField widgets now support more relation types, so custom layouts work better across more objects.\n"
},
{
"slug": "1.21.0",
"date": "2026-04-09",
"release": "1.21.0",
"title": "Email replies",
"previewImage": "/images/releases/1.21/1.21.0-email-replies.webp",
"content": "\n# Email replies\n\nEmail thread widgets and an inline reply composer make it easier to work directly from messaging records.\n\n![](/images/releases/1.21/1.21.0-email-replies.webp)\n\n# Maintenance mode\n\nA new maintenance mode helps admins pause workspace changes during sensitive operations.\n\n![](/images/releases/1.21/1.21.0-maintenance-mode.webp)\n"
},
{
"slug": "1.20.0",
"date": "2026-03-31",
"release": "1.20.0",
"title": "Easier field editing",
"previewImage": "/images/releases/1.20/1.20.0-easier-field-editing.webp",
"content": "\n# Easier field editing\n\nCreate fields and edit field widgets more directly from record pages.\n\n![](/images/releases/1.20/1.20.0-easier-field-editing.webp)\n\n# Field widgets\n\nField widgets are easier to configure directly from the page where you use them.\n\n![](/images/releases/1.20/1.20.0-field-widgets.webp)\n"
},
{
"slug": "1.19.0",
"date": "2026-03-12",
"release": "1.19.0",
"title": "Self-hosted billing",
"previewImage": "/images/releases/1.19/1.19.0-invite-roles.webp",
"content": "\n# Self-hosted billing\n\nSelf-hosted workspaces can now track usage more cleanly with usage-based billing support.\n\n# Invite roles\n\nChoose a role directly when inviting a teammate to a workspace.\n\n![](/images/releases/1.19/1.19.0-invite-roles.webp)\n"
},
{
"slug": "1.18.0",
"date": "2026-02-18",
"release": "1.18.0",
"title": "Sidebar items",
"previewImage": "/images/releases/1.18/1.18.0-sidebar-items.webp",
"content": "\n# Sidebar items\n\nCreate and organize sidebar items more easily with a cleaner navigation setup.\n\n![](/images/releases/1.18/1.18.0-sidebar-items.webp)\n\n# Live updates\n\nSome teammate changes now appear right away, so collaboration feels faster and more up to date.\n\n![](/images/releases/1.18/1.18.0-live-updates.webp)\n"
},
{
"slug": "1.17.0",
"date": "2026-02-10",
"release": "1.17.0",
"title": "AI chat",
"previewImage": "/images/releases/1.17/1.17.0-ai-chat.webp",
"content": "\n# AI chat\n\nAI Chat is easier to use with a cleaner experience and clearer model choices.\n\n![](/images/releases/1.17/1.17.0-ai-chat.webp)\n\n# Custom sidebar\n\nFavorites now live in the navigation menu, making the sidebar easier to organize and customize.\n"
},
{
"slug": "1.16.0",
"date": "2026-01-23",
"release": "1.16.0",
"title": "Files in records",
"previewImage": "/images/releases/1.16/1.16.0-files-in-records.webp",
"content": "\n# Files in records\n\nAdd files directly to records so documents, screenshots, and other assets stay attached to the right work.\n\n![](/images/releases/1.16/1.16.0-files-in-records.webp)\n\n# Flexible relations\n\nCreate more flexible relationships between objects, including more advanced many-to-many setups.\n\n![](/images/releases/1.16/1.16.0-flexible-relations.webp)\n"
},
{
"slug": "1.15.0",
"date": "2026-01-08",
"release": "1.15.0",
"title": "Updated by",
"previewImage": "/images/releases/1.15/1.15.0-updated-by-official.webp",
"content": "\n# Updated by\n\nYou can now see who last updated a record\n\n![](/images/releases/1.15/1.15.0-updated-by-official.webp)\n"
},
{
"slug": "1.14.0",
"date": "2025-12-20",
"release": "1.14.0",
"title": "Resize navbar and side panel",
"previewImage": "/images/releases/1.14/1.14.0-resize-navbar-and-side-panel.webp",
"content": "\n# Resize navbar and side panel\n\nYou can now resize the side panel and navigation menu to view content more easily. This is especially useful on record pages with long content.\n\n![](/images/releases/1.14/1.14.0-resize-navbar-and-side-panel.webp)\n"
},
{
"slug": "1.13.0",
"date": "2025-12-17",
"release": "1.13.0",
"title": "Stop Workflow Button",
"previewImage": "/images/releases/1.13/1.13.0-stop-workflow-button.webp",
"content": "\n# Stop Workflow Button\n\nTake control of your running workflows with the new Stop Workflow button. When a workflow is in progress, you can now immediately halt its execution, giving you the flexibility to cancel operations that are no longer needed or troubleshoot issues in real-time.\n\n![](/images/releases/1.13/1.13.0-stop-workflow-button.webp)\n"
},
{
"slug": "1.12.0",
"date": "2025-12-02",
"release": "1.12.0",
"title": "Revamped Side Panel",
"previewImage": "/images/releases/1.12/1.12.0-side-panel.webp",
"content": "\n# Revamped Side Panel\n\nThe side panel now opens next to your content rather than above it, giving you a better overview while configuring workflows or viewing record details. This new layout is especially handy for workflows and the upcoming dashboards feature.\n\n![](/images/releases/1.12/1.12.0-side-panel.webp)\n\n# Granular Email Folder Sync\n\nChoose exactly which Gmail labels or Outlook folders to sync on your workspace. This gives you more control over your email data and better privacy by importing only the folders you need.\n\n![](/images/releases/1.12/1.12.0-folder-sync.webp)\n"
},
{
"slug": "1.11.0",
"date": "2025-11-18",
"release": "1.11.0",
"title": "Unlisted Views",
"previewImage": "/images/releases/1.11/1.11.0-unlisted-views.webp",
"content": "\n# Unlisted Views\n\nYou can now create personal views that stay out of the shared Workspace section and appear instead under a separate **My unlisted views** section in the view picker. These unlisted views are only listed for their creator, but can still be opened by teammates via direct link.\n\n![](/images/releases/1.11/1.11.0-unlisted-views.webp)\n\n# Morph Many Relationships\n\nCreate flexible relationships where a single field can connect to multiple different object types. For example, an Opportunity can now relate to either a Person or a Company, giving you more versatile data modeling capabilities.\n\n![](/images/releases/1.11/1.11.0-morph-relations.webp)\n"
},
{
"slug": "1.10.0",
"date": "2025-11-11",
"release": "1.10.0",
"title": "Calendar View for Objects",
"previewImage": "/images/releases/1.10/1.10.0-calendar.webp",
"content": "\n# Calendar View for Objects\n\nYou can now visualize your records in a monthly calendar view. This new view type makes it easy to track time-based data like events, deadlines, and scheduled activities directly within any object.\n\n![](/images/releases/1.10/1.10.0-calendar.webp)\n\n# Dashboards in Labs (Beta)\n\nCreate custom charts and visualizations using your workspace data with the new Dashboards feature. Available in Labs, this beta feature lets you build powerful analytics and insights to monitor your business metrics.\n\n![](/images/releases/1.10/1.10.0-dashboards.webp)\n"
},
{
"slug": "1.8.0",
"date": "2025-10-16",
"release": "1.8.0",
"title": "Workflow Iterator Node",
"previewImage": "/images/releases/1.8/1.8-workflow-iterator.webp",
"content": "\n# Workflow Iterator Node\n\nYou can now loop through items in your workflows using the new iterator node. This powerful feature allows you to process multiple records sequentially, performing actions on each item in a collection.\n\n![](/images/releases/1.8/1.8-workflow-iterator.webp)\n\n# Workflow Bulk Select\n\nManual trigger workflows now support bulk selection, allowing you to select multiple records at once to pass into your workflow. This is particularly useful when combined with the iterator node to process several records in one workflow run.\n\n![](/images/releases/1.8/1.8-bulk-select.webp)\n\n# Workflow Search Node Limit\n\nThe search node now lets you customize the result limit above 1, enabling you to retrieve multiple records in a single search operation. This enhancement works seamlessly with the iterator node for processing search results.\n\n![](/images/releases/1.8/1.8-search-limit.webp)\n"
},
{
"slug": "1.7.0",
"date": "2025-10-02",
"release": "1.7.0",
"title": "User impersonation",
"previewImage": "/images/releases/1.7/1.7-impersonating.webp",
"content": "\n# User impersonation\n\nYou can now impersonate a workspace user as an admin. This allows you to see the workspace as that user would, which is useful for troubleshooting issues or understanding user experience.\n\n![](/images/releases/1.7/1.7-impersonating.webp)\n\n# Record is created or updated trigger\n\nYou can now trigger workflows when a record is created or updated.\n\n![](/images/releases/1.7/1.7-upsert.webp)\n"
},
{
"slug": "1.6.0",
"date": "2025-09-19",
"release": "1.6.0",
"title": "Workflow improvements",
"previewImage": "/images/releases/1.6/1.6-workflows-improvements.webp",
"content": "\n# Workflow improvements\n\nYou now have the ability to duplicate nodes, change node types, and use a streamlined filter design in your workflows.\n\n![](/images/releases/1.6/1.6-workflows-improvements.webp)\n"
},
{
"slug": "1.5.0",
"date": "2025-09-11",
"release": "1.5.0",
"title": "Workflow branches",
"previewImage": "/images/releases/1.5/1.5-workflow-branches.webp",
"content": "\n# Workflow branches\n\nWorkflow branches allow workflows to split paths, enabling conditional logic and multiple outcome flows in automation.\n\n![](/images/releases/1.5/1.5-workflow-branches.webp)\n"
},
{
"slug": "1.4.0",
"date": "2025-08-29",
"release": "1.4.0",
"title": "Field Level Permission",
"previewImage": "/images/releases/1.4/1.4-field-permissions.webp",
"content": "\n# Field Level Permission\n\nYou can now control which fields a role can view or edit. This adds more granular access control on top of existing object-level permissions.\n\n![](/images/releases/1.4/1.4-field-permissions.webp)\n\n# Workflow Filters\n\nAdd filters between workflow steps with conditions and only let data continue if it meets the criteria you define.\n\n![](/images/releases/1.4/1.4-workflow-filters.webp)\n\n# Two Factor Authentication\n\nEnabled two-factor authentication with authenticator apps like 1Password, Authy, or Microsoft Authenticator to add an extra layer of security at sign-in.\n\n![](/images/releases/1.4/1.4-two-factor-auth.webp)\n"
},
{
"slug": "1.3.0",
"date": "2025-08-11",
"release": "1.3.0",
"title": "IMAP",
"previewImage": "/images/releases/1.3/1.3-IMAP.webp",
"content": "\n# IMAP\n\nWeve added IMAP support to let you connect email accounts for receiving messages. You can also set up SMTP to send emails and CalDAV to sync calendars.\n\n![](/images/releases/1.3/1.3-IMAP.webp)\n\n# Merge Records\n\nThis feature gives you the ability to merge two records into one. This combines their information and keeps linked data, helping you remove duplicates and keep records accurate.\n\n![](/images/releases/1.3/1.3-merge.webp)\n"
},
{
"slug": "1.2.0",
"date": "2025-07-25",
"release": "1.2.0",
"title": "Import Relations",
"previewImage": "/images/releases/1.2/1.2-import-relations.webp",
"content": "\n# Import Relations\n\nWhen importing records, you can now import relations between records. For example, you can import a CSV file that includes a column for related records, such as linking contacts to companies or tasks to projects.\n![](/images/releases/1.2/1.2-import-relations.webp)\n\n# Any Field filter\n\nWeve added an \"any field search\" filter that lets you search across all fields at once. For example, it can allow you to locate a customer by their phone number, whether it's stored in the \"Mobile,\" \"Office,\" or \"Direct Line\" field.\n\n![](/images/releases/1.2/1.2-any-fields.webp)\n"
},
{
"slug": "1.1.0",
"date": "2025-07-10",
"release": "1.1.0",
"title": "Multi-Record Workflow Triggers",
"previewImage": "/images/releases/1.1/1.1-multi-manual-trigger.webp",
"content": "\n# Multi-Record Workflow Triggers\n\nYou can now run workflows on many records at once. Previously, manual workflows could only be triggered one record at a time. This change improves productivity for bulk operations.\n\nWith the ability to trigger workflows on many records, you can now:\n\nSend bulk emails to selected contacts\n\nUpdate many records with the same workflow logic\n\nProcess batches of data more efficiently\n\n![](/images/releases/1.1/1.1-multi-manual-trigger.webp)\n"
},
{
"slug": "1.00.0",
"date": "2025-06-25",
"release": "1.00.0",
"title": "Permissions V2",
"previewImage": "/images/releases/1.00/1.00-permissions.webp",
"content": "\n# Permissions V2\n\nCreate and manage custom roles. Grant or revoke access for each object to Read/Create/Edit/Delete records. Give granular access to settings like the ability to manage users, data models or APIs.\n\n![](/images/releases/1.00/1.00-permissions.webp)\n\n# Workflows\n\nIntroducing workflows, a powerful way to let you automate actions with form triggers, conditions, HTTP requests, webhooks, and serverless functions!\n\n![](/images/releases/1.00/1.00-workflow.webp)\n\n# Import V2\n\nCSV import now supports 2,000+ rows, sub-fields such as labels and secondary phone numbers, and automatic field matching. Validations, layout, and upserts are improved for smoother, more accurate imports.\n\n![](/images/releases/1.00/1.00-import-update.webp)\n\n# Sub-field Filtering\n\nSub-field filtering is now supported for currency, address, name, email, link, phone, and actor fields. For example, you can filter by the amount in a currency without needing the full field.\n\n![](/images/releases/1.00/1.00-subfield-filtering.webp)\n\n# Performance Improvements\n\nWeve cut key load and interaction times by over 3,000ms, which means pages now load 2x faster!\n\n![](/images/releases/1.00/1.00-performance-improvement.webp)\n"
},
{
"slug": "0.52.0",
"date": "2025-04-25",
"release": "0.52.0",
"title": "Add records to filtered views",
"previewImage": "/images/releases/0.52.0/0.52-filtered-views-records.webp",
"content": "\n# Add records to filtered views\n\nCreating records on filtered views now applies the view filter to the newly created record. This feature is compatible with Text, Date_Time, Date, Number, Select, Rating, Multi_Select, Array, and Boolean fields.\n\n![](/images/releases/0.52.0/0.52-filtered-views-records.webp)\n\n# Custom date formats\n\nChoose how to display dates in any field using a universal Unicode date format that best suits your use case.\n\n![](/images/releases/0.52.0/0.52-custom-date-format.webp)\n\n# Other improvements\n\nA new breadcrumb navigation across the app, improved keyboard menu navigation, and a new focus state for table views.\n"
},
{
"slug": "0.51.0",
"date": "2025-03-27",
"release": "0.51.0",
"title": "Revamp View Options Menu",
"previewImage": "/images/releases/0.51.0/0.51-options-menu.webp",
"content": "\n# Revamp View Options Menu\n\nYou can now rename a view and change its type between kanban or table directly from the view options menu located to the right of the filter and sort buttons.\n\n![](/images/releases/0.51.0/0.51-options-menu.webp)\n"
},
{
"slug": "0.50.0",
"date": "2025-03-27",
"release": "0.50.0",
"title": "Permissions V1",
"previewImage": "/images/releases/0.50/0.50-permissions.webp",
"content": "\n# Permissions V1\n\nAbility to set User and Admin permissions for each user. Admins can edit workspace settings, while Users can only edit records. Custom permission creation will be available in a future update.\n\n![](/images/releases/0.50/0.50-permissions.webp)\n\n# Advanced filters\n\nAdvanced filter enables precise database content filtering through nested conditional operators (AND/OR), multiple field filters, and customizable filter groups for complex query construction.\n\n![](/images/releases/0.50/0.50-advanced-filters.webp)\n"
},
{
"slug": "0.44.0",
"date": "2025-03-17",
"release": "0.44.0",
"title": "New Side Panel",
"previewImage": "/images/releases/0.44/0.44-side-panel.webp",
"content": "\n# New Side Panel\n\n**Quick Access to Records**: Side panel lets you view and edit records without leaving your current page.\n\n**Keyboard Navigation**: Navigate the side panel with keyboard for better accessibility and faster workflows.\n\n**Pinned Actions**: More actions now displayed directly in the navbar for easier access.\n\n![](/images/releases/0.44/0.44-side-panel.webp)\n\n# Admin Panel\n\n**App Health Check**: Added a health check feature to the admin panel. Now shows real-time status and key metrics.\n\n**Environment Variables**: Admin panel now has read-only access to environment variables. Better transparency, easier config management.\n\n![](/images/releases/0.44/0.44-admin-panel.webp)\n"
},
{
"slug": "0.43.0",
"date": "2025-03-04",
"release": "0.43.0",
"title": "Upgraded Search",
"previewImage": "/images/releases/0.43.0/search-upgrade.webp",
"content": "\n# Upgraded Search\n\nThe search feature now includes ranking scores. This helps you find the most relevant results faster.\n\n![](/images/releases/0.43.0/search-upgrade.webp)\n\n# Internal Email Privacy\n\nInternal team emails won't sync, protecting privacy by preventing access to internal discussions.\n\n![](/images/releases/0.43.0/email-privacy.webp)\n"
},
{
"slug": "0.42.0",
"date": "2025-02-18",
"release": "0.42.0",
"title": "Microsoft o365 Integration",
"previewImage": "/images/releases/0.42/0.42-microsoft.webp",
"content": "\n# Microsoft o365 Integration\n\nYou can now link your Microsoft account to easily manage your messages and events right within your workspace.\n\n![](/images/releases/0.42/0.42-microsoft.webp)\n\n# Translation in 30+ Languages\n\nExpanded support for 30+ languages, so users can navigate and use the software in their preferred language.\n\n![](/images/releases/0.42/0.42-translation.webp)\n\n# Attachment Visualizer\n\nYou can now preview file attachments without downloading them.\n\n![](/images/releases/0.42/0.42-document-viewer.webp)\n"
},
{
"slug": "0.41.0",
"date": "2025-02-04",
"release": "0.41.0",
"title": "Labs",
"previewImage": "/images/releases/0.41/0.41-labs.webp",
"content": "\n# Labs\n\nEnable beta features using the new Labs tab in settings. The first beta release introduces our workflow engine. Enjoy!\n\n![](/images/releases/0.41/0.41-labs.webp)\n"
},
{
"slug": "0.40.0",
"date": "2025-01-17",
"release": "0.40.0",
"title": "View Groups",
"previewImage": "/images/releases/0.40/0.40-group-by.webp",
"content": "\n# View Groups\n\nAdded \"Group By\" in tables to better organize entries, like grouping companies by industry for clearer data visualization. This feature is available in the table Options menu.\n\n![](/images/releases/0.40/0.40-group-by.webp)\n\n# Aggregates\n\nIntroduced a feature to calculate and display data summaries, such as sums and latest entries, for quick insights and streamlined data analysis.\n\n![](/images/releases/0.40/0.40-aggregates.webp)\n"
},
{
"slug": "0.35.0",
"date": "2024-12-20",
"release": "0.35.0",
"title": "Favorites Views and Favorites Folders",
"previewImage": "/images/releases/0.35/0.35-Favorites.webp",
"content": "\n# Favorites Views and Favorites Folders\n\nYou can now add your views to favorites for quick access and organize your favorites into folders for better management.\n\n![](/images/releases/0.35/0.35-Favorites.webp)\n"
},
{
"slug": "0.34.0",
"date": "2024-12-12",
"release": "0.34.0",
"title": "Customize your sub-domain",
"previewImage": "/images/releases/0.34/0.34-subdomains.webp",
"content": "\n# Customize your sub-domain\n\nEach workspace now gets a dedicated sub-domain for a more secure experience. And soon you will be able to set your own domain.\n\n![](/images/releases/0.34/0.34-subdomains.webp)\n"
},
{
"slug": "0.33.0",
"date": "2024-11-21",
"release": "0.33.0",
"title": "Filter by Multi-Select",
"previewImage": "/images/releases/0.33/0.33-multiselect-filter.webp",
"content": "\n# Filter by Multi-Select\n\nYou can now filter an object (People, Companies, Opportunities, etc.) using any multiselect field.\n\n![](/images/releases/0.33/0.33-multiselect-filter.webp)\n\n# Percentage in number fields\n\nYou can now create number fields that display a percentage instead of a regular number.\n\n![](/images/releases/0.33/0.33-percentage-number.webp)\n"
},
{
"slug": "0.32.0",
"date": "2024-11-12",
"release": "0.32.0",
"title": "Smart ⌘K",
"previewImage": "/images/releases/0.32/0.32-improved-cmdk.webp",
"content": "\n# Smart ⌘K\n\nWe started a major ⌘K revamp that now understands the context to display appropriate actions. For example, on a record page, you can add the record as a favorite, or if you select multiple records on an index, you can export them as a CSV.\n\n![](/images/releases/0.32/0.32-improved-cmdk.webp)\n\n# Webhooks multi-object filtering\n\nYou can now filter multiple actions simultaneously with a single webhook. For example, you can create a webhook that triggers only when a person or company is updated or created.\n\n![](/images/releases/0.32/0.32-webhooks.webp)\n"
},
{
"slug": "0.31.0",
"date": "2024-10-07",
"release": "0.31.0",
"title": "Advanced Settings",
"previewImage": "/images/releases/0.31/0.31-advanced-settings.webp",
"content": "\n# Advanced Settings\n\nTo maintain the simplicity of Twenty, we are introducing \"Advanced Settings.\" This option consolidates all settings intended for advanced use cases, often preferred by developers, such as API and function settings or security settings.\n\n![](/images/releases/0.31/0.31-advanced-settings.webp)\n\n# More powerful search\n\nWe have significantly enhanced our search performance, making it feel instantaneous when searching for records such as people, companies, or tasks.\n\n![](/images/releases/0.31/0.31-search.webp)\n"
},
{
"slug": "0.30.0",
"date": "2024-09-18",
"release": "0.30.0",
"title": "New Settings layout",
"previewImage": "/images/releases/0.30/0.30-new-settings.webp",
"content": "\n# New Settings layout\n\nExperience a more compact and intuitive settings layout, now featuring a breadcrumb navigation for easier access and better organization.\n\n![](/images/releases/0.30/0.30-new-settings.webp)\n\n# Add Several emails for one contact\n\nEnhance your contact management by adding many email addresses for a single contact. All emails sent to these addresses will be automatically synced with the contact, ensuring you never miss an important communication.\n\n![](/images/releases/0.30/0.30-emails.webp)\n\n# New Array field type\n\nDevelopers can now take advantage of the new array field type to store non-predefined values.\n\n![](/images/releases/0.30/0.30-array-field.webp)\n"
},
{
"slug": "0.24.0",
"date": "2024-08-29",
"release": "0.24.0",
"title": "Soft Delete",
"previewImage": "/images/releases/0.24/0.24-soft-delete.webp",
"content": "\n# Soft Delete\n\nSoft delete feature added: Deleted records are now hidden from view but recoverable from the \"Deleted record\" option in any object options menu. No more drama!\n\n![](/images/releases/0.24/0.24-soft-delete.webp)\n"
},
{
"slug": "0.23.0",
"date": "2024-08-01",
"release": "0.23.0",
"title": "Notes and Tasks standard objects",
"previewImage": "/images/releases/0.23/0.23-notes-tasks.webp",
"content": "\n# Notes and Tasks standard objects\n\nLike any regular object, add some custom fields to your tasks and companies or create some custom views to better organize your content.\n\n![](/images/releases/0.23/0.23-notes-tasks.webp)\n\n# Created By\n\nQuickly identify who created a given record and what was the origin of the creation, whether it was through a CSV import, an API, or manual input.\n\n![](/images/releases/0.23/0.23-created-by.webp)\n\n# Webhooks filter\n\nFilter the content a webhook is returning so it only pings your URL when a specific action occurs, such as on creating a company.\n\n![](/images/releases/0.23/0.23-filter-webhooks.webp)\n"
},
{
"slug": "0.22.0",
"date": "2024-07-11",
"release": "0.22.0",
"title": "Enhanced Kanban Board",
"previewImage": "/images/releases/0.22/0.22-kanban-improvements.webp",
"content": "\n# Enhanced Kanban Board\n\n**Edit Kanban Stages:** You can now edit Kanban stages directly from the app, not just from the settings. This makes it easier to manage and customize your workflow on the fly.\n\n**\"No Value\" Column:** Cards that are not assigned to a specific value will now appear in a \"No Value\" column. This column can be shown or hidden as needed, ensuring no cards are overlooked.\n\n![](/images/releases/0.22/0.22-kanban-improvements.webp)\n\n# Revamped Navigation Bar\n\nNavigate more quickly with our revamped record page navbar:\n\nNavigate directly from one record page to another.\n\nView the total number of records within a view.\n\nEasily return to the corresponding index view with a new \"Close\" button.\n\n![](/images/releases/0.22/0.22-navbar.webp)\n\n# Bulk Deletion\n\nYou can now delete up to 10,000 records at once. (For when you want to Marie Kondo your database! 🧹)\n\n![](/images/releases/0.22/0.22-mass-deletion.webp)\n"
},
{
"slug": "0.21.0",
"date": "2024-06-28",
"release": "0.21.0",
"title": "Enhanced One-to-Many Relations Editing",
"previewImage": "/images/releases/0.21/0.21-many-many.webp",
"content": "\n# Enhanced One-to-Many Relations Editing\n\nYou can now edit one-to-many relations directly from the \"many side\". This means you can assign people to a company directly from the company list view, instead of having to navigate to each individual person's profile to assign them a company.\n\n![](/images/releases/0.21/0.21-many-many.webp)\n\n# Advanced Email and Calendar Settings\n\nWe've introduced advanced settings for email and calendar management:\n\n**Auto-Create Contact Options:** Choose when an email interaction should automatically create a contact. Options include:\n\nPeople I've sent emails to and received emails from\n\nPeople I've sent emails to\n\nDon't auto create contact\n\n**Email Exclusions:** Ability to exclude non-professional emails (e.g., Gmail, Outlook) and team emails (e.g., support@, team@) from being synced to the CRM.\n\n**Calendar Events:** Auto-contact creation settings are now available for calendar events as well.\n\n![](/images/releases/0.21/0.21-advanced-email-settings.webp)\n"
},
{
"slug": "0.20.0",
"date": "2024-06-14",
"release": "0.20.0",
"title": "Enhanced Timeline",
"previewImage": "/images/releases/0.20/0.20-timeline.webp",
"content": "\n# Enhanced Timeline\n\nThe timeline on every record page has been significantly improved. It now provides detailed updates for:\n\nRecord creations\n\nField updates\n\nReceived emails\n\nCreated calendar events\n\n![](/images/releases/0.20/0.20-timeline.webp)\n\n# Improved Onboarding Experience\n\nOur onboarding process has been streamlined to let you import your calendar and emails seamlessly. You can now also configure your privacy settings directly during onboarding, allowing you to choose between sharing content with your team or keeping it hidden.\n\n![](/images/releases/0.20/0.20-onboarding.webp)\n\n# Email and calendar Blocklist\n\nTo enhance privacy, you can now add specific email addresses to a blocklist within the \"Accounts\" settings. This feature prevents sensitive content from being synced to the CRM when corresponding with certain individuals. This can be particularly useful when managing sensitive deals.\n\n![](/images/releases/0.20/0.20-blocklist.webp)\n"
},
{
"slug": "0.12.0",
"date": "2024-05-24",
"release": "0.12.0",
"title": "Notifications",
"previewImage": "/images/releases/0.12/0.12-notifications.webp",
"content": "\n# Notifications\n\nIntroduced a new design for notifications featuring lighter colors.\n\n![](/images/releases/0.12/0.12-notifications.webp)\n\n# Skeleton Loading\n\nImplemented skeleton loading to improve user experience by displaying placeholder content while data is being fetched.\n\n![](/images/releases/0.12/0.12-loader.webp)\n\n# Link field\n\nIntroduced a new Link Field type to add and manage one or several external URLs on any object. Available in custom objects starting today.\n\n![](/images/releases/0.12/0.12-link-field.webp)\n\n# Data Model Diagram\n\nIntroduced a \"Data Model Diagram\" feature that allows users to visualize the relationships between different objects within the CRM.\n\n![](/images/releases/0.12/0.12-database-diagram.webp)\n"
},
{
"slug": "0.11.0",
"date": "2024-05-06",
"release": "0.11.0",
"title": "Google Calendar Integration",
"previewImage": "/images/releases/0.11/0.11-calendar.webp",
"content": "\n# Google Calendar Integration\n\nWith Google Calendar integration, you can track all your team's events with a company or person in your CRM. Choose the information level visible to your teammates for better control.\n\n![](/images/releases/0.11/0.11-calendar.webp)\n\n# Improved Performance\n\nWe have improved app performance, shaving off over 500ms on each page.\n\n![](/images/releases/0.11/0.11-speed.webp)\n"
},
{
"slug": "0.10.0",
"date": "2024-04-15",
"release": "0.10.0",
"title": "More Field Types, More Power",
"previewImage": "/images/releases/0.10/0.10-multi-select.webp",
"content": "\n# More Field Types, More Power\n\nEnhance your data handling capabilities with the addition of four new field types:\n\n## Multi-Select Field\n\nThe `Multi-Select Field` allows for tagging a record with multiple attributes, providing a flexible way to classify and filter data.\n\n**Example Use Case**: Tag a company record with multiple industries such as \"Retail,\" \"Technology,\" and \"Finance,\" enabling more nuanced segmentation and analysis.\n\n![](/images/releases/0.10/0.10-multi-select.webp)\n\n## Currency Field\n\nDesigned specifically for financial data, the `Currency Field` ensures correct calculation and standard formatting for monetary figures.\n\n**Example Use Case**: Record and manage global transactions in their original currencies while maintaining precision in financial reporting.\n\n![](/images/releases/0.10/0.10-currency.webp)\n\n## Datetime Field\n\nWith the `Datetime Field`, you can effectively track events, deadlines, and activities with enhanced accuracy, improving time management and scheduling.\n\n**Example Use Case**: Precisely track and set reminders for project milestones and deadlines.\n\n![](/images/releases/0.10/0.10-datetime.webp)\n\n## JSON Field\n\nThe `JSON Field` allows for the storage of complex, structured data within a single field, thus expanding the capabilities for data customisation and integration.\n\n**Example Use Case**: Store configurable data for a product, such as feature flags or customization options, directly within a CRM record.\n\n![](/images/releases/0.10/0.10-json.webp)\n"
},
{
"slug": "0.4.0",
"date": "2024-04-01",
"release": "0.4.0",
"title": "Relation Fields on Record Page",
"previewImage": "/images/releases/0.4/0.4-expand-relation-card.webp",
"content": "\n# Relation Fields on Record Page\n\nOn record pages, you can now expand relation cards to view their fields without navigating to their individual record pages. For example, on a Company record page, you can expand an employee card to view their job title. This feature applies to all types of objects, including custom ones.\n\n![](/images/releases/0.4/0.4-expand-relation-card.webp)\n\n# Address Field Type\n\nThe new `Address Field` Type enables entry of a full address in one field, while structurally storing each address part - such as street name and number - in separate subfields.\n\n![](/images/releases/0.4/0.4-address-field-type.webp)\n\n# Multi-Workspace\n\nYou can now switch between workspaces by clicking your workspace name at the top left of the screen. This feature will only appear if you have been invited to join another workspace.\n\n![](/images/releases/0.4/0.4-multi-workspace.webp)\n"
},
{
"slug": "0.3.3",
"date": "2024-03-19",
"release": "0.3.3",
"title": "Gmail integration",
"previewImage": "/images/releases/0.3.3_emails.webp",
"content": "\n# Gmail integration\n\nConnect your Gmail account to automatically associate emails with relevant 'People' and 'Companies'. Contacts you've emailed will be automatically added to 'People', excluding non-personal emails like support@ and team@. Control your privacy by selecting the information you share with your team.\n\n![](/images/releases/0.3.3_emails.webp)\n\n# Kanbans on any object\n\nCreate a Kanban view on any object and streamline processes like recruitment or onboarding.\n\n![](/images/releases/0.3.3_kanban.webp)\n\n# Self-Onboarding\n\nWe are pleased to reopen the cloud subscription to everyone. No more waiting!\n\n![](/images/releases/0.3.3_sign_up.webp)\n"
},
{
"slug": "0.3.2",
"date": "2024-02-29",
"release": "0.3.2",
"title": "New record page",
"previewImage": "/images/releases/0.3.2_new_layout.webp",
"content": "\n# New record page\n\nThe record page now features a clearer layout with increased space for content\n\n![](/images/releases/0.3.2_new_layout.webp)\n"
},
{
"slug": "0.3.1",
"date": "2024-02-16",
"release": "0.3.1",
"title": "Contributors page",
"previewImage": "/images/releases/0.3.1_contributors.webp",
"content": "\n# Contributors page\n\nContributors now have their very own [hall of fame](https://twenty.com/contributors).\n\n![rating](/images/releases/0.3.1_contributors.webp)\n"
},
{
"slug": "0.3.0",
"date": "2024-02-03",
"release": "0.3.0",
"title": "Rating field",
"previewImage": "/images/releases/0.3.0_rating.webp",
"content": "\n# Rating field\n\nThe new Rating field represents a numeric value from zero to five, it can be useful for various use-cases such as scoring leads.\n\n![rating](/images/releases/0.3.0_rating.webp)\n"
},
{
"slug": "0.2.3",
"date": "2024-01-17",
"release": "0.2.3",
"title": "Webhooks",
"previewImage": "/images/releases/0.2.3_webhooks.webp",
"content": "\n# Webhooks\n\nDevelopers can now use webhooks to synchronize customer data updates in real-time across applications.\n\n![Webhooks](/images/releases/0.2.3_webhooks.webp)\n\n# Relations on Record Pages\n\nYou can now navigate from one object to another directly from the record detail page.\n\n![Webhooks](/images/releases/0.2.3_relations.webp)\n"
}
] as LocalReleaseNote[];
@@ -2,9 +2,6 @@ import { loadLocalReleaseNotes } from '@/lib/releases/load-local-release-notes';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
const IMAGE_REGEX_GLOBAL = /!\[[^\]]*\]\(([^)]+)\)/g;
const HEADING_REGEX_GLOBAL = /^#\s+(.+)$/gm;
export type LatestReleasePreview = {
image: string;
imageAlt: string;
@@ -20,24 +17,9 @@ export function getLatestReleasePreview(): LatestReleasePreview | null {
return null;
}
const imageMatches = [...latest.content.matchAll(IMAGE_REGEX_GLOBAL)];
const imageMatch = imageMatches[0];
const image = imageMatch?.[1];
if (!image) {
return null;
}
const imageIndex = imageMatch.index ?? latest.content.length;
const headingMatches = [...latest.content.matchAll(HEADING_REGEX_GLOBAL)];
const headingMatch = [...headingMatches]
.reverse()
.find((match) => (match.index ?? -1) < imageIndex);
const featureTitle = headingMatch?.[1]?.trim();
return {
image,
imageAlt: `Twenty release ${latest.release}${featureTitle ? `${featureTitle}` : ''}`,
image: latest.previewImage,
imageAlt: `Twenty release ${latest.release}${latest.title}`,
imageScale: 1.04,
title: msg`See what shipped in ${latest.release}`,
description: msg`Track every release with changelogs, highlights and demos of the newest features.`,
@@ -1,79 +1,6 @@
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import type { LocalReleaseNote } from '@/lib/releases/types';
import { compareSemanticVersions } from '@/lib/releases/compare-semantic-versions';
function normalizeFrontmatterDate(dateValue: unknown): string {
if (typeof dateValue === 'string') {
return dateValue;
}
if (dateValue instanceof Date && !Number.isNaN(dateValue.getTime())) {
return dateValue.toISOString().slice(0, 10);
}
return '';
}
function resolveReleasesDirectory(): string | null {
const candidates = [
path.join(process.cwd(), 'src', 'content', 'releases'),
path.join(
process.cwd(),
'packages',
'twenty-website',
'src',
'content',
'releases',
),
];
for (const directoryPath of candidates) {
if (fs.existsSync(directoryPath)) {
return directoryPath;
}
}
return null;
}
import { GENERATED_RELEASE_NOTES } from './generated-release-notes';
import type { LocalReleaseNote } from './types';
export function loadLocalReleaseNotes(): LocalReleaseNote[] {
const directoryPath = resolveReleasesDirectory();
if (!directoryPath) {
return [];
}
const fileNames = fs.readdirSync(directoryPath);
const notes: LocalReleaseNote[] = [];
for (const fileName of fileNames) {
if (!fileName.endsWith('.md') && !fileName.endsWith('.mdx')) {
continue;
}
const fullPath = path.join(directoryPath, fileName);
const raw = fs.readFileSync(fullPath, 'utf-8');
const { data, content } = matter(raw);
const dateValue = data.Date ?? data.date;
const releaseValue = data.release;
const date = normalizeFrontmatterDate(dateValue);
const release = typeof releaseValue === 'string' ? releaseValue : '';
if (!release) {
continue;
}
notes.push({
slug: fileName.replace(/\.mdx?$/i, ''),
date,
release,
content,
});
}
notes.sort((a, b) => compareSemanticVersions(b.release, a.release));
return notes;
return GENERATED_RELEASE_NOTES;
}
@@ -1,6 +1,8 @@
export type LocalReleaseNote = {
content: string;
date: string;
previewImage: string;
release: string;
slug: string;
title: string;
};
@@ -106,12 +106,16 @@ describe('buildReleaseListJsonLd', () => {
slug: '1.18.0',
release: '1.18.0',
date: '2026-04-01',
previewImage: '/images/releases/1.18/1.18.0-sidebar-items.webp',
title: 'Highlight one',
content: '# Highlight one\n\nBody text\n\n# Highlight two\n',
},
{
slug: '1.17.0',
release: '1.17.0',
date: '2026-03-15',
previewImage: '/images/releases/1.17/1.17.0-ai-chat.webp',
title: 'Real headline',
content: '## Smaller heading\n\n# Real headline\n',
},
];
@@ -138,17 +142,21 @@ describe('buildReleaseListJsonLd', () => {
});
});
it('falls back to "Twenty <release>" as the headline when the body has no h1', () => {
it('uses the typed release title instead of scraping the markdown body', () => {
const data = buildReleaseListJsonLd([
{
slug: '0.1.0',
release: '0.1.0',
date: '2025-01-01',
previewImage: '/images/releases/0.10/0.10-json.webp',
title: 'Structured release title',
content: 'Just paragraphs, no headings.\n',
},
]) as { itemListElement: Array<Record<string, { headline: string }>> };
expect(data.itemListElement[0].item.headline).toBe('Twenty 0.1.0');
expect(data.itemListElement[0].item.headline).toBe(
'Structured release title',
);
});
it('omits datePublished when the frontmatter date is missing', () => {
@@ -157,6 +165,8 @@ describe('buildReleaseListJsonLd', () => {
slug: '0.2.0',
release: '0.2.0',
date: '',
previewImage: '/images/releases/0.10/0.10-currency.webp',
title: 'Headline',
content: '# Headline\n',
},
]) as {
@@ -79,14 +79,6 @@ export const buildFaqPageJsonLd = (
})),
});
const extractReleaseHeadline = (note: LocalReleaseNote): string => {
const match = note.content.match(/^\s*#\s+(.+?)\s*$/m);
if (match && match[1]) {
return match[1].trim();
}
return `Twenty ${note.release}`;
};
export const buildReleaseListJsonLd = (
notes: readonly LocalReleaseNote[],
): JsonLdValue => {
@@ -107,7 +99,7 @@ export const buildReleaseListJsonLd = (
item: {
'@type': 'TechArticle',
'@id': `${releasesUrl}#${note.release}`,
headline: extractReleaseHeadline(note),
headline: note.title,
name: `Twenty ${note.release}`,
url: `${releasesUrl}#${note.release}`,
...(note.date ? { datePublished: note.date } : {}),