Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/cli/commands/cover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export function registerCoverCommand(program: Command) {
size: (opts.size as ImageSize) ?? config.image.size,
style: config.image.style,
coverField: config.markdown.coverField,
preserveFrontmatterOrder: config.markdown.preserveFrontmatterOrder,
aiProvider,
imageProvider,
storageProvider: opts.generate ? storageProvider : undefined,
Expand Down
1 change: 1 addition & 0 deletions src/cli/commands/fix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export function registerFixCommand(program: Command) {
draft,
updates,
apply: opts.apply ?? false,
preserveFrontmatterOrder: config.markdown.preserveFrontmatterOrder,
});
}

Expand Down
1 change: 1 addition & 0 deletions src/cli/commands/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export function registerPrepareCommand(program: Command) {
size: config.image.size as ImageSize,
style: config.image.style,
coverField: config.markdown.coverField,
preserveFrontmatterOrder: config.markdown.preserveFrontmatterOrder,
aiProvider,
imageProvider,
storageProvider,
Expand Down
1 change: 1 addition & 0 deletions src/cli/commands/seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export function registerSeoCommand(program: Command) {
const result = await optimizeSeoWorkflow(file, {
apply: opts.apply,
aiProvider,
preserveFrontmatterOrder: config.markdown.preserveFrontmatterOrder,
});

if (opts.json) {
Expand Down
8 changes: 6 additions & 2 deletions src/tools/update-frontmatter/updateFrontmatterTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ export async function updateFrontmatterTool(input: {
draft: BlogDraft;
updates: Record<string, unknown>;
apply: boolean;
preserveFrontmatterOrder?: boolean;
}): Promise<DraftPatch> {
const { draft, updates, apply } = input;
const { draft, updates, apply, preserveFrontmatterOrder = true } = input;

const updatedFrontmatter = { ...draft.frontmatter };
const changes: DraftChange[] = [];
Expand All @@ -27,7 +28,10 @@ export async function updateFrontmatterTool(input: {
}
}

const updatedContent = stringifyFrontmatter(updatedFrontmatter, draft.markdownBody);
const updatedContent = stringifyFrontmatter(updatedFrontmatter, draft.markdownBody, {
preserveFrontmatterOrder,
originalRawContent: draft.rawContent,
});
const { diff } = generateDiffTool({
original: draft.rawContent,
updated: updatedContent,
Expand Down
73 changes: 71 additions & 2 deletions src/utils/frontmatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,75 @@ export function parseFrontmatter(rawContent: string): ParsedFrontmatter {
}
}

export function stringifyFrontmatter(frontmatter: BlogFrontmatter, body: string): string {
return matter.stringify(body, frontmatter);
function extractTopLevelFrontmatterKeys(rawContent: string): string[] {
const parsed = matter(rawContent);
const rawFrontmatter = (parsed as { matter?: string }).matter;
if (!rawFrontmatter) {
return [];
}

const keys: string[] = [];
const seen = new Set<string>();

for (const line of rawFrontmatter.split('\n')) {
if (/^\s/.test(line)) {
continue;
}

const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
continue;
}

const match = line.match(/^([^:#][^:]*?):(?:\s|$)/);
if (!match) {
continue;
}

const key = match[1].trim().replace(/^['"]|['"]$/g, '');
if (key && !seen.has(key)) {
seen.add(key);
keys.push(key);
}
}

return keys;
}

function orderFrontmatterByOriginalKeys(
frontmatter: BlogFrontmatter,
originalKeys: string[],
): BlogFrontmatter {
const ordered = Object.create(null) as BlogFrontmatter;

for (const key of originalKeys) {
if (Object.prototype.hasOwnProperty.call(frontmatter, key)) {
ordered[key] = frontmatter[key];
}
}

for (const [key, value] of Object.entries(frontmatter)) {
if (!Object.prototype.hasOwnProperty.call(ordered, key)) {
ordered[key] = value;
}
}

return ordered;
}

export function stringifyFrontmatter(
frontmatter: BlogFrontmatter,
body: string,
options?: { preserveFrontmatterOrder?: boolean; originalRawContent?: string },
): string {
if (!options?.preserveFrontmatterOrder || !options.originalRawContent) {
return matter.stringify(body, frontmatter);
}

const originalKeys = extractTopLevelFrontmatterKeys(options.originalRawContent);
if (originalKeys.length === 0) {
return matter.stringify(body, frontmatter);
}

return matter.stringify(body, orderFrontmatterByOriginalKeys(frontmatter, originalKeys));
}
2 changes: 2 additions & 0 deletions src/workflows/generateAndAttachCoverWorkflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export type GenerateAndAttachCoverOptions = {
size: ImageSize;
style?: string;
coverField: CoverFieldName;
preserveFrontmatterOrder?: boolean;
aiProvider: TextAIProvider;
imageProvider?: ImageGenerationProvider;
storageProvider?: AssetStorageProvider;
Expand Down Expand Up @@ -134,6 +135,7 @@ export async function generateAndAttachCoverWorkflow(
draft,
updates: { [options.coverField]: coverUrl },
apply: options.apply,
preserveFrontmatterOrder: options.preserveFrontmatterOrder,
});
}

Expand Down
9 changes: 7 additions & 2 deletions src/workflows/optimizeSeoWorkflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export type OptimizeSeoResult = {

export async function optimizeSeoWorkflow(
filePath: string,
options: { apply?: boolean; aiProvider?: TextAIProvider },
options: { apply?: boolean; aiProvider?: TextAIProvider; preserveFrontmatterOrder?: boolean },
): Promise<OptimizeSeoResult> {
const draft = await parseMarkdownTool({ filePath });
const { suggestions } = await reviewSeoTool({ draft }, options.aiProvider);
Expand All @@ -29,7 +29,12 @@ export async function optimizeSeoWorkflow(
}

if (Object.keys(updates).length > 0) {
patch = await updateFrontmatterTool({ draft, updates, apply: true });
patch = await updateFrontmatterTool({
draft,
updates,
apply: true,
preserveFrontmatterOrder: options.preserveFrontmatterOrder,
});
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/workflows/prepareDraftWorkflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export type PrepareDraftOptions = {
size: ImageSize;
style?: string;
coverField: CoverFieldName;
preserveFrontmatterOrder?: boolean;
aiProvider?: TextAIProvider;
imageProvider?: ImageGenerationProvider;
storageProvider?: AssetStorageProvider;
Expand Down Expand Up @@ -89,6 +90,7 @@ export async function prepareDraftWorkflow(
draft,
updates: { [options.coverField]: coverUrl },
apply: options.apply,
preserveFrontmatterOrder: options.preserveFrontmatterOrder,
});
}
}
Expand Down
82 changes: 82 additions & 0 deletions tests/tools/updateFrontmatterTool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,39 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import type { BlogDraft, BlogFrontmatter } from '../../src/core/types.js';
import { parseMarkdownTool } from '../../src/tools/parse-markdown/index.js';
import { updateFrontmatterTool } from '../../src/tools/update-frontmatter/index.js';

const fixture = (name: string) => path.join(import.meta.dirname, '..', 'fixtures', name);
const extractFrontmatterKeys = (content: string): string[] => {
const block = content.match(/^---\n([\s\S]*?)\n---/)?.[1];
if (!block) return [];

const keys: string[] = [];
for (const line of block.split('\n')) {
if (/^\s/.test(line)) continue;
if (!line.trim()) continue;
const key = line.match(/^([^:#][^:]*?):(?:\s|$)/)?.[1]?.trim();
if (key) keys.push(key);
}
return keys;
};

const createDraft = (frontmatter: BlogFrontmatter, rawContent: string): BlogDraft => ({
filePath: '/tmp/test-post.md',
rawContent,
frontmatter,
markdownBody: '\nBody\n',
metadata: {
tags: [],
headings: [],
links: [],
images: [],
wordCount: 1,
estimatedReadingTimeMinutes: 1,
},
});

describe('updateFrontmatterTool', () => {
it('generates a patch with updated fields', async () => {
Expand Down Expand Up @@ -66,6 +95,59 @@ describe('updateFrontmatterTool', () => {
expect(patch.updatedContent).toContain('slug: tanstack-query-vue');
expect(patch.updatedContent).toContain('description: Updated description.');
});

it('preserves original key order by default even when frontmatter object order differs', async () => {
const rawContent = `---
title: My title
description: My description
slug: my-slug
---
Body`;
const draft = createDraft(
{ slug: 'my-slug', title: 'My title', description: 'My description' },
rawContent,
);

const patch = await updateFrontmatterTool({
draft,
updates: { canonical: 'https://example.com/my-slug' },
apply: false,
});

expect(extractFrontmatterKeys(patch.updatedContent)).toEqual([
'title',
'description',
'slug',
'canonical',
]);
});

it('uses current object key order when preserveFrontmatterOrder is false', async () => {
const rawContent = `---
title: My title
description: My description
slug: my-slug
---
Body`;
const draft = createDraft(
{ slug: 'my-slug', title: 'My title', description: 'My description' },
rawContent,
);

const patch = await updateFrontmatterTool({
draft,
updates: { canonical: 'https://example.com/my-slug' },
apply: false,
preserveFrontmatterOrder: false,
});

expect(extractFrontmatterKeys(patch.updatedContent)).toEqual([
'slug',
'title',
'description',
'canonical',
]);
});
});

describe('updateFrontmatterTool — apply vs dry-run (disk I/O)', () => {
Expand Down
Loading