From 6e6e33c4baedb0267e287cd2696437e63a364010 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 11:07:19 +0000 Subject: [PATCH 1/4] Initial plan From 4672407ee46afba19dd8a0f0442b83a1225bd4ff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 11:15:41 +0000 Subject: [PATCH 2/4] fix: honor preserveFrontmatterOrder when updating frontmatter Agent-Logs-Url: https://github.com/mayashavin/ktavi/sessions/2881c632-fc51-4c10-9f54-040e61d197a0 --- src/cli/commands/cover.ts | 1 + src/cli/commands/fix.ts | 1 + src/cli/commands/prepare.ts | 1 + src/cli/commands/seo.ts | 1 + .../updateFrontmatterTool.ts | 8 +- src/utils/frontmatter.ts | 69 +++++++++++++++- .../generateAndAttachCoverWorkflow.ts | 2 + src/workflows/optimizeSeoWorkflow.ts | 9 ++- src/workflows/prepareDraftWorkflow.ts | 2 + tests/tools/updateFrontmatterTool.test.ts | 81 +++++++++++++++++++ 10 files changed, 169 insertions(+), 6 deletions(-) diff --git a/src/cli/commands/cover.ts b/src/cli/commands/cover.ts index 9ae7e1e..2ef123d 100644 --- a/src/cli/commands/cover.ts +++ b/src/cli/commands/cover.ts @@ -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, diff --git a/src/cli/commands/fix.ts b/src/cli/commands/fix.ts index 60a12fe..a051892 100644 --- a/src/cli/commands/fix.ts +++ b/src/cli/commands/fix.ts @@ -54,6 +54,7 @@ export function registerFixCommand(program: Command) { draft, updates, apply: opts.apply ?? false, + preserveFrontmatterOrder: config.markdown.preserveFrontmatterOrder, }); } diff --git a/src/cli/commands/prepare.ts b/src/cli/commands/prepare.ts index 6ea61e9..e560c81 100644 --- a/src/cli/commands/prepare.ts +++ b/src/cli/commands/prepare.ts @@ -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, diff --git a/src/cli/commands/seo.ts b/src/cli/commands/seo.ts index 677e956..85ad261 100644 --- a/src/cli/commands/seo.ts +++ b/src/cli/commands/seo.ts @@ -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) { diff --git a/src/tools/update-frontmatter/updateFrontmatterTool.ts b/src/tools/update-frontmatter/updateFrontmatterTool.ts index 24b2de1..0263492 100644 --- a/src/tools/update-frontmatter/updateFrontmatterTool.ts +++ b/src/tools/update-frontmatter/updateFrontmatterTool.ts @@ -7,8 +7,9 @@ export async function updateFrontmatterTool(input: { draft: BlogDraft; updates: Record; apply: boolean; + preserveFrontmatterOrder?: boolean; }): Promise { - const { draft, updates, apply } = input; + const { draft, updates, apply, preserveFrontmatterOrder = true } = input; const updatedFrontmatter = { ...draft.frontmatter }; const changes: DraftChange[] = []; @@ -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, diff --git a/src/utils/frontmatter.ts b/src/utils/frontmatter.ts index a8c5e9d..ef23ec1 100644 --- a/src/utils/frontmatter.ts +++ b/src/utils/frontmatter.ts @@ -27,6 +27,71 @@ 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(); + + for (const line of rawFrontmatter.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#') || /^\s/.test(line)) { + 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: BlogFrontmatter = {}; + + for (const key of originalKeys) { + if (key in frontmatter) { + ordered[key] = frontmatter[key]; + } + } + + for (const [key, value] of Object.entries(frontmatter)) { + if (!(key in ordered)) { + 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)); } diff --git a/src/workflows/generateAndAttachCoverWorkflow.ts b/src/workflows/generateAndAttachCoverWorkflow.ts index e099fd3..5421044 100644 --- a/src/workflows/generateAndAttachCoverWorkflow.ts +++ b/src/workflows/generateAndAttachCoverWorkflow.ts @@ -35,6 +35,7 @@ export type GenerateAndAttachCoverOptions = { size: ImageSize; style?: string; coverField: CoverFieldName; + preserveFrontmatterOrder?: boolean; aiProvider: TextAIProvider; imageProvider?: ImageGenerationProvider; storageProvider?: AssetStorageProvider; @@ -134,6 +135,7 @@ export async function generateAndAttachCoverWorkflow( draft, updates: { [options.coverField]: coverUrl }, apply: options.apply, + preserveFrontmatterOrder: options.preserveFrontmatterOrder, }); } diff --git a/src/workflows/optimizeSeoWorkflow.ts b/src/workflows/optimizeSeoWorkflow.ts index c796ba3..6c0bedd 100644 --- a/src/workflows/optimizeSeoWorkflow.ts +++ b/src/workflows/optimizeSeoWorkflow.ts @@ -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 { const draft = await parseMarkdownTool({ filePath }); const { suggestions } = await reviewSeoTool({ draft }, options.aiProvider); @@ -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, + }); } } diff --git a/src/workflows/prepareDraftWorkflow.ts b/src/workflows/prepareDraftWorkflow.ts index f8caa87..a742cbf 100644 --- a/src/workflows/prepareDraftWorkflow.ts +++ b/src/workflows/prepareDraftWorkflow.ts @@ -39,6 +39,7 @@ export type PrepareDraftOptions = { size: ImageSize; style?: string; coverField: CoverFieldName; + preserveFrontmatterOrder?: boolean; aiProvider?: TextAIProvider; imageProvider?: ImageGenerationProvider; storageProvider?: AssetStorageProvider; @@ -89,6 +90,7 @@ export async function prepareDraftWorkflow( draft, updates: { [options.coverField]: coverUrl }, apply: options.apply, + preserveFrontmatterOrder: options.preserveFrontmatterOrder, }); } } diff --git a/tests/tools/updateFrontmatterTool.test.ts b/tests/tools/updateFrontmatterTool.test.ts index 2ed6fc0..fb16146 100644 --- a/tests/tools/updateFrontmatterTool.test.ts +++ b/tests/tools/updateFrontmatterTool.test.ts @@ -2,10 +2,38 @@ 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 (!line.trim() || /^\s/.test(line)) 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 () => { @@ -66,6 +94,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)', () => { From 81850d6f6d2586380f033256d0893d20bb9d176e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 11:18:32 +0000 Subject: [PATCH 3/4] test: stabilize frontmatter order helper checks Agent-Logs-Url: https://github.com/mayashavin/ktavi/sessions/2881c632-fc51-4c10-9f54-040e61d197a0 --- src/utils/frontmatter.ts | 6 +++++- tests/tools/updateFrontmatterTool.test.ts | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/utils/frontmatter.ts b/src/utils/frontmatter.ts index ef23ec1..be04637 100644 --- a/src/utils/frontmatter.ts +++ b/src/utils/frontmatter.ts @@ -38,8 +38,12 @@ function extractTopLevelFrontmatterKeys(rawContent: string): string[] { const seen = new Set(); for (const line of rawFrontmatter.split('\n')) { + if (/^\s/.test(line)) { + continue; + } + const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#') || /^\s/.test(line)) { + if (!trimmed || trimmed.startsWith('#')) { continue; } diff --git a/tests/tools/updateFrontmatterTool.test.ts b/tests/tools/updateFrontmatterTool.test.ts index fb16146..390f9dd 100644 --- a/tests/tools/updateFrontmatterTool.test.ts +++ b/tests/tools/updateFrontmatterTool.test.ts @@ -13,7 +13,8 @@ const extractFrontmatterKeys = (content: string): string[] => { const keys: string[] = []; for (const line of block.split('\n')) { - if (!line.trim() || /^\s/.test(line)) continue; + if (/^\s/.test(line)) continue; + if (!line.trim()) continue; const key = line.match(/^([^:#][^:]*?):(?:\s|$)/)?.[1]?.trim(); if (key) keys.push(key); } From d59e6eba041e08d6aaa12d29382006663e4f3965 Mon Sep 17 00:00:00 2001 From: Maya Shavin <6650139+mayashavin@users.noreply.github.com> Date: Tue, 12 May 2026 14:31:53 +0300 Subject: [PATCH 4/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/utils/frontmatter.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/utils/frontmatter.ts b/src/utils/frontmatter.ts index be04637..9b4034e 100644 --- a/src/utils/frontmatter.ts +++ b/src/utils/frontmatter.ts @@ -66,16 +66,16 @@ function orderFrontmatterByOriginalKeys( frontmatter: BlogFrontmatter, originalKeys: string[], ): BlogFrontmatter { - const ordered: BlogFrontmatter = {}; + const ordered = Object.create(null) as BlogFrontmatter; for (const key of originalKeys) { - if (key in frontmatter) { + if (Object.prototype.hasOwnProperty.call(frontmatter, key)) { ordered[key] = frontmatter[key]; } } for (const [key, value] of Object.entries(frontmatter)) { - if (!(key in ordered)) { + if (!Object.prototype.hasOwnProperty.call(ordered, key)) { ordered[key] = value; } }