diff --git a/packages/sv-utils/src/files.ts b/packages/sv-utils/src/files.ts index 8b8633f16..69955f858 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 = { @@ -76,3 +77,37 @@ export function loadPackageJson(cwd: string): { const { data } = parseJson(source); return { source, data: data as Package }; } + +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 8dc8fbb4b..034c0385d 100644 --- a/packages/sv-utils/src/index.ts +++ b/packages/sv-utils/src/index.ts @@ -28,6 +28,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'; @@ -73,7 +74,14 @@ export { sanitizeName } from './sanitize.ts'; export { downloadJson } from './downloadJson.ts'; // File system helpers (sync, workspace-relative paths) -export { fileExists, loadFile, loadPackageJson, saveFile, type Package } from './files.ts'; +export { + fileExists, + loadFile, + loadPackageJson, + saveFile, + type Package, + removeEmptyNextSteps +} from './files.ts'; // Terminal styling export { color } from './color.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..90bf57791 --- /dev/null +++ b/packages/sv-utils/src/tests/md.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest'; +import { findHeader, findSection, joinContent } from '../tooling/md.ts'; + +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'); + }); +}); + +describe('findHeader', () => { + it('finds a header', () => { + const content = '# Hello\n\nSome content\n\n## World\n'; + const result = findHeader(content, '# Hello'); + + 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('returns null when the header is not found', () => { + const content = '# Hello\n\nSome content'; + const result = findHeader(content, '## Missing'); + + expect(result).toBeNull(); + }); + + 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('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('finds a header with trailing whitespace', () => { + const content = '# Header \ncontent'; + const result = findHeader(content, '# Header'); + + expect(result).not.toBeNull(); + }); + + it('finds the first occurrence when duplicate headers exist', () => { + const content = '# Title\n\ncontent1\n\n# Title\n\ncontent2'; + const result = findHeader(content, '# Title'); + + 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).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'); + + expect(result).not.toBeNull(); + expect(result!.header).toBe('# Hello'); + expect(result!.innerContent).toBe('\n\nSome content\n\n## World\nmore content\n\n'); + }); + + it('returns null when the header is not found', () => { + const content = '# Existing\ncontent'; + const result = findSection(content, '## Missing'); + + expect(result).toBeNull(); + }); + + 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('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('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('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 new file mode 100644 index 000000000..d92c20aa2 --- /dev/null +++ b/packages/sv-utils/src/tooling/md.ts @@ -0,0 +1,127 @@ +type Header = `${'#' | '##' | '###' | '####' | '#####' | '######'} ${string}`; +export type Line = string | false | undefined | null | 0 | 0n; + +const HEADER_REGEX = /^#{1,6} .+$/m; + +function getHeaderLevel(header: Header): number { + return header.split(' ')[0].length; +} + +// 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 + */ +export 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`; + + 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); + + 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`; +} + +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}) ${escapeStringRegexp(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) + }; +} + +export 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); + const nextHeaderMatch = afterHeader.match(nextHeaderRegex); + + let end: number; + if (nextHeaderMatch) { + end = headerEnd + nextHeaderMatch.index!; + } else { + end = content.length; + } + + const innerContent = content.slice(headerEnd, end); + const after = content.slice(end); + + return { + before, + after, + header, + innerContent, + start, + end + }; +} + +export function appendContent(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); +} diff --git a/packages/sv/src/addons/better-auth.ts b/packages/sv/src/addons/better-auth.ts index c62845295..adaf0dcc0 100644 --- a/packages/sv/src/addons/better-auth.ts +++ b/packages/sv/src/addons/better-auth.ts @@ -2,6 +2,7 @@ import { log } from '@clack/prompts'; import { type AstTypes, Walker, + addNextSteps, color, dedent, transforms, @@ -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 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( `${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 b710343b1..0fb4f7b83 100644 --- a/packages/sv/src/addons/drizzle.ts +++ b/packages/sv/src/addons/drizzle.ts @@ -1,4 +1,5 @@ import { + addNextSteps, color, dedent, type TransformFn, @@ -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 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')) { 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..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, 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'; @@ -89,6 +89,13 @@ export default defineAddon({ } }) ); + + sv.file('README.md', (content) => { + return addNextSteps(content, [ + '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 4f2340da7..6599d4f16 100644 --- a/packages/sv/src/addons/sveltekit-adapter.ts +++ b/packages/sv/src/addons/sveltekit-adapter.ts @@ -1,4 +1,5 @@ import { + addNextSteps, color, text, transforms, @@ -227,6 +228,15 @@ export default defineAddon({ ); } } + + if (options.adapter === 'cloudflare') { + sv.file('README.md', (content) => { + return addNextSteps(content, [ + 'Cloudflare Adapter', + '- Run `npm run gen` to generate Cloudflare types' + ]); + }); + } }, nextSteps({ options, packageManager }) { const steps: string[] = []; diff --git a/packages/sv/src/cli/create.ts b/packages/sv/src/cli/create.ts index 5f28b9aa5..e4c4484bd 100644 --- a/packages/sv/src/cli/create.ts +++ b/packages/sv/src/cli/create.ts @@ -1,5 +1,5 @@ import * as p from '@clack/prompts'; -import { color, loadPackageJson, resolveCommandArray } from '@sveltejs/sv-utils'; +import { color, resolveCommandArray, removeEmptyNextSteps } from '@sveltejs/sv-utils'; import { Command, Option } from 'commander'; import fs from 'node:fs'; import path from 'node:path'; @@ -387,7 +387,16 @@ 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); + + 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); 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..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 @@ -2,6 +2,18 @@ Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). +## Next Steps + +general +- 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! diff --git a/packages/sv/src/core/common.ts b/packages/sv/src/core/common.ts index dce24eab7..cf90bb5ea 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 = 'general\n- Configure your environment variables based on the `.env.example`.'; + + const setupPattern = /## Next Steps[\s\S]*?(?=## |$)/; + const setupMatch = content.match(setupPattern); + if (!setupMatch) { + fs.writeFileSync(readmePath, content + message + `\n`); + + return; + } + + const existingSection = setupMatch[0]; + const updatedSection = existingSection.replace(/(## Next Steps)\n/, `$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(); diff --git a/packages/sv/src/create/shared/README.md b/packages/sv/src/create/shared/README.md index a0da9f54d..cc5e0f91d 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). +## Next Steps + ## Creating a project If you're seeing this, you've probably already done this step. Congrats!