diff --git a/publish/src/core/change-detection.ts b/publish/src/core/change-detection.ts new file mode 100644 index 0000000..3a8208b --- /dev/null +++ b/publish/src/core/change-detection.ts @@ -0,0 +1,67 @@ +export type GitRunner = (args: string[]) => Promise + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +export function filterPostDirs(lines: string[], contentRoot: string): string[] { + const root = contentRoot.replace(/\/$/, '') + const prefix = root + '/' + const indexRe = new RegExp(`^${escapeRegex(prefix)}([^/]+)/index\\.md$`) + const assetRe = new RegExp(`^${escapeRegex(prefix)}([^/]+)/`) + const drafts = prefix + '_' + const dirs = new Set() + for (const line of lines) { + const l = line.trim() + if (!l) continue + if (l.startsWith(drafts)) continue + const indexMatch = l.match(indexRe) + if (indexMatch) { + dirs.add(`${prefix}${indexMatch[1]}`) + continue + } + const assetMatch = l.match(assetRe) + if (assetMatch && !l.endsWith('.md')) { + dirs.add(`${prefix}${assetMatch[1]}`) + } + } + return [...dirs].sort() +} + +const defaultRunner: GitRunner = async (args) => { + const proc = new Deno.Command('git', { args, stdout: 'piped', stderr: 'piped' }) + const out = await proc.output() + if (out.code !== 0) { + throw new Error(`git ${args.join(' ')} failed: ${new TextDecoder().decode(out.stderr)}`) + } + return new TextDecoder().decode(out.stdout) +} + +export interface DiffArgs { + from: string + to: string + contentRoot: string + runner?: GitRunner +} + +export async function changedPostDirs(args: DiffArgs): Promise { + const runner = args.runner ?? defaultRunner + const stdout = await runner(['diff', '--name-only', `${args.from}..${args.to}`]) + return filterPostDirs(stdout.split('\n'), args.contentRoot) +} + +export async function allPostDirs(contentRoot: string): Promise { + const result: string[] = [] + for await (const entry of Deno.readDir(contentRoot)) { + if (entry.isDirectory && !entry.name.startsWith('_')) { + const indexPath = `${contentRoot}/${entry.name}/index.md` + try { + const stat = await Deno.stat(indexPath) + if (stat.isFile) result.push(`${contentRoot}/${entry.name}`) + } catch { + // skip folders without index.md + } + } + } + return result.sort() +} diff --git a/publish/tests/change-detection_test.ts b/publish/tests/change-detection_test.ts new file mode 100644 index 0000000..8da17cd --- /dev/null +++ b/publish/tests/change-detection_test.ts @@ -0,0 +1,55 @@ +import { assertEquals } from '@std/assert' +import { + changedPostDirs, + filterPostDirs, + type GitRunner, +} from '../src/core/change-detection.ts' + +Deno.test('filterPostDirs: extrahiert post-ordner aus dateipfaden (content/posts)', () => { + const lines = [ + 'content/posts/a/index.md', + 'content/posts/b/image.png', + 'content/posts/c/other.md', + 'README.md', + 'app/src/lib/x.ts', + ] + assertEquals( + filterPostDirs(lines, 'content/posts').sort(), + ['content/posts/a', 'content/posts/b'], + ) +}) + +Deno.test('filterPostDirs: respektiert alternativen root (blog/)', () => { + const lines = [ + 'blog/x/index.md', + 'blog/y/pic.png', + 'content/posts/z/index.md', + 'README.md', + ] + assertEquals(filterPostDirs(lines, 'blog').sort(), ['blog/x', 'blog/y']) +}) + +Deno.test('filterPostDirs: ignoriert _drafts und non-index.md', () => { + const lines = [ + 'content/posts/a/index.md', + 'content/posts/a/extra.md', + 'content/posts/_drafts/x/index.md', + ] + assertEquals(filterPostDirs(lines, 'content/posts'), ['content/posts/a']) +}) + +Deno.test('changedPostDirs: nutzt git diff --name-only A..B', async () => { + const runner: GitRunner = (args) => { + assertEquals(args[0], 'diff') + assertEquals(args[1], '--name-only') + assertEquals(args[2], 'HEAD~1..HEAD') + return Promise.resolve('content/posts/x/index.md\nREADME.md\n') + } + const dirs = await changedPostDirs({ + from: 'HEAD~1', + to: 'HEAD', + contentRoot: 'content/posts', + runner, + }) + assertEquals(dirs, ['content/posts/x']) +})