diff --git a/.changeset/coupling-sv-and-sv-utils.md b/.changeset/coupling-sv-and-sv-utils.md new file mode 100644 index 000000000..f7232eff5 --- /dev/null +++ b/.changeset/coupling-sv-and-sv-utils.md @@ -0,0 +1,22 @@ +--- +'sv': minor +'@sveltejs/sv-utils': minor +--- + +feat: sv / sv-utils coupling, pnpm helpers, experimental add-ons, and API snapshots + +**Highlights** + +- Replace `sv.pnpmBuildDependency` with `sv.file` plus `pnpm.onlyBuiltDependencies` from `@sveltejs/sv-utils` and `file.findUp`. + +**`@sveltejs/sv-utils`** + +- Add `pnpm.onlyBuiltDependencies` to append packages to `onlyBuiltDependencies` in pnpm YAML via `transforms.yaml`. +- Type `YamlDocument` (`parse.yaml`) with `get` / `set` using `unknown` so consumers narrow explicitly; align YAML transforms with that contract. + +**`sv`** + +- Refactor workspace / engine / package-manager flows around file IO and package JSON loading (`loadFile`, `saveFile`, `loadPackageJson`), and trim workspace addon path handling; update addons accordingly. +- Reorganize the public `testing` entry for Vitest helpers and document the surface. +- Add generated `api-surface` markdown snapshots and a `scripts/generate-api-surface.js` helper (wired through the build) to track the public API. +- Remove deprecated `pnpmBuildDependency` usage and stop exporting internal pnpm-only-built helpers from the public `sv` surface. diff --git a/documentation/docs/50-api/10-sv.md b/documentation/docs/50-api/10-sv.md index 70809023a..7e74f6f57 100644 --- a/documentation/docs/50-api/10-sv.md +++ b/documentation/docs/50-api/10-sv.md @@ -48,7 +48,7 @@ export default defineAddon({ }); ``` -The `sv` object in `run` provides `file`, `dependency`, `devDependency`, `execute`, and `pnpmBuildDependency`. For file transforms (AST-based editing of scripts, Svelte components, CSS, JSON, etc.), see [`@sveltejs/sv-utils`](sv-utils). +The `sv` object in `run` provides `file`, `dependency`, `devDependency`, and `execute`. For file transforms (AST-based editing of scripts, Svelte components, CSS, JSON, etc.) and package manager helpers, see [`@sveltejs/sv-utils`](sv-utils). ## `defineAddonOptions` diff --git a/documentation/docs/50-api/20-sv-utils.md b/documentation/docs/50-api/20-sv-utils.md index 600ca9a0c..3e5a543cf 100644 --- a/documentation/docs/50-api/20-sv-utils.md +++ b/documentation/docs/50-api/20-sv-utils.md @@ -137,7 +137,7 @@ Return `false` from any transform callback to abort - the original content is re import { transforms } from '@sveltejs/sv-utils'; sv.file( - file.eslintConfig, + 'eslint.config.js', transforms.script(({ ast, js }) => { const { value: existing } = js.exports.createDefault(ast, { fallback: myConfig }); if (existing !== myConfig) { @@ -229,3 +229,18 @@ Namespaced helpers for AST manipulation: - **`json.*`** - arrayUpsert, packageScriptsUpsert - **`html.*`** - attribute manipulation - **`text.*`** - upsert lines in flat files (.env, .gitignore) + +## Package manager helpers + +### `pnpm.onlyBuiltDependencies` + +Returns a transform for `pnpm-workspace.yaml` that adds packages to the `onlyBuiltDependencies` list. Use with `sv.file` when the project uses pnpm. + +```js +// @noErrors +import { pnpm } from '@sveltejs/sv-utils'; + +if (packageManager === 'pnpm') { + sv.file(file.findUp('pnpm-workspace.yaml'), pnpm.onlyBuiltDependencies('my-native-dep')); +} +``` diff --git a/package.json b/package.json index caf32fd8c..b9d98b03c 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "scripts": { "build": "tsdown", + "postbuild": "node scripts/generate-api-surface.js", "changeset:publish": "changeset publish", "check": "pnpm --parallel check", "dev": "tsdown -w & pnpm --parallel check -w & wait", diff --git a/packages/sv-utils/api-surface.md b/packages/sv-utils/api-surface.md new file mode 100644 index 000000000..d6d69bc91 --- /dev/null +++ b/packages/sv-utils/api-surface.md @@ -0,0 +1,816 @@ +# @sveltejs/sv-utils - Public API Surface + + + +```ts +/*! + * Copyright (c) Squirrel Chat et al., All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +declare class TomlDate extends Date { + #private; + constructor(date: string | Date); + isDateTime(): boolean; + isLocal(): boolean; + isDate(): boolean; + isTime(): boolean; + isValid(): boolean; + toISOString(): string; + static wrapAsOffsetDateTime(jsDate: Date, offset?: string): TomlDate; + static wrapAsLocalDateTime(jsDate: Date): TomlDate; + static wrapAsLocalDate(jsDate: Date): TomlDate; + static wrapAsLocalTime(jsDate: Date): TomlDate; +} +type TomlPrimitive = string | number | bigint | boolean | TomlDate; +type TomlTable = { + [key: string]: TomlValue; +}; +type TomlValue = TomlPrimitive | TomlValue[] | TomlTable; +declare module 'estree' { + interface TSTypeAnnotation { + type: 'TSTypeAnnotation'; + typeAnnotation: TSStringKeyword | TSTypeReference | TSUnionType | TSIndexedAccessType; + } + interface TSStringKeyword { + type: 'TSStringKeyword'; + } + interface TSNullKeyword { + type: 'TSNullKeyword'; + } + interface TSTypeReference { + type: 'TSTypeReference'; + typeName: Identifier; + } + interface TSAsExpression extends BaseNode { + type: 'TSAsExpression'; + expression: Expression; + typeAnnotation: TSTypeAnnotation['typeAnnotation']; + } + interface TSModuleDeclaration extends BaseNode { + type: 'TSModuleDeclaration'; + global: boolean; + declare: boolean; + id: Identifier; + body: TSModuleBlock; + } + interface TSModuleBlock extends BaseNode { + type: 'TSModuleBlock'; + body: Array; + } + interface TSInterfaceDeclaration extends BaseNode { + type: 'TSInterfaceDeclaration'; + id: Identifier; + body: TSInterfaceBody; + } + interface TSInterfaceBody extends BaseNode { + type: 'TSInterfaceBody'; + body: TSPropertySignature[]; + } + interface TSPropertySignature extends BaseNode { + type: 'TSPropertySignature'; + computed: boolean; + key: Identifier; + optional?: boolean; + typeAnnotation: TSTypeAnnotation; + } + interface TSProgram extends Omit { + body: Array; + } + interface TSUnionType { + type: 'TSUnionType'; + types: Array; + } + interface TSImportType { + type: 'TSImportType'; + argument: Literal; + qualifier: Identifier; + } + interface TSIndexedAccessType { + type: 'TSIndexedAccessType'; + objectType: TSImportType; + indexType: TSLiteralType; + } + interface TSLiteralType { + type: 'TSLiteralType'; + literal: Literal; + } + interface TSSatisfiesExpression extends BaseNode { + type: 'TSSatisfiesExpression'; + expression: Expression; + typeAnnotation: TSTypeAnnotation['typeAnnotation']; + } + interface BaseNodeWithoutComments { + type: string; + loc?: SourceLocation | null | undefined; + range?: [number, number] | undefined; + start?: number; + end?: number; + } + interface Identifier { + typeAnnotation?: TSTypeAnnotation; + } + interface ExpressionMap { + TSAsExpression: TSAsExpression; + TSSatisfiesExpression: TSSatisfiesExpression; + } + interface NodeMap { + TSModuleDeclaration: TSModuleDeclaration; + TSInterfaceDeclaration: TSInterfaceDeclaration; + } + interface ImportDeclaration { + importKind: 'type' | 'value'; + } +} +type CommentType = { + type: 'Line' | 'Block'; + value: string; +}; +declare class Comments { + private original; + private leading; + private trailing; + constructor(); + add( + node: BaseNode$1, + comment: CommentType, + options?: { + position?: 'leading' | 'trailing'; + } + ): void; + remove(predicate: (comment: estree.Comment) => boolean | undefined | null): void; +} + +type YamlDocument = { + get(key: string): unknown; + set(key: string, value: unknown): void; +}; +type ParseBase = { + source: string; + + generateCode(): string; +}; +declare function parseScript(source: string): { + ast: estree.Program; + comments: Comments; +} & ParseBase; +declare function parseCss(source: string): { + ast: Omit; +} & ParseBase; +declare function parseHtml(source: string): { + ast: SvelteAst.Fragment; +} & ParseBase; +declare function parseJson(source: string): { + data: any; +} & ParseBase; +declare function parseYaml(source: string): { + data: YamlDocument; +} & ParseBase; +declare function parseSvelte(source: string): { + ast: SvelteAst.Root; +} & ParseBase; +declare function parseToml(source: string): { + data: TomlTable; +} & ParseBase; + +type Dedent = { + (strings: TemplateStringsArray, ...values: unknown[]): string; + (source: string): string; +}; +declare const dedent: Dedent; +declare module 'zimmerframe' { + export function walk< + T extends { + type: string; + }, + U extends Record | null + >(node: T, state: U, visitors: Visitors): T; + type BaseNode = { + type: string; + }; + type NodeOf = X extends { + type: T; + } + ? X + : never; + type SpecialisedVisitors = { + [K in T['type']]?: Visitor, U, T>; + }; + export type Visitor = (node: T, context: Context) => V | void; + export type Visitors = T['type'] extends '_' + ? never + : SpecialisedVisitors & { + _?: Visitor; + }; + export interface Context { + next: (state?: U) => T | void; + path: T[]; + state: U; + stop: () => void; + visit: (node: T, state?: U) => T; + } + export {}; +} //# sourceMappingURL=index.d.ts.map +declare function resolveCommandArray(agent: Agent, command: Command, args: string[]): string[]; +declare namespace index_d_exports$1 { + export { addAtRule, addDeclaration, addImports, addRule }; +} +declare function addRule( + node: SvelteAst.CSS.StyleSheetBase, + options: { + selector: string; + } +): SvelteAst.CSS.Rule; +declare function addDeclaration( + node: SvelteAst.CSS.Rule, + options: { + property: string; + value: string; + } +): void; +declare function addImports( + node: SvelteAst.CSS.StyleSheetBase, + options: { + imports: string[]; + } +): void; +declare function addAtRule( + node: SvelteAst.CSS.StyleSheetBase, + options: { + name: string; + params: string; + append: boolean; + } +): SvelteAst.CSS.Atrule; +declare namespace array_d_exports { + export { append, create$1 as create, prepend }; +} +declare function create$1(): estree.ArrayExpression; +declare function append( + node: estree.ArrayExpression, + element: string | estree.Expression | estree.SpreadElement +): void; +declare function prepend( + node: estree.ArrayExpression, + element: string | estree.Expression | estree.SpreadElement +): void; +declare namespace object_d_exports { + export { create, overrideProperties, property, propertyNode }; +} +type ObjectPrimitiveValues = string | number | boolean | undefined | null; +type ObjectValues = ObjectPrimitiveValues | Record | ObjectValues[]; +type ObjectMap = Record; +declare function property( + node: estree.ObjectExpression, + options: { + name: string; + fallback: T; + } +): T; +declare function propertyNode( + node: estree.ObjectExpression, + options: { + name: string; + fallback: T; + } +): estree.Property; +declare function create(properties: ObjectMap): estree.ObjectExpression; +declare function overrideProperties( + objectExpression: estree.ObjectExpression, + properties: ObjectMap +): void; +declare namespace common_d_exports { + export { + addJsDocComment, + addJsDocTypeComment, + appendFromString, + appendStatement, + areNodesEqual, + contains, + createBlockStatement, + createExpressionStatement, + createLiteral, + createSatisfies, + createSpread, + createTypeProperty, + hasTypeProperty, + parseExpression, + parseFromString, + parseStatement, + typeAnnotate + }; +} +declare function addJsDocTypeComment( + node: estree.Node, + comments: Comments, + options: { + type: string; + } +): void; +declare function addJsDocComment( + node: estree.Node, + comments: Comments, + options: { + params: Record; + } +): void; +declare function typeAnnotate( + node: estree.Expression, + options: { + type: string; + } +): estree.TSAsExpression; +declare function createSatisfies( + node: estree.Expression, + options: { + type: string; + } +): estree.TSSatisfiesExpression; +declare function createSpread(argument: estree.Expression): estree.SpreadElement; +declare function createLiteral(value: string | number | boolean | null): estree.Literal; +declare function areNodesEqual(node: estree.Node, otherNode: estree.Node): boolean; +declare function createBlockStatement(): estree.BlockStatement; +declare function createExpressionStatement(options: { + expression: estree.Expression; +}): estree.ExpressionStatement; +declare function appendFromString( + node: estree.BlockStatement | estree.Program, + options: { + code: string; + comments?: Comments; + } +): void; +declare function parseExpression(code: string): estree.Expression; +declare function parseStatement(code: string): estree.Statement; +declare function parseFromString(code: string): T; + +declare function appendStatement( + node: estree.BlockStatement | estree.Program, + options: { + statement: estree.Statement; + } +): void; + +declare function contains(node: estree.Node, targetNode: estree.Node): boolean; +declare function hasTypeProperty( + node: estree.TSInterfaceDeclaration['body']['body'][number], + options: { + name: string; + } +): boolean; +declare function createTypeProperty( + name: string, + value: string, + optional?: boolean +): estree.TSInterfaceBody['body'][number]; +declare namespace function_d_exports { + export { createArrow, createCall, getArgument }; +} +declare function createCall(options: { + name: string; + args: string[]; + useIdentifiers?: boolean; +}): estree.CallExpression; +declare function createArrow(options: { + body: estree.Expression | estree.BlockStatement; + async: boolean; +}): estree.ArrowFunctionExpression; +declare function getArgument( + node: estree.CallExpression, + options: { + index: number; + fallback: T; + } +): T; +declare namespace imports_d_exports { + export { addDefault, addEmpty, addNamed, addNamespace$1 as addNamespace, find, remove }; +} +declare function addEmpty( + node: estree.Program, + options: { + from: string; + } +): void; +declare function addNamespace$1( + node: estree.Program, + options: { + from: string; + as: string; + } +): void; +declare function addDefault( + node: estree.Program, + options: { + from: string; + as: string; + } +): void; +declare function addNamed( + node: estree.Program, + options: { + imports: Record | string[]; + from: string; + isType?: boolean; + } +): void; +declare function find( + ast: estree.Program, + options: { + name: string; + from: string; + } +): + | { + statement: estree.ImportDeclaration; + alias: string; + } + | { + statement: undefined; + alias: undefined; + }; +declare function remove( + ast: estree.Program, + options: { + name: string; + from: string; + statement?: estree.ImportDeclaration; + } +): void; +declare namespace variables_d_exports { + export { createIdentifier, declaration, typeAnnotateDeclarator }; +} +declare function declaration( + node: estree.Program | estree.Declaration, + options: { + kind: 'const' | 'let' | 'var'; + name: string; + value: estree.Expression; + } +): estree.VariableDeclaration; +declare function createIdentifier(name: string): estree.Identifier; +declare function typeAnnotateDeclarator( + node: estree.VariableDeclarator, + options: { + typeName: string; + } +): estree.VariableDeclarator; +declare namespace exports_d_exports { + export { ExportDefaultResult, addNamespace, createDefault, createNamed }; +} +type ExportDefaultResult = { + astNode: estree.ExportDefaultDeclaration; + value: T; + isFallback: boolean; +}; +declare function createDefault( + node: estree.Program, + options: { + fallback: T; + } +): ExportDefaultResult; +declare function createNamed( + node: estree.Program, + options: { + name: string; + fallback: estree.VariableDeclaration; + } +): estree.ExportNamedDeclaration; +declare function addNamespace( + node: estree.Program, + options: { + from: string; + as?: string; + } +): void; +declare namespace kit_d_exports { + export { addGlobalAppInterface, addHooksHandle }; +} +declare function addGlobalAppInterface( + node: estree.TSProgram, + options: { + name: 'Error' | 'Locals' | 'PageData' | 'PageState' | 'Platform'; + } +): estree.TSInterfaceDeclaration; +declare function addHooksHandle( + node: estree.Program, + options: { + language: 'ts' | 'js'; + newHandleName: string; + handleContent: string; + comments: Comments; + } +): void; +declare namespace vite_d_exports { + export { addPlugin, configProperty, getConfig }; +} +declare const addPlugin: ( + ast: estree.Program, + options: { + code: string; + mode?: 'append' | 'prepend'; + } +) => void; + +declare function configProperty( + ast: estree.Program, + config: estree.ObjectExpression, + options: { + name: string; + fallback: T; + } +): T; +declare const getConfig: (ast: estree.Program) => estree.ObjectExpression; +declare namespace index_d_exports$3 { + export { + array_d_exports as array, + common_d_exports as common, + exports_d_exports as exports, + function_d_exports as functions, + imports_d_exports as imports, + kit_d_exports as kit, + object_d_exports as object, + variables_d_exports as variables, + vite_d_exports as vite + }; +} +declare namespace index_d_exports$2 { + export { addAttribute, addFromRawHtml, appendElement, createElement, insertElement }; +} +declare function createElement( + tagName: string, + attributes?: Record +): SvelteAst.RegularElement; +declare function addAttribute(element: SvelteAst.RegularElement, name: string, value: string): void; +declare function insertElement( + fragment: SvelteAst.Fragment, + elementToInsert: SvelteAst.Fragment['nodes'][0] +): void; +declare function appendElement( + fragment: SvelteAst.Fragment, + elementToAppend: SvelteAst.Fragment['nodes'][0] +): void; +declare function addFromRawHtml(fragment: SvelteAst.Fragment, html: string): void; +declare namespace text_d_exports { + export { upsert }; +} +type CommentEntry = { + text: string; + mode: 'append' | 'prepend'; +}; +type CommentOption = string | Array; + +declare function upsert( + content: string, + key: string, + options?: { + value?: string; + comment?: CommentOption; + separator?: boolean; + } +): string; +declare namespace json_d_exports { + export { arrayUpsert, packageScriptsUpsert }; +} +declare function arrayUpsert( + data: any, + key: string, + value: any, + options?: { + mode?: 'append' | 'prepend'; + } +): void; +declare function packageScriptsUpsert( + data: any, + key: string, + value: string, + options?: { + mode?: 'append' | 'prepend'; + } +): void; +declare namespace index_d_exports$4 { + export { RootWithInstance, addFragment, addSlot, ensureScript }; +} +type RootWithInstance = SvelteAst.Root & { + instance: SvelteAst.Script; +}; +declare function ensureScript( + ast: SvelteAst.Root, + options?: { + language?: 'ts' | 'js'; + } +): asserts ast is RootWithInstance; +declare function addSlot( + ast: SvelteAst.Root, + options: { + svelteVersion: string; + language?: 'ts' | 'js'; + } +): void; +declare function addFragment( + ast: SvelteAst.Root, + content: string, + options?: { + mode?: 'append' | 'prepend'; + } +): void; +type TransformFn = (content: string) => string; +type TransformOptions = { + onError?: (error: unknown) => void; +}; + +declare const transforms: { + script( + cb: (file: { + ast: estree.Program; + comments: Comments; + content: string; + js: typeof index_d_exports$3; + }) => void | false, + options?: TransformOptions + ): (content: string) => string; + + svelte( + cb: (file: { + ast: SvelteAst.Root; + content: string; + svelte: typeof index_d_exports$4; + js: typeof index_d_exports$3; + }) => void | false, + options?: TransformOptions + ): (content: string) => string; + + svelteScript( + scriptOptions: { + language: 'ts' | 'js'; + }, + cb: (file: { + ast: RootWithInstance; + content: string; + svelte: typeof index_d_exports$4; + js: typeof index_d_exports$3; + }) => void | false, + options?: TransformOptions + ): TransformFn; + + css( + cb: (file: { + ast: Omit; + content: string; + css: typeof index_d_exports$1; + }) => void | false, + options?: TransformOptions + ): TransformFn; + + json( + cb: (file: { data: T; content: string; json: typeof json_d_exports }) => void | false, + options?: TransformOptions + ): TransformFn; + + yaml( + cb: (file: { data: YamlDocument; content: string }) => void | false, + options?: TransformOptions + ): TransformFn; + + toml( + cb: (file: { data: TomlTable; content: string }) => void | false, + options?: TransformOptions + ): TransformFn; + + html( + cb: (file: { + ast: SvelteAst.Fragment; + content: string; + html: typeof index_d_exports$2; + }) => void | false, + options?: TransformOptions + ): TransformFn; + + text(cb: (file: { content: string; text: typeof text_d_exports }) => string | false): TransformFn; +}; +declare namespace pnpm_d_exports { + export { onlyBuiltDependencies }; +} + +declare function onlyBuiltDependencies(...packages: string[]): TransformFn; +type Version = { + major?: number; + minor?: number; + patch?: number; +}; +declare function splitVersion(str: string): Version; +declare function isVersionUnsupportedBelow( + versionStr: string, + belowStr: string +): boolean | undefined; +type Printer = (content: string, alt?: string) => string; +declare function createPrinter(...conditions: boolean[]): Printer[]; + +declare function sanitizeName(name: string, style: 'package' | 'wrangler'): string; +declare const downloadJson: (url: string) => Promise; +type Package = { + name: string; + version: string; + dependencies?: Record; + devDependencies?: Record; + bugs?: string; + repository?: { + type: string; + url: string; + }; + keywords?: string[]; + workspaces?: string[]; +}; +declare function fileExists(cwd: string, filePath: string): boolean; + +declare function loadFile(cwd: string, filePath: string): string; + +declare function saveFile(cwd: string, filePath: string, content: string): void; +declare function loadPackageJson(cwd: string): { + source: string; + data: Package; + generateCode: () => string; +}; +type ColorInput = string | string[]; +declare const color: { + addon: (str: ColorInput) => string; + command: (str: ColorInput) => string; + env: (str: ColorInput) => string; + path: (str: ColorInput) => string; + route: (str: ColorInput) => string; + website: (str: ColorInput) => string; + optional: (str: ColorInput) => string; + dim: (str: ColorInput) => string; + success: (str: ColorInput) => string; + warning: (str: ColorInput) => string; + error: (str: ColorInput) => string; + hidden: (str: ColorInput) => string; +}; + +declare const parse: { + css: typeof parseCss; + html: typeof parseHtml; + json: typeof parseJson; + script: typeof parseScript; + svelte: typeof parseSvelte; + toml: typeof parseToml; + yaml: typeof parseYaml; +}; +export { + AGENTS, + type AgentName, + type estree as AstTypes, + COMMANDS, + type Comments, + type Package, + type SvelteAst, + type TransformFn, + index_d_exports as Walker, + type YamlDocument, + color, + constructCommand, + createPrinter, + index_d_exports$1 as css, + dedent, + detect, + downloadJson, + fileExists, + index_d_exports$2 as html, + isVersionUnsupportedBelow, + index_d_exports$3 as js, + json_d_exports as json, + loadFile, + loadPackageJson, + parse, + pnpm_d_exports as pnpm, + resolveCommand, + resolveCommandArray, + sanitizeName, + saveFile, + splitVersion, + index_d_exports$4 as svelte, + text_d_exports as text, + transforms +}; +``` diff --git a/packages/sv-utils/src/dedent.ts b/packages/sv-utils/src/dedent.ts new file mode 100644 index 000000000..1210bdac9 --- /dev/null +++ b/packages/sv-utils/src/dedent.ts @@ -0,0 +1,13 @@ +import dedentImpl from 'dedent'; + +/** + * Template-tag or single-string dedent helper (same behavior as the `dedent` package). + * Types are hand-written so the public `.d.mts` does not inline `dedent`'s full declarations. + */ +export type Dedent = { + (strings: TemplateStringsArray, ...values: unknown[]): string; + (source: string): string; +}; + +const dedent: Dedent = dedentImpl as Dedent; +export default dedent; diff --git a/packages/sv-utils/src/files.ts b/packages/sv-utils/src/files.ts index 4dd22c359..233a11323 100644 --- a/packages/sv-utils/src/files.ts +++ b/packages/sv-utils/src/files.ts @@ -13,22 +13,13 @@ export type Package = { workspaces?: string[]; }; -export function getPackageJson(cwd: string): { - source: string; - data: Package; - generateCode: () => string; -} { - const packageText = readFile(cwd, commonFilePaths.packageJson); - if (!packageText) { - const pkgPath = path.join(cwd, commonFilePaths.packageJson); - throw new Error(`Invalid workspace: missing '${pkgPath}'`); - } - - const { data, generateCode } = parseJson(packageText); - return { source: packageText, data: data as Package, generateCode }; +export function fileExists(cwd: string, filePath: string): boolean { + const fullFilePath = path.resolve(cwd, filePath); + return fs.existsSync(fullFilePath); } -export function readFile(cwd: string, filePath: string): string { +/** Synchronous load of a workspace-relative file as UTF-8 text; missing files yield `''`. */ +export function loadFile(cwd: string, filePath: string): string { const fullFilePath = path.resolve(cwd, filePath); if (!fileExists(cwd, filePath)) { @@ -40,12 +31,8 @@ export function readFile(cwd: string, filePath: string): string { return text; } -export function fileExists(cwd: string, filePath: string): boolean { - const fullFilePath = path.resolve(cwd, filePath); - return fs.existsSync(fullFilePath); -} - -export function writeFile(cwd: string, filePath: string, content: string): void { +/** Synchronous write of a workspace-relative file (creates parent dirs). */ +export function saveFile(cwd: string, filePath: string, content: string): void { const fullFilePath = path.resolve(cwd, filePath); const fullDirectoryPath = path.dirname(fullFilePath); @@ -58,44 +45,17 @@ export function writeFile(cwd: string, filePath: string, content: string): void fs.writeFileSync(fullFilePath, content, 'utf8'); } -export function installPackages( - dependencies: Array<{ pkg: string; version: string; dev: boolean }>, - cwd: string -): string { - const { data, generateCode } = getPackageJson(cwd); - - for (const dependency of dependencies) { - if (dependency.dev) { - data.devDependencies ??= {}; - data.devDependencies[dependency.pkg] = dependency.version; - } else { - data.dependencies ??= {}; - data.dependencies[dependency.pkg] = dependency.version; - } +export function loadPackageJson(cwd: string): { + source: string; + data: Package; + generateCode: () => string; +} { + const packageText = loadFile(cwd, 'package.json'); + if (!packageText) { + const pkgPath = path.join(cwd, 'package.json'); + throw new Error(`Invalid workspace: missing '${pkgPath}'`); } - if (data.dependencies) data.dependencies = alphabetizeProperties(data.dependencies); - if (data.devDependencies) data.devDependencies = alphabetizeProperties(data.devDependencies); - - writeFile(cwd, commonFilePaths.packageJson, generateCode()); - return commonFilePaths.packageJson; -} - -function alphabetizeProperties(obj: Record) { - const orderedObj: Record = {}; - const sortedEntries = Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)); - for (const [key, value] of sortedEntries) { - orderedObj[key] = value; - } - return orderedObj; + const { data, generateCode } = parseJson(packageText); + return { source: packageText, data: data as Package, generateCode }; } - -export const commonFilePaths = { - packageJson: 'package.json', - svelteConfig: 'svelte.config.js', - svelteConfigTS: 'svelte.config.ts', - jsconfig: 'jsconfig.json', - tsconfig: 'tsconfig.json', - viteConfig: 'vite.config.js', - viteConfigTS: 'vite.config.ts' -} as const; diff --git a/packages/sv-utils/src/index.ts b/packages/sv-utils/src/index.ts index 29005fa4a..74f334c47 100644 --- a/packages/sv-utils/src/index.ts +++ b/packages/sv-utils/src/index.ts @@ -1,8 +1,3 @@ -import { - resolveCommand as _resolveCommand, - type Agent, - type Command -} from 'package-manager-detector'; import { parseCss, parseHtml, @@ -14,22 +9,19 @@ import { } from './tooling/parsers.ts'; // External re-exports -export { default as dedent } from 'dedent'; +export { default as dedent } from './dedent.ts'; export * as Walker from 'zimmerframe'; + +// Package managers (delegates to `package-manager-detector`; see `pm.ts`) export { AGENTS, type AgentName, COMMANDS, constructCommand, detect, - resolveCommand -} from 'package-manager-detector'; - -/** Resolves a package manager command and returns it as a string array (command + args). */ -export function resolveCommandArray(agent: Agent, command: Command, args: string[]): string[] { - const cmd = _resolveCommand(agent, command, args)!; - return [cmd.command, ...cmd.args]; -} + resolveCommand, + resolveCommandArray +} from './pm.ts'; // Parsing & language namespaces export * as css from './tooling/css/index.ts'; @@ -39,6 +31,9 @@ export * as text from './tooling/text.ts'; export * as json from './tooling/json.ts'; export * as svelte from './tooling/svelte/index.ts'; +// Package manager helpers +export * as pnpm from './pnpm.ts'; + // Transforms — sv-utils = what to do to content, sv = where and when to do it. export { transforms } from './tooling/transforms.ts'; @@ -77,16 +72,8 @@ export { createPrinter } from './utils.ts'; export { sanitizeName } from './sanitize.ts'; export { downloadJson } from './downloadJson.ts'; -// File system helpers -export { - commonFilePaths, - fileExists, - getPackageJson, - installPackages, - readFile, - writeFile, - type Package -} from './files.ts'; +// File system helpers (sync, workspace-relative paths) +export { fileExists, loadFile, loadPackageJson, saveFile, type Package } from './files.ts'; // Terminal styling export { color } from './color.ts'; @@ -94,3 +81,4 @@ export { color } from './color.ts'; // Types export type { Comments, AstTypes, SvelteAst } from './tooling/index.ts'; export type { TransformFn } from './tooling/transforms.ts'; +export type { YamlDocument } from './tooling/parsers.ts'; diff --git a/packages/sv-utils/src/pm.ts b/packages/sv-utils/src/pm.ts new file mode 100644 index 000000000..660b23c6a --- /dev/null +++ b/packages/sv-utils/src/pm.ts @@ -0,0 +1,24 @@ +/** + * Thin wrapper around [`package-manager-detector`](https://github.com/antfu/package-manager-detector). + * Only the symbols re-exported from the package root are public — we keep this module small and + * avoid exposing the full upstream surface. + */ +import { + resolveCommand as _resolveCommand, + type Agent, + type Command +} from 'package-manager-detector'; + +export { + AGENTS, + type AgentName, + COMMANDS, + constructCommand, + detect, + resolveCommand +} from 'package-manager-detector'; + +export function resolveCommandArray(agent: Agent, command: Command, args: string[]): string[] { + const cmd = _resolveCommand(agent, command, args)!; + return [cmd.command, ...cmd.args]; +} diff --git a/packages/sv-utils/src/pnpm.ts b/packages/sv-utils/src/pnpm.ts new file mode 100644 index 000000000..c7979e8f3 --- /dev/null +++ b/packages/sv-utils/src/pnpm.ts @@ -0,0 +1,26 @@ +import { transforms, type TransformFn } from './tooling/transforms.ts'; + +/** + * Returns a TransformFn for `pnpm-workspace.yaml` that adds packages to `onlyBuiltDependencies`. + * + * Use with `sv.file`: + * ```ts + * if (packageManager === 'pnpm') { + * sv.file(file.findUp('pnpm-workspace.yaml'), pnpm.onlyBuiltDependencies('my-native-dep')); + * } + * ``` + */ +export function onlyBuiltDependencies(...packages: string[]): TransformFn { + return transforms.yaml(({ data }) => { + const existing = data.get('onlyBuiltDependencies') as + | { items?: Array<{ value: string } | string> } + | undefined; + const items: Array<{ value: string } | string> = existing?.items ?? []; + for (const pkg of packages) { + if (items.includes(pkg)) continue; + if (items.some((y) => typeof y === 'object' && y.value === pkg)) continue; + items.push(pkg); + } + data.set('onlyBuiltDependencies', items); + }); +} diff --git a/packages/sv-utils/src/tooling/parsers.ts b/packages/sv-utils/src/tooling/parsers.ts index f5661ebc2..6ae53f255 100644 --- a/packages/sv-utils/src/tooling/parsers.ts +++ b/packages/sv-utils/src/tooling/parsers.ts @@ -1,6 +1,15 @@ import type { TomlTable } from 'smol-toml'; import * as utils from './index.ts'; +/** + * Minimal shape for YAML document roots from `parse.yaml` — avoids re-exporting the full `yaml` types. + * At runtime this is the library’s document type; only `get` / `set` are part of the public contract. + */ +export type YamlDocument = { + get(key: string): unknown; + set(key: string, value: unknown): void; +}; + type ParseBase = { source: string; /** @@ -52,14 +61,12 @@ export function parseJson(source: string): { data: any } & ParseBase { return { data, source, generateCode }; } -export function parseYaml( - source: string -): { data: ReturnType } & ParseBase { +export function parseYaml(source: string): { data: YamlDocument } & ParseBase { if (!source) source = ''; const data = utils.parseYaml(source); - const generateCode = () => utils.serializeYaml(data); + const generateCode = () => utils.serializeYaml(data as Parameters[0]); - return { data, source, generateCode }; + return { data: data as YamlDocument, source, generateCode }; } export function parseSvelte(source: string): { ast: utils.SvelteAst.Root } & ParseBase { diff --git a/packages/sv-utils/src/tooling/transforms.ts b/packages/sv-utils/src/tooling/transforms.ts index 5d27c1696..361b5925b 100644 --- a/packages/sv-utils/src/tooling/transforms.ts +++ b/packages/sv-utils/src/tooling/transforms.ts @@ -12,7 +12,8 @@ import { parseScript, parseSvelte, parseToml, - parseYaml + parseYaml, + type YamlDocument } from './parsers.ts'; import { type RootWithInstance, ensureScript } from './svelte/index.ts'; import * as svelteNs from './svelte/index.ts'; @@ -190,7 +191,7 @@ export const transforms = { * Return `false` from the callback to abort - the original content is returned unchanged. */ yaml( - cb: (file: { data: ReturnType['data']; content: string }) => void | false, + cb: (file: { data: YamlDocument; content: string }) => void | false, options?: TransformOptions ): TransformFn { return (content) => { diff --git a/packages/sv/api-surface-testing.md b/packages/sv/api-surface-testing.md new file mode 100644 index 000000000..5f0ded335 --- /dev/null +++ b/packages/sv/api-surface-testing.md @@ -0,0 +1,85 @@ +# sv (testing) - Public API Surface + + + +```ts +type ProjectVariant = 'kit-js' | 'kit-ts' | 'vite-js' | 'vite-ts'; +declare const variants: ProjectVariant[]; +type CreateProject = (options: { + testId: string; + variant: ProjectVariant; + clean?: boolean; +}) => string; +declare module 'vitest' { + interface ProvidedContext { + testDir: string; + templatesDir: string; + variants: ProjectVariant[]; + } +} +declare function setupGlobal({ + TEST_DIR, + pre, + post +}: { + TEST_DIR: string; + pre?: () => Promise; + post?: () => Promise; +}): ({ provide }: TestProject) => Promise<() => Promise>; +type Fixtures = { + page: Page; + cwd(addonTestCase: AddonTestCase): string; +}; +type AddonTestCase = { + variant: ProjectVariant; + kind: { + type: string; + options: OptionMap; + }; +}; +type SetupTestOptions = { + kinds: Array['kind']>; + filter?: (addonTestCase: AddonTestCase) => boolean; + browser?: boolean; + preAdd?: (o: { addonTestCase: AddonTestCase; cwd: string }) => Promise | void; +}; +type PrepareServerOptions = { + cwd: string; + page: Page; + buildCommand?: string; + previewCommand?: string; +}; +type PrepareServerReturn = { + url: string; + close: () => Promise; +}; +declare function prepareServer({ + cwd, + page, + buildCommand, + previewCommand +}: PrepareServerOptions): Promise; +type VitestContext = Pick; +declare function createSetupTest(vitest: VitestContext): ( + addons: Addons, + options?: SetupTestOptions +) => { + test: vitest.TestAPI; + testCases: Array>; + prepareServer: typeof prepareServer; +}; +export { + AddonTestCase, + CreateProject, + Fixtures, + PrepareServerOptions, + PrepareServerReturn, + ProjectVariant, + SetupTestOptions, + VitestContext, + createSetupTest, + prepareServer, + setupGlobal, + variants +}; +``` diff --git a/packages/sv/api-surface.md b/packages/sv/api-surface.md new file mode 100644 index 000000000..77af976cf --- /dev/null +++ b/packages/sv/api-surface.md @@ -0,0 +1,62 @@ +# sv - Public API Surface + + + +```ts +type TemplateType = (typeof templateTypes)[number]; +type LanguageType = (typeof languageTypes)[number]; +declare const templateTypes: readonly ['minimal', 'demo', 'library', 'addon', 'svelte']; +declare const languageTypes: readonly ['typescript', 'checkjs', 'none']; +type Options = { + cwd: string; + name: string; + template: TemplateType; + types: LanguageType; +}; +declare function create({ cwd, ...options }: Options): void; +type FileEditor = Workspace & { + content: string; +}; +type FileType = { + name: (options: Workspace) => string; + condition?: ConditionDefinition; + content: (editor: FileEditor) => string; +}; +export { + type Addon, + type AddonDefinition, + type AddonInput, + type AddonMap, + type AddonReference, + type AddonResult, + type AddonSource, + type BaseQuestion, + type BooleanQuestion, + type ConfiguredAddon, + type FileEditor, + type FileType, + type InstallOptions, + type LanguageType, + type LoadedAddon, + type MultiSelectQuestion, + type NumberQuestion, + type OptionBuilder, + type OptionDefinition, + type OptionMap, + type OptionValues, + type PreparedAddon, + type Question, + type SelectQuestion, + type SetupResult, + type StringQuestion, + type SvApi, + type TemplateType, + type Workspace, + type WorkspaceOptions, + add, + create, + defineAddon, + defineAddonOptions, + officialAddons +}; +``` diff --git a/packages/sv/src/addons/drizzle.ts b/packages/sv/src/addons/drizzle.ts index f807f11cc..b710343b1 100644 --- a/packages/sv/src/addons/drizzle.ts +++ b/packages/sv/src/addons/drizzle.ts @@ -3,6 +3,7 @@ import { dedent, type TransformFn, transforms, + pnpm, resolveCommandArray, fileExists, createPrinter @@ -89,7 +90,17 @@ export default defineAddon({ if (!isKit) return unsupported('Requires SvelteKit'); }, - run: ({ sv, language, options, directory, dependencyVersion, cwd, cancel, file }) => { + run: ({ + sv, + language, + options, + directory, + dependencyVersion, + cwd, + cancel, + file, + packageManager + }) => { if (options.database === 'd1' && !dependencyVersion('@sveltejs/adapter-cloudflare')) { return cancel('Cloudflare D1 requires @sveltejs/adapter-cloudflare - add the adapter first'); } @@ -124,7 +135,9 @@ export default defineAddon({ // not a devDependency due to bundling issues sv.dependency('better-sqlite3', '^12.6.2'); sv.devDependency('@types/better-sqlite3', '^7.6.13'); - sv.pnpmBuildDependency('better-sqlite3'); + if (packageManager === 'pnpm') { + sv.file(file.findUp('pnpm-workspace.yaml'), pnpm.onlyBuiltDependencies('better-sqlite3')); + } } if (options.sqlite === 'libsql' || options.sqlite === 'turso') @@ -206,7 +219,7 @@ export default defineAddon({ const hasPrettier = Boolean(dependencyVersion('prettier')); if (hasPrettier) { sv.file( - file.prettierignore, + '.prettierignore', transforms.text(({ content, text }) => text.upsert(content, '/drizzle/')) ); } diff --git a/packages/sv/src/addons/eslint.ts b/packages/sv/src/addons/eslint.ts index 58b1d00a4..4d6197676 100644 --- a/packages/sv/src/addons/eslint.ts +++ b/packages/sv/src/addons/eslint.ts @@ -31,7 +31,7 @@ export default defineAddon({ ); sv.file( - file.eslintConfig, + 'eslint.config.js', transforms.script(({ ast, comments, js }) => { const eslintConfigs: Array = []; js.imports.addDefault(ast, { from: './svelte.config.js', as: 'svelteConfig' }); @@ -156,14 +156,14 @@ export default defineAddon({ ); sv.file( - file.vscodeExtensions, + '.vscode/extensions.json', transforms.json(({ data, json }) => { json.arrayUpsert(data, 'recommendations', 'dbaeumer.vscode-eslint'); }) ); if (prettierInstalled) { - sv.file(file.eslintConfig, addEslintConfigPrettier); + sv.file('eslint.config.js', addEslintConfigPrettier); } } }); diff --git a/packages/sv/src/addons/prettier.ts b/packages/sv/src/addons/prettier.ts index eea8f1252..69629f2cc 100644 --- a/packages/sv/src/addons/prettier.ts +++ b/packages/sv/src/addons/prettier.ts @@ -16,7 +16,7 @@ export default defineAddon({ sv.devDependency('prettier-plugin-svelte', '^3.4.1'); sv.file( - file.prettierignore, + '.prettierignore', transforms.text(({ content }) => { if (content) return false; return dedent` @@ -34,7 +34,7 @@ export default defineAddon({ ); sv.file( - file.prettierrc, + '.prettierrc', transforms.json( ({ data, json }) => { if (Object.keys(data).length === 0) { @@ -82,7 +82,7 @@ export default defineAddon({ ); sv.file( - file.vscodeExtensions, + '.vscode/extensions.json', transforms.json(({ data, json }) => { json.arrayUpsert(data, 'recommendations', 'esbenp.prettier-vscode'); }) @@ -98,7 +98,7 @@ export default defineAddon({ if (eslintInstalled) { sv.devDependency('eslint-config-prettier', '^10.1.8'); - sv.file(file.eslintConfig, addEslintConfigPrettier); + sv.file('eslint.config.js', addEslintConfigPrettier); } } }); diff --git a/packages/sv/src/addons/sveltekit-adapter.ts b/packages/sv/src/addons/sveltekit-adapter.ts index dd3f9a3a2..4f2340da7 100644 --- a/packages/sv/src/addons/sveltekit-adapter.ts +++ b/packages/sv/src/addons/sveltekit-adapter.ts @@ -1,10 +1,10 @@ import { color, - resolveCommandArray, text, transforms, + resolveCommandArray, fileExists, - getPackageJson, + loadPackageJson, sanitizeName } from '@sveltejs/sv-utils'; import { defineAddon, defineAddonOptions } from '../core/config.ts'; @@ -140,7 +140,7 @@ export default defineAddon({ } if (!data.name) { - const pkg = getPackageJson(cwd); + const pkg = loadPackageJson(cwd); data.name = sanitizeName(pkg.data.name, 'wrangler'); } diff --git a/packages/sv/src/addons/tailwindcss.ts b/packages/sv/src/addons/tailwindcss.ts index 5dd26ce2c..2755ea392 100644 --- a/packages/sv/src/addons/tailwindcss.ts +++ b/packages/sv/src/addons/tailwindcss.ts @@ -1,4 +1,4 @@ -import { transforms } from '@sveltejs/sv-utils'; +import { pnpm, transforms } from '@sveltejs/sv-utils'; import { defineAddon, defineAddonOptions } from '../core/config.ts'; const plugins = [ @@ -30,12 +30,14 @@ export default defineAddon({ shortDescription: 'css framework', homepage: 'https://tailwindcss.com', options, - run: ({ sv, options, file, isKit, directory, dependencyVersion, language }) => { + run: ({ sv, options, file, isKit, directory, dependencyVersion, language, packageManager }) => { const prettierInstalled = Boolean(dependencyVersion('prettier')); sv.devDependency('tailwindcss', '^4.1.18'); sv.devDependency('@tailwindcss/vite', '^4.1.18'); - sv.pnpmBuildDependency('@tailwindcss/oxide'); + if (packageManager === 'pnpm') { + sv.file(file.findUp('pnpm-workspace.yaml'), pnpm.onlyBuiltDependencies('@tailwindcss/oxide')); + } if (prettierInstalled) sv.devDependency('prettier-plugin-tailwindcss', '^0.7.2'); @@ -106,7 +108,7 @@ export default defineAddon({ } sv.file( - file.vscodeSettings, + '.vscode/settings.json', transforms.json(({ data }) => { data['files.associations'] ??= {}; data['files.associations']['*.css'] = 'tailwindcss'; @@ -114,7 +116,7 @@ export default defineAddon({ ); sv.file( - file.vscodeExtensions, + '.vscode/extensions.json', transforms.json(({ data, json }) => { json.arrayUpsert(data, 'recommendations', 'bradlc.vscode-tailwindcss'); }) @@ -122,7 +124,7 @@ export default defineAddon({ if (prettierInstalled) { sv.file( - file.prettierrc, + '.prettierrc', transforms.json(({ data, json }) => { json.arrayUpsert(data, 'plugins', 'prettier-plugin-tailwindcss'); data.tailwindStylesheet ??= file.getRelative({ to: file.stylesheet }); diff --git a/packages/sv/src/cli/add.ts b/packages/sv/src/cli/add.ts index 466613ab6..5f967f901 100644 --- a/packages/sv/src/cli/add.ts +++ b/packages/sv/src/cli/add.ts @@ -23,7 +23,7 @@ import { downloadPackage, getPackageJSON } from '../core/fetch-packages.ts'; import { formatFiles } from '../core/formatFiles.ts'; import { AGENT_NAMES, - addPnpmBuildDependencies, + addPnpmOnlyBuiltDependencies, installDependencies, installOption, packageManagerPrompt @@ -677,7 +677,7 @@ export async function runAddonsApply({ setupResults: {} }; - const { filesToFormat, pnpmBuildDependencies, status } = await applyAddons({ + const { filesToFormat, status } = await applyAddons({ loadedAddons, workspace, setupResults, @@ -712,10 +712,7 @@ export async function runAddonsApply({ ? await packageManagerPrompt(options.cwd) : options.install; - await addPnpmBuildDependencies(workspace.cwd, packageManager, [ - 'esbuild', - ...pnpmBuildDependencies - ]); + addPnpmOnlyBuiltDependencies(workspace.cwd, packageManager, 'esbuild'); const argsFormattedAddons: string[] = []; for (const loaded of successfulAddons) { diff --git a/packages/sv/src/cli/create.ts b/packages/sv/src/cli/create.ts index 65697c24e..75888d8ee 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, resolveCommandArray, commonFilePaths, getPackageJson } from '@sveltejs/sv-utils'; +import { color, loadPackageJson, resolveCommandArray } from '@sveltejs/sv-utils'; import { Command, Option } from 'commander'; import fs from 'node:fs'; import path from 'node:path'; @@ -10,7 +10,7 @@ import type { LoadedAddon, OptionValues, SetupResult } from '../core/config.ts'; import { formatFiles } from '../core/formatFiles.ts'; import { AGENT_NAMES, - addPnpmBuildDependencies, + addPnpmOnlyBuiltDependencies, detectPackageManager, installDependencies, installOption, @@ -324,7 +324,8 @@ async function createProject(cwd: ProjectPath, options: Options) { answers = result.answers; } - createKit(projectPath, { + createKit({ + cwd: projectPath, name: projectName, template, types: language @@ -395,7 +396,7 @@ async function createProject(cwd: ProjectPath, options: Options) { } const addOnNextSteps = getNextSteps(addOnSuccessfulAddons, workspace, answers, addonSetupResults); - await addPnpmBuildDependencies(projectPath, packageManager, ['esbuild']); + addPnpmOnlyBuiltDependencies(projectPath, packageManager, 'esbuild'); if (packageManager) { await installDependencies(packageManager, projectPath); await formatFiles({ packageManager, cwd: projectPath, filesToFormat: addOnFilesToFormat }); @@ -464,7 +465,7 @@ export async function createVirtualWorkspace({ // Let's read the package.json of the template we will use and add the dependencies to the override const templatePackageJsonPath = dist(`templates/${template}`); - const { data: packageJson } = getPackageJson(templatePackageJsonPath); + const { data: packageJson } = loadPackageJson(templatePackageJsonPath); override.dependencies = { ...packageJson.devDependencies, ...packageJson.dependencies, @@ -478,8 +479,11 @@ export async function createVirtualWorkspace({ language: type === 'typescript' ? 'ts' : 'js', file: { ...tentativeWorkspace.file, - viteConfig: type === 'typescript' ? commonFilePaths.viteConfigTS : commonFilePaths.viteConfig, - svelteConfig: commonFilePaths.svelteConfig // currently we always use js files, never typescript files + viteConfig: + type === 'typescript' + ? common.commonFilePaths.viteConfigTS + : common.commonFilePaths.viteConfig, + svelteConfig: common.commonFilePaths.svelteConfig // currently we always use js files, never typescript files } }; diff --git a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/pnpm-workspace.yaml b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/pnpm-workspace.yaml new file mode 100644 index 000000000..fd050a463 --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +onlyBuiltDependencies: + - '@tailwindcss/oxide' diff --git a/packages/sv/src/core/common.ts b/packages/sv/src/core/common.ts index 856b8e0c5..c834a08a7 100644 --- a/packages/sv/src/core/common.ts +++ b/packages/sv/src/core/common.ts @@ -1,9 +1,9 @@ import * as p from '@clack/prompts'; import { - type AgentName, color, - resolveCommandArray, - isVersionUnsupportedBelow + isVersionUnsupportedBelow, + type AgentName, + resolveCommandArray } from '@sveltejs/sv-utils'; import type { Argument, Command, Help, HelpConfiguration, Option } from 'commander'; import fs from 'node:fs'; @@ -324,3 +324,13 @@ export function updateAgent( fs.writeFileSync(agentPath, content); } } + +export const commonFilePaths = { + packageJson: 'package.json', + svelteConfig: 'svelte.config.js', + svelteConfigTS: 'svelte.config.ts', + jsconfig: 'jsconfig.json', + tsconfig: 'tsconfig.json', + viteConfig: 'vite.config.js', + viteConfigTS: 'vite.config.ts' +} as const; diff --git a/packages/sv/src/core/config.ts b/packages/sv/src/core/config.ts index 3d00c1412..b0c02226e 100644 --- a/packages/sv/src/core/config.ts +++ b/packages/sv/src/core/config.ts @@ -21,8 +21,6 @@ export type Scripts = { }; export type SvApi = { - /** Add a package to the pnpm onlyBuiltDependencies. */ - pnpmBuildDependency: (pkg: string) => void; /** Add a package to the dependencies. */ dependency: (pkg: string, version: string) => void; /** Add a package to the dev dependencies. */ diff --git a/packages/sv/src/core/engine.ts b/packages/sv/src/core/engine.ts index b0bc4d053..7cb87c48d 100644 --- a/packages/sv/src/core/engine.ts +++ b/packages/sv/src/core/engine.ts @@ -1,15 +1,16 @@ import * as p from '@clack/prompts'; import { color, - resolveCommand, - type AgentName, fileExists, - installPackages, - readFile, - writeFile + loadFile, + loadPackageJson, + saveFile, + resolveCommand, + type AgentName } from '@sveltejs/sv-utils'; import { NonZeroExitError, exec } from 'tinyexec'; import { createLoadedAddon } from '../cli/add.ts'; +import { commonFilePaths } from './common.ts'; import { getErrorHint, type Addon, @@ -22,6 +23,38 @@ import { import { TESTING } from './env.ts'; import { createWorkspace, type Workspace } from './workspace.ts'; +function alphabetizePackageJsonDependencies(obj: Record) { + const ordered: Record = {}; + for (const [key, value] of Object.entries(obj).sort(([a], [b]) => a.localeCompare(b))) { + ordered[key] = value; + } + return ordered; +} + +function updatePackages( + dependencies: Array<{ pkg: string; version: string; dev: boolean }>, + cwd: string +): string { + const { data, generateCode } = loadPackageJson(cwd); + + for (const dependency of dependencies) { + if (dependency.dev) { + data.devDependencies ??= {}; + data.devDependencies[dependency.pkg] = dependency.version; + } else { + data.dependencies ??= {}; + data.dependencies[dependency.pkg] = dependency.version; + } + } + + if (data.dependencies) data.dependencies = alphabetizePackageJsonDependencies(data.dependencies); + if (data.devDependencies) + data.devDependencies = alphabetizePackageJsonDependencies(data.devDependencies); + + saveFile(cwd, commonFilePaths.packageJson, generateCode()); + return commonFilePaths.packageJson; +} + export type InstallOptions = { cwd: string; addons: Addons; @@ -71,11 +104,9 @@ export async function applyAddons({ options }: ApplyAddonOptions): Promise<{ filesToFormat: string[]; - pnpmBuildDependencies: string[]; status: Record; }> { const filesToFormat = new Set(); - const allPnpmBuildDependencies: string[] = []; const status: Record = {}; const addonDefs = loadedAddons.map((l) => l.addon); @@ -95,7 +126,7 @@ export async function applyAddons({ // If we don't have a formatter yet, check if the addon adds one if (!hasFormatter) hasFormatter = !!addonWorkspace.dependencyVersion('prettier'); - const { files, pnpmBuildDependencies, cancels } = await runAddon({ + const { files, cancels } = await runAddon({ workspace: addonWorkspace, workspaceOptions, addon, @@ -104,7 +135,6 @@ export async function applyAddons({ }); files.forEach((f) => filesToFormat.add(f)); - pnpmBuildDependencies.forEach((s) => allPnpmBuildDependencies.push(s)); if (cancels.length === 0) { status[addon.id] = 'success'; } else { @@ -114,7 +144,6 @@ export async function applyAddons({ return { filesToFormat: hasFormatter ? Array.from(filesToFormat) : [], - pnpmBuildDependencies: allPnpmBuildDependencies, status }; } @@ -176,15 +205,14 @@ async function runAddon({ addon, loaded, multiple, workspace, workspaceOptions } } const dependencies: Array<{ pkg: string; version: string; dev: boolean }> = []; - const pnpmBuildDependencies: string[] = []; const sv: SvApi = { file: (path, edit) => { try { - const content = fileExists(workspace.cwd, path) ? readFile(workspace.cwd, path) : ''; + const content = fileExists(workspace.cwd, path) ? loadFile(workspace.cwd, path) : ''; const editedContent = edit(content); if (editedContent === '' || editedContent === false) return content; - writeFile(workspace.cwd, path, editedContent); + saveFile(workspace.cwd, path, editedContent); files.add(path); } catch (e) { if (e instanceof Error) { @@ -225,9 +253,6 @@ async function runAddon({ addon, loaded, multiple, workspace, workspaceOptions } }, devDependency: (pkg, version) => { dependencies.push({ pkg, version, dev: true }); - }, - pnpmBuildDependency: (pkg) => { - pnpmBuildDependencies.push(pkg); } }; @@ -250,13 +275,12 @@ async function runAddon({ addon, loaded, multiple, workspace, workspaceOptions } } if (cancels.length === 0) { - const pkgPath = installPackages(dependencies, workspace.cwd); + const pkgPath = updatePackages(dependencies, workspace.cwd); files.add(pkgPath); } return { files: Array.from(files), - pnpmBuildDependencies, cancels }; } diff --git a/packages/sv/src/core/package-manager.ts b/packages/sv/src/core/package-manager.ts index c0ed185c7..5154a3ceb 100644 --- a/packages/sv/src/core/package-manager.ts +++ b/packages/sv/src/core/package-manager.ts @@ -6,8 +6,7 @@ import { color, constructCommand, detect, - isVersionUnsupportedBelow, - parse + pnpm } from '@sveltejs/sv-utils'; import { Option } from 'commander'; import * as find from 'empathic/find'; @@ -93,68 +92,16 @@ export function getUserAgent(): AgentName | undefined { return AGENTS.includes(name) ? name : undefined; } -export async function addPnpmBuildDependencies( +export function addPnpmOnlyBuiltDependencies( cwd: string, packageManager: AgentName | null | undefined, - allowedPackages: string[] -): Promise { - // other package managers are currently not affected by this change - if (!packageManager || packageManager !== 'pnpm' || allowedPackages.length === 0) return; - - let confIn: 'package.json' | 'pnpm-workspace.yaml' = 'package.json'; - const pnpmVersion = await getPnpmVersion(); - if (pnpmVersion) { - confIn = isVersionUnsupportedBelow(pnpmVersion, '10.5') - ? 'package.json' - : 'pnpm-workspace.yaml'; - } + ...packages: string[] +): void { + if (packageManager !== 'pnpm' || packages.length === 0) return; - // find the workspace root (if present) const found = find.up('pnpm-workspace.yaml', { cwd }); - - if (confIn === 'pnpm-workspace.yaml') { - const content = found ? fs.readFileSync(found, 'utf-8') : ''; - const { data, generateCode } = parse.yaml(content); - - const onlyBuiltDependencies = data.get('onlyBuiltDependencies'); - const items: Array<{ value: string } | string> = onlyBuiltDependencies?.items ?? []; - - for (const item of allowedPackages) { - if (items.includes(item)) continue; - if (items.some((y) => typeof y === 'object' && y.value === item)) continue; - items.push(item); - } - data.set('onlyBuiltDependencies', items); - - const newContent = generateCode(); - const pnpmWorkspacePath = found ?? path.join(cwd, 'pnpm-workspace.yaml'); - if (newContent !== content) fs.writeFileSync(pnpmWorkspacePath, newContent, 'utf-8'); - } else { - // else is package.json (fallback) - const rootDir = found ? path.dirname(found) : cwd; - const pkgPath = path.join(rootDir, 'package.json'); - const content = fs.readFileSync(pkgPath, 'utf-8'); - const { data, generateCode } = parse.json(content); - - // add the packages where we install scripts should be executed - data.pnpm ??= {}; - data.pnpm.onlyBuiltDependencies ??= []; - for (const allowedPackage of allowedPackages) { - if (data.pnpm.onlyBuiltDependencies.includes(allowedPackage)) continue; - data.pnpm.onlyBuiltDependencies.push(allowedPackage); - } - - // save the updated package.json - const newContent = generateCode(); - if (newContent !== content) fs.writeFileSync(pkgPath, newContent, 'utf-8'); - } -} - -async function getPnpmVersion(): Promise { - let v: string | undefined = undefined; - try { - const proc = await exec('pnpm', ['--version'], { throwOnError: true }); - v = proc.stdout.trim(); - } catch {} - return v; + const filePath = found ?? path.join(cwd, 'pnpm-workspace.yaml'); + const content = found ? fs.readFileSync(found, 'utf-8') : ''; + const newContent = pnpm.onlyBuiltDependencies(...packages)(content); + if (newContent !== content) fs.writeFileSync(filePath, newContent, 'utf-8'); } diff --git a/packages/sv/src/core/workspace.ts b/packages/sv/src/core/workspace.ts index 987787419..35ceac23c 100644 --- a/packages/sv/src/core/workspace.ts +++ b/packages/sv/src/core/workspace.ts @@ -3,13 +3,13 @@ import { type AstTypes, js, parse, - commonFilePaths, - getPackageJson, - readFile + loadFile, + loadPackageJson } from '@sveltejs/sv-utils'; import * as find from 'empathic/find'; import fs from 'node:fs'; import path from 'node:path'; +import { commonFilePaths } from './common.ts'; import type { OptionDefinition, OptionValues } from './options.ts'; import { detectPackageManager } from './package-manager.ts'; @@ -37,15 +37,14 @@ export type Workspace = { package: 'package.json'; gitignore: '.gitignore'; - prettierignore: '.prettierignore'; - prettierrc: '.prettierrc'; - eslintConfig: 'eslint.config.js'; - - vscodeSettings: '.vscode/settings.json'; - vscodeExtensions: '.vscode/extensions.json'; - /** Get the relative path between two files */ getRelative: ({ from, to }: { from?: string; to: string }) => string; + + /** + * Find a file by walking up the directory tree from cwd. + * Returns the relative path from cwd, or the filename itself if not found. + */ + findUp: (filename: string) => string; }; isKit: boolean; directory: { @@ -103,7 +102,7 @@ export async function createWorkspace({ directory.length >= workspaceRoot.length ) { if (fs.existsSync(path.join(directory, commonFilePaths.packageJson))) { - const { data: packageJson } = getPackageJson(directory); + const { data: packageJson } = loadPackageJson(directory); dependencies = { ...packageJson.devDependencies, ...packageJson.dependencies, @@ -142,11 +141,6 @@ export async function createWorkspace({ stylesheet, package: 'package.json', gitignore: '.gitignore', - prettierignore: '.prettierignore', - prettierrc: '.prettierrc', - eslintConfig: 'eslint.config.js', - vscodeSettings: '.vscode/settings.json', - vscodeExtensions: '.vscode/extensions.json', getRelative({ from, to }) { from = from ?? ''; let relativePath = path.posix.relative(path.posix.dirname(from), to); @@ -155,6 +149,15 @@ export async function createWorkspace({ relativePath = `./${relativePath}`; } return relativePath; + }, + findUp(filename) { + const found = find.up(filename, { cwd: resolvedCwd }); + if (!found) return filename; + // don't escape .test-output during tests + if (resolvedCwd.includes('.test-output') && !found.includes('.test-output')) { + return filename; + } + return path.relative(resolvedCwd, found); } }, isKit, @@ -173,7 +176,7 @@ function findWorkspaceRoot(cwd: string): string { return directory; } // in other package managers it's a workspaces key in the package.json - const { data } = getPackageJson(directory); + const { data } = loadPackageJson(directory); if (data.workspaces) { return directory; } @@ -189,7 +192,7 @@ function findWorkspaceRoot(cwd: string): string { } function parseKitOptions(cwd: string, svelteConfigPath: string) { - const configSource = readFile(cwd, svelteConfigPath); + const configSource = loadFile(cwd, svelteConfigPath); const { ast } = parse.script(configSource); const defaultExport = ast.body.find((s) => s.type === 'ExportDefaultDeclaration'); diff --git a/packages/sv/src/create/index.ts b/packages/sv/src/create/index.ts index 5a2947cbe..ffe49c5ee 100644 --- a/packages/sv/src/create/index.ts +++ b/packages/sv/src/create/index.ts @@ -1,6 +1,7 @@ -import { sanitizeName, commonFilePaths } from '@sveltejs/sv-utils'; +import { sanitizeName } from '@sveltejs/sv-utils'; import fs from 'node:fs'; import path from 'node:path'; +import { commonFilePaths } from '../core/common.ts'; import { mkdirp, copy, dist, getSharedFiles, replace, kv } from './utils.ts'; export type TemplateType = (typeof templateTypes)[number]; @@ -11,6 +12,7 @@ const templateTypes = ['minimal', 'demo', 'library', 'addon', 'svelte'] as const const languageTypes = ['typescript', 'checkjs', 'none'] as const; export type Options = { + cwd: string; name: string; template: TemplateType; types: LanguageType; @@ -32,7 +34,7 @@ export type Common = { }>; }; -export function create(cwd: string, options: Options): void { +export function create({ cwd, ...options }: Options): void { mkdirp(cwd); write_template_files(options.template, options.types, options.name, cwd); @@ -76,7 +78,7 @@ function write_template_files(template: string, types: LanguageType, name: strin }); } -function write_common_files(cwd: string, options: Options, name: string) { +function write_common_files(cwd: string, options: Omit, name: string) { const files = getSharedFiles(); const pkg_file = path.join(cwd, commonFilePaths.packageJson); @@ -105,7 +107,7 @@ function write_common_files(cwd: string, options: Options, name: string) { fs.writeFileSync(pkg_file, JSON.stringify(pkg, null, '\t') + '\n'); } -function matches_condition(condition: Condition, options: Options) { +function matches_condition(condition: Condition, options: Omit) { if (templateTypes.includes(condition as TemplateType)) { return options.template === condition; } diff --git a/packages/sv/src/create/playground.ts b/packages/sv/src/create/playground.ts index ae2fae244..f93788126 100644 --- a/packages/sv/src/create/playground.ts +++ b/packages/sv/src/create/playground.ts @@ -5,11 +5,11 @@ import { parse, svelte, downloadJson, - Walker, - commonFilePaths + Walker } from '@sveltejs/sv-utils'; import fs from 'node:fs'; import path from 'node:path'; +import { commonFilePaths } from '../core/common.ts'; import { getSharedFiles } from './utils.ts'; export function validatePlaygroundUrl(link: string): boolean { diff --git a/packages/sv/src/create/tests/check.ts b/packages/sv/src/create/tests/check.ts index e00b3c33f..c214a375b 100644 --- a/packages/sv/src/create/tests/check.ts +++ b/packages/sv/src/create/tests/check.ts @@ -42,7 +42,7 @@ for (const template of templates.filter((t) => t !== 'addon')) { const cwd = path.join(test_workspace_dir, `${template}-${types}`); fs.rmSync(cwd, { recursive: true, force: true }); - create(cwd, { name: `create-svelte-test-${template}-${types}`, template, types }); + create({ cwd, name: `create-svelte-test-${template}-${types}`, template, types }); await add({ cwd, addons: { eslint: officialAddons.eslint }, options: { eslint: {} } }); const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf-8')); diff --git a/packages/sv/src/create/tests/playground.ts b/packages/sv/src/create/tests/playground.ts index fbc79b5d1..5d7778de0 100644 --- a/packages/sv/src/create/tests/playground.ts +++ b/packages/sv/src/create/tests/playground.ts @@ -153,7 +153,8 @@ test('real world download and convert playground async', async () => { fs.rmSync(directory, { recursive: true }); } - create(directory, { + create({ + cwd: directory, name: 'real-world-playground', template: 'minimal', types: 'typescript' @@ -204,7 +205,8 @@ test('real world download and convert playground without async', async () => { fs.rmSync(directory, { recursive: true }); } - create(directory, { + create({ + cwd: directory, name: 'real-world-playground-old', template: 'minimal', types: 'typescript' diff --git a/packages/sv/src/index.ts b/packages/sv/src/index.ts index 874abb260..3c3fe50b0 100644 --- a/packages/sv/src/index.ts +++ b/packages/sv/src/index.ts @@ -4,7 +4,37 @@ export type { AddonMap, InstallOptions, OptionMap } from './core/engine.ts'; export { officialAddons } from './addons/index.ts'; // Addon authoring API export { defineAddon, defineAddonOptions } from './core/config.ts'; -export type * from './core/processors.ts'; -export type * from './core/options.ts'; -export type * from './core/config.ts'; -export type * from './core/workspace.ts'; + +// options.ts - question types for addon options +export type { + Question, + OptionDefinition, + OptionValues, + BooleanQuestion, + StringQuestion, + NumberQuestion, + SelectQuestion, + MultiSelectQuestion, + BaseQuestion +} from './core/options.ts'; + +// config.ts - addon definition and pipeline types +export type { + Addon, + SvApi, + AddonDefinition, + SetupResult, + OptionBuilder, + AddonInput, + AddonSource, + AddonReference, + LoadedAddon, + PreparedAddon, + ConfiguredAddon, + AddonResult +} from './core/config.ts'; + +// workspace.ts +export type { Workspace, WorkspaceOptions } from './core/workspace.ts'; + +export type { FileEditor, FileType } from './core/processors.ts'; diff --git a/packages/sv/src/testing.ts b/packages/sv/src/testing.ts index 285592dd5..1075f4496 100644 --- a/packages/sv/src/testing.ts +++ b/packages/sv/src/testing.ts @@ -7,10 +7,9 @@ import pstree, { type PS } from 'ps-tree'; import { exec, x } from 'tinyexec'; import type { TestProject } from 'vitest/node'; import { add, type AddonMap, type OptionMap } from './core/engine.ts'; -import { addPnpmBuildDependencies } from './core/package-manager.ts'; +import { addPnpmOnlyBuiltDependencies } from './core/package-manager.ts'; import { create } from './create/index.ts'; -export { addPnpmBuildDependencies } from './core/package-manager.ts'; export type ProjectVariant = 'kit-js' | 'kit-ts' | 'vite-js' | 'vite-ts'; export const variants: ProjectVariant[] = ['kit-js', 'kit-ts', 'vite-js', 'vite-ts']; @@ -29,7 +28,8 @@ type SetupOptions = { /** @default false */ clean?: boolean; }; -export function setup({ cwd, clean = false, variants }: SetupOptions): { templatesDir: string } { + +function setup({ cwd, clean = false, variants }: SetupOptions): { templatesDir: string } { const workingDir = path.resolve(cwd); if (clean && fs.existsSync(workingDir)) { fs.rmSync(workingDir, { force: true, recursive: true }); @@ -43,13 +43,13 @@ export function setup({ cwd, clean = false, variants }: SetupOptions): { templat if (fs.existsSync(templatePath)) continue; if (variant === 'kit-js') { - create(templatePath, { name: variant, template: 'minimal', types: 'checkjs' }); + create({ cwd: templatePath, name: variant, template: 'minimal', types: 'checkjs' }); } else if (variant === 'kit-ts') { - create(templatePath, { name: variant, template: 'minimal', types: 'typescript' }); + create({ cwd: templatePath, name: variant, template: 'minimal', types: 'typescript' }); } else if (variant === 'vite-js') { - create(templatePath, { name: variant, template: 'svelte', types: 'none' }); + create({ cwd: templatePath, name: variant, template: 'svelte', types: 'none' }); } else if (variant === 'vite-ts') { - create(templatePath, { name: variant, template: 'svelte', types: 'typescript' }); + create({ cwd: templatePath, name: variant, template: 'svelte', types: 'typescript' }); } else { throw new Error(`Unknown project variant: ${variant}`); } @@ -59,7 +59,8 @@ export function setup({ cwd, clean = false, variants }: SetupOptions): { templat } type CreateOptions = { cwd: string; testName: string; templatesDir: string }; -export function createProject({ cwd, testName, templatesDir }: CreateOptions): CreateProject { + +function createProject({ cwd, testName, templatesDir }: CreateOptions): CreateProject { // create the reference dir const testDir = path.resolve(cwd, testName); fs.mkdirSync(testDir, { recursive: true }); @@ -75,7 +76,8 @@ export function createProject({ cwd, testName, templatesDir }: CreateOptions): C } type PreviewOptions = { cwd: string; command?: string }; -export async function startPreview({ + +async function startPreview({ cwd, command = 'npm run preview' }: PreviewOptions): Promise<{ url: string; close: () => Promise }> { @@ -335,13 +337,13 @@ export function createSetupTest( if (options?.preAdd) { await options.preAdd({ addonTestCase, cwd }); } - const { pnpmBuildDependencies } = await add({ + await add({ cwd, addons, options: kind.options, packageManager: 'pnpm' }); - await addPnpmBuildDependencies(cwd, 'pnpm', ['esbuild', ...pnpmBuildDependencies]); + addPnpmOnlyBuiltDependencies(cwd, 'pnpm', 'esbuild'); } execSync('pnpm install', { cwd: path.resolve(cwd, testName), stdio: 'pipe' }); diff --git a/scripts/generate-api-surface.js b/scripts/generate-api-surface.js new file mode 100644 index 000000000..e1f3c03d4 --- /dev/null +++ b/scripts/generate-api-surface.js @@ -0,0 +1,170 @@ +/** + * Reads generated `.d.mts` files and writes compact `api-surface.md` snapshots per package. + * + * Strips region/source-map directives, non-deprecated block comments, import-only lines, + * and excess blank lines. Blocks mentioning `@deprecated` are kept verbatim. + * + * @remarks + * Run: `node scripts/generate-api-surface.js` — or via root `postbuild` after `pnpm build`. + * Output is formatted with Prettier using the repo root `prettier.config.js` so it matches `pnpm format`. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import prettier from 'prettier'; + +const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +/** Absolute path to the repo Prettier config (explicit so formatting does not depend on cwd). */ +const PRETTIER_CONFIG = path.join(ROOT, 'prettier.config.js'); + +const packages = [ + { + name: 'sv', + dts: 'packages/sv/dist/src/index.d.mts', + out: 'packages/sv/api-surface.md' + }, + { + name: 'sv (testing)', + dts: 'packages/sv/dist/src/testing.d.mts', + out: 'packages/sv/api-surface-testing.md' + }, + { + name: '@sveltejs/sv-utils', + dts: 'packages/sv-utils/dist/index.d.mts', + out: 'packages/sv-utils/api-surface.md' + } +]; + +/** + * Remove `//#region` / `//#endregion` lines emitted by the DTS bundler. + * @param {string} source + * @returns {string} + */ +function stripRegionDirectives(source) { + return source + .split('\n') + .filter((line) => !/^\s*\/\/#(region|endregion)\b/.test(line)) + .join('\n'); +} + +/** + * Remove `//# sourceMappingURL=...` lines from declaration emit. + * @param {string} source + * @returns {string} + */ +function stripSourceMappingUrl(source) { + return source + .split('\n') + .filter((line) => !/^\s*\/\/#\s*sourceMappingURL=/.test(line)) + .join('\n'); +} + +/** + * Remove slash-star-star block comments unless they contain `@deprecated` (full block kept). + * @param {string} source + * @returns {string} + */ +function stripJsDoc(source) { + return source.replace(/\/\*\*[\s\S]*?\*\//g, (match) => { + if (/@deprecated\b/.test(match)) { + return match; + } + return ''; + }); +} + +/** + * Drop top-level `import …` lines (types-only noise in the snapshot). + * @param {string} source + * @returns {string} + */ +function stripImportLines(source) { + return source + .split('\n') + .filter((line) => !line.match(/^import\s/)) + .join('\n'); +} + +/** + * Collapse three or more consecutive newlines to two. + * @param {string} source + * @returns {string} + */ +function collapseBlankLines(source) { + return source.replace(/\n{3,}/g, '\n\n'); +} + +/** + * Apply all cleaning steps to declaration text. + * @param {string} source + * @returns {string} + */ +function clean(source) { + let result = stripRegionDirectives(source); + result = stripSourceMappingUrl(result); + result = stripJsDoc(result); + result = stripImportLines(result); + result = collapseBlankLines(result); + return result.trim() + '\n'; +} + +/** + * Format a file with repo Prettier options (plugins + overrides). + * @param {string} absPath absolute path to the markdown file + * @returns {Promise} + */ +async function formatWithPrettier(absPath) { + const raw = fs.readFileSync(absPath, 'utf8'); + const options = + (await prettier.resolveConfig(absPath, { + config: PRETTIER_CONFIG + })) ?? {}; + const formatted = await prettier.format(raw, { ...options, filepath: absPath }); + fs.writeFileSync(absPath, formatted, 'utf8'); +} + +/** @returns {Promise} number of api-surface files written */ +export async function generateApiSurface() { + let generated = 0; + for (const pkg of packages) { + const dtsPath = path.resolve(ROOT, pkg.dts); + if (!fs.existsSync(dtsPath)) { + console.warn(` skipped ${pkg.name} - ${pkg.dts} not found (run build first)`); + continue; + } + + const raw = fs.readFileSync(dtsPath, 'utf8'); + const cleaned = clean(raw); + + const header = + `# ${pkg.name} - Public API Surface\n\n` + + `\n\n` + + '```ts\n'; + const footer = '```\n'; + + const outPath = path.resolve(ROOT, pkg.out); + fs.writeFileSync(outPath, header + cleaned + footer, 'utf8'); + await formatWithPrettier(outPath); + generated++; + console.log(` ${pkg.name} -> ${pkg.out}`); + } + + if (generated === 0) { + console.warn('No .d.mts files found - run `pnpm build` first.'); + process.exit(1); + } + + return generated; +} + +const isMain = + process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); + +if (isMain) { + generateApiSurface().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/tsdown.config.ts b/tsdown.config.ts index 1a564bdcc..da4d41568 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -103,8 +103,15 @@ export default defineConfig([ }, failOnWarn: true, deps: { - neverBundle: [/^svelte/, '@types/estree', 'estree'], - onlyBundle: ['dedent', 'package-manager-detector', 'smol-toml', 'yaml', 'zimmerframe'] + neverBundle: [ + /^svelte/, + '@types/estree', + 'estree', + 'yaml', + 'dedent', + 'package-manager-detector' + ], + onlyBundle: ['smol-toml', 'zimmerframe'] } } ]);