From c86450b2f91f53959f7adedb228414784a1f7a15 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sat, 4 Apr 2026 02:50:46 +0800 Subject: [PATCH 01/12] core --- packages/sv-utils/src/index.ts | 1 + packages/sv-utils/src/tooling/md.ts | 43 +++++++++++++++++++++ packages/sv/src/addons/better-auth.ts | 9 +++++ packages/sv/src/addons/drizzle.ts | 11 ++++++ packages/sv/src/addons/playwright.ts | 9 ++++- packages/sv/src/addons/sveltekit-adapter.ts | 10 +++++ packages/sv/src/create/shared/README.md | 2 + 7 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 packages/sv-utils/src/tooling/md.ts diff --git a/packages/sv-utils/src/index.ts b/packages/sv-utils/src/index.ts index 64c7b21e2..be21a78e6 100644 --- a/packages/sv-utils/src/index.ts +++ b/packages/sv-utils/src/index.ts @@ -36,6 +36,7 @@ export * as css from './tooling/css/index.ts'; export * as js from './tooling/js/index.ts'; export * as html from './tooling/html/index.ts'; export * as text from './tooling/text.ts'; +export * as md from './tooling/md.ts'; export * as json from './tooling/json.ts'; export * as svelte from './tooling/svelte/index.ts'; diff --git a/packages/sv-utils/src/tooling/md.ts b/packages/sv-utils/src/tooling/md.ts new file mode 100644 index 000000000..8e592e2d9 --- /dev/null +++ b/packages/sv-utils/src/tooling/md.ts @@ -0,0 +1,43 @@ +export function upsert( + content: string, + header: `${'#' | '##' | '###' | '####' | '#####' | '######'} ${string}`, + lines: Array +): string { + const [headerLevel, ...headerNameArray] = header.split(' '); + const headerName = headerNameArray.join(' '); + const linesToAdd = lines.filter(Boolean).join('\n'); + + const sectionRegex = new RegExp(`^(${headerLevel})\\s+${escapeRegex(headerName)}\\s*$`, 'm'); + + const match = content.match(sectionRegex); + + if (match) { + const headerStart = match.index!; + const headerLineEnd = content.indexOf('\n', headerStart); + + const restOfContent = content.slice(headerLineEnd + 1); + const nextHeaderMatch = restOfContent.match(/^#{1,6}\s+.+$/m); + + if (nextHeaderMatch) { + const nextHeaderIndex = restOfContent.indexOf(nextHeaderMatch[0]); + + const insertPos = headerLineEnd + 1 + nextHeaderIndex; + const before = content.slice(0, insertPos); + const after = content.slice(insertPos); + + return before + linesToAdd + '\n\n' + after; + } + + const insertPos = content.length; + const before = content.slice(0, insertPos); + const after = content.slice(insertPos); + + return before + '\n' + linesToAdd + '\n' + after; + } + + return content.trim() + `\n\n${headerLevel} ${headerName}\n\n${linesToAdd}\n`; +} + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/packages/sv/src/addons/better-auth.ts b/packages/sv/src/addons/better-auth.ts index c62845295..3b6d15894 100644 --- a/packages/sv/src/addons/better-auth.ts +++ b/packages/sv/src/addons/better-auth.ts @@ -4,6 +4,7 @@ import { Walker, color, dedent, + md, transforms, resolveCommandArray, createPrinter, @@ -92,6 +93,14 @@ export default defineAddon({ sv.file('.env', generateEnv(demoGithub, false)); sv.file('.env.example', generateEnv(demoGithub, true)); + sv.file('README.md', (content) => { + return md.upsert(content, '## Add-on Setup', [ + 'better-auth', + '- Run `npm run auth:schema` to generate the auth schema', + '- Run `npm run db:push` to update your database' + ]); + }); + sv.file( `${directory.lib}/server/auth.${language}`, transforms.script(({ ast, comments, js }) => { diff --git a/packages/sv/src/addons/drizzle.ts b/packages/sv/src/addons/drizzle.ts index 756a08ba2..d06dcdd70 100644 --- a/packages/sv/src/addons/drizzle.ts +++ b/packages/sv/src/addons/drizzle.ts @@ -2,6 +2,7 @@ import { color, dedent, type TransformFn, + md, transforms, pnpm, resolveCommandArray, @@ -146,6 +147,16 @@ export default defineAddon({ sv.file('.env', generateEnv(options, false)); sv.file('.env.example', generateEnv(options, true)); + sv.file('README.md', (content) => { + return md.upsert(content, '## Add-on Setup', [ + 'Drizzle', + options.database === 'd1' && + '- Run `npm run wrangler d1 create ` to create a D1 database', + options.docker && '- Run `npm run db:start` to start the docker container', + '- Run `npm run db:push` to update your database schema' + ]); + }); + if (options.docker && (options.mysql === 'mysql2' || options.postgresql === 'postgres.js')) { const composeFileOptions = [ // First item has higher priority diff --git a/packages/sv/src/addons/playwright.ts b/packages/sv/src/addons/playwright.ts index e6c11dc6d..9606e0e82 100644 --- a/packages/sv/src/addons/playwright.ts +++ b/packages/sv/src/addons/playwright.ts @@ -1,5 +1,5 @@ import { log } from '@clack/prompts'; -import { color, dedent, transforms } from '@sveltejs/sv-utils'; +import { color, dedent, md, transforms } from '@sveltejs/sv-utils'; import { defineAddon } from '../core/config.ts'; import { addToDemoPage } from './common.ts'; @@ -89,6 +89,13 @@ export default defineAddon({ } }) ); + + sv.file('README.md', (content) => { + return md.upsert(content, '## Add-on Setup', [ + 'Playwright', + '- Run `npx playwright install` to download browsers' + ]); + }); }, nextSteps: ({ isKit }) => { diff --git a/packages/sv/src/addons/sveltekit-adapter.ts b/packages/sv/src/addons/sveltekit-adapter.ts index dd3f9a3a2..b9510fa4a 100644 --- a/packages/sv/src/addons/sveltekit-adapter.ts +++ b/packages/sv/src/addons/sveltekit-adapter.ts @@ -1,6 +1,7 @@ import { color, resolveCommandArray, + md, text, transforms, fileExists, @@ -227,6 +228,15 @@ export default defineAddon({ ); } } + + if (options.adapter === 'cloudflare') { + sv.file('README.md', (content) => { + return md.upsert(content, '## Add-on Setup', [ + 'Cloudflare Adapter', + '- Run `npm run gen` to generate Cloudflare types' + ]); + }); + } }, nextSteps({ options, packageManager }) { const steps: string[] = []; diff --git a/packages/sv/src/create/shared/README.md b/packages/sv/src/create/shared/README.md index a0da9f54d..b1dedd1e3 100644 --- a/packages/sv/src/create/shared/README.md +++ b/packages/sv/src/create/shared/README.md @@ -2,6 +2,8 @@ Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). +## Add-on Setup + ## Creating a project If you're seeing this, you've probably already done this step. Congrats! From f91a93c852d630136a15301e14d8b9063eebd895 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sat, 4 Apr 2026 02:50:34 +0800 Subject: [PATCH 02/12] `.env.example` msg --- packages/sv/src/cli/create.ts | 3 ++- packages/sv/src/core/common.ts | 24 +++++++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/sv/src/cli/create.ts b/packages/sv/src/cli/create.ts index 9d32ffa47..a4dc56248 100644 --- a/packages/sv/src/cli/create.ts +++ b/packages/sv/src/cli/create.ts @@ -386,7 +386,8 @@ async function createProject(cwd: ProjectPath, options: Options) { if (argsFormattedAddons.length > 0) argsFormatted.push('--add', ...argsFormattedAddons); const prompt = common.buildAndLogArgs(packageManager, 'create', argsFormatted, [directory]); - common.updateReadme(directory, prompt); + common.insertPrompt(directory, prompt); + if (fs.existsSync(path.join(directory, '.env.example'))) common.insertEnvMsg(directory); common.updateAgent(directory, language, packageManager ?? 'npm', loadedAddons); diff --git a/packages/sv/src/core/common.ts b/packages/sv/src/core/common.ts index 856b8e0c5..372e98918 100644 --- a/packages/sv/src/core/common.ts +++ b/packages/sv/src/core/common.ts @@ -246,7 +246,7 @@ export function buildAndLogArgs( return message; } -export function updateReadme(projectPath: string, command: string) { +export function insertPrompt(projectPath: string, command: string) { const readmePath = path.join(projectPath, 'README.md'); if (!fs.existsSync(readmePath)) return; @@ -271,6 +271,28 @@ export function updateReadme(projectPath: string, command: string) { fs.writeFileSync(readmePath, content); } +export function insertEnvMsg(projectPath: string) { + const readmePath = path.join(projectPath, 'README.md'); + if (!fs.existsSync(readmePath)) return; + + let content = fs.readFileSync(readmePath, 'utf-8'); + const message = 'Configure your environment variables based on the `.env.example`.'; + + const setupPattern = /## Add-on Setup[\s\S]*?(?=## |$)/; + const setupMatch = content.match(setupPattern); + if (!setupMatch) { + fs.writeFileSync(readmePath, content + message + `\n`); + + return; + } + + const existingSection = setupMatch[0]; + const updatedSection = existingSection.replace(/(## Add-on Setup)/, `$1\n\n${message}`); + + content = content.replace(setupPattern, updatedSection); + fs.writeFileSync(readmePath, content); +} + export function errorAndExit(message: string) { p.log.error(message); p.log.message(); From 92cb831107f79616484f37280b93f2ae84d45888 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sat, 4 Apr 2026 02:48:59 +0800 Subject: [PATCH 03/12] snapshot --- .../src/cli/tests/snapshots/create-only/README.md | 2 ++ .../snapshots/create-with-all-addons/README.md | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/packages/sv/src/cli/tests/snapshots/create-only/README.md b/packages/sv/src/cli/tests/snapshots/create-only/README.md index 7844bbfc6..8f6370c25 100644 --- a/packages/sv/src/cli/tests/snapshots/create-only/README.md +++ b/packages/sv/src/cli/tests/snapshots/create-only/README.md @@ -2,6 +2,8 @@ Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). +## Add-on Setup + ## Creating a project If you're seeing this, you've probably already done this step. Congrats! diff --git a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/README.md b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/README.md index c65e1b6f7..aaf0a747a 100644 --- a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/README.md +++ b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/README.md @@ -2,6 +2,20 @@ Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). +## Add-on Setup + +Configure your environment variables based on the `.env.example`. + +Playwright +- Run `npx playwright install` to download browsers + +Drizzle +- Run `npm run db:push` to update your database schema + +better-auth +- Run `npm run auth:schema` to generate the auth schema +- Run `npm run db:push` to update your database + ## Creating a project If you're seeing this, you've probably already done this step. Congrats! From db35f06a89725dd7043e09d1e60b0dc17624c9dd Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sun, 5 Apr 2026 22:46:26 +0800 Subject: [PATCH 04/12] add test fix test --- packages/sv-utils/src/tests/md.ts | 70 +++++++++++++++++++++++++++ packages/sv-utils/src/tooling/md.ts | 73 +++++++++++++++++++---------- 2 files changed, 117 insertions(+), 26 deletions(-) create mode 100644 packages/sv-utils/src/tests/md.ts diff --git a/packages/sv-utils/src/tests/md.ts b/packages/sv-utils/src/tests/md.ts new file mode 100644 index 000000000..ecb3a753d --- /dev/null +++ b/packages/sv-utils/src/tests/md.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { upsert } from '../tooling/md.ts'; + +describe('md upsert', () => { + it("adds to the end when there's no header option", () => { + const content = '# Hello\n\nSome content\n\n## World\n'; + const result = upsert(content, ['new line']); + expect(result).toBe('# Hello\n\nSome content\n\n## World\n\nnew line\n'); + }); + + it('add lines under existing header', () => { + const content = '# Hello\n\nSome content\n\n## World\n'; + const result = upsert(content, ['new line'], { header: '# Hello' }); + expect(result).toBe('# Hello\n\nSome content\nnew line\n\n## World\n'); + + const result2 = upsert(content, ['new line'], { header: '## World' }); + expect(result2).toBe('# Hello\n\nSome content\n\n## World\n\nnew line\n'); + }); + + it('create new header section when not found', () => { + const content = '# Existing\n\ncontent'; + const result = upsert(content, ['new line'], { header: '## New Section' }); + expect(result).toBe('# Existing\n\ncontent\n\n## New Section\n\nnew line\n'); + }); + + it('works with different header levels', () => { + expect(upsert('', ['line'], { header: '# H1' })).toBe('# H1\n\nline\n'); + expect(upsert('', ['line'], { header: '## H2' })).toBe('## H2\n\nline\n'); + expect(upsert('', ['line'], { header: '### H3' })).toBe('### H3\n\nline\n'); + expect(upsert('', ['line'], { header: '#### H4' })).toBe('#### H4\n\nline\n'); + expect(upsert('', ['line'], { header: '##### H5' })).toBe('##### H5\n\nline\n'); + expect(upsert('', ['line'], { header: '###### H6' })).toBe('###### H6\n\nline\n'); + }); + + it('filters falsy values from lines', () => { + const content = '# Section\n'; + const result = upsert(content, ['valid', false, null, undefined, 0, 0n, '', 'also valid'], { + header: '# Section' + }); + expect(result).toBe('# Section\n\nvalid\nalso valid\n'); + }); + + it('adds multiple lines', () => { + const content = '# Section'; + const result = upsert(content, ['line 1', 'line 2', 'line 3'], { header: '# Section' }); + expect(result).toBe('# Section\n\nline 1\nline 2\nline 3\n'); + }); + + it('handles a header containing all common regex metacharacters', () => { + const trickyHeader = '# [v1.0]* + (Review)? ^Search$ | {Match}.'; + const content = `${trickyHeader}\nexisting_data: true`; + const linesToAdd = 'new_data: true'; + + const result = upsert(content, [linesToAdd], { header: trickyHeader }); + + expect(result).toContain(`${trickyHeader}\nexisting_data: true\n${linesToAdd}\n`); + }); + + it('appends lines without header', () => { + const content = 'existing content'; + const result = upsert(content, ['new line'], {}); + expect(result).toBe('existing content\nnew line\n'); + }); + + it('appends to content ending with newline', () => { + const content = 'existing content\n'; + const result = upsert(content, ['new line'], {}); + expect(result).toBe('existing content\nnew line\n'); + }); +}); diff --git a/packages/sv-utils/src/tooling/md.ts b/packages/sv-utils/src/tooling/md.ts index 8e592e2d9..6f97c6016 100644 --- a/packages/sv-utils/src/tooling/md.ts +++ b/packages/sv-utils/src/tooling/md.ts @@ -1,43 +1,64 @@ +type Header = `${'#' | '##' | '###' | '####' | '#####' | '######'} ${string}`; + +const HEADER_REGEX = /^#{1,6} .+$/m; + export function upsert( content: string, - header: `${'#' | '##' | '###' | '####' | '#####' | '######'} ${string}`, - lines: Array + lines: Array, + options?: { + header?: Header; + // mode: 'prepend'|'append', + // position: Header + } ): string { - const [headerLevel, ...headerNameArray] = header.split(' '); - const headerName = headerNameArray.join(' '); - const linesToAdd = lines.filter(Boolean).join('\n'); - - const sectionRegex = new RegExp(`^(${headerLevel})\\s+${escapeRegex(headerName)}\\s*$`, 'm'); + const { header } = options ?? {}; - const match = content.match(sectionRegex); - - if (match) { - const headerStart = match.index!; - const headerLineEnd = content.indexOf('\n', headerStart); + const linesToAdd = lines.filter(Boolean).join('\n'); - const restOfContent = content.slice(headerLineEnd + 1); - const nextHeaderMatch = restOfContent.match(/^#{1,6}\s+.+$/m); + if (!header) { + return joinContent(content, `${linesToAdd}\n`); + } - if (nextHeaderMatch) { - const nextHeaderIndex = restOfContent.indexOf(nextHeaderMatch[0]); + const [headerLevel, ...headerNameArray] = header.split(' '); + const headerName = headerNameArray.join(' '); + const sectionRegex = new RegExp(`^(${headerLevel}) ${escapeRegex(headerName)}\\s*$`, 'm'); + const headerMatch = content.match(sectionRegex); - const insertPos = headerLineEnd + 1 + nextHeaderIndex; - const before = content.slice(0, insertPos); - const after = content.slice(insertPos); + if (!headerMatch) { + const length = content.trim().length; + const separator = length ? '\n\n' : ''; - return before + linesToAdd + '\n\n' + after; - } + return `${content.trimEnd()}${separator}${header}\n\n${linesToAdd}\n`; + } - const insertPos = content.length; - const before = content.slice(0, insertPos); - const after = content.slice(insertPos); + const headerStart = headerMatch.index!; + const headerLineEnd = content.indexOf('\n', headerStart); + const restOfContent = content.slice(headerLineEnd + 1); + const nextHeaderMatch = restOfContent.match(HEADER_REGEX); - return before + '\n' + linesToAdd + '\n' + after; + if (headerLineEnd === -1 || !nextHeaderMatch) { + return joinContent(content, `${linesToAdd}\n`); } - return content.trim() + `\n\n${headerLevel} ${headerName}\n\n${linesToAdd}\n`; + const nextHeaderIndex = restOfContent.indexOf(nextHeaderMatch[0]); + const insertPos = headerLineEnd + 1 + nextHeaderIndex; + const before = content.slice(0, insertPos); + const after = content.slice(insertPos); + + return joinContent(before, linesToAdd) + '\n\n' + after; } function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } + +function joinContent(content: string, newContent: string) { + const trimmedContent = content.trimEnd(); + if (!trimmedContent) return `${newContent}`; + + const lastLine = trimmedContent.split('\n').at(-1) ?? ''; + const isMdHeader = HEADER_REGEX.test(lastLine); + const separator = isMdHeader ? '\n\n' : '\n'; + + return `${trimmedContent}${separator}${newContent}`; +} From bfae0cde8735f831cf2a919767ed0c7d40afb8fc Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Mon, 6 Apr 2026 17:03:01 +0800 Subject: [PATCH 05/12] update new interface --- packages/sv/src/addons/better-auth.ts | 14 +++++++++----- packages/sv/src/addons/drizzle.ts | 18 +++++++++++------- packages/sv/src/addons/playwright.ts | 9 +++++---- packages/sv/src/addons/sveltekit-adapter.ts | 9 +++++---- 4 files changed, 30 insertions(+), 20 deletions(-) diff --git a/packages/sv/src/addons/better-auth.ts b/packages/sv/src/addons/better-auth.ts index 3b6d15894..10d7d43d5 100644 --- a/packages/sv/src/addons/better-auth.ts +++ b/packages/sv/src/addons/better-auth.ts @@ -94,11 +94,15 @@ export default defineAddon({ sv.file('.env.example', generateEnv(demoGithub, true)); sv.file('README.md', (content) => { - return md.upsert(content, '## Add-on Setup', [ - 'better-auth', - '- Run `npm run auth:schema` to generate the auth schema', - '- Run `npm run db:push` to update your database' - ]); + return md.upsert( + content, + [ + 'better-auth', + '- Run `npm run auth:schema` to generate the auth schema', + '- Run `npm run db:push` to update your database' + ], + { header: '## Add-on Setup' } + ); }); sv.file( diff --git a/packages/sv/src/addons/drizzle.ts b/packages/sv/src/addons/drizzle.ts index d06dcdd70..67c72c296 100644 --- a/packages/sv/src/addons/drizzle.ts +++ b/packages/sv/src/addons/drizzle.ts @@ -148,13 +148,17 @@ export default defineAddon({ sv.file('.env.example', generateEnv(options, true)); sv.file('README.md', (content) => { - return md.upsert(content, '## Add-on Setup', [ - 'Drizzle', - options.database === 'd1' && - '- Run `npm run wrangler d1 create ` to create a D1 database', - options.docker && '- Run `npm run db:start` to start the docker container', - '- Run `npm run db:push` to update your database schema' - ]); + return md.upsert( + content, + [ + 'Drizzle', + options.database === 'd1' && + '- Run `npm run wrangler d1 create ` to create a D1 database', + options.docker && '- Run `npm run db:start` to start the docker container', + '- Run `npm run db:push` to update your database schema' + ], + { header: '## Add-on Setup' } + ); }); if (options.docker && (options.mysql === 'mysql2' || options.postgresql === 'postgres.js')) { diff --git a/packages/sv/src/addons/playwright.ts b/packages/sv/src/addons/playwright.ts index 9606e0e82..fcf5aa395 100644 --- a/packages/sv/src/addons/playwright.ts +++ b/packages/sv/src/addons/playwright.ts @@ -91,10 +91,11 @@ export default defineAddon({ ); sv.file('README.md', (content) => { - return md.upsert(content, '## Add-on Setup', [ - 'Playwright', - '- Run `npx playwright install` to download browsers' - ]); + return md.upsert( + content, + ['Playwright', '- Run `npx playwright install` to download browsers'], + { header: '## Add-on Setup' } + ); }); }, diff --git a/packages/sv/src/addons/sveltekit-adapter.ts b/packages/sv/src/addons/sveltekit-adapter.ts index b9510fa4a..08c9671a5 100644 --- a/packages/sv/src/addons/sveltekit-adapter.ts +++ b/packages/sv/src/addons/sveltekit-adapter.ts @@ -231,10 +231,11 @@ export default defineAddon({ if (options.adapter === 'cloudflare') { sv.file('README.md', (content) => { - return md.upsert(content, '## Add-on Setup', [ - 'Cloudflare Adapter', - '- Run `npm run gen` to generate Cloudflare types' - ]); + return md.upsert( + content, + ['Cloudflare Adapter', '- Run `npm run gen` to generate Cloudflare types'], + { header: '## Add-on Setup' } + ); }); } }, From e131e2780b09e1900a4e695551a9a88843ab28c3 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Mon, 6 Apr 2026 17:41:22 +0800 Subject: [PATCH 06/12] support prepend --- packages/sv-utils/src/tests/md.ts | 26 ++++++++++++++++++++++++++ packages/sv-utils/src/tooling/md.ts | 23 ++++++++++++++++++++--- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/packages/sv-utils/src/tests/md.ts b/packages/sv-utils/src/tests/md.ts index ecb3a753d..c259288c7 100644 --- a/packages/sv-utils/src/tests/md.ts +++ b/packages/sv-utils/src/tests/md.ts @@ -67,4 +67,30 @@ describe('md upsert', () => { const result = upsert(content, ['new line'], {}); expect(result).toBe('existing content\nnew line\n'); }); + + it('should append by default', () => { + const content = '# Section\n\nold content\n\n## Next\n'; + const withDefault = upsert(content, ['new'], { header: '# Section' }); + const withExplicit = upsert(content, ['new'], { header: '# Section', mode: 'append' }); + expect(withDefault).toBe('# Section\n\nold content\nnew\n\n## Next\n'); + expect(withDefault).toBe(withExplicit); + }); + + it('adds content right after header', () => { + const content = '# Hello\n\nSome content\n\n## World\n'; + const result = upsert(content, ['new line'], { header: '# Hello', mode: 'prepend' }); + expect(result).toBe('# Hello\n\nnew line\nSome content\n\n## World\n'); + }); + + it('prepend multiple lines', () => { + const content = '# Section\n\nexisting content'; + const result = upsert(content, ['line 1', 'line 2'], { header: '# Section', mode: 'prepend' }); + expect(result).toBe('# Section\n\nline 1\nline 2\nexisting content\n'); + }); + + it('prepend works with different header levels', () => { + const content = '## H2\n\nexisting'; + const result = upsert(content, ['new'], { header: '## H2', mode: 'prepend' }); + expect(result).toBe('## H2\n\nnew\nexisting\n'); + }); }); diff --git a/packages/sv-utils/src/tooling/md.ts b/packages/sv-utils/src/tooling/md.ts index 6f97c6016..94cff0649 100644 --- a/packages/sv-utils/src/tooling/md.ts +++ b/packages/sv-utils/src/tooling/md.ts @@ -7,11 +7,11 @@ export function upsert( lines: Array, options?: { header?: Header; - // mode: 'prepend'|'append', + mode?: 'prepend' | 'append'; // position: Header } ): string { - const { header } = options ?? {}; + const { header, mode = 'append' } = options ?? {}; const linesToAdd = lines.filter(Boolean).join('\n'); @@ -36,12 +36,29 @@ export function upsert( const restOfContent = content.slice(headerLineEnd + 1); const nextHeaderMatch = restOfContent.match(HEADER_REGEX); - if (headerLineEnd === -1 || !nextHeaderMatch) { + if (headerLineEnd === -1) { + return joinContent(content, `${linesToAdd}\n`); + } + + if (!nextHeaderMatch) { + if (mode === 'prepend') { + const before = content.slice(0, headerLineEnd + 1); + const after = content.slice(headerLineEnd + 1).replace(/^\n/, ''); + return `${before}\n${linesToAdd}\n${after}\n`; + } return joinContent(content, `${linesToAdd}\n`); } const nextHeaderIndex = restOfContent.indexOf(nextHeaderMatch[0]); const insertPos = headerLineEnd + 1 + nextHeaderIndex; + + if (mode === 'prepend') { + const before = content.slice(0, headerLineEnd + 1); + const middle = content.slice(headerLineEnd + 1, insertPos).replace(/^\n/, ''); + const after = content.slice(insertPos); + return `${before}\n${linesToAdd}\n${middle}${after}`; + } + const before = content.slice(0, insertPos); const after = content.slice(insertPos); From 60b94ec715b009f39d392ec1d78f85befaaf7c13 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Mon, 6 Apr 2026 19:28:45 +0800 Subject: [PATCH 07/12] refactor --- packages/sv-utils/src/tooling/md.ts | 67 +++++++++++++---------------- 1 file changed, 31 insertions(+), 36 deletions(-) diff --git a/packages/sv-utils/src/tooling/md.ts b/packages/sv-utils/src/tooling/md.ts index 94cff0649..ff3c958ad 100644 --- a/packages/sv-utils/src/tooling/md.ts +++ b/packages/sv-utils/src/tooling/md.ts @@ -8,15 +8,13 @@ export function upsert( options?: { header?: Header; mode?: 'prepend' | 'append'; - // position: Header } ): string { const { header, mode = 'append' } = options ?? {}; - const linesToAdd = lines.filter(Boolean).join('\n'); if (!header) { - return joinContent(content, `${linesToAdd}\n`); + return joinContent(content, linesToAdd); } const [headerLevel, ...headerNameArray] = header.split(' '); @@ -25,57 +23,54 @@ export function upsert( const headerMatch = content.match(sectionRegex); if (!headerMatch) { - const length = content.trim().length; - const separator = length ? '\n\n' : ''; - - return `${content.trimEnd()}${separator}${header}\n\n${linesToAdd}\n`; + return joinContent(content, header, linesToAdd); } const headerStart = headerMatch.index!; const headerLineEnd = content.indexOf('\n', headerStart); - const restOfContent = content.slice(headerLineEnd + 1); + const restOfContent = headerLineEnd === -1 ? '' : content.slice(headerLineEnd + 1); const nextHeaderMatch = restOfContent.match(HEADER_REGEX); - if (headerLineEnd === -1) { - return joinContent(content, `${linesToAdd}\n`); - } - - if (!nextHeaderMatch) { - if (mode === 'prepend') { - const before = content.slice(0, headerLineEnd + 1); - const after = content.slice(headerLineEnd + 1).replace(/^\n/, ''); - return `${before}\n${linesToAdd}\n${after}\n`; - } - return joinContent(content, `${linesToAdd}\n`); - } - - const nextHeaderIndex = restOfContent.indexOf(nextHeaderMatch[0]); - const insertPos = headerLineEnd + 1 + nextHeaderIndex; - + let insertPos: number; if (mode === 'prepend') { - const before = content.slice(0, headerLineEnd + 1); - const middle = content.slice(headerLineEnd + 1, insertPos).replace(/^\n/, ''); - const after = content.slice(insertPos); - return `${before}\n${linesToAdd}\n${middle}${after}`; + insertPos = headerLineEnd + 1; + } else if (nextHeaderMatch) { + insertPos = headerLineEnd + 1 + restOfContent.indexOf(nextHeaderMatch[0]); + } else { + insertPos = content.length; } const before = content.slice(0, insertPos); const after = content.slice(insertPos); - return joinContent(before, linesToAdd) + '\n\n' + after; + return joinContent(before, linesToAdd, after); } function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -function joinContent(content: string, newContent: string) { - const trimmedContent = content.trimEnd(); - if (!trimmedContent) return `${newContent}`; +/** + * Ensure the distance between a header and a non-header is always \n\n + */ +function joinContent(...args: string[]): string { + const trimmedArgs = args.map((content) => content.trim()).filter((content) => content !== ''); + + if (trimmedArgs.length === 0) return ''; + if (trimmedArgs.length === 1) return `${trimmedArgs[0]}\n`; - const lastLine = trimmedContent.split('\n').at(-1) ?? ''; - const isMdHeader = HEADER_REGEX.test(lastLine); - const separator = isMdHeader ? '\n\n' : '\n'; + let result = trimmedArgs[0]; + for (let i = 1; i < trimmedArgs.length; i++) { + const prev = result.trimEnd(); + const prevLastLine = prev.split('\n').at(-1)!; + const prevIsHeader = HEADER_REGEX.test(prevLastLine); - return `${trimmedContent}${separator}${newContent}`; + const curr = trimmedArgs[i]; + const currFirstLine = curr.split('\n').at(0)!; + const currIsHeader = HEADER_REGEX.test(currFirstLine); + + const separator = prevIsHeader || currIsHeader ? '\n\n' : '\n'; + result = `${prev}${separator}${curr}`; + } + return `${result}\n`; } From 93a9e92646363aca63b5005cc5d7eb4406cdeca8 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Mon, 6 Apr 2026 23:27:00 +0800 Subject: [PATCH 08/12] another function --- packages/sv-utils/src/tooling/md.ts | 32 ++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/sv-utils/src/tooling/md.ts b/packages/sv-utils/src/tooling/md.ts index ff3c958ad..0b65c5d72 100644 --- a/packages/sv-utils/src/tooling/md.ts +++ b/packages/sv-utils/src/tooling/md.ts @@ -17,18 +17,13 @@ export function upsert( return joinContent(content, linesToAdd); } - const [headerLevel, ...headerNameArray] = header.split(' '); - const headerName = headerNameArray.join(' '); - const sectionRegex = new RegExp(`^(${headerLevel}) ${escapeRegex(headerName)}\\s*$`, 'm'); - const headerMatch = content.match(sectionRegex); + const headerMatch = findHeader(content, header); if (!headerMatch) { return joinContent(content, header, linesToAdd); } + const { end: headerLineEnd, after: restOfContent } = headerMatch; - const headerStart = headerMatch.index!; - const headerLineEnd = content.indexOf('\n', headerStart); - const restOfContent = headerLineEnd === -1 ? '' : content.slice(headerLineEnd + 1); const nextHeaderMatch = restOfContent.match(HEADER_REGEX); let insertPos: number; @@ -74,3 +69,26 @@ function joinContent(...args: string[]): string { } return `${result}\n`; } + +function findHeader( + content: string, + header: Header +): { before: string; after: string; start: number; end: number } | null { + const [headerLevel, ...headerNameArray] = header.split(' '); + const headerName = headerNameArray.join(' '); + + const sectionRegex = new RegExp(`^(${headerLevel}) ${escapeRegex(headerName)}\\s*$`, 'm'); + const headerMatch = content.match(sectionRegex); + + if (!headerMatch) return null; + + const start = headerMatch.index!; + const end = start + header.length; + + return { + start, + end, + before: content.slice(0, start), + after: content.slice(end + 1) + }; +} From 09b907d8d4c2485294ffc8e06314bb0c7936fb56 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Tue, 7 Apr 2026 09:13:42 +0800 Subject: [PATCH 09/12] stuff --- packages/sv-utils/src/tests/md.ts | 38 ++++---- packages/sv-utils/src/tooling/md.ts | 145 +++++++++++++++++++++------- 2 files changed, 128 insertions(+), 55 deletions(-) diff --git a/packages/sv-utils/src/tests/md.ts b/packages/sv-utils/src/tests/md.ts index c259288c7..5d2b7555f 100644 --- a/packages/sv-utils/src/tests/md.ts +++ b/packages/sv-utils/src/tests/md.ts @@ -68,29 +68,31 @@ describe('md upsert', () => { expect(result).toBe('existing content\nnew line\n'); }); - it('should append by default', () => { - const content = '# Section\n\nold content\n\n## Next\n'; - const withDefault = upsert(content, ['new'], { header: '# Section' }); - const withExplicit = upsert(content, ['new'], { header: '# Section', mode: 'append' }); - expect(withDefault).toBe('# Section\n\nold content\nnew\n\n## Next\n'); - expect(withDefault).toBe(withExplicit); + it('works with object header syntax', () => { + const content = '# Section\n\nexisting'; + const result = upsert(content, ['new'], { header: { name: '# Section' } }); + expect(result).toBe('# Section\n\nexisting\nnew\n'); }); - it('adds content right after header', () => { - const content = '# Hello\n\nSome content\n\n## World\n'; - const result = upsert(content, ['new line'], { header: '# Hello', mode: 'prepend' }); - expect(result).toBe('# Hello\n\nnew line\nSome content\n\n## World\n'); + it('adds child header under parent section', () => { + const content = '# Parent\n\nparent content'; + const result = upsert(content, ['child content'], { + header: { name: '## Child', parent: '# Parent' } + }); + expect(result).toBe('# Parent\n\nparent content\n\n## Child\n\nchild content\n'); }); - it('prepend multiple lines', () => { - const content = '# Section\n\nexisting content'; - const result = upsert(content, ['line 1', 'line 2'], { header: '# Section', mode: 'prepend' }); - expect(result).toBe('# Section\n\nline 1\nline 2\nexisting content\n'); + it('creates parent section if not found', () => { + const content = '# Existing'; + const result = upsert(content, ['child content'], { + header: { name: '## Child', parent: '# New Parent' } + }); + expect(result).toBe('# Existing\n\n# New Parent\n\n## Child\n\nchild content\n'); }); - it('prepend works with different header levels', () => { - const content = '## H2\n\nexisting'; - const result = upsert(content, ['new'], { header: '## H2', mode: 'prepend' }); - expect(result).toBe('## H2\n\nnew\nexisting\n'); + it('works with nested headers', () => { + const content = '# Parent\n\n## Child1\n\ncontent1\n\n## Child2\n\ncontent2'; + const result = upsert(content, ['new'], { header: { name: '## Child1' } }); + expect(result).toBe('# Parent\n\n## Child1\n\ncontent1\nnew\n\n## Child2\n\ncontent2\n'); }); }); diff --git a/packages/sv-utils/src/tooling/md.ts b/packages/sv-utils/src/tooling/md.ts index 0b65c5d72..78a36d42b 100644 --- a/packages/sv-utils/src/tooling/md.ts +++ b/packages/sv-utils/src/tooling/md.ts @@ -2,43 +2,8 @@ type Header = `${'#' | '##' | '###' | '####' | '#####' | '######'} ${string}`; const HEADER_REGEX = /^#{1,6} .+$/m; -export function upsert( - content: string, - lines: Array, - options?: { - header?: Header; - mode?: 'prepend' | 'append'; - } -): string { - const { header, mode = 'append' } = options ?? {}; - const linesToAdd = lines.filter(Boolean).join('\n'); - - if (!header) { - return joinContent(content, linesToAdd); - } - - const headerMatch = findHeader(content, header); - - if (!headerMatch) { - return joinContent(content, header, linesToAdd); - } - const { end: headerLineEnd, after: restOfContent } = headerMatch; - - const nextHeaderMatch = restOfContent.match(HEADER_REGEX); - - let insertPos: number; - if (mode === 'prepend') { - insertPos = headerLineEnd + 1; - } else if (nextHeaderMatch) { - insertPos = headerLineEnd + 1 + restOfContent.indexOf(nextHeaderMatch[0]); - } else { - insertPos = content.length; - } - - const before = content.slice(0, insertPos); - const after = content.slice(insertPos); - - return joinContent(before, linesToAdd, after); +function getHeaderLevel(header: Header): number { + return header.split(' ')[0].length; } function escapeRegex(str: string): string { @@ -92,3 +57,109 @@ function findHeader( after: content.slice(end + 1) }; } + +function findSection( + content: string, + header: Header +): { + before: string; + after: string; + header: Header; + innerContent: string; + start: number; + end: number; +} | null { + const headerMatch = findHeader(content, header); + + if (!headerMatch) return null; + + const { start, end: headerEnd, before } = headerMatch; + const level = getHeaderLevel(header); + const nextHeaderRegex = new RegExp(`^#{${level}} `, 'm'); + const afterHeader = content.slice(headerEnd + 1); + const nextHeaderMatch = afterHeader.match(nextHeaderRegex); + + let end: number; + if (nextHeaderMatch) { + end = headerEnd + 1 + afterHeader.indexOf(nextHeaderMatch[0]); + } else { + end = content.length; + } + + const innerContent = content.slice(headerEnd + 1, end); + const after = content.slice(end); + + return { + before, + after, + header, + innerContent, + start, + end + }; +} + +export function upsert( + content: string, + lines: Array, + options?: { + header?: + | Header + | { + name: Header; + parent?: Header; + }; + } +): string { + const { header } = options ?? {}; + const linesToAdd = lines.filter(Boolean).join('\n'); + + if (!header) { + return joinContent(content, linesToAdd); + } + + if (typeof header === 'string') { + return asdf(content, linesToAdd, header); + } else { + if (!header.parent) { + return asdf(content, linesToAdd, header.name); + } + const section = findSection(content, header.parent); + + if (!section) { + return joinContent(content, header.parent, header.name, linesToAdd); + } + + return joinContent( + section.before, + section.header, + section.innerContent, + header.name, + linesToAdd, + section.after + ); + } +} + +function asdf(content: string, linesToAdd: string, header: Header): string { + const section = findSection(content, header); + + if (!section) { + return joinContent(content, header, linesToAdd); + } + + const { start, end, innerContent } = section; + const firstNextHeaderMatch = innerContent.match(HEADER_REGEX); + + let insertPos: number; + if (firstNextHeaderMatch) { + insertPos = start + header.length + 1 + innerContent.indexOf(firstNextHeaderMatch[0]); + } else { + insertPos = end; + } + + const before = content.slice(0, insertPos); + const after = content.slice(insertPos); + + return joinContent(before, linesToAdd, after); +} From 40a33b702cdb96e31541c20f2b16f7a0f3c9b41c Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Tue, 7 Apr 2026 13:01:27 +0800 Subject: [PATCH 10/12] . --- packages/sv-utils/src/tooling/md.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/sv-utils/src/tooling/md.ts b/packages/sv-utils/src/tooling/md.ts index 78a36d42b..ea2be2a74 100644 --- a/packages/sv-utils/src/tooling/md.ts +++ b/packages/sv-utils/src/tooling/md.ts @@ -163,3 +163,18 @@ function asdf(content: string, linesToAdd: string, header: Header): string { return joinContent(before, linesToAdd, after); } + +export function addNextSteps( + content: string, + lines: Array +): string { + const linesToAdd = lines.filter(Boolean).join('\n'); + + const svSection = findSection(content, '# sv'); + if (!svSection) return content; + + const firstChildMatch = svSection.innerContent.match(/^## Add-on Setup\s*$/m); + if (!firstChildMatch) return content; + + return asdf(content, linesToAdd, '## Add-on Setup'); +} From 39bac3d200e4521db210fa263d171915433e2e78 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Tue, 7 Apr 2026 13:59:02 +0800 Subject: [PATCH 11/12] . --- packages/sv-utils/src/files.ts | 35 +++ packages/sv-utils/src/index.ts | 2 + packages/sv-utils/src/tests/md.ts | 206 ++++++++++++------ packages/sv-utils/src/tooling/md.ts | 85 ++------ packages/sv/src/addons/better-auth.ts | 16 +- packages/sv/src/addons/drizzle.ts | 20 +- packages/sv/src/addons/playwright.ts | 11 +- packages/sv/src/addons/sveltekit-adapter.ts | 11 +- packages/sv/src/cli/create.ts | 16 +- .../cli/tests/snapshots/create-only/README.md | 2 - .../create-with-all-addons/README.md | 4 +- packages/sv/src/core/common.ts | 4 +- packages/sv/src/create/shared/README.md | 2 +- 13 files changed, 236 insertions(+), 178 deletions(-) diff --git a/packages/sv-utils/src/files.ts b/packages/sv-utils/src/files.ts index 4dd22c359..a8fab60ca 100644 --- a/packages/sv-utils/src/files.ts +++ b/packages/sv-utils/src/files.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import { appendContent, findHeader, findSection, joinContent, type Line } from './tooling/md.ts'; import { parseJson } from './tooling/parsers.ts'; export type Package = { @@ -99,3 +100,37 @@ export const commonFilePaths = { viteConfig: 'vite.config.js', viteConfigTS: 'vite.config.ts' } as const; + +export function addNextSteps(content: string, lines: Line[]): string { + const linesToAdd = lines.filter(Boolean).join('\n'); + + const svSection = findSection(content, '# sv'); + if (!svSection) return content; + + const header = '## Next Steps'; + const nextStepsHeader = findHeader(svSection.innerContent, header); + if (!nextStepsHeader) return content; + + return appendContent(content, linesToAdd, header); +} + +export function removeEmptyNextSteps(content: string): string { + const svSection = findSection(content, '# sv'); + if (!svSection) return content; + + const header = '## Next Steps'; + const nextStepsSection = findSection(svSection.innerContent, header); + if (!nextStepsSection) return content; + + if (nextStepsSection.innerContent.trim() === '') { + return joinContent( + svSection.before, + svSection.header, + nextStepsSection.before, + // a workaround for a very naive implementation that doesn't account for comments which also starts with `#` + nextStepsSection.after + svSection.after + ); + } + + return content; +} diff --git a/packages/sv-utils/src/index.ts b/packages/sv-utils/src/index.ts index be21a78e6..320ba6b60 100644 --- a/packages/sv-utils/src/index.ts +++ b/packages/sv-utils/src/index.ts @@ -83,11 +83,13 @@ export { downloadJson } from './downloadJson.ts'; // File system helpers export { + addNextSteps, commonFilePaths, fileExists, getPackageJson, installPackages, readFile, + removeEmptyNextSteps, writeFile, type Package } from './files.ts'; diff --git a/packages/sv-utils/src/tests/md.ts b/packages/sv-utils/src/tests/md.ts index 5d2b7555f..90bf57791 100644 --- a/packages/sv-utils/src/tests/md.ts +++ b/packages/sv-utils/src/tests/md.ts @@ -1,98 +1,172 @@ import { describe, expect, it } from 'vitest'; -import { upsert } from '../tooling/md.ts'; +import { findHeader, findSection, joinContent } from '../tooling/md.ts'; -describe('md upsert', () => { - it("adds to the end when there's no header option", () => { - const content = '# Hello\n\nSome content\n\n## World\n'; - const result = upsert(content, ['new line']); - expect(result).toBe('# Hello\n\nSome content\n\n## World\n\nnew line\n'); +describe('joinContent', () => { + it('joins two non-headers', () => { + const result = joinContent('hello', 'world'); + expect(result).toBe('hello\nworld\n'); + }); + + it('joins a header and a non-header', () => { + const result = joinContent('# Hello', 'content'); + expect(result).toBe('# Hello\n\ncontent\n'); + }); + + it('joins two headers', () => { + const result = joinContent('# Hello', '## World'); + expect(result).toBe('# Hello\n\n## World\n'); + }); + + it('joins a non-header and a header', () => { + const result = joinContent('content', '# Next'); + expect(result).toBe('content\n\n# Next\n'); + }); + + it('filters out empty strings', () => { + const result = joinContent('hello', '', 'world'); + expect(result).toBe('hello\nworld\n'); + }); + + it('returns an empty string when all inputs are empty', () => { + const result = joinContent('', '', ''); + expect(result).toBe(''); + }); + + it('trims content before joining', () => { + const result = joinContent(' hello ', ' world '); + expect(result).toBe('hello\nworld\n'); + }); + + it('handles a single argument', () => { + const result = joinContent('hello'); + expect(result).toBe('hello\n'); }); +}); - it('add lines under existing header', () => { +describe('findHeader', () => { + it('finds a header', () => { const content = '# Hello\n\nSome content\n\n## World\n'; - const result = upsert(content, ['new line'], { header: '# Hello' }); - expect(result).toBe('# Hello\n\nSome content\nnew line\n\n## World\n'); + const result = findHeader(content, '# Hello'); - const result2 = upsert(content, ['new line'], { header: '## World' }); - expect(result2).toBe('# Hello\n\nSome content\n\n## World\n\nnew line\n'); + expect(result).not.toBeNull(); + expect(result!.start).toBe(0); + expect(result!.end).toBe(7); + expect(result!.before).toBe(''); + expect(result!.after).toBe('\nSome content\n\n## World\n'); }); - it('create new header section when not found', () => { - const content = '# Existing\n\ncontent'; - const result = upsert(content, ['new line'], { header: '## New Section' }); - expect(result).toBe('# Existing\n\ncontent\n\n## New Section\n\nnew line\n'); + it('returns null when the header is not found', () => { + const content = '# Hello\n\nSome content'; + const result = findHeader(content, '## Missing'); + + expect(result).toBeNull(); }); - it('works with different header levels', () => { - expect(upsert('', ['line'], { header: '# H1' })).toBe('# H1\n\nline\n'); - expect(upsert('', ['line'], { header: '## H2' })).toBe('## H2\n\nline\n'); - expect(upsert('', ['line'], { header: '### H3' })).toBe('### H3\n\nline\n'); - expect(upsert('', ['line'], { header: '#### H4' })).toBe('#### H4\n\nline\n'); - expect(upsert('', ['line'], { header: '##### H5' })).toBe('##### H5\n\nline\n'); - expect(upsert('', ['line'], { header: '###### H6' })).toBe('###### H6\n\nline\n'); + it('finds a header in the middle of content', () => { + const content = 'some prefix\n\n# Header\n\nsome suffix'; + const result = findHeader(content, '# Header'); + + expect(result).not.toBeNull(); + expect(result!.before).toBe('some prefix\n\n'); + expect(result!.after).toBe('\nsome suffix'); }); - it('filters falsy values from lines', () => { - const content = '# Section\n'; - const result = upsert(content, ['valid', false, null, undefined, 0, 0n, '', 'also valid'], { - header: '# Section' - }); - expect(result).toBe('# Section\n\nvalid\nalso valid\n'); + it('handles different header levels', () => { + const content = '## H2\n\n### H3\n\n#### H4'; + + expect(findHeader(content, '## H2')).not.toBeNull(); + expect(findHeader(content, '### H3')).not.toBeNull(); + expect(findHeader(content, '#### H4')).not.toBeNull(); }); - it('adds multiple lines', () => { - const content = '# Section'; - const result = upsert(content, ['line 1', 'line 2', 'line 3'], { header: '# Section' }); - expect(result).toBe('# Section\n\nline 1\nline 2\nline 3\n'); + it('finds a header with trailing whitespace', () => { + const content = '# Header \ncontent'; + const result = findHeader(content, '# Header'); + + expect(result).not.toBeNull(); }); - it('handles a header containing all common regex metacharacters', () => { - const trickyHeader = '# [v1.0]* + (Review)? ^Search$ | {Match}.'; - const content = `${trickyHeader}\nexisting_data: true`; - const linesToAdd = 'new_data: true'; + it('finds the first occurrence when duplicate headers exist', () => { + const content = '# Title\n\ncontent1\n\n# Title\n\ncontent2'; + const result = findHeader(content, '# Title'); - const result = upsert(content, [linesToAdd], { header: trickyHeader }); + expect(result).not.toBeNull(); + expect(result!.before).toBe(''); + expect(result!.after).toBe('\ncontent1\n\n# Title\n\ncontent2'); + }); + + it('handles a header with special regex characters', () => { + const content = '# [test]* (example) $special\ncontent'; + const result = findHeader(content, '# [test]* (example) $special'); - expect(result).toContain(`${trickyHeader}\nexisting_data: true\n${linesToAdd}\n`); + expect(result).not.toBeNull(); }); +}); + +describe('findSection', () => { + it('finds a section and returns the header with its inner content', () => { + const content = '# Hello\n\nSome content\n\n## World\nmore content\n\n# Another'; + const result = findSection(content, '# Hello'); - it('appends lines without header', () => { - const content = 'existing content'; - const result = upsert(content, ['new line'], {}); - expect(result).toBe('existing content\nnew line\n'); + expect(result).not.toBeNull(); + expect(result!.header).toBe('# Hello'); + expect(result!.innerContent).toBe('\n\nSome content\n\n## World\nmore content\n\n'); }); - it('appends to content ending with newline', () => { - const content = 'existing content\n'; - const result = upsert(content, ['new line'], {}); - expect(result).toBe('existing content\nnew line\n'); + it('returns null when the header is not found', () => { + const content = '# Existing\ncontent'; + const result = findSection(content, '## Missing'); + + expect(result).toBeNull(); }); - it('works with object header syntax', () => { - const content = '# Section\n\nexisting'; - const result = upsert(content, ['new'], { header: { name: '# Section' } }); - expect(result).toBe('# Section\n\nexisting\nnew\n'); + it('should all add up', () => { + const content = + '# Parent\n\n## Child\nchild content\n\n### Grandchild\ngc content\n\n### Hmm\n\n## Two\n\n## One'; + const result = findSection(content, '## Child'); + + expect(result).not.toBeNull(); + expect( + result!.before.length + + result!.header.length + + result!.innerContent.length + + result!.after.length + ).toEqual(content.length); }); - it('adds child header under parent section', () => { - const content = '# Parent\n\nparent content'; - const result = upsert(content, ['child content'], { - header: { name: '## Child', parent: '# Parent' } - }); - expect(result).toBe('# Parent\n\nparent content\n\n## Child\n\nchild content\n'); + it('finds a section up to the next header of the same level', () => { + const content = '# Parent\n\n## Child1\ncontent1\n\n## Child2\ncontent2\n\n# Sibling'; + const result = findSection(content, '# Parent'); + + expect(result).not.toBeNull(); + expect(result!.innerContent).toBe('\n\n## Child1\ncontent1\n\n## Child2\ncontent2\n\n'); }); - it('creates parent section if not found', () => { - const content = '# Existing'; - const result = upsert(content, ['child content'], { - header: { name: '## Child', parent: '# New Parent' } - }); - expect(result).toBe('# Existing\n\n# New Parent\n\n## Child\n\nchild content\n'); + it('finds a nested section within a parent', () => { + const content = + '# Parent\n\n## Child\nchild content\n\n### Grandchild\ngc content\n\n### Hmm\n\n## Two\n\n## One'; + const result = findSection(content, '## Child'); + + expect(result).not.toBeNull(); + expect(result!.header).toBe('## Child'); + expect(result!.innerContent).toBe( + '\nchild content\n\n### Grandchild\ngc content\n\n### Hmm\n\n' + ); }); - it('works with nested headers', () => { - const content = '# Parent\n\n## Child1\n\ncontent1\n\n## Child2\n\ncontent2'; - const result = upsert(content, ['new'], { header: { name: '## Child1' } }); - expect(result).toBe('# Parent\n\n## Child1\n\ncontent1\nnew\n\n## Child2\n\ncontent2\n'); + it('handles a section at the end of content', () => { + const content = '# Last\n\nfinal content'; + const result = findSection(content, '# Last'); + + expect(result).not.toBeNull(); + expect(result!.innerContent).toBe('\n\nfinal content'); + }); + + it('returns the inner content for a header with no content', () => { + const content = '# Empty\n\n## Next'; + const result = findSection(content, '# Empty'); + + expect(result).not.toBeNull(); + expect(result!.innerContent).toBe('\n\n## Next'); }); }); diff --git a/packages/sv-utils/src/tooling/md.ts b/packages/sv-utils/src/tooling/md.ts index ea2be2a74..d92c20aa2 100644 --- a/packages/sv-utils/src/tooling/md.ts +++ b/packages/sv-utils/src/tooling/md.ts @@ -1,4 +1,5 @@ type Header = `${'#' | '##' | '###' | '####' | '#####' | '######'} ${string}`; +export type Line = string | false | undefined | null | 0 | 0n; const HEADER_REGEX = /^#{1,6} .+$/m; @@ -6,14 +7,17 @@ function getHeaderLevel(header: Header): number { return header.split(' ')[0].length; } -function escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +// vendor from https://github.com/sindresorhus/escape-string-regexp/blob/main/index.js +export function escapeStringRegexp(string: string): string { + // Escape characters with special meaning either inside or outside character sets. + // Use a simple backslash escape when it’s always valid, and a `\xnn` escape when the simpler form would be disallowed by Unicode patterns’ stricter grammar. + return string.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&').replace(/-/g, '\\x2d'); } /** * Ensure the distance between a header and a non-header is always \n\n */ -function joinContent(...args: string[]): string { +export function joinContent(...args: string[]): string { const trimmedArgs = args.map((content) => content.trim()).filter((content) => content !== ''); if (trimmedArgs.length === 0) return ''; @@ -22,11 +26,11 @@ function joinContent(...args: string[]): string { let result = trimmedArgs[0]; for (let i = 1; i < trimmedArgs.length; i++) { const prev = result.trimEnd(); - const prevLastLine = prev.split('\n').at(-1)!; + const prevLastLine = prev.split('\n').at(-1) ?? ''; const prevIsHeader = HEADER_REGEX.test(prevLastLine); const curr = trimmedArgs[i]; - const currFirstLine = curr.split('\n').at(0)!; + const currFirstLine = curr.split('\n').at(0) ?? ''; const currIsHeader = HEADER_REGEX.test(currFirstLine); const separator = prevIsHeader || currIsHeader ? '\n\n' : '\n'; @@ -35,14 +39,14 @@ function joinContent(...args: string[]): string { return `${result}\n`; } -function findHeader( +export function findHeader( content: string, header: Header ): { before: string; after: string; start: number; end: number } | null { const [headerLevel, ...headerNameArray] = header.split(' '); const headerName = headerNameArray.join(' '); - const sectionRegex = new RegExp(`^(${headerLevel}) ${escapeRegex(headerName)}\\s*$`, 'm'); + const sectionRegex = new RegExp(`^(${headerLevel}) ${escapeStringRegexp(headerName)}\\s*$`, 'm'); const headerMatch = content.match(sectionRegex); if (!headerMatch) return null; @@ -58,7 +62,7 @@ function findHeader( }; } -function findSection( +export function findSection( content: string, header: Header ): { @@ -76,17 +80,17 @@ function findSection( const { start, end: headerEnd, before } = headerMatch; const level = getHeaderLevel(header); const nextHeaderRegex = new RegExp(`^#{${level}} `, 'm'); - const afterHeader = content.slice(headerEnd + 1); + const afterHeader = content.slice(headerEnd); const nextHeaderMatch = afterHeader.match(nextHeaderRegex); let end: number; if (nextHeaderMatch) { - end = headerEnd + 1 + afterHeader.indexOf(nextHeaderMatch[0]); + end = headerEnd + nextHeaderMatch.index!; } else { end = content.length; } - const innerContent = content.slice(headerEnd + 1, end); + const innerContent = content.slice(headerEnd, end); const after = content.slice(end); return { @@ -99,49 +103,7 @@ function findSection( }; } -export function upsert( - content: string, - lines: Array, - options?: { - header?: - | Header - | { - name: Header; - parent?: Header; - }; - } -): string { - const { header } = options ?? {}; - const linesToAdd = lines.filter(Boolean).join('\n'); - - if (!header) { - return joinContent(content, linesToAdd); - } - - if (typeof header === 'string') { - return asdf(content, linesToAdd, header); - } else { - if (!header.parent) { - return asdf(content, linesToAdd, header.name); - } - const section = findSection(content, header.parent); - - if (!section) { - return joinContent(content, header.parent, header.name, linesToAdd); - } - - return joinContent( - section.before, - section.header, - section.innerContent, - header.name, - linesToAdd, - section.after - ); - } -} - -function asdf(content: string, linesToAdd: string, header: Header): string { +export function appendContent(content: string, linesToAdd: string, header: Header): string { const section = findSection(content, header); if (!section) { @@ -163,18 +125,3 @@ function asdf(content: string, linesToAdd: string, header: Header): string { return joinContent(before, linesToAdd, after); } - -export function addNextSteps( - content: string, - lines: Array -): string { - const linesToAdd = lines.filter(Boolean).join('\n'); - - const svSection = findSection(content, '# sv'); - if (!svSection) return content; - - const firstChildMatch = svSection.innerContent.match(/^## Add-on Setup\s*$/m); - if (!firstChildMatch) return content; - - return asdf(content, linesToAdd, '## Add-on Setup'); -} diff --git a/packages/sv/src/addons/better-auth.ts b/packages/sv/src/addons/better-auth.ts index 10d7d43d5..adaf0dcc0 100644 --- a/packages/sv/src/addons/better-auth.ts +++ b/packages/sv/src/addons/better-auth.ts @@ -2,9 +2,9 @@ import { log } from '@clack/prompts'; import { type AstTypes, Walker, + addNextSteps, color, dedent, - md, transforms, resolveCommandArray, createPrinter, @@ -94,15 +94,11 @@ export default defineAddon({ sv.file('.env.example', generateEnv(demoGithub, true)); sv.file('README.md', (content) => { - return md.upsert( - content, - [ - 'better-auth', - '- Run `npm run auth:schema` to generate the auth schema', - '- Run `npm run db:push` to update your database' - ], - { header: '## Add-on Setup' } - ); + return addNextSteps(content, [ + 'better-auth', + '- Run `npm run auth:schema` to generate the auth schema', + '- Run `npm run db:push` to update your database' + ]); }); sv.file( diff --git a/packages/sv/src/addons/drizzle.ts b/packages/sv/src/addons/drizzle.ts index 67c72c296..e74a7816a 100644 --- a/packages/sv/src/addons/drizzle.ts +++ b/packages/sv/src/addons/drizzle.ts @@ -1,8 +1,8 @@ import { + addNextSteps, color, dedent, type TransformFn, - md, transforms, pnpm, resolveCommandArray, @@ -148,17 +148,13 @@ export default defineAddon({ sv.file('.env.example', generateEnv(options, true)); sv.file('README.md', (content) => { - return md.upsert( - content, - [ - 'Drizzle', - options.database === 'd1' && - '- Run `npm run wrangler d1 create ` to create a D1 database', - options.docker && '- Run `npm run db:start` to start the docker container', - '- Run `npm run db:push` to update your database schema' - ], - { header: '## Add-on Setup' } - ); + return addNextSteps(content, [ + 'Drizzle', + options.database === 'd1' && + '- Run `npm run wrangler d1 create ` to create a D1 database', + options.docker && '- Run `npm run db:start` to start the docker container', + '- Run `npm run db:push` to update your database schema' + ]); }); if (options.docker && (options.mysql === 'mysql2' || options.postgresql === 'postgres.js')) { diff --git a/packages/sv/src/addons/playwright.ts b/packages/sv/src/addons/playwright.ts index fcf5aa395..a533377a9 100644 --- a/packages/sv/src/addons/playwright.ts +++ b/packages/sv/src/addons/playwright.ts @@ -1,5 +1,5 @@ import { log } from '@clack/prompts'; -import { color, dedent, md, transforms } from '@sveltejs/sv-utils'; +import { addNextSteps, color, dedent, transforms } from '@sveltejs/sv-utils'; import { defineAddon } from '../core/config.ts'; import { addToDemoPage } from './common.ts'; @@ -91,11 +91,10 @@ export default defineAddon({ ); sv.file('README.md', (content) => { - return md.upsert( - content, - ['Playwright', '- Run `npx playwright install` to download browsers'], - { header: '## Add-on Setup' } - ); + return addNextSteps(content, [ + 'Playwright', + '- Run `npx playwright install` to download browsers' + ]); }); }, diff --git a/packages/sv/src/addons/sveltekit-adapter.ts b/packages/sv/src/addons/sveltekit-adapter.ts index 08c9671a5..840cd4ab5 100644 --- a/packages/sv/src/addons/sveltekit-adapter.ts +++ b/packages/sv/src/addons/sveltekit-adapter.ts @@ -1,7 +1,7 @@ import { + addNextSteps, color, resolveCommandArray, - md, text, transforms, fileExists, @@ -231,11 +231,10 @@ export default defineAddon({ if (options.adapter === 'cloudflare') { sv.file('README.md', (content) => { - return md.upsert( - content, - ['Cloudflare Adapter', '- Run `npm run gen` to generate Cloudflare types'], - { header: '## Add-on Setup' } - ); + return addNextSteps(content, [ + 'Cloudflare Adapter', + '- Run `npm run gen` to generate Cloudflare types' + ]); }); } }, diff --git a/packages/sv/src/cli/create.ts b/packages/sv/src/cli/create.ts index a4dc56248..c04ccf013 100644 --- a/packages/sv/src/cli/create.ts +++ b/packages/sv/src/cli/create.ts @@ -1,5 +1,11 @@ import * as p from '@clack/prompts'; -import { color, resolveCommandArray, commonFilePaths, getPackageJson } from '@sveltejs/sv-utils'; +import { + color, + resolveCommandArray, + commonFilePaths, + getPackageJson, + removeEmptyNextSteps +} from '@sveltejs/sv-utils'; import { Command, Option } from 'commander'; import fs from 'node:fs'; import path from 'node:path'; @@ -387,8 +393,16 @@ async function createProject(cwd: ProjectPath, options: Options) { const prompt = common.buildAndLogArgs(packageManager, 'create', argsFormatted, [directory]); common.insertPrompt(directory, prompt); + if (fs.existsSync(path.join(directory, '.env.example'))) common.insertEnvMsg(directory); + const readmePath = path.join(directory, 'README.md'); + if (fs.existsSync(readmePath)) { + const readmeContent = fs.readFileSync(readmePath, 'utf-8'); + const cleanedContent = removeEmptyNextSteps(readmeContent); + fs.writeFileSync(readmePath, cleanedContent, 'utf-8'); + } + common.updateAgent(directory, language, packageManager ?? 'npm', loadedAddons); if (packageManager) { diff --git a/packages/sv/src/cli/tests/snapshots/create-only/README.md b/packages/sv/src/cli/tests/snapshots/create-only/README.md index 8f6370c25..7844bbfc6 100644 --- a/packages/sv/src/cli/tests/snapshots/create-only/README.md +++ b/packages/sv/src/cli/tests/snapshots/create-only/README.md @@ -2,8 +2,6 @@ Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). -## Add-on Setup - ## Creating a project If you're seeing this, you've probably already done this step. Congrats! diff --git a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/README.md b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/README.md index aaf0a747a..6876f4e1b 100644 --- a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/README.md +++ b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/README.md @@ -2,16 +2,14 @@ Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). -## Add-on Setup +## Next Steps Configure your environment variables based on the `.env.example`. Playwright - Run `npx playwright install` to download browsers - Drizzle - Run `npm run db:push` to update your database schema - better-auth - Run `npm run auth:schema` to generate the auth schema - Run `npm run db:push` to update your database diff --git a/packages/sv/src/core/common.ts b/packages/sv/src/core/common.ts index 372e98918..823c64256 100644 --- a/packages/sv/src/core/common.ts +++ b/packages/sv/src/core/common.ts @@ -278,7 +278,7 @@ export function insertEnvMsg(projectPath: string) { let content = fs.readFileSync(readmePath, 'utf-8'); const message = 'Configure your environment variables based on the `.env.example`.'; - const setupPattern = /## Add-on Setup[\s\S]*?(?=## |$)/; + const setupPattern = /## Next Steps[\s\S]*?(?=## |$)/; const setupMatch = content.match(setupPattern); if (!setupMatch) { fs.writeFileSync(readmePath, content + message + `\n`); @@ -287,7 +287,7 @@ export function insertEnvMsg(projectPath: string) { } const existingSection = setupMatch[0]; - const updatedSection = existingSection.replace(/(## Add-on Setup)/, `$1\n\n${message}`); + const updatedSection = existingSection.replace(/(## Next Steps)/, `$1\n\n${message}`); content = content.replace(setupPattern, updatedSection); fs.writeFileSync(readmePath, content); diff --git a/packages/sv/src/create/shared/README.md b/packages/sv/src/create/shared/README.md index b1dedd1e3..cc5e0f91d 100644 --- a/packages/sv/src/create/shared/README.md +++ b/packages/sv/src/create/shared/README.md @@ -2,7 +2,7 @@ Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). -## Add-on Setup +## Next Steps ## Creating a project From a4606a62e917fd665b6388369a9264cb666319fd Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Wed, 8 Apr 2026 01:52:17 +0800 Subject: [PATCH 12/12] fix template --- .../src/cli/tests/snapshots/create-with-all-addons/README.md | 4 ++-- packages/sv/src/core/common.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/README.md b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/README.md index 6876f4e1b..c80fe73e2 100644 --- a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/README.md +++ b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/README.md @@ -4,8 +4,8 @@ Everything you need to build a Svelte project, powered by [`sv`](https://github. ## Next Steps -Configure your environment variables based on the `.env.example`. - +general +- Configure your environment variables based on the `.env.example`. Playwright - Run `npx playwright install` to download browsers Drizzle diff --git a/packages/sv/src/core/common.ts b/packages/sv/src/core/common.ts index 823c64256..7c8115e76 100644 --- a/packages/sv/src/core/common.ts +++ b/packages/sv/src/core/common.ts @@ -276,7 +276,7 @@ export function insertEnvMsg(projectPath: string) { if (!fs.existsSync(readmePath)) return; let content = fs.readFileSync(readmePath, 'utf-8'); - const message = 'Configure your environment variables based on the `.env.example`.'; + const message = 'general\n- Configure your environment variables based on the `.env.example`.'; const setupPattern = /## Next Steps[\s\S]*?(?=## |$)/; const setupMatch = content.match(setupPattern); @@ -287,7 +287,7 @@ export function insertEnvMsg(projectPath: string) { } const existingSection = setupMatch[0]; - const updatedSection = existingSection.replace(/(## Next Steps)/, `$1\n\n${message}`); + const updatedSection = existingSection.replace(/(## Next Steps)\n/, `$1\n\n${message}`); content = content.replace(setupPattern, updatedSection); fs.writeFileSync(readmePath, content);