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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions packages/sv-utils/src/files.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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;
}
10 changes: 9 additions & 1 deletion packages/sv-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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';
Expand Down
172 changes: 172 additions & 0 deletions packages/sv-utils/src/tests/md.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
127 changes: 127 additions & 0 deletions packages/sv-utils/src/tooling/md.ts
Original file line number Diff line number Diff line change
@@ -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);
}
9 changes: 9 additions & 0 deletions packages/sv/src/addons/better-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { log } from '@clack/prompts';
import {
type AstTypes,
Walker,
addNextSteps,
color,
dedent,
transforms,
Expand Down Expand Up @@ -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 }) => {
Expand Down
Loading
Loading