From 9da497fbe18161321cdd1ed1aa368de2d22a267d Mon Sep 17 00:00:00 2001 From: Manuel <30698007+manuel3108@users.noreply.github.com> Date: Thu, 14 Nov 2024 16:59:06 +0100 Subject: [PATCH] chore: simpler / more flexible api (#181) * mock api * mock paraglide adder * improve? api * small tweaks * more tweaks * more * start implementing new api (no clue if it works, requires cleanup) * rename * rename some stuff * migrate eslint * migrate mdsvex * migrate playwright * migrate prettier * delete routify * update storybook * update tailwindcss * migrate vitest * migrate drizzle * migrate lucia * exract `dependsOn` into seperate function * cleanup * migrate community adder * try out `setup` api, without actually implementing it * skip writing empty files * make `setup` function work * rename dir because vitest hates us * dont expose `options` * simplify * pm * remove old tests * initial install helper impl * ignores * add initial tests * ..pin storybook * fix scripts * add storybook test * simplify * deps * add retries * not needed * tweak community template * add fixtures * community template tests * naming * tweaks * unneeded * clean * lockfile trickery * fix eslint test * fix paraglide * tweak workflow * ... * fix lint * fix check * simplify * simplify * upgrade vitest * fix nit * fix lint * simplify * cleanup * rename `vi` to `vitest` * windows fixes * more windows annoyances * tweaks * add `test:ui` script * `tinyexec` `throwOnError: true` * revert * properly terminate child processes * dont skip storybook * cleanup on failure to load the page * unused * newline * use vitest workspaces * add `try-catch` while killing processes * increase navigation timeout * apply merge changes * fix timeout * add retries * remove storybook log * cleanup cli package * simplify * fix lucia tests * dont print external command output while testing * fix storybook * fixes * format before evaluating test * fix dir path * setup matrix * skip running docker containers outside of linux runners * print console output? * fix lint? * force `npm` for storybook? * Revert "force `npm` for storybook?" This reverts commit ef2df6ec003f342814ac240053748b292a20968a. * try latest * Revert "try latest" This reverts commit 1c095d614d0484710408a8605b54b1d0be145cc1. * Revert "print console output?" This reverts commit 9d5b6b20bfdbfc31c23b4652bde24adb62b47a11. * skip runnung storybook tests in ci on windows * improve * missing filename * enhance tests * pipe stdio during tests * skip storybook * fixes * fix test * fix: apply defaults to unspecified options * unpin storybook and remove skipping windows ci * use `defineProject` instead for better type safety in a workspace * remove silent flag * use latest * temp log * unwrap * clear cache and log cause * move it along * run sequentially * revert * test * exclude windows from running concurrently * tweaks * dont return string from `file` function * improve `unsupported` * log `stderr` on failed dep installs * tweak error * simplify * fixes and tweaks * fix --------- Co-authored-by: AdrianGonz97 <31664583+AdrianGonz97@users.noreply.github.com> --- community-adder-template/src/index.js | 38 +- packages/adders/_config/official.ts | 2 +- packages/adders/_tests/all-addons/test.ts | 30 + packages/adders/_tests/eslint/test.ts | 12 +- packages/adders/_tests/prettier/test.ts | 12 +- packages/adders/_tests/storybook/test.ts | 5 +- packages/adders/common.ts | 8 +- packages/adders/drizzle/index.ts | 446 +++++++-------- packages/adders/eslint/index.ts | 234 ++++---- packages/adders/lucia/index.ts | 633 ++++++++++----------- packages/adders/mdsvex/index.ts | 53 +- packages/adders/paraglide/index.ts | 329 +++++------ packages/adders/playwright/index.ts | 131 ++--- packages/adders/prettier/index.ts | 146 +++-- packages/adders/storybook/index.ts | 13 +- packages/adders/tailwindcss/index.ts | 205 +++---- packages/adders/vitest-addon/index.ts | 165 +++--- packages/adders/vitest.config.ts | 4 +- packages/cli/commands/add/index.ts | 193 ++----- packages/cli/commands/add/preconditions.ts | 31 +- packages/cli/commands/add/processor.ts | 36 -- packages/cli/commands/add/utils.ts | 17 +- packages/cli/commands/add/workspace.ts | 24 +- packages/cli/lib/install.ts | 166 ++++-- packages/cli/utils/common.ts | 20 +- packages/cli/utils/errors.ts | 9 + packages/cli/utils/package-manager.ts | 8 +- packages/core/adder/config.ts | 28 +- packages/core/vitest.config.ts | 4 +- packages/create/vitest.config.ts | 4 +- packages/migrate/vitest.config.ts | 4 +- 31 files changed, 1434 insertions(+), 1576 deletions(-) create mode 100644 packages/adders/_tests/all-addons/test.ts delete mode 100644 packages/cli/commands/add/processor.ts create mode 100644 packages/cli/utils/errors.ts diff --git a/community-adder-template/src/index.js b/community-adder-template/src/index.js index c063124b..11b34a15 100644 --- a/community-adder-template/src/index.js +++ b/community-adder-template/src/index.js @@ -12,27 +12,23 @@ export const options = defineAdderOptions({ export default defineAdder({ id: 'community-addon', - environments: { kit: true, svelte: true }, options, - packages: [], - files: [ - { - name: () => 'adder-template-demo.txt', - content: ({ content, options }) => { - if (options.demo) { - return 'This is a text file made by the Community Adder Template demo!'; - } - return content; + setup: ({ kit, unsupported }) => { + if (!kit) unsupported('Requires SvelteKit'); + }, + run: ({ sv, options, typescript }) => { + sv.file('adder-template-demo.txt', (content) => { + if (options.demo) { + return 'This is a text file made by the Community Adder Template demo!'; } - }, - { - name: () => 'src/DemoComponent.svelte', - content: ({ content, options, typescript }) => { - if (!options.demo) return content; - const { script, generateCode } = parseSvelte(content, { typescript }); - imports.addDefault(script.ast, '../adder-template-demo.txt?raw', 'demo'); - return generateCode({ script: script.generateCode(), template: '{demo}' }); - } - } - ] + return content; + }); + + sv.file('src/DemoComponent.svelte', (content) => { + if (!options.demo) return content; + const { script, generateCode } = parseSvelte(content, { typescript }); + imports.addDefault(script.ast, '../adder-template-demo.txt?raw', 'demo'); + return generateCode({ script: script.generateCode(), template: '{demo}' }); + }); + } }); diff --git a/packages/adders/_config/official.ts b/packages/adders/_config/official.ts index 6beba501..afc5d7bd 100644 --- a/packages/adders/_config/official.ts +++ b/packages/adders/_config/official.ts @@ -24,7 +24,7 @@ export const officialAdders = [ mdsvex, paraglide, storybook -]; +] as AdderWithoutExplicitArgs[]; export function getAdderDetails(id: string): AdderWithoutExplicitArgs { const details = officialAdders.find((a) => a.id === id); diff --git a/packages/adders/_tests/all-addons/test.ts b/packages/adders/_tests/all-addons/test.ts new file mode 100644 index 00000000..934b3da1 --- /dev/null +++ b/packages/adders/_tests/all-addons/test.ts @@ -0,0 +1,30 @@ +import process from 'node:process'; +import { expect } from '@playwright/test'; +import { setupTest } from '../_setup/suite.ts'; +import { officialAdders } from '../../index.ts'; +import type { AddonMap, OptionMap } from 'sv'; + +const windowsCI = process.env.CI && process.platform === 'win32'; +const addons = officialAdders.reduce((addonMap, addon) => { + if (addon.id === 'storybook' && windowsCI) return addonMap; + addonMap[addon.id] = addon; + return addonMap; +}, {}); + +const defaultOptions = officialAdders.reduce>((options, addon) => { + options[addon.id] = {}; + return options; +}, {}); + +const { test, variants, prepareServer } = setupTest(addons); + +const kitOnly = variants.filter((v) => v.startsWith('kit')); +test.concurrent.for(kitOnly)('run all addons - %s', async (variant, { page, ...ctx }) => { + const cwd = await ctx.run(variant, defaultOptions); + + const { close } = await prepareServer({ cwd, page }); + // kill server process when we're done + ctx.onTestFinished(async () => await close()); + + expect(true).toBe(true); +}); diff --git a/packages/adders/_tests/eslint/test.ts b/packages/adders/_tests/eslint/test.ts index 6a8d3e20..9ca06c5c 100644 --- a/packages/adders/_tests/eslint/test.ts +++ b/packages/adders/_tests/eslint/test.ts @@ -1,3 +1,6 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { execSync } from 'node:child_process'; import { expect } from '@playwright/test'; import { setupTest } from '../_setup/suite.ts'; import eslint from '../../eslint/index.ts'; @@ -11,5 +14,12 @@ test.concurrent.for(variants)('core - %s', async (variant, { page, ...ctx }) => // kill server process when we're done ctx.onTestFinished(async () => await close()); - expect(true).toBe(true); + const unlintedFile = 'let foo = "";\nif (Boolean(foo)) {\n//\n}'; + fs.writeFileSync(path.resolve(cwd, 'foo.js'), unlintedFile, 'utf8'); + + expect(() => execSync('pnpm lint', { cwd, stdio: 'pipe' })).toThrowError(); + + expect(() => execSync('pnpm eslint --fix .', { cwd, stdio: 'pipe' })).not.toThrowError(); + + expect(() => execSync('pnpm lint', { cwd, stdio: 'pipe' })).not.toThrowError(); }); diff --git a/packages/adders/_tests/prettier/test.ts b/packages/adders/_tests/prettier/test.ts index 1b3f2992..87f2552f 100644 --- a/packages/adders/_tests/prettier/test.ts +++ b/packages/adders/_tests/prettier/test.ts @@ -1,3 +1,6 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { execSync } from 'node:child_process'; import { expect } from '@playwright/test'; import { setupTest } from '../_setup/suite.ts'; import prettier from '../../prettier/index.ts'; @@ -11,5 +14,12 @@ test.concurrent.for(variants)('core - %s', async (variant, { page, ...ctx }) => // kill server process when we're done ctx.onTestFinished(async () => await close()); - expect(true).toBe(true); + const unformattedFile = 'const foo = "bar"'; + fs.writeFileSync(path.resolve(cwd, 'foo.js'), unformattedFile, 'utf8'); + + expect(() => execSync('pnpm lint', { cwd, stdio: 'pipe' })).toThrowError(); + + expect(() => execSync('pnpm format', { cwd, stdio: 'pipe' })).not.toThrowError(); + + expect(() => execSync('pnpm lint', { cwd, stdio: 'pipe' })).not.toThrowError(); }); diff --git a/packages/adders/_tests/storybook/test.ts b/packages/adders/_tests/storybook/test.ts index 4fdc82a4..8a9e0258 100644 --- a/packages/adders/_tests/storybook/test.ts +++ b/packages/adders/_tests/storybook/test.ts @@ -7,9 +7,10 @@ const { test, variants, prepareServer } = setupTest({ storybook }); let port = 6006; -const skip = process.env.CI && process.platform === 'win32'; -test.skipIf(skip).concurrent.for(variants)( +const windowsCI = process.env.CI && process.platform === 'win32'; +test.for(variants)( 'storybook loaded - %s', + { concurrent: !windowsCI }, async (variant, { page, ...ctx }) => { const cwd = await ctx.run(variant, { storybook: {} }); diff --git a/packages/adders/common.ts b/packages/adders/common.ts index 239e9dde..6f4d849b 100644 --- a/packages/adders/common.ts +++ b/packages/adders/common.ts @@ -1,8 +1,7 @@ import { imports, exports, common } from '@sveltejs/cli-core/js'; -import { type Question, type FileEditor } from '@sveltejs/cli-core'; import { parseScript, parseSvelte } from '@sveltejs/cli-core/parsers'; -export function addEslintConfigPrettier({ content }: FileEditor>): string { +export function addEslintConfigPrettier(content: string): string { const { ast, generateCode } = parseScript(content); // if a default import for `eslint-plugin-svelte` already exists, then we'll use their specifier's name instead @@ -65,10 +64,7 @@ export function addEslintConfigPrettier({ content }: FileEditor>, - path: string -): string { +export function addToDemoPage(content: string, path: string): string { const { template, generateCode } = parseSvelte(content); for (const node of template.ast.childNodes) { diff --git a/packages/adders/drizzle/index.ts b/packages/adders/drizzle/index.ts index 69a15b9c..c5821ffd 100644 --- a/packages/adders/drizzle/index.ts +++ b/packages/adders/drizzle/index.ts @@ -1,5 +1,5 @@ import { common, exports, functions, imports, object, variables } from '@sveltejs/cli-core/js'; -import { defineAdder, defineAdderOptions, dedent, type FileEditor } from '@sveltejs/cli-core'; +import { defineAdder, defineAdderOptions, dedent, type OptionValues } from '@sveltejs/cli-core'; import { parseJson, parseScript } from '@sveltejs/cli-core/parsers'; const PORTS = { @@ -65,72 +65,39 @@ const options = defineAdderOptions({ export default defineAdder({ id: 'drizzle', - environments: { svelte: false, kit: true }, homepage: 'https://orm.drizzle.team', options, - packages: [ - { name: 'drizzle-orm', version: '^0.33.0', dev: false }, - { name: 'drizzle-kit', version: '^0.22.0', dev: true }, + setup: ({ kit, unsupported }) => { + if (!kit) unsupported('Requires SvelteKit'); + }, + run: ({ sv, typescript, options, kit }) => { + const ext = typescript ? 'ts' : 'js'; + + sv.dependency('drizzle-orm', '^0.33.0'); + sv.devDependency('drizzle-kit', '^0.22.0'); + // MySQL - { - name: 'mysql2', - version: '^3.11.0', - dev: false, - condition: ({ options }) => options.mysql === 'mysql2' - }, - { - name: '@planetscale/database', - version: '^1.18.0', - dev: false, - condition: ({ options }) => options.mysql === 'planetscale' - }, + if (options.mysql === 'mysql2') sv.dependency('mysql2', '^3.11.0'); + if (options.mysql === 'planetscale') sv.dependency('@planetscale/database', '^1.18.0'); + // PostgreSQL - { - name: '@neondatabase/serverless', - version: '^0.9.4', - dev: false, - condition: ({ options }) => options.postgresql === 'neon' - }, - { - name: 'postgres', - version: '^3.4.4', - dev: false, - condition: ({ options }) => options.postgresql === 'postgres.js' - }, + if (options.postgresql === 'neon') sv.dependency('@neondatabase/serverless', '^0.9.4'); + if (options.postgresql === 'postgres.js') sv.dependency('postgres', '^3.4.4'); + // SQLite - { - name: 'better-sqlite3', - version: '^11.1.2', - dev: false, - condition: ({ options }) => options.sqlite === 'better-sqlite3' - }, - { - name: '@types/better-sqlite3', - version: '^7.6.11', - dev: true, - condition: ({ options }) => options.sqlite === 'better-sqlite3' - }, - { - name: '@libsql/client', - version: '^0.9.0', - dev: false, - condition: ({ options }) => options.sqlite === 'libsql' || options.sqlite === 'turso' + if (options.sqlite === 'better-sqlite3') { + sv.dependency('better-sqlite3', '^11.1.2'); + sv.devDependency('@types/better-sqlite3', '^7.6.11'); } - ], - files: [ - { - name: () => '.env', - content: generateEnvFileContent - }, - { - name: () => '.env.example', - content: generateEnvFileContent - }, - { - name: () => 'docker-compose.yml', - condition: ({ options }) => - options.docker && (options.mysql === 'mysql2' || options.postgresql === 'postgres.js'), - content: ({ content, options }) => { + + if (options.sqlite === 'libsql' || options.sqlite === 'turso') + sv.dependency('@libsql/client', '^0.9.0'); + + sv.file('.env', (content) => generateEnvFileContent(content, options)); + sv.file('.env.example', (content) => generateEnvFileContent(content, options)); + + if (options.docker && (options.mysql === 'mysql2' || options.postgresql === 'postgres.js')) { + sv.file('docker-compose.yml', (content) => { // if the file already exists, don't modify it // (in the future, we could add some tooling for modifying yaml) if (content.length > 0) return content; @@ -168,218 +135,205 @@ export default defineAdder({ `; return content; - } - }, - { - name: () => 'package.json', - content: ({ content, options }) => { - const { data, generateCode } = parseJson(content); - data.scripts ??= {}; - const scripts: Record = data.scripts; - if (options.docker) scripts['db:start'] ??= 'docker compose up'; - scripts['db:push'] ??= 'drizzle-kit push'; - scripts['db:migrate'] ??= 'drizzle-kit migrate'; - scripts['db:studio'] ??= 'drizzle-kit studio'; - return generateCode(); - } - }, - { - // Adds the db file to the gitignore if an ignore is present - name: () => '.gitignore', - condition: ({ options }) => options.database === 'sqlite', - content: ({ content }) => { + }); + } + + sv.file('package.json', (content) => { + const { data, generateCode } = parseJson(content); + data.scripts ??= {}; + const scripts: Record = data.scripts; + if (options.docker) scripts['db:start'] ??= 'docker compose up'; + scripts['db:push'] ??= 'drizzle-kit push'; + scripts['db:migrate'] ??= 'drizzle-kit migrate'; + scripts['db:studio'] ??= 'drizzle-kit studio'; + return generateCode(); + }); + + if (options.database === 'sqlite') { + sv.file('.gitignore', (content) => { + // Adds the db file to the gitignore if an ignore is present if (content.length === 0) return content; if (!content.includes('\n*.db')) { content = content.trimEnd() + '\n\n# SQLite\n*.db'; } return content; - } - }, - { - name: ({ typescript }) => `drizzle.config.${typescript ? 'ts' : 'js'}`, - content: ({ options, content, typescript }) => { - const { ast, generateCode } = parseScript(content); + }); + } - imports.addNamed(ast, 'drizzle-kit', { defineConfig: 'defineConfig' }); + sv.file(`drizzle.config.${ext}`, (content) => { + const { ast, generateCode } = parseScript(content); - const envCheckStatement = common.statementFromString( - "if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');" - ); - common.addStatement(ast, envCheckStatement); - - const fallback = common.expressionFromString('defineConfig({})'); - const { value: exportDefault } = exports.defaultExport(ast, fallback); - if (exportDefault.type !== 'CallExpression') return content; - - const objExpression = exportDefault.arguments?.[0]; - if (!objExpression || objExpression.type !== 'ObjectExpression') return content; - - const driver = options.sqlite === 'turso' ? common.createLiteral('turso') : undefined; - const authToken = - options.sqlite === 'turso' - ? common.expressionFromString('process.env.DATABASE_AUTH_TOKEN') - : undefined; - - object.properties(objExpression, { - schema: common.createLiteral(`./src/lib/server/db/schema.${typescript ? 'ts' : 'js'}`), - dbCredentials: object.create({ - url: common.expressionFromString('process.env.DATABASE_URL'), - authToken - }), - verbose: { type: 'BooleanLiteral', value: true }, - strict: { type: 'BooleanLiteral', value: true }, - driver + imports.addNamed(ast, 'drizzle-kit', { defineConfig: 'defineConfig' }); + + const envCheckStatement = common.statementFromString( + "if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');" + ); + common.addStatement(ast, envCheckStatement); + + const fallback = common.expressionFromString('defineConfig({})'); + const { value: exportDefault } = exports.defaultExport(ast, fallback); + if (exportDefault.type !== 'CallExpression') return content; + + const objExpression = exportDefault.arguments?.[0]; + if (!objExpression || objExpression.type !== 'ObjectExpression') return content; + + const driver = options.sqlite === 'turso' ? common.createLiteral('turso') : undefined; + const authToken = + options.sqlite === 'turso' + ? common.expressionFromString('process.env.DATABASE_AUTH_TOKEN') + : undefined; + + object.properties(objExpression, { + schema: common.createLiteral(`./src/lib/server/db/schema.${typescript ? 'ts' : 'js'}`), + dbCredentials: object.create({ + url: common.expressionFromString('process.env.DATABASE_URL'), + authToken + }), + verbose: { type: 'BooleanLiteral', value: true }, + strict: { type: 'BooleanLiteral', value: true }, + driver + }); + + object.overrideProperties(objExpression, { + dialect: common.createLiteral(options.database) + }); + + // The `driver` property is only required for _some_ sqlite DBs. + // We'll need to remove it if it's anything but sqlite + if (options.database !== 'sqlite') object.removeProperty(objExpression, 'driver'); + + return generateCode(); + }); + + sv.file(`${kit?.libDirectory}/server/db/schema.${ext}`, (content) => { + const { ast, generateCode } = parseScript(content); + + let userSchemaExpression; + if (options.database === 'sqlite') { + imports.addNamed(ast, 'drizzle-orm/sqlite-core', { + sqliteTable: 'sqliteTable', + text: 'text', + integer: 'integer' }); - object.overrideProperties(objExpression, { - dialect: common.createLiteral(options.database) + userSchemaExpression = common.expressionFromString(`sqliteTable('user', { + id: integer('id').primaryKey(), + age: integer('age') + })`); + } + if (options.database === 'mysql') { + imports.addNamed(ast, 'drizzle-orm/mysql-core', { + mysqlTable: 'mysqlTable', + serial: 'serial', + text: 'text', + int: 'int' }); - // The `driver` property is only required for _some_ sqlite DBs. - // We'll need to remove it if it's anything but sqlite - if (options.database !== 'sqlite') object.removeProperty(objExpression, 'driver'); + userSchemaExpression = common.expressionFromString(`mysqlTable('user', { + id: serial('id').primaryKey(), + age: int('age'), + })`); + } + if (options.database === 'postgresql') { + imports.addNamed(ast, 'drizzle-orm/pg-core', { + pgTable: 'pgTable', + serial: 'serial', + text: 'text', + integer: 'integer' + }); - return generateCode(); + userSchemaExpression = common.expressionFromString(`pgTable('user', { + id: serial('id').primaryKey(), + age: integer('age'), + })`); } - }, - { - name: ({ kit, typescript }) => - `${kit?.libDirectory}/server/db/schema.${typescript ? 'ts' : 'js'}`, - content: ({ content, options }) => { - const { ast, generateCode } = parseScript(content); - - let userSchemaExpression; - if (options.database === 'sqlite') { - imports.addNamed(ast, 'drizzle-orm/sqlite-core', { - sqliteTable: 'sqliteTable', - text: 'text', - integer: 'integer' - }); - - userSchemaExpression = common.expressionFromString(`sqliteTable('user', { - id: integer('id').primaryKey(), - age: integer('age') - })`); - } - if (options.database === 'mysql') { - imports.addNamed(ast, 'drizzle-orm/mysql-core', { - mysqlTable: 'mysqlTable', - serial: 'serial', - text: 'text', - int: 'int' - }); - - userSchemaExpression = common.expressionFromString(`mysqlTable('user', { - id: serial('id').primaryKey(), - age: int('age'), - })`); - } - if (options.database === 'postgresql') { - imports.addNamed(ast, 'drizzle-orm/pg-core', { - pgTable: 'pgTable', - serial: 'serial', - text: 'text', - integer: 'integer' - }); - - userSchemaExpression = common.expressionFromString(`pgTable('user', { - id: serial('id').primaryKey(), - age: integer('age'), - })`); - } - if (!userSchemaExpression) throw new Error('unreachable state...'); - const userIdentifier = variables.declaration(ast, 'const', 'user', userSchemaExpression); - exports.namedExport(ast, 'user', userIdentifier); + if (!userSchemaExpression) throw new Error('unreachable state...'); + const userIdentifier = variables.declaration(ast, 'const', 'user', userSchemaExpression); + exports.namedExport(ast, 'user', userIdentifier); - return generateCode(); - } - }, - { - name: ({ kit, typescript }) => - `${kit?.libDirectory}/server/db/index.${typescript ? 'ts' : 'js'}`, - content: ({ content, options }) => { - const { ast, generateCode } = parseScript(content); - - imports.addNamed(ast, '$env/dynamic/private', { env: 'env' }); - - // env var checks - const dbURLCheck = common.statementFromString( - "if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');" - ); - common.addStatement(ast, dbURLCheck); + return generateCode(); + }); - let clientExpression; - // SQLite - if (options.sqlite === 'better-sqlite3') { - imports.addDefault(ast, 'better-sqlite3', 'Database'); - imports.addNamed(ast, 'drizzle-orm/better-sqlite3', { drizzle: 'drizzle' }); + sv.file(`${kit?.libDirectory}/server/db/index.${ext}`, (content) => { + const { ast, generateCode } = parseScript(content); - clientExpression = common.expressionFromString('new Database(env.DATABASE_URL)'); - } - if (options.sqlite === 'libsql' || options.sqlite === 'turso') { - imports.addNamed(ast, '@libsql/client', { createClient: 'createClient' }); - imports.addNamed(ast, 'drizzle-orm/libsql', { drizzle: 'drizzle' }); - - if (options.sqlite === 'turso') { - imports.addNamed(ast, '$app/environment', { dev: 'dev' }); - // auth token check in prod - const authTokenCheck = common.statementFromString( - "if (!dev && !env.DATABASE_AUTH_TOKEN) throw new Error('DATABASE_AUTH_TOKEN is not set');" - ); - common.addStatement(ast, authTokenCheck); - - clientExpression = common.expressionFromString( - 'createClient({ url: env.DATABASE_URL, authToken: env.DATABASE_AUTH_TOKEN })' - ); - } else { - clientExpression = common.expressionFromString( - 'createClient({ url: env.DATABASE_URL })' - ); - } - } - // MySQL - if (options.mysql === 'mysql2') { - imports.addDefault(ast, 'mysql2/promise', 'mysql'); - imports.addNamed(ast, 'drizzle-orm/mysql2', { drizzle: 'drizzle' }); + imports.addNamed(ast, '$env/dynamic/private', { env: 'env' }); + + // env var checks + const dbURLCheck = common.statementFromString( + "if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');" + ); + common.addStatement(ast, dbURLCheck); + + let clientExpression; + // SQLite + if (options.sqlite === 'better-sqlite3') { + imports.addDefault(ast, 'better-sqlite3', 'Database'); + imports.addNamed(ast, 'drizzle-orm/better-sqlite3', { drizzle: 'drizzle' }); + + clientExpression = common.expressionFromString('new Database(env.DATABASE_URL)'); + } + if (options.sqlite === 'libsql' || options.sqlite === 'turso') { + imports.addNamed(ast, '@libsql/client', { createClient: 'createClient' }); + imports.addNamed(ast, 'drizzle-orm/libsql', { drizzle: 'drizzle' }); + + if (options.sqlite === 'turso') { + imports.addNamed(ast, '$app/environment', { dev: 'dev' }); + // auth token check in prod + const authTokenCheck = common.statementFromString( + "if (!dev && !env.DATABASE_AUTH_TOKEN) throw new Error('DATABASE_AUTH_TOKEN is not set');" + ); + common.addStatement(ast, authTokenCheck); clientExpression = common.expressionFromString( - 'await mysql.createConnection(env.DATABASE_URL)' + 'createClient({ url: env.DATABASE_URL, authToken: env.DATABASE_AUTH_TOKEN })' ); + } else { + clientExpression = common.expressionFromString('createClient({ url: env.DATABASE_URL })'); } - if (options.mysql === 'planetscale') { - imports.addNamed(ast, '@planetscale/database', { Client: 'Client' }); - imports.addNamed(ast, 'drizzle-orm/planetscale-serverless', { drizzle: 'drizzle' }); + } + // MySQL + if (options.mysql === 'mysql2') { + imports.addDefault(ast, 'mysql2/promise', 'mysql'); + imports.addNamed(ast, 'drizzle-orm/mysql2', { drizzle: 'drizzle' }); - clientExpression = common.expressionFromString('new Client({ url: env.DATABASE_URL })'); - } - // PostgreSQL - if (options.postgresql === 'neon') { - imports.addNamed(ast, '@neondatabase/serverless', { neon: 'neon' }); - imports.addNamed(ast, 'drizzle-orm/neon-http', { drizzle: 'drizzle' }); + clientExpression = common.expressionFromString( + 'await mysql.createConnection(env.DATABASE_URL)' + ); + } + if (options.mysql === 'planetscale') { + imports.addNamed(ast, '@planetscale/database', { Client: 'Client' }); + imports.addNamed(ast, 'drizzle-orm/planetscale-serverless', { drizzle: 'drizzle' }); - clientExpression = common.expressionFromString('neon(env.DATABASE_URL)'); - } - if (options.postgresql === 'postgres.js') { - imports.addDefault(ast, 'postgres', 'postgres'); - imports.addNamed(ast, 'drizzle-orm/postgres-js', { drizzle: 'drizzle' }); + clientExpression = common.expressionFromString('new Client({ url: env.DATABASE_URL })'); + } + // PostgreSQL + if (options.postgresql === 'neon') { + imports.addNamed(ast, '@neondatabase/serverless', { neon: 'neon' }); + imports.addNamed(ast, 'drizzle-orm/neon-http', { drizzle: 'drizzle' }); - clientExpression = common.expressionFromString('postgres(env.DATABASE_URL)'); - } + clientExpression = common.expressionFromString('neon(env.DATABASE_URL)'); + } + if (options.postgresql === 'postgres.js') { + imports.addDefault(ast, 'postgres', 'postgres'); + imports.addNamed(ast, 'drizzle-orm/postgres-js', { drizzle: 'drizzle' }); - if (!clientExpression) throw new Error('unreachable state...'); - const clientIdentifier = variables.declaration(ast, 'const', 'client', clientExpression); - common.addStatement(ast, clientIdentifier); + clientExpression = common.expressionFromString('postgres(env.DATABASE_URL)'); + } - const drizzleCall = functions.callByIdentifier('drizzle', ['client']); - const db = variables.declaration(ast, 'const', 'db', drizzleCall); - exports.namedExport(ast, 'db', db); + if (!clientExpression) throw new Error('unreachable state...'); + const clientIdentifier = variables.declaration(ast, 'const', 'client', clientExpression); + common.addStatement(ast, clientIdentifier); - return generateCode(); - } - } - ], + const drizzleCall = functions.callByIdentifier('drizzle', ['client']); + const db = variables.declaration(ast, 'const', 'db', drizzleCall); + exports.namedExport(ast, 'db', db); + + return generateCode(); + }); + }, nextSteps: ({ options, highlighter, packageManager }) => { const steps = [ `You will need to set ${highlighter.env('DATABASE_URL')} in your production environment` @@ -397,7 +351,7 @@ export default defineAdder({ } }); -function generateEnvFileContent({ content, options: opts }: FileEditor) { +function generateEnvFileContent(content: string, opts: OptionValues) { const DB_URL_KEY = 'DATABASE_URL'; if (opts.docker) { // we'll prefill with the default docker db credentials diff --git a/packages/adders/eslint/index.ts b/packages/adders/eslint/index.ts index 086b722f..b29b769e 100644 --- a/packages/adders/eslint/index.ts +++ b/packages/adders/eslint/index.ts @@ -1,5 +1,3 @@ -import fs from 'node:fs'; -import path from 'node:path'; import { addEslintConfigPrettier } from '../common.ts'; import { defineAdder, log } from '@sveltejs/cli-core'; import { @@ -16,138 +14,122 @@ import { parseJson, parseScript } from '@sveltejs/cli-core/parsers'; export default defineAdder({ id: 'eslint', - environments: { svelte: true, kit: true }, homepage: 'https://eslint.org', options: {}, - packages: [ - { name: 'eslint', version: '^9.7.0', dev: true }, - { name: '@types/eslint', version: '^9.6.0', dev: true }, - { name: 'globals', version: '^15.0.0', dev: true }, - { - name: 'typescript-eslint', - version: '^8.0.0', - dev: true, - condition: ({ typescript }) => typescript - }, - { name: 'eslint-plugin-svelte', version: '^2.36.0', dev: true }, - { - name: 'eslint-config-prettier', - version: '^9.1.0', - dev: true, - condition: ({ dependencyVersion }) => Boolean(dependencyVersion('prettier')) - } - ], - files: [ - { - name: () => 'package.json', - content: ({ content }) => { - const { data, generateCode } = parseJson(content); - data.scripts ??= {}; - const scripts: Record = data.scripts; - const LINT_CMD = 'eslint .'; - scripts['lint'] ??= LINT_CMD; - if (!scripts['lint'].includes(LINT_CMD)) scripts['lint'] += ` && ${LINT_CMD}`; - return generateCode(); + run: ({ sv, typescript, dependencyVersion }) => { + const prettierInstalled = Boolean(dependencyVersion('prettier')); + + sv.devDependency('eslint', '^9.7.0'); + sv.devDependency('@types/eslint', '^9.6.0'); + sv.devDependency('globals', '^15.0.0'); + sv.devDependency('eslint-plugin-svelte', '^2.36.0'); + + if (typescript) sv.devDependency('typescript-eslint', '^8.0.0'); + + if (prettierInstalled) sv.devDependency('eslint-config-prettier', '^9.1.0'); + + sv.file('package.json', (content) => { + const { data, generateCode } = parseJson(content); + data.scripts ??= {}; + const scripts: Record = data.scripts; + const LINT_CMD = 'eslint .'; + scripts['lint'] ??= LINT_CMD; + if (!scripts['lint'].includes(LINT_CMD)) scripts['lint'] += ` && ${LINT_CMD}`; + return generateCode(); + }); + + sv.file('.vscode/settings.json', (content) => { + if (!content) return content; + + const { data, generateCode } = parseJson(content); + const validate: string[] | undefined = data['eslint.validate']; + if (validate && !validate.includes('svelte')) { + validate.push('svelte'); } - }, - { - name: () => '.vscode/settings.json', - // we'll only want to run this step if the file exists - condition: ({ cwd }) => fs.existsSync(path.join(cwd, '.vscode', 'settings.json')), - content: ({ content }) => { - const { data, generateCode } = parseJson(content); - const validate: string[] | undefined = data['eslint.validate']; - if (validate && !validate.includes('svelte')) { - validate.push('svelte'); - } - return generateCode(); + return generateCode(); + }); + + sv.file('eslint.config.js', (content) => { + const { ast, generateCode } = parseScript(content); + + const eslintConfigs: Array< + AstKinds.ExpressionKind | AstTypes.SpreadElement | AstTypes.ObjectExpression + > = []; + + const jsConfig = common.expressionFromString('js.configs.recommended'); + eslintConfigs.push(jsConfig); + + if (typescript) { + const tsConfig = common.expressionFromString('ts.configs.recommended'); + eslintConfigs.push(common.createSpreadElement(tsConfig)); } - }, - { - name: () => 'eslint.config.js', - content: ({ content, typescript }) => { - const { ast, generateCode } = parseScript(content); - - const eslintConfigs: Array< - AstKinds.ExpressionKind | AstTypes.SpreadElement | AstTypes.ObjectExpression - > = []; - - const jsConfig = common.expressionFromString('js.configs.recommended'); - eslintConfigs.push(jsConfig); - - if (typescript) { - const tsConfig = common.expressionFromString('ts.configs.recommended'); - eslintConfigs.push(common.createSpreadElement(tsConfig)); - } - - const svelteConfig = common.expressionFromString('svelte.configs["flat/recommended"]'); - eslintConfigs.push(common.createSpreadElement(svelteConfig)); - - const globalsBrowser = common.createSpreadElement( - common.expressionFromString('globals.browser') - ); - const globalsNode = common.createSpreadElement(common.expressionFromString('globals.node')); - const globalsObjLiteral = object.createEmpty(); - globalsObjLiteral.properties = [globalsBrowser, globalsNode]; - const globalsConfig = object.create({ + + const svelteConfig = common.expressionFromString('svelte.configs["flat/recommended"]'); + eslintConfigs.push(common.createSpreadElement(svelteConfig)); + + const globalsBrowser = common.createSpreadElement( + common.expressionFromString('globals.browser') + ); + const globalsNode = common.createSpreadElement(common.expressionFromString('globals.node')); + const globalsObjLiteral = object.createEmpty(); + globalsObjLiteral.properties = [globalsBrowser, globalsNode]; + const globalsConfig = object.create({ + languageOptions: object.create({ + globals: globalsObjLiteral + }) + }); + eslintConfigs.push(globalsConfig); + + if (typescript) { + const svelteTSParserConfig = object.create({ + files: common.expressionFromString('["**/*.svelte"]'), languageOptions: object.create({ - globals: globalsObjLiteral + parserOptions: object.create({ + parser: common.expressionFromString('ts.parser') + }) }) }); - eslintConfigs.push(globalsConfig); - - if (typescript) { - const svelteTSParserConfig = object.create({ - files: common.expressionFromString('["**/*.svelte"]'), - languageOptions: object.create({ - parserOptions: object.create({ - parser: common.expressionFromString('ts.parser') - }) - }) - }); - eslintConfigs.push(svelteTSParserConfig); - } + eslintConfigs.push(svelteTSParserConfig); + } - const ignoresConfig = object.create({ - ignores: common.expressionFromString('["build/", ".svelte-kit/", "dist/"]') - }); - eslintConfigs.push(ignoresConfig); - - let exportExpression: AstTypes.ArrayExpression | AstTypes.CallExpression; - if (typescript) { - const tsConfigCall = functions.call('ts.config', []); - tsConfigCall.arguments.push(...eslintConfigs); - exportExpression = tsConfigCall; - } else { - const eslintArray = array.createEmpty(); - eslintConfigs.map((x) => array.push(eslintArray, x)); - exportExpression = eslintArray; - } - - const defaultExport = exports.defaultExport(ast, exportExpression); - // if it's not the config we created, then we'll leave it alone and exit out - if (defaultExport.value !== exportExpression) { - log.warn('An eslint config is already defined. Skipping initialization.'); - return content; - } - - // type annotate config - if (!typescript) - common.addJsDocTypeComment(defaultExport.astNode, "import('eslint').Linter.Config[]"); - - // imports - if (typescript) imports.addDefault(ast, 'typescript-eslint', 'ts'); - imports.addDefault(ast, 'globals', 'globals'); - imports.addDefault(ast, 'eslint-plugin-svelte', 'svelte'); - imports.addDefault(ast, '@eslint/js', 'js'); - - return generateCode(); + const ignoresConfig = object.create({ + ignores: common.expressionFromString('["build/", ".svelte-kit/", "dist/"]') + }); + eslintConfigs.push(ignoresConfig); + + let exportExpression: AstTypes.ArrayExpression | AstTypes.CallExpression; + if (typescript) { + const tsConfigCall = functions.call('ts.config', []); + tsConfigCall.arguments.push(...eslintConfigs); + exportExpression = tsConfigCall; + } else { + const eslintArray = array.createEmpty(); + eslintConfigs.map((x) => array.push(eslintArray, x)); + exportExpression = eslintArray; } - }, - { - name: () => 'eslint.config.js', - condition: ({ dependencyVersion }) => Boolean(dependencyVersion('prettier')), - content: addEslintConfigPrettier + + const defaultExport = exports.defaultExport(ast, exportExpression); + // if it's not the config we created, then we'll leave it alone and exit out + if (defaultExport.value !== exportExpression) { + log.warn('An eslint config is already defined. Skipping initialization.'); + return content; + } + + // type annotate config + if (!typescript) + common.addJsDocTypeComment(defaultExport.astNode, "import('eslint').Linter.Config[]"); + + // imports + if (typescript) imports.addDefault(ast, 'typescript-eslint', 'ts'); + imports.addDefault(ast, 'globals', 'globals'); + imports.addDefault(ast, 'eslint-plugin-svelte', 'svelte'); + imports.addDefault(ast, '@eslint/js', 'js'); + + return generateCode(); + }); + + if (prettierInstalled) { + sv.file('eslint.config.js', addEslintConfigPrettier); } - ] + } }); diff --git a/packages/adders/lucia/index.ts b/packages/adders/lucia/index.ts index 18e9aaa7..7a6c3b8c 100644 --- a/packages/adders/lucia/index.ts +++ b/packages/adders/lucia/index.ts @@ -8,8 +8,7 @@ import { utils, Walker } from '@sveltejs/cli-core'; -import { common, exports, imports, variables, object, functions, kit } from '@sveltejs/cli-core/js'; -// eslint-disable-next-line no-duplicate-imports +import * as js from '@sveltejs/cli-core/js'; import type { AstTypes } from '@sveltejs/cli-core/js'; import { parseScript } from '@sveltejs/cli-core/parsers'; import { addToDemoPage } from '../common.ts'; @@ -35,321 +34,315 @@ const options = defineAdderOptions({ export default defineAdder({ id: 'lucia', - environments: { svelte: false, kit: true }, homepage: 'https://lucia-auth.com', options, - packages: [ - { name: '@oslojs/crypto', version: '^1.0.1', dev: false }, - { name: '@oslojs/encoding', version: '^1.1.0', dev: false }, - // password hashing for demo - { - name: '@node-rs/argon2', - version: '^1.1.0', - condition: ({ options }) => options.demo, - dev: false + setup: ({ kit, dependencyVersion, unsupported, dependsOn }) => { + if (!kit) unsupported('Requires SvelteKit'); + if (!dependencyVersion('drizzle-orm')) dependsOn('drizzle'); + }, + run: ({ sv, typescript, options, kit, dependencyVersion }) => { + const ext = typescript ? 'ts' : 'js'; + + sv.dependency('@oslojs/crypto', '^1.0.1'); + sv.dependency('@oslojs/encoding', '^1.1.0'); + + if (options.demo) { + // password hashing for demo + sv.dependency('@node-rs/argon2', '^1.1.0'); } - ], - dependsOn: ['drizzle'], - files: [ - { - name: ({ typescript }) => `drizzle.config.${typescript ? 'ts' : 'js'}`, - content: ({ content }) => { - const { ast, generateCode } = parseScript(content); - const isProp = (name: string, node: AstTypes.ObjectProperty) => - node.key.type === 'Identifier' && node.key.name === name; - - // prettier-ignore - Walker.walk(ast as AstTypes.ASTNode, {}, { - ObjectProperty(node) { - if (isProp('dialect', node) && node.value.type === 'StringLiteral') { - drizzleDialect = node.value.value as Dialect; - } - if (isProp('schema', node) && node.value.type === 'StringLiteral') { - schemaPath = node.value.value; - } - } - }) - if (!drizzleDialect) { - throw new Error('Failed to detect DB dialect in your `drizzle.config.[js|ts]` file'); - } - if (!schemaPath) { - throw new Error('Failed to find schema path in your `drizzle.config.[js|ts]` file'); + sv.file(`drizzle.config.${ext}`, (content) => { + const { ast, generateCode } = parseScript(content); + const isProp = (name: string, node: AstTypes.ObjectProperty) => + node.key.type === 'Identifier' && node.key.name === name; + + // prettier-ignore + Walker.walk(ast as AstTypes.ASTNode, {}, { + ObjectProperty(node) { + if (isProp('dialect', node) && node.value.type === 'StringLiteral') { + drizzleDialect = node.value.value as Dialect; + } + if (isProp('schema', node) && node.value.type === 'StringLiteral') { + schemaPath = node.value.value; + } } - return generateCode(); + }) + + if (!drizzleDialect) { + throw new Error('Failed to detect DB dialect in your `drizzle.config.[js|ts]` file'); } - }, - { - name: () => schemaPath, - content: ({ content, options, typescript }) => { - const { ast, generateCode } = parseScript(content); - const createTable = (name: string) => functions.call(TABLE_TYPE[drizzleDialect], [name]); + if (!schemaPath) { + throw new Error('Failed to find schema path in your `drizzle.config.[js|ts]` file'); + } + return generateCode(); + }); - const userDecl = variables.declaration(ast, 'const', 'user', createTable('user')); - const sessionDecl = variables.declaration(ast, 'const', 'session', createTable('session')); + sv.file(schemaPath, (content) => { + const { ast, generateCode } = parseScript(content); + const createTable = (name: string) => js.functions.call(TABLE_TYPE[drizzleDialect], [name]); - const user = exports.namedExport(ast, 'user', userDecl); - const session = exports.namedExport(ast, 'session', sessionDecl); + const userDecl = js.variables.declaration(ast, 'const', 'user', createTable('user')); + const sessionDecl = js.variables.declaration(ast, 'const', 'session', createTable('session')); - const userTable = getCallExpression(user); - const sessionTable = getCallExpression(session); + const user = js.exports.namedExport(ast, 'user', userDecl); + const session = js.exports.namedExport(ast, 'session', sessionDecl); - if (!userTable || !sessionTable) { - throw new Error('failed to find call expression of `user` or `session`'); - } + const userTable = getCallExpression(user); + const sessionTable = getCallExpression(session); - if (userTable.arguments.length === 1) { - userTable.arguments.push(object.createEmpty()); - } - if (sessionTable.arguments.length === 1) { - sessionTable.arguments.push(object.createEmpty()); - } + if (!userTable || !sessionTable) { + throw new Error('failed to find call expression of `user` or `session`'); + } - const userAttributes = userTable.arguments[1]; - const sessionAttributes = sessionTable.arguments[1]; - if ( - userAttributes?.type !== 'ObjectExpression' || - sessionAttributes?.type !== 'ObjectExpression' - ) { - throw new Error('unexpected shape of `user` or `session` table definition'); - } + if (userTable.arguments.length === 1) { + userTable.arguments.push(js.object.createEmpty()); + } + if (sessionTable.arguments.length === 1) { + sessionTable.arguments.push(js.object.createEmpty()); + } - if (drizzleDialect === 'sqlite') { - imports.addNamed(ast, 'drizzle-orm/sqlite-core', { - sqliteTable: 'sqliteTable', - text: 'text', - integer: 'integer' - }); - object.overrideProperties(userAttributes, { - id: common.expressionFromString("text('id').primaryKey()") - }); - if (options.demo) { - object.overrideProperties(userAttributes, { - username: common.expressionFromString("text('username').notNull().unique()"), - passwordHash: common.expressionFromString("text('password_hash').notNull()") - }); - } - object.overrideProperties(sessionAttributes, { - id: common.expressionFromString("text('id').primaryKey()"), - userId: common.expressionFromString( - "text('user_id').notNull().references(() => user.id)" - ), - expiresAt: common.expressionFromString( - "integer('expires_at', { mode: 'timestamp' }).notNull()" - ) + const userAttributes = userTable.arguments[1]; + const sessionAttributes = sessionTable.arguments[1]; + if ( + userAttributes?.type !== 'ObjectExpression' || + sessionAttributes?.type !== 'ObjectExpression' + ) { + throw new Error('unexpected shape of `user` or `session` table definition'); + } + + if (drizzleDialect === 'sqlite') { + js.imports.addNamed(ast, 'drizzle-orm/sqlite-core', { + sqliteTable: 'sqliteTable', + text: 'text', + integer: 'integer' + }); + js.object.overrideProperties(userAttributes, { + id: js.common.expressionFromString("text('id').primaryKey()") + }); + if (options.demo) { + js.object.overrideProperties(userAttributes, { + username: js.common.expressionFromString("text('username').notNull().unique()"), + passwordHash: js.common.expressionFromString("text('password_hash').notNull()") }); } - if (drizzleDialect === 'mysql') { - imports.addNamed(ast, 'drizzle-orm/mysql-core', { - mysqlTable: 'mysqlTable', - varchar: 'varchar', - datetime: 'datetime' - }); - object.overrideProperties(userAttributes, { - id: common.expressionFromString("varchar('id', { length: 255 }).primaryKey()") - }); - if (options.demo) { - object.overrideProperties(userAttributes, { - username: common.expressionFromString( - "varchar('username', { length: 32 }).notNull().unique()" - ), - passwordHash: common.expressionFromString( - "varchar('password_hash', { length: 255 }).notNull()" - ) - }); - } - object.overrideProperties(sessionAttributes, { - id: common.expressionFromString("varchar('id', { length: 255 }).primaryKey()"), - userId: common.expressionFromString( - "varchar('user_id', { length: 255 }).notNull().references(() => user.id)" + js.object.overrideProperties(sessionAttributes, { + id: js.common.expressionFromString("text('id').primaryKey()"), + userId: js.common.expressionFromString( + "text('user_id').notNull().references(() => user.id)" + ), + expiresAt: js.common.expressionFromString( + "integer('expires_at', { mode: 'timestamp' }).notNull()" + ) + }); + } + if (drizzleDialect === 'mysql') { + js.imports.addNamed(ast, 'drizzle-orm/mysql-core', { + mysqlTable: 'mysqlTable', + varchar: 'varchar', + datetime: 'datetime' + }); + js.object.overrideProperties(userAttributes, { + id: js.common.expressionFromString("varchar('id', { length: 255 }).primaryKey()") + }); + if (options.demo) { + js.object.overrideProperties(userAttributes, { + username: js.common.expressionFromString( + "varchar('username', { length: 32 }).notNull().unique()" ), - expiresAt: common.expressionFromString("datetime('expires_at').notNull()") + passwordHash: js.common.expressionFromString( + "varchar('password_hash', { length: 255 }).notNull()" + ) }); } - if (drizzleDialect === 'postgresql') { - imports.addNamed(ast, 'drizzle-orm/pg-core', { - pgTable: 'pgTable', - text: 'text', - timestamp: 'timestamp' - }); - object.overrideProperties(userAttributes, { - id: common.expressionFromString("text('id').primaryKey()") - }); - if (options.demo) { - object.overrideProperties(userAttributes, { - username: common.expressionFromString("text('username').notNull().unique()"), - passwordHash: common.expressionFromString("text('password_hash').notNull()") - }); - } - object.overrideProperties(sessionAttributes, { - id: common.expressionFromString("text('id').primaryKey()"), - userId: common.expressionFromString( - "text('user_id').notNull().references(() => user.id)" - ), - expiresAt: common.expressionFromString( - "timestamp('expires_at', { withTimezone: true, mode: 'date' }).notNull()" - ) + js.object.overrideProperties(sessionAttributes, { + id: js.common.expressionFromString("varchar('id', { length: 255 }).primaryKey()"), + userId: js.common.expressionFromString( + "varchar('user_id', { length: 255 }).notNull().references(() => user.id)" + ), + expiresAt: js.common.expressionFromString("datetime('expires_at').notNull()") + }); + } + if (drizzleDialect === 'postgresql') { + js.imports.addNamed(ast, 'drizzle-orm/pg-core', { + pgTable: 'pgTable', + text: 'text', + timestamp: 'timestamp' + }); + js.object.overrideProperties(userAttributes, { + id: js.common.expressionFromString("text('id').primaryKey()") + }); + if (options.demo) { + js.object.overrideProperties(userAttributes, { + username: js.common.expressionFromString("text('username').notNull().unique()"), + passwordHash: js.common.expressionFromString("text('password_hash').notNull()") }); } + js.object.overrideProperties(sessionAttributes, { + id: js.common.expressionFromString("text('id').primaryKey()"), + userId: js.common.expressionFromString( + "text('user_id').notNull().references(() => user.id)" + ), + expiresAt: js.common.expressionFromString( + "timestamp('expires_at', { withTimezone: true, mode: 'date' }).notNull()" + ) + }); + } - let code = generateCode(); - if (typescript) { - if (!code.includes('export type Session =')) { - code += '\n\nexport type Session = typeof session.$inferSelect;'; - } - if (!code.includes('export type User =')) { - code += '\n\nexport type User = typeof user.$inferSelect;'; - } + let code = generateCode(); + if (typescript) { + if (!code.includes('export type Session =')) { + code += '\n\nexport type Session = typeof session.$inferSelect;'; + } + if (!code.includes('export type User =')) { + code += '\n\nexport type User = typeof user.$inferSelect;'; } - return code; } - }, - { - name: ({ kit, typescript }) => `${kit?.libDirectory}/server/auth.${typescript ? 'ts' : 'js'}`, - content: ({ content, typescript }) => { - const { ast, generateCode } = parseScript(content); + return code; + }); + + sv.file(`${kit?.libDirectory}/server/auth.${ext}`, (content) => { + const { ast, generateCode } = parseScript(content); + + js.imports.addNamespace(ast, '$lib/server/db/schema', 'table'); + js.imports.addNamed(ast, '$lib/server/db', { db: 'db' }); + js.imports.addNamed(ast, '@oslojs/encoding', { + encodeBase64url: 'encodeBase64url', + encodeHexLowerCase: 'encodeHexLowerCase' + }); + js.imports.addNamed(ast, '@oslojs/crypto/sha2', { sha256: 'sha256' }); + js.imports.addNamed(ast, 'drizzle-orm', { eq: 'eq' }); + if (typescript) { + js.imports.addNamed(ast, '@sveltejs/kit', { RequestEvent: 'RequestEvent' }, true); + } - imports.addNamespace(ast, '$lib/server/db/schema', 'table'); - imports.addNamed(ast, '$lib/server/db', { db: 'db' }); - imports.addNamed(ast, '@oslojs/encoding', { - encodeBase32LowerCase: 'encodeBase32LowerCase', - encodeHexLowerCase: 'encodeHexLowerCase' - }); - imports.addNamed(ast, '@oslojs/crypto/sha2', { sha256: 'sha256' }); - imports.addNamed(ast, 'drizzle-orm', { eq: 'eq' }); - if (typescript) { - imports.addNamed(ast, '@sveltejs/kit', { RequestEvent: 'RequestEvent' }, true); - } + const ms = new MagicString(generateCode().trim()); + const [ts] = utils.createPrinter(typescript); - const ms = new MagicString(generateCode().trim()); - const [ts] = utils.createPrinter(typescript); + if (!ms.original.includes('const DAY_IN_MS')) { + ms.append('\n\nconst DAY_IN_MS = 1000 * 60 * 60 * 24;'); + } + if (!ms.original.includes('export const sessionCookieName')) { + ms.append("\n\nexport const sessionCookieName = 'auth-session';"); + } + if (!ms.original.includes('export function generateSessionToken')) { + const generateSessionToken = dedent` + export function generateSessionToken() { + const bytes = crypto.getRandomValues(new Uint8Array(18)); + const token = encodeBase64url(bytes); + return token; + }`; + ms.append(`\n\n${generateSessionToken}`); + } + if (!ms.original.includes('async function createSession')) { + const createSession = dedent` + ${ts('', '/**')} + ${ts('', ' * @param {string} token')} + ${ts('', ' * @param {string} userId')} + ${ts('', ' */')} + export async function createSession(token${ts(': string')}, userId${ts(': string')}) { + const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); + const session${ts(': table.Session')} = { + id: sessionId, + userId, + expiresAt: new Date(Date.now() + DAY_IN_MS * 30) + }; + await db.insert(table.session).values(session); + return session; + }`; + ms.append(`\n\n${createSession}`); + } + if (!ms.original.includes('async function validateSessionToken')) { + const validateSessionToken = dedent` + ${ts('', '/** @param {string} token */')} + export async function validateSessionToken(token${ts(': string')}) { + const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); + const [result] = await db + .select({ + // Adjust user table here to tweak returned data + user: { id: table.user.id, username: table.user.username }, + session: table.session + }) + .from(table.session) + .innerJoin(table.user, eq(table.session.userId, table.user.id)) + .where(eq(table.session.id, sessionId)); + + if (!result) { + return { session: null, user: null }; + } + const { session, user } = result; - if (!ms.original.includes('const DAY_IN_MS')) { - ms.append('\n\nconst DAY_IN_MS = 1000 * 60 * 60 * 24;'); - } - if (!ms.original.includes('export const sessionCookieName')) { - ms.append("\n\nexport const sessionCookieName = 'auth-session';"); - } - if (!ms.original.includes('export function generateSessionToken')) { - const generateSessionToken = dedent` - export function generateSessionToken() { - const bytes = crypto.getRandomValues(new Uint8Array(20)); - const token = encodeBase32LowerCase(bytes); - return token; - }`; - ms.append(`\n\n${generateSessionToken}`); - } - if (!ms.original.includes('async function createSession')) { - const createSession = dedent` - ${ts('', '/**')} - ${ts('', ' * @param {string} token')} - ${ts('', ' * @param {string} userId')} - ${ts('', ' */')} - export async function createSession(token${ts(': string')}, userId${ts(': string')}) { - const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); - const session${ts(': table.Session')} = { - id: sessionId, - userId, - expiresAt: new Date(Date.now() + DAY_IN_MS * 30) - }; - await db.insert(table.session).values(session); - return session; - }`; - ms.append(`\n\n${createSession}`); - } - if (!ms.original.includes('async function validateSessionToken')) { - const validateSessionToken = dedent` - ${ts('', '/** @param {string} token */')} - export async function validateSessionToken(token${ts(': string')}) { - const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); - const [result] = await db - .select({ - // Adjust user table here to tweak returned data - user: { id: table.user.id, username: table.user.username }, - session: table.session - }) - .from(table.session) - .innerJoin(table.user, eq(table.session.userId, table.user.id)) - .where(eq(table.session.id, sessionId)); - - if (!result) { - return { session: null, user: null }; - } - const { session, user } = result; - - const sessionExpired = Date.now() >= session.expiresAt.getTime(); - if (sessionExpired) { - await db.delete(table.session).where(eq(table.session.id, session.id)); - return { session: null, user: null }; - } - - const renewSession = Date.now() >= session.expiresAt.getTime() - DAY_IN_MS * 15; - if (renewSession) { - session.expiresAt = new Date(Date.now() + DAY_IN_MS * 30); - await db - .update(table.session) - .set({ expiresAt: session.expiresAt }) - .where(eq(table.session.id, session.id)); - } - - return { session, user }; - }`; - ms.append(`\n\n${validateSessionToken}`); - } - if (typescript && !ms.original.includes('export type SessionValidationResult')) { - const sessionType = - 'export type SessionValidationResult = Awaited>;'; - ms.append(`\n\n${sessionType}`); - } - if (!ms.original.includes('async function invalidateSession')) { - const invalidateSession = dedent` - ${ts('', '/** @param {string} sessionId */')} - export async function invalidateSession(sessionId${ts(': string')}) { - await db.delete(table.session).where(eq(table.session.id, sessionId)); - }`; - ms.append(`\n\n${invalidateSession}`); - } - if (!ms.original.includes('export function setSessionTokenCookie')) { - const setSessionTokenCookie = dedent` - ${ts('', '/**')} - ${ts('', ' * @param {import("@sveltejs/kit").RequestEvent} event')} - ${ts('', ' * @param {string} token')} - ${ts('', ' * @param {Date} expiresAt')} - ${ts('', ' */')} - export function setSessionTokenCookie(event${ts(': RequestEvent')}, token${ts(': string')}, expiresAt${ts(': Date')}) { - event.cookies.set(sessionCookieName, token, { - expires: expiresAt, - path: '/' - }); - }`; - ms.append(`\n\n${setSessionTokenCookie}`); - } - if (!ms.original.includes('export function deleteSessionTokenCookie')) { - const deleteSessionTokenCookie = dedent` - ${ts('', '/** @param {import("@sveltejs/kit").RequestEvent} event */')} - export function deleteSessionTokenCookie(event${ts(': RequestEvent')}) { - event.cookies.delete(sessionCookieName, { - path: '/' - }); - }`; - ms.append(`\n\n${deleteSessionTokenCookie}`); - } - return ms.toString(); + const sessionExpired = Date.now() >= session.expiresAt.getTime(); + if (sessionExpired) { + await db.delete(table.session).where(eq(table.session.id, session.id)); + return { session: null, user: null }; + } + + const renewSession = Date.now() >= session.expiresAt.getTime() - DAY_IN_MS * 15; + if (renewSession) { + session.expiresAt = new Date(Date.now() + DAY_IN_MS * 30); + await db + .update(table.session) + .set({ expiresAt: session.expiresAt }) + .where(eq(table.session.id, session.id)); + } + + return { session, user }; + }`; + ms.append(`\n\n${validateSessionToken}`); } - }, - { - name: () => 'src/app.d.ts', - condition: ({ typescript }) => typescript, - content: ({ content }) => { + if (typescript && !ms.original.includes('export type SessionValidationResult')) { + const sessionType = + 'export type SessionValidationResult = Awaited>;'; + ms.append(`\n\n${sessionType}`); + } + if (!ms.original.includes('async function invalidateSession')) { + const invalidateSession = dedent` + ${ts('', '/** @param {string} sessionId */')} + export async function invalidateSession(sessionId${ts(': string')}) { + await db.delete(table.session).where(eq(table.session.id, sessionId)); + }`; + ms.append(`\n\n${invalidateSession}`); + } + if (!ms.original.includes('export function setSessionTokenCookie')) { + const setSessionTokenCookie = dedent` + ${ts('', '/**')} + ${ts('', ' * @param {import("@sveltejs/kit").RequestEvent} event')} + ${ts('', ' * @param {string} token')} + ${ts('', ' * @param {Date} expiresAt')} + ${ts('', ' */')} + export function setSessionTokenCookie(event${ts(': RequestEvent')}, token${ts(': string')}, expiresAt${ts(': Date')}) { + event.cookies.set(sessionCookieName, token, { + expires: expiresAt, + path: '/' + }); + }`; + ms.append(`\n\n${setSessionTokenCookie}`); + } + if (!ms.original.includes('export function deleteSessionTokenCookie')) { + const deleteSessionTokenCookie = dedent` + ${ts('', '/** @param {import("@sveltejs/kit").RequestEvent} event */')} + export function deleteSessionTokenCookie(event${ts(': RequestEvent')}) { + event.cookies.delete(sessionCookieName, { + path: '/' + }); + }`; + ms.append(`\n\n${deleteSessionTokenCookie}`); + } + + return ms.toString(); + }); + + if (typescript) { + sv.file('src/app.d.ts', (content) => { const { ast, generateCode } = parseScript(content); - const locals = kit.addGlobalAppInterface(ast, 'Locals'); + const locals = js.kit.addGlobalAppInterface(ast, 'Locals'); if (!locals) { throw new Error('Failed detecting `locals` interface in `src/app.d.ts`'); } - const user = locals.body.body.find((prop) => common.hasTypeProp('user', prop)); - const session = locals.body.body.find((prop) => common.hasTypeProp('session', prop)); + const user = locals.body.body.find((prop) => js.common.hasTypeProp('user', prop)); + const session = locals.body.body.find((prop) => js.common.hasTypeProp('session', prop)); if (!user) { locals.body.body.push(createLuciaType('user')); @@ -358,29 +351,22 @@ export default defineAdder({ locals.body.body.push(createLuciaType('session')); } return generateCode(); - } - }, - { - name: ({ typescript }) => `src/hooks.server.${typescript ? 'ts' : 'js'}`, - content: ({ content, typescript }) => { - const { ast, generateCode } = parseScript(content); - imports.addNamespace(ast, '$lib/server/auth.js', 'auth'); - kit.addHooksHandle(ast, typescript, 'handleAuth', getAuthHandleContent()); - return generateCode(); - } - }, - // DEMO - // login/register - { - name: ({ kit }) => `${kit?.routesDirectory}/demo/+page.svelte`, - condition: ({ options }) => options.demo, - content: (editor) => addToDemoPage(editor, 'lucia') - }, - { - name: ({ kit, typescript }) => - `${kit!.routesDirectory}/demo/lucia/login/+page.server.${typescript ? 'ts' : 'js'}`, - condition: ({ options }) => options.demo, - content({ content, typescript, kit }) { + }); + } + + sv.file(`src/hooks.server.${ext}`, (content) => { + const { ast, generateCode } = parseScript(content); + js.imports.addNamespace(ast, '$lib/server/auth.js', 'auth'); + js.kit.addHooksHandle(ast, typescript, 'handleAuth', getAuthHandleContent()); + return generateCode(); + }); + + if (options.demo) { + sv.file(`${kit?.routesDirectory}/demo/+page.svelte`, (content) => { + return addToDemoPage(content, 'lucia'); + }); + + sv.file(`${kit!.routesDirectory}/demo/lucia/login/+page.server.${ext}`, (content) => { if (content) { const filePath = `${kit!.routesDirectory}/demo/lucia/login/+page.server.${typescript ? 'ts' : 'js'}`; log.warn(`Existing ${colors.yellow(filePath)} file. Could not update.`); @@ -501,12 +487,9 @@ export default defineAdder({ ); } `; - } - }, - { - name: ({ kit }) => `${kit!.routesDirectory}/demo/lucia/login/+page.svelte`, - condition: ({ options }) => options.demo, - content({ content, dependencyVersion, typescript, kit }) { + }); + + sv.file(`${kit!.routesDirectory}/demo/lucia/login/+page.svelte`, (content) => { if (content) { const filePath = `${kit!.routesDirectory}/demo/lucia/login/+page.svelte`; log.warn(`Existing ${colors.yellow(filePath)} file. Could not update.`); @@ -537,14 +520,9 @@ export default defineAdder({

{form?.message ?? ''}

`; - } - }, - // logout - { - name: ({ kit, typescript }) => - `${kit!.routesDirectory}/demo/lucia/+page.server.${typescript ? 'ts' : 'js'}`, - condition: ({ options }) => options.demo, - content({ content, typescript, kit }) { + }); + + sv.file(`${kit!.routesDirectory}/demo/lucia/+page.server.${ext}`, (content) => { if (content) { const filePath = `${kit!.routesDirectory}/demo/lucia/+page.server.${typescript ? 'ts' : 'js'}`; log.warn(`Existing ${colors.yellow(filePath)} file. Could not update.`); @@ -575,12 +553,9 @@ export default defineAdder({ }, }; `; - } - }, - { - name: ({ kit }) => `${kit!.routesDirectory}/demo/lucia/+page.svelte`, - condition: ({ options }) => options.demo, - content({ content, dependencyVersion, typescript, kit }) { + }); + + sv.file(`${kit!.routesDirectory}/demo/lucia/+page.svelte`, (content) => { if (content) { const filePath = `${kit!.routesDirectory}/demo/lucia/+page.svelte`; log.warn(`Existing ${colors.yellow(filePath)} file. Could not update.`); @@ -602,9 +577,9 @@ export default defineAdder({ `; - } + }); } - ], + }, nextSteps: ({ highlighter, options, packageManager }) => { const steps = [ `Run ${highlighter.command(`${packageManager} run db:push`)} to update your database schema` diff --git a/packages/adders/mdsvex/index.ts b/packages/adders/mdsvex/index.ts index 33316fe3..f132dbc6 100644 --- a/packages/adders/mdsvex/index.ts +++ b/packages/adders/mdsvex/index.ts @@ -4,41 +4,38 @@ import { parseScript } from '@sveltejs/cli-core/parsers'; export default defineAdder({ id: 'mdsvex', - environments: { svelte: true, kit: true }, homepage: 'https://mdsvex.pngwn.io', options: {}, - packages: [{ name: 'mdsvex', version: '^0.11.2', dev: true }], - files: [ - { - name: () => 'svelte.config.js', - content: ({ content }) => { - const { ast, generateCode } = parseScript(content); + run: ({ sv }) => { + sv.devDependency('mdsvex', '^0.11.2'); - imports.addNamed(ast, 'mdsvex', { mdsvex: 'mdsvex' }); + sv.file('svelte.config.js', (content) => { + const { ast, generateCode } = parseScript(content); - const { value: exportDefault } = exports.defaultExport(ast, object.createEmpty()); + imports.addNamed(ast, 'mdsvex', { mdsvex: 'mdsvex' }); - // preprocess - let preprocessorArray = object.property(exportDefault, 'preprocess', array.createEmpty()); - const isArray = preprocessorArray.type === 'ArrayExpression'; + const { value: exportDefault } = exports.defaultExport(ast, object.createEmpty()); - if (!isArray) { - const previousElement = preprocessorArray; - preprocessorArray = array.createEmpty(); - array.push(preprocessorArray, previousElement); - object.overrideProperty(exportDefault, 'preprocess', preprocessorArray); - } + // preprocess + let preprocessorArray = object.property(exportDefault, 'preprocess', array.createEmpty()); + const isArray = preprocessorArray.type === 'ArrayExpression'; - const mdsvexCall = functions.call('mdsvex', []); - array.push(preprocessorArray, mdsvexCall); + if (!isArray) { + const previousElement = preprocessorArray; + preprocessorArray = array.createEmpty(); + array.push(preprocessorArray, previousElement); + object.overrideProperty(exportDefault, 'preprocess', preprocessorArray); + } - // extensions - const extensionsArray = object.property(exportDefault, 'extensions', array.createEmpty()); - array.push(extensionsArray, '.svelte'); - array.push(extensionsArray, '.svx'); + const mdsvexCall = functions.call('mdsvex', []); + array.push(preprocessorArray, mdsvexCall); - return generateCode(); - } - } - ] + // extensions + const extensionsArray = object.property(exportDefault, 'extensions', array.createEmpty()); + array.push(extensionsArray, '.svelte'); + array.push(extensionsArray, '.svx'); + + return generateCode(); + }); + } }); diff --git a/packages/adders/paraglide/index.ts b/packages/adders/paraglide/index.ts index b2f1b49d..3a828834 100644 --- a/packages/adders/paraglide/index.ts +++ b/packages/adders/paraglide/index.ts @@ -1,5 +1,3 @@ -import fs from 'node:fs'; -import path from 'node:path'; import MagicString from 'magic-string'; import { colors, dedent, defineAdder, defineAdderOptions, log, utils } from '@sveltejs/cli-core'; import { @@ -10,7 +8,7 @@ import { object, variables, exports, - kit + kit as kitJs } from '@sveltejs/cli-core/js'; import * as html from '@sveltejs/cli-core/html'; import { parseHtml, parseJson, parseScript, parseSvelte } from '@sveltejs/cli-core/parsers'; @@ -37,7 +35,7 @@ const options = defineAdderOptions({ question: `Which languages would you like to support? ${colors.gray('(e.g. en,de-ch)')}`, type: 'string', default: 'en', - validate(input: any) { + validate(input) { const { invalidLanguageTags, validLanguageTags } = parseLanguageTagInput(input); if (invalidLanguageTags.length > 0) { @@ -63,186 +61,160 @@ const options = defineAdderOptions({ export default defineAdder({ id: 'paraglide', - environments: { svelte: false, kit: true }, homepage: 'https://inlang.com', options, - packages: [ - { - name: '@inlang/paraglide-sveltekit', - version: '^0.11.1', - dev: false - } - ], - files: [ - { - // create an inlang project if it doesn't exist yet - name: () => 'project.inlang/settings.json', - condition: ({ cwd }) => !fs.existsSync(path.join(cwd, 'project.inlang/settings.json')), - content: ({ options, content }) => { - const { data, generateCode } = parseJson(content); + setup: ({ kit, unsupported }) => { + if (!kit) unsupported('Requires SvelteKit'); + }, + run: ({ sv, options, typescript, kit, dependencyVersion }) => { + const ext = typescript ? 'ts' : 'js'; + if (!kit) throw new Error('SvelteKit is required'); - for (const key in DEFAULT_INLANG_PROJECT) { - data[key] = DEFAULT_INLANG_PROJECT[key as keyof typeof DEFAULT_INLANG_PROJECT]; - } - const { validLanguageTags } = parseLanguageTagInput(options.availableLanguageTags); - const sourceLanguageTag = validLanguageTags[0]; + sv.dependency('@inlang/paraglide-sveltekit', '^0.11.1'); - data.sourceLanguageTag = sourceLanguageTag; - data.languageTags = validLanguageTags; + sv.file('project.inlang/settings.json', (content) => { + if (content) return content; - return generateCode(); + const { data, generateCode } = parseJson(content); + + for (const key in DEFAULT_INLANG_PROJECT) { + data[key] = DEFAULT_INLANG_PROJECT[key as keyof typeof DEFAULT_INLANG_PROJECT]; } - }, - { - // add the vite plugin - name: ({ typescript }) => `vite.config.${typescript ? 'ts' : 'js'}`, - content: ({ content }) => { - const { ast, generateCode } = parseScript(content); - - const vitePluginName = 'paraglide'; - imports.addNamed(ast, '@inlang/paraglide-sveltekit/vite', { paraglide: vitePluginName }); - - const { value: rootObject } = exports.defaultExport( - ast, - functions.call('defineConfig', []) - ); - const param1 = functions.argumentByIndex(rootObject, 0, object.createEmpty()); + const { validLanguageTags } = parseLanguageTagInput(options.availableLanguageTags); + const sourceLanguageTag = validLanguageTags[0]; - const pluginsArray = object.property(param1, 'plugins', array.createEmpty()); - const pluginFunctionCall = functions.call(vitePluginName, []); - const pluginConfig = object.create({ - project: common.createLiteral('./project.inlang'), - outdir: common.createLiteral('./src/lib/paraglide') - }); - functions.argumentByIndex(pluginFunctionCall, 0, pluginConfig); - array.push(pluginsArray, pluginFunctionCall); + data.sourceLanguageTag = sourceLanguageTag; + data.languageTags = validLanguageTags; - return generateCode(); - } - }, - { - // src/lib/i18n file - name: ({ typescript }) => `src/lib/i18n.${typescript ? 'ts' : 'js'}`, - content({ content }) { - const { ast, generateCode } = parseScript(content); - - imports.addNamed(ast, '@inlang/paraglide-sveltekit', { createI18n: 'createI18n' }); - imports.addDefault(ast, '$lib/paraglide/runtime', '* as runtime'); - - const createI18nExpression = common.expressionFromString('createI18n(runtime)'); - const i18n = variables.declaration(ast, 'const', 'i18n', createI18nExpression); - - const existingExport = exports.namedExport(ast, 'i18n', i18n); - if (existingExport.declaration != i18n) { - log.warn('Setting up $lib/i18n failed because it already exports an i18n function'); - } + return generateCode(); + }); - return generateCode(); - } - }, - { - // reroute hook - name: ({ typescript }) => `src/hooks.${typescript ? 'ts' : 'js'}`, - content({ content }) { - const { ast, generateCode } = parseScript(content); - - imports.addNamed(ast, '$lib/i18n', { - i18n: 'i18n' - }); - - const expression = common.expressionFromString('i18n.reroute()'); - const rerouteIdentifier = variables.declaration(ast, 'const', 'reroute', expression); - - const existingExport = exports.namedExport(ast, 'reroute', rerouteIdentifier); - if (existingExport.declaration != rerouteIdentifier) { - log.warn('Adding the reroute hook automatically failed. Add it manually'); - } + // add the vite plugin + sv.file(`vite.config.${ext}`, (content) => { + const { ast, generateCode } = parseScript(content); - return generateCode(); + const vitePluginName = 'paraglide'; + imports.addNamed(ast, '@inlang/paraglide-sveltekit/vite', { paraglide: vitePluginName }); + + const { value: rootObject } = exports.defaultExport(ast, functions.call('defineConfig', [])); + const param1 = functions.argumentByIndex(rootObject, 0, object.createEmpty()); + + const pluginsArray = object.property(param1, 'plugins', array.createEmpty()); + const pluginFunctionCall = functions.call(vitePluginName, []); + const pluginConfig = object.create({ + project: common.createLiteral('./project.inlang'), + outdir: common.createLiteral('./src/lib/paraglide') + }); + functions.argumentByIndex(pluginFunctionCall, 0, pluginConfig); + array.push(pluginsArray, pluginFunctionCall); + + return generateCode(); + }); + + // src/lib/i18n file + sv.file(`src/lib/i18n.${ext}`, (content) => { + const { ast, generateCode } = parseScript(content); + + imports.addNamed(ast, '@inlang/paraglide-sveltekit', { createI18n: 'createI18n' }); + imports.addDefault(ast, '$lib/paraglide/runtime', '* as runtime'); + + const createI18nExpression = common.expressionFromString('createI18n(runtime)'); + const i18n = variables.declaration(ast, 'const', 'i18n', createI18nExpression); + + const existingExport = exports.namedExport(ast, 'i18n', i18n); + if (existingExport.declaration !== i18n) { + log.warn('Setting up $lib/i18n failed because it already exports an i18n function'); } - }, - { - // handle hook - name: ({ typescript }) => `src/hooks.server.${typescript ? 'ts' : 'js'}`, - content({ content, typescript }) { - const { ast, generateCode } = parseScript(content); - imports.addNamed(ast, '$lib/i18n', { - i18n: 'i18n' - }); + return generateCode(); + }); - const hookHandleContent = 'i18n.handle()'; - kit.addHooksHandle(ast, typescript, 'handleParaglide', hookHandleContent); + // reroute hook + sv.file(`src/hooks.${ext}`, (content) => { + const { ast, generateCode } = parseScript(content); - return generateCode(); + imports.addNamed(ast, '$lib/i18n', { i18n: 'i18n' }); + + const expression = common.expressionFromString('i18n.reroute()'); + const rerouteIdentifier = variables.declaration(ast, 'const', 'reroute', expression); + + const existingExport = exports.namedExport(ast, 'reroute', rerouteIdentifier); + if (existingExport.declaration !== rerouteIdentifier) { + log.warn('Adding the reroute hook automatically failed. Add it manually'); } - }, - { - // add the component to the layout - name: ({ kit }) => `${kit?.routesDirectory}/+layout.svelte`, - content: ({ content, dependencyVersion, typescript }) => { - const { script, template, generateCode } = parseSvelte(content, { typescript }); - const paraglideComponentName = 'ParaglideJS'; - imports.addNamed(script.ast, '@inlang/paraglide-sveltekit', { - [paraglideComponentName]: paraglideComponentName - }); - imports.addNamed(script.ast, '$lib/i18n', { - i18n: 'i18n' - }); + return generateCode(); + }); - if (template.source.length === 0) { - const svelteVersion = dependencyVersion('svelte'); - if (!svelteVersion) throw new Error('Failed to determine svelte version'); + // handle hook + sv.file(`src/hooks.server.${ext}`, (content) => { + const { ast, generateCode } = parseScript(content); - html.addSlot(script.ast, template.ast, svelteVersion); - } + imports.addNamed(ast, '$lib/i18n', { i18n: 'i18n' }); - const templateCode = new MagicString(template.generateCode()); - if (!templateCode.original.includes('\n'); - templateCode.append('\n'); - } + const hookHandleContent = 'i18n.handle()'; + kitJs.addHooksHandle(ast, typescript, 'handleParaglide', hookHandleContent); + + return generateCode(); + }); + + // add the component to the layout + sv.file(`${kit.routesDirectory}/+layout.svelte`, (content) => { + const { script, template, generateCode } = parseSvelte(content, { typescript }); + + const paraglideComponentName = 'ParaglideJS'; + imports.addNamed(script.ast, '@inlang/paraglide-sveltekit', { + [paraglideComponentName]: paraglideComponentName + }); + imports.addNamed(script.ast, '$lib/i18n', { i18n: 'i18n' }); + + if (template.source.length === 0) { + const svelteVersion = dependencyVersion('svelte'); + if (!svelteVersion) throw new Error('Failed to determine svelte version'); - return generateCode({ script: script.generateCode(), template: templateCode.toString() }); + html.addSlot(script.ast, template.ast, svelteVersion); } - }, - { - // add the text-direction and lang attribute placeholders to app.html - name: () => 'src/app.html', - content: ({ content }) => { - const { ast, generateCode } = parseHtml(content); - - const htmlNode = ast.children.find( - (child): child is html.HtmlElement => - child.type === html.HtmlElementType.Tag && child.name === 'html' - ); - if (!htmlNode) { - log.warn( - "Could not find node in app.html. You'll need to add the language placeholder manually" - ); - return generateCode(); - } - htmlNode.attribs = { - ...htmlNode.attribs, - lang: '%paraglide.lang%', - dir: '%paraglide.textDirection%' - }; + const templateCode = new MagicString(template.generateCode()); + if (!templateCode.original.includes('\n'); + templateCode.append('\n'); + } + + return generateCode({ script: script.generateCode(), template: templateCode.toString() }); + }); + + // add the text-direction and lang attribute placeholders to app.html + sv.file('src/app.html', (content) => { + const { ast, generateCode } = parseHtml(content); + + const htmlNode = ast.children.find( + (child): child is html.HtmlElement => + child.type === html.HtmlElementType.Tag && child.name === 'html' + ); + if (!htmlNode) { + log.warn( + "Could not find node in app.html. You'll need to add the language placeholder manually" + ); return generateCode(); } - }, - { - name: ({ kit }) => `${kit?.routesDirectory}/demo/+page.svelte`, - condition: ({ options }) => options.demo, - content: (editor) => addToDemoPage(editor, 'paraglide') - }, - { + htmlNode.attribs = { + ...htmlNode.attribs, + lang: '%paraglide.lang%', + dir: '%paraglide.textDirection%' + }; + + return generateCode(); + }); + + if (options.demo) { + sv.file(`${kit.routesDirectory}/demo/+page.svelte`, (content) => { + return addToDemoPage(content, 'paraglide'); + }); + // add usage example - name: ({ kit }) => `${kit?.routesDirectory}/demo/paraglide/+page.svelte`, - condition: ({ options }) => options.demo, - content({ content, options, typescript }) { + sv.file(`${kit.routesDirectory}/demo/paraglide/+page.svelte`, (content) => { const { script, template, generateCode } = parseSvelte(content, { typescript }); imports.addDefault(script.ast, '$lib/paraglide/messages.js', '* as m'); @@ -265,15 +237,15 @@ export default defineAdder({ scriptCode.trim(); scriptCode.append('\n\n'); scriptCode.append(dedent` - ${ts('', '/**')} - ${ts('', '* @param import("$lib/paraglide/runtime").AvailableLanguageTag newLanguage')} - ${ts('', '*/')} - function switchToLanguage(newLanguage${ts(': AvailableLanguageTag')}) { - const canonicalPath = i18n.route($page.url.pathname); - const localisedPath = i18n.resolveRoute(canonicalPath, newLanguage); - goto(localisedPath); - } - `); + ${ts('', '/**')} + ${ts('', '* @param import("$lib/paraglide/runtime").AvailableLanguageTag newLanguage')} + ${ts('', '*/')} + function switchToLanguage(newLanguage${ts(': AvailableLanguageTag')}) { + const canonicalPath = i18n.route($page.url.pathname); + const localisedPath = i18n.resolveRoute(canonicalPath, newLanguage); + goto(localisedPath); + } + `); } const templateCode = new MagicString(template.source); @@ -293,26 +265,21 @@ export default defineAdder({ templateCode.append(`
\n${links}\n
`); return generateCode({ script: scriptCode.toString(), template: templateCode.toString() }); - } + }); } - ], - postInstall: ({ cwd, options }) => { - const jsonData: Record = {}; - jsonData['$schema'] = 'https://inlang.com/schema/inlang-message-format'; const { validLanguageTags } = parseLanguageTagInput(options.availableLanguageTags); for (const languageTag of validLanguageTags) { - jsonData.hello_world = `Hello, {name} from ${languageTag}!`; - - const filePath = `messages/${languageTag}.json`; - const directoryPath = path.dirname(filePath); - const fullDirectoryPath = path.join(cwd, directoryPath); - const fullFilePath = path.join(cwd, filePath); + sv.file(`messages/${languageTag}.json`, (content) => { + const { data, generateCode } = parseJson(content); + data['$schema'] = 'https://inlang.com/schema/inlang-message-format'; + data.hello_world = `Hello, {name} from ${languageTag}!`; - fs.mkdirSync(fullDirectoryPath, { recursive: true }); - fs.writeFileSync(fullFilePath, JSON.stringify(jsonData, null, 2) + '\n'); + return generateCode(); + }); } }, + nextSteps: ({ highlighter }) => { const steps = [ `Edit your messages in ${highlighter.path('messages/en.json')}`, diff --git a/packages/adders/playwright/index.ts b/packages/adders/playwright/index.ts index e05251ed..db3833f0 100644 --- a/packages/adders/playwright/index.ts +++ b/packages/adders/playwright/index.ts @@ -1,84 +1,75 @@ -import fs from 'node:fs'; -import { join } from 'node:path'; import { dedent, defineAdder, log } from '@sveltejs/cli-core'; import { common, exports, imports, object } from '@sveltejs/cli-core/js'; import { parseJson, parseScript } from '@sveltejs/cli-core/parsers'; export default defineAdder({ id: 'playwright', - environments: { svelte: true, kit: true }, homepage: 'https://playwright.dev', options: {}, - packages: [{ name: '@playwright/test', version: '^1.45.3', dev: true }], - files: [ - { - name: () => 'package.json', - content: ({ content }) => { - const { data, generateCode } = parseJson(content); - data.scripts ??= {}; - const scripts: Record = data.scripts; - const TEST_CMD = 'playwright test'; - const RUN_TEST = 'npm run test:e2e'; - scripts['test:e2e'] ??= TEST_CMD; - scripts['test'] ??= RUN_TEST; - if (!scripts['test'].includes(RUN_TEST)) scripts['test'] += ` && ${RUN_TEST}`; - return generateCode(); - } - }, - { - name: () => '.gitignore', - condition: ({ cwd }) => fs.existsSync(join(cwd, '.gitignore')), - content: ({ content }) => { - if (content.includes('test-results')) return content; - return 'test-results\n' + content.trim(); - } - }, - { - name: ({ typescript }) => `e2e/demo.test.${typescript ? 'ts' : 'js'}`, - content: ({ content }) => { - if (content) return content; + run: ({ sv, typescript }) => { + const ext = typescript ? 'ts' : 'js'; - return dedent` - import { expect, test } from '@playwright/test'; + sv.devDependency('@playwright/test', '^1.45.3'); - test('home page has expected h1', async ({ page }) => { - await page.goto('/'); - await expect(page.locator('h1')).toBeVisible(); - }); - `; - } - }, - { - name: ({ typescript }) => `playwright.config.${typescript ? 'ts' : 'js'}`, - content: ({ content }) => { - const { ast, generateCode } = parseScript(content); - const defineConfig = common.expressionFromString('defineConfig({})'); - const defaultExport = exports.defaultExport(ast, defineConfig); + sv.file('package.json', (content) => { + const { data, generateCode } = parseJson(content); + data.scripts ??= {}; + const scripts: Record = data.scripts; + const TEST_CMD = 'playwright test'; + const RUN_TEST = 'npm run test:e2e'; + scripts['test:e2e'] ??= TEST_CMD; + scripts['test'] ??= RUN_TEST; + if (!scripts['test'].includes(RUN_TEST)) scripts['test'] += ` && ${RUN_TEST}`; + return generateCode(); + }); + + sv.file('.gitignore', (content) => { + if (!content) return content; + if (content.includes('test-results')) return content; + return 'test-results\n' + content.trim(); + }); + + sv.file(`e2e/demo.test.${ext}`, (content) => { + if (content) return content; + + return dedent` + import { expect, test } from '@playwright/test'; + + test('home page has expected h1', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('h1')).toBeVisible(); + }); + `; + }); + + sv.file(`playwright.config.${ext}`, (content) => { + const { ast, generateCode } = parseScript(content); + const defineConfig = common.expressionFromString('defineConfig({})'); + const defaultExport = exports.defaultExport(ast, defineConfig); - const config = { - webServer: object.create({ - command: common.createLiteral('npm run build && npm run preview'), - port: common.expressionFromString('4173') - }), - testDir: common.createLiteral('e2e') - }; + const config = { + webServer: object.create({ + command: common.createLiteral('npm run build && npm run preview'), + port: common.expressionFromString('4173') + }), + testDir: common.createLiteral('e2e') + }; - if ( - defaultExport.value.type === 'CallExpression' && - defaultExport.value.arguments[0]?.type === 'ObjectExpression' - ) { - // uses the `defineConfig` helper - imports.addNamed(ast, '@playwright/test', { defineConfig: 'defineConfig' }); - object.properties(defaultExport.value.arguments[0], config); - } else if (defaultExport.value.type === 'ObjectExpression') { - // if the config is just an object expression, just add the property - object.properties(defaultExport.value, config); - } else { - // unexpected config shape - log.warn('Unexpected playwright config for playwright adder. Could not update.'); - } - return generateCode(); + if ( + defaultExport.value.type === 'CallExpression' && + defaultExport.value.arguments[0]?.type === 'ObjectExpression' + ) { + // uses the `defineConfig` helper + imports.addNamed(ast, '@playwright/test', { defineConfig: 'defineConfig' }); + object.properties(defaultExport.value.arguments[0], config); + } else if (defaultExport.value.type === 'ObjectExpression') { + // if the config is just an object expression, just add the property + object.properties(defaultExport.value, config); + } else { + // unexpected config shape + log.warn('Unexpected playwright config for playwright adder. Could not update.'); } - } - ] + return generateCode(); + }); + } }); diff --git a/packages/adders/prettier/index.ts b/packages/adders/prettier/index.ts index 9be7b903..2a5c21cf 100644 --- a/packages/adders/prettier/index.ts +++ b/packages/adders/prettier/index.ts @@ -4,102 +4,86 @@ import { parseJson } from '@sveltejs/cli-core/parsers'; export default defineAdder({ id: 'prettier', - environments: { svelte: true, kit: true }, homepage: 'https://prettier.io', options: {}, - packages: [ - { name: 'prettier', version: '^3.3.2', dev: true }, - { name: 'prettier-plugin-svelte', version: '^3.2.6', dev: true }, - { - name: 'eslint-config-prettier', - version: '^9.1.0', - dev: true, - condition: ({ dependencyVersion }) => hasEslint(dependencyVersion) - } - ], - files: [ - { - name: () => '.prettierignore', - content: ({ content }) => { - if (content) return content; - return dedent` - # Package Managers - package-lock.json - pnpm-lock.yaml - yarn.lock - `; + run: ({ sv, dependencyVersion }) => { + sv.devDependency('prettier', '^3.3.2'); + sv.devDependency('prettier-plugin-svelte', '^3.2.6'); + + sv.file('.prettierignore', (content) => { + if (content) return content; + return dedent` + # Package Managers + package-lock.json + pnpm-lock.yaml + yarn.lock + `; + }); + + sv.file('.prettierrc', (content) => { + const { data, generateCode } = parseJson(content); + if (Object.keys(data).length === 0) { + // we'll only set these defaults if there is no pre-existing config + data.useTabs = true; + data.singleQuote = true; + data.trailingComma = 'none'; + data.printWidth = 100; } - }, - { - name: () => '.prettierrc', - content: ({ content }) => { - const { data, generateCode } = parseJson(content); - if (Object.keys(data).length === 0) { - // we'll only set these defaults if there is no pre-existing config - data.useTabs = true; - data.singleQuote = true; - data.trailingComma = 'none'; - data.printWidth = 100; - } - data.plugins ??= []; - data.overrides ??= []; + data.plugins ??= []; + data.overrides ??= []; - const plugins: string[] = data.plugins; - if (!plugins.includes('prettier-plugin-svelte')) { - data.plugins.unshift('prettier-plugin-svelte'); - } + const plugins: string[] = data.plugins; + if (!plugins.includes('prettier-plugin-svelte')) { + data.plugins.unshift('prettier-plugin-svelte'); + } - const overrides: Array<{ files: string | string[]; options?: { parser?: string } }> = - data.overrides; - const override = overrides.find((o) => o?.options?.parser === 'svelte'); - if (!override) { - overrides.push({ files: '*.svelte', options: { parser: 'svelte' } }); - } - return generateCode(); + const overrides: Array<{ files: string | string[]; options?: { parser?: string } }> = + data.overrides; + const override = overrides.find((o) => o?.options?.parser === 'svelte'); + if (!override) { + overrides.push({ files: '*.svelte', options: { parser: 'svelte' } }); } - }, - { - name: () => 'package.json', - content: ({ content, dependencyVersion }) => { - const { data, generateCode } = parseJson(content); + return generateCode(); + }); - data.scripts ??= {}; - const scripts: Record = data.scripts; - const CHECK_CMD = 'prettier --check .'; - scripts['format'] ??= 'prettier --write .'; + const eslintVersion = dependencyVersion('eslint'); + const eslintInstalled = hasEslint(eslintVersion); - if (hasEslint(dependencyVersion)) { - scripts['lint'] ??= `${CHECK_CMD} && eslint .`; - if (!scripts['lint'].includes(CHECK_CMD)) scripts['lint'] += ` && ${CHECK_CMD}`; - } else { - scripts['lint'] ??= CHECK_CMD; - } - return generateCode(); + sv.file('package.json', (content) => { + const { data, generateCode } = parseJson(content); + + data.scripts ??= {}; + const scripts: Record = data.scripts; + const CHECK_CMD = 'prettier --check .'; + scripts['format'] ??= 'prettier --write .'; + + if (eslintInstalled) { + scripts['lint'] ??= `${CHECK_CMD} && eslint .`; + if (!scripts['lint'].includes(CHECK_CMD)) scripts['lint'] += ` && ${CHECK_CMD}`; + } else { + scripts['lint'] ??= CHECK_CMD; } - }, - { - name: () => 'eslint.config.js', - condition: ({ dependencyVersion }) => { - // We only want this to execute when it's `false`, not falsy + return generateCode(); + }); + + if (eslintVersion?.startsWith(SUPPORTED_ESLINT_VERSION) === false) { + log.warn( + `An older major version of ${colors.yellow( + 'eslint' + )} was detected. Skipping ${colors.yellow('eslint-config-prettier')} installation.` + ); + } - if (dependencyVersion('eslint')?.startsWith(SUPPORTED_ESLINT_VERSION) === false) { - log.warn( - `An older major version of ${colors.yellow( - 'eslint' - )} was detected. Skipping ${colors.yellow('eslint-config-prettier')} installation.` - ); - } - return hasEslint(dependencyVersion); - }, - content: addEslintConfigPrettier + if (eslintInstalled) { + sv.devDependency('eslint-config-prettier', '^9.1.0'); + sv.file('eslint.config.js', addEslintConfigPrettier); } - ] + } }); const SUPPORTED_ESLINT_VERSION = '9'; -function hasEslint(dependencyVersion: (pkg: string) => string | undefined): boolean { - const version = dependencyVersion('eslint'); +function hasEslint(version: string | undefined): boolean { return !!version && version.startsWith(SUPPORTED_ESLINT_VERSION); } diff --git a/packages/adders/storybook/index.ts b/packages/adders/storybook/index.ts index 20fdc5e8..0869a62b 100644 --- a/packages/adders/storybook/index.ts +++ b/packages/adders/storybook/index.ts @@ -2,16 +2,9 @@ import { defineAdder } from '@sveltejs/cli-core'; export default defineAdder({ id: 'storybook', - environments: { kit: true, svelte: true }, homepage: 'https://storybook.js.org', options: {}, - packages: [], - scripts: [ - { - description: 'applies storybook', - args: ['storybook@8.3.6', 'init', '--skip-install', '--no-dev'], - stdio: 'inherit' - } - ], - files: [] + run: async ({ sv }) => { + await sv.execute(['storybook@latest', 'init', '--skip-install', '--no-dev'], 'inherit'); + } }); diff --git a/packages/adders/tailwindcss/index.ts b/packages/adders/tailwindcss/index.ts index b0f30d33..0560fa9f 100644 --- a/packages/adders/tailwindcss/index.ts +++ b/packages/adders/tailwindcss/index.ts @@ -1,4 +1,4 @@ -import { defineAdder, defineAdderOptions, type PackageDefinition } from '@sveltejs/cli-core'; +import { defineAdder, defineAdderOptions } from '@sveltejs/cli-core'; import { addImports } from '@sveltejs/cli-core/css'; import { array, common, exports, imports, object } from '@sveltejs/cli-core/js'; import { parseCss, parseScript, parseJson, parseSvelte } from '@sveltejs/cli-core/parsers'; @@ -38,13 +38,6 @@ const plugins: Plugin[] = [ } ]; -const pluginPackages: Array> = plugins.map((x) => ({ - name: x.package, - version: x.version, - dev: true, - condition: ({ options }) => options.plugins.includes(x.id) -})); - const options = defineAdderOptions({ plugins: { type: 'multiselect', @@ -57,121 +50,113 @@ const options = defineAdderOptions({ export default defineAdder({ id: 'tailwindcss', alias: 'tailwind', - environments: { svelte: true, kit: true }, homepage: 'https://tailwindcss.com', options, - packages: [ - { name: 'tailwindcss', version: '^3.4.9', dev: true }, - { name: 'autoprefixer', version: '^10.4.20', dev: true }, - { - name: 'prettier-plugin-tailwindcss', - version: '^0.6.5', - dev: true, - condition: ({ dependencyVersion }) => Boolean(dependencyVersion('prettier')) - }, - ...pluginPackages - ], - files: [ - { - name: ({ typescript }) => `tailwind.config.${typescript ? 'ts' : 'js'}`, - content: ({ options, typescript, content }) => { - const { ast, generateCode } = parseScript(content); - let root; - const rootExport = object.createEmpty(); - if (typescript) { - imports.addNamed(ast, 'tailwindcss', { Config: 'Config' }, true); - root = common.satisfiesExpression(rootExport, 'Config'); - } + run: ({ sv, options, typescript, kit, dependencyVersion }) => { + const ext = typescript ? 'ts' : 'js'; + const prettierInstalled = Boolean(dependencyVersion('prettier')); - const { astNode: exportDeclaration, value: node } = exports.defaultExport( - ast, - root ?? rootExport - ); + sv.devDependency('tailwindcss', '^3.4.9'); + sv.devDependency('autoprefixer', '^10.4.20'); - const config = node.type === 'TSSatisfiesExpression' ? node.expression : node; - if (config.type !== 'ObjectExpression') { - throw new Error(`Unexpected tailwind config shape: ${config.type}`); - } + if (prettierInstalled) sv.devDependency('prettier-plugin-tailwindcss', '^0.6.5'); - if (!typescript) { - common.addJsDocTypeComment(exportDeclaration, "import('tailwindcss').Config"); - } + for (const plugin of plugins) { + if (!options.plugins.includes(plugin.id)) continue; - const contentArray = object.property(config, 'content', array.createEmpty()); - array.push(contentArray, './src/**/*.{html,js,svelte,ts}'); + sv.dependency(plugin.package, plugin.version); + } - const themeObject = object.property(config, 'theme', object.createEmpty()); - object.property(themeObject, 'extend', object.createEmpty()); + sv.file(`tailwind.config.${ext}`, (content) => { + const { ast, generateCode } = parseScript(content); + let root; + const rootExport = object.createEmpty(); + if (typescript) { + imports.addNamed(ast, 'tailwindcss', { Config: 'Config' }, true); + root = common.satisfiesExpression(rootExport, 'Config'); + } - const pluginsArray = object.property(config, 'plugins', array.createEmpty()); + const { astNode: exportDeclaration, value: node } = exports.defaultExport( + ast, + root ?? rootExport + ); - for (const plugin of plugins) { - if (!options.plugins.includes(plugin.id)) continue; - imports.addDefault(ast, plugin.package, plugin.identifier); - array.push(pluginsArray, { type: 'Identifier', name: plugin.identifier }); - } - - return generateCode(); + const config = node.type === 'TSSatisfiesExpression' ? node.expression : node; + if (config.type !== 'ObjectExpression') { + throw new Error(`Unexpected tailwind config shape: ${config.type}`); } - }, - { - name: () => 'postcss.config.js', - content: ({ content }) => { - const { ast, generateCode } = parseScript(content); - const { value: rootObject } = exports.defaultExport(ast, object.createEmpty()); - const pluginsObject = object.property(rootObject, 'plugins', object.createEmpty()); - - object.property(pluginsObject, 'tailwindcss', object.createEmpty()); - object.property(pluginsObject, 'autoprefixer', object.createEmpty()); - return generateCode(); + + if (!typescript) { + common.addJsDocTypeComment(exportDeclaration, "import('tailwindcss').Config"); } - }, - { - name: () => 'src/app.css', - content: ({ content }) => { - const layerImports = ['base', 'components', 'utilities'].map( - (layer) => `tailwindcss/${layer}` - ); - if (layerImports.every((i) => content.includes(i))) { - return content; - } - const { ast, generateCode } = parseCss(content); - const originalFirst = ast.first; + const contentArray = object.property(config, 'content', array.createEmpty()); + array.push(contentArray, './src/**/*.{html,js,svelte,ts}'); - const specifiers = layerImports.map((i) => `'${i}'`); - const nodes = addImports(ast, specifiers); + const themeObject = object.property(config, 'theme', object.createEmpty()); + object.property(themeObject, 'extend', object.createEmpty()); - if ( - originalFirst !== ast.first && - originalFirst?.type === 'atrule' && - originalFirst.name === 'import' - ) { - originalFirst.raws.before = '\n'; - } + const pluginsArray = object.property(config, 'plugins', array.createEmpty()); - // We remove the first node to avoid adding a newline at the top of the stylesheet - nodes.shift(); + for (const plugin of plugins) { + if (!options.plugins.includes(plugin.id)) continue; + imports.addDefault(ast, plugin.package, plugin.identifier); + array.push(pluginsArray, { type: 'Identifier', name: plugin.identifier }); + } - // Each node is prefixed with single newline, ensuring the imports will always be single spaced. - // Without this, the CSS printer will vary the spacing depending on the current state of the stylesheet - nodes.forEach((n) => (n.raws.before = '\n')); + return generateCode(); + }); + + sv.file('postcss.config.js', (content) => { + const { ast, generateCode } = parseScript(content); + const { value: rootObject } = exports.defaultExport(ast, object.createEmpty()); + const pluginsObject = object.property(rootObject, 'plugins', object.createEmpty()); + + object.property(pluginsObject, 'tailwindcss', object.createEmpty()); + object.property(pluginsObject, 'autoprefixer', object.createEmpty()); + return generateCode(); + }); + + sv.file('src/app.css', (content) => { + const layerImports = ['base', 'components', 'utilities'].map( + (layer) => `tailwindcss/${layer}` + ); + if (layerImports.every((i) => content.includes(i))) { + return content; + } - return generateCode(); + const { ast, generateCode } = parseCss(content); + const originalFirst = ast.first; + + const specifiers = layerImports.map((i) => `'${i}'`); + const nodes = addImports(ast, specifiers); + + if ( + originalFirst !== ast.first && + originalFirst?.type === 'atrule' && + originalFirst.name === 'import' + ) { + originalFirst.raws.before = '\n'; } - }, - { - name: () => 'src/App.svelte', - content: ({ content, typescript }) => { + + // We remove the first node to avoid adding a newline at the top of the stylesheet + nodes.shift(); + + // Each node is prefixed with single newline, ensuring the imports will always be single spaced. + // Without this, the CSS printer will vary the spacing depending on the current state of the stylesheet + nodes.forEach((n) => (n.raws.before = '\n')); + + return generateCode(); + }); + + if (!kit) { + sv.file('src/App.svelte', (content) => { const { script, generateCode } = parseSvelte(content, { typescript }); imports.addEmpty(script.ast, './app.css'); return generateCode({ script: script.generateCode() }); - }, - condition: ({ kit }) => !kit - }, - { - name: ({ kit }) => `${kit?.routesDirectory}/+layout.svelte`, - content: ({ content, typescript, dependencyVersion }) => { + }); + } else { + sv.file(`${kit?.routesDirectory}/+layout.svelte`, (content) => { const { script, template, generateCode } = parseSvelte(content, { typescript }); imports.addEmpty(script.ast, '../app.css'); @@ -185,12 +170,11 @@ export default defineAdder({ script: script.generateCode(), template: content.length === 0 ? template.generateCode() : undefined }); - }, - condition: ({ kit }) => Boolean(kit) - }, - { - name: () => '.prettierrc', - content: ({ content }) => { + }); + } + + if (dependencyVersion('prettier')) { + sv.file('.prettierrc', (content) => { const { data, generateCode } = parseJson(content); const PLUGIN_NAME = 'prettier-plugin-tailwindcss'; @@ -200,8 +184,7 @@ export default defineAdder({ if (!plugins.includes(PLUGIN_NAME)) plugins.push(PLUGIN_NAME); return generateCode(); - }, - condition: ({ dependencyVersion }) => Boolean(dependencyVersion('prettier')) + }); } - ] + } }); diff --git a/packages/adders/vitest-addon/index.ts b/packages/adders/vitest-addon/index.ts index 4cf3d2e7..ac72be43 100644 --- a/packages/adders/vitest-addon/index.ts +++ b/packages/adders/vitest-addon/index.ts @@ -4,102 +4,97 @@ import { parseJson, parseScript } from '@sveltejs/cli-core/parsers'; export default defineAdder({ id: 'vitest', - environments: { svelte: true, kit: true }, homepage: 'https://vitest.dev', options: {}, - packages: [{ name: 'vitest', version: '^2.0.4', dev: true }], - files: [ - { - name: () => 'package.json', - content: ({ content }) => { - const { data, generateCode } = parseJson(content); - data.scripts ??= {}; - const scripts: Record = data.scripts; - const TEST_CMD = 'vitest'; - // we use `--run` so that vitest doesn't run in watch mode when running `npm run test` - const RUN_TEST = 'npm run test:unit -- --run'; - scripts['test:unit'] ??= TEST_CMD; - scripts['test'] ??= RUN_TEST; - if (!scripts['test'].includes(RUN_TEST)) scripts['test'] += ` && ${RUN_TEST}`; - return generateCode(); - } - }, - { - name: ({ typescript }) => `src/demo.spec.${typescript ? 'ts' : 'js'}`, - content: ({ content }) => { - if (content) return content; + run: ({ sv, typescript }) => { + const ext = typescript ? 'ts' : 'js'; + + sv.devDependency('vitest', '^2.0.4'); + + sv.file('package.json', (content) => { + const { data, generateCode } = parseJson(content); + data.scripts ??= {}; + const scripts: Record = data.scripts; + const TEST_CMD = 'vitest'; + // we use `--run` so that vitest doesn't run in watch mode when running `npm run test` + const RUN_TEST = 'npm run test:unit -- --run'; + scripts['test:unit'] ??= TEST_CMD; + scripts['test'] ??= RUN_TEST; + if (!scripts['test'].includes(RUN_TEST)) scripts['test'] += ` && ${RUN_TEST}`; + return generateCode(); + }); - return dedent` - import { describe, it, expect } from 'vitest'; + sv.file(`src/demo.spec.${ext}`, (content) => { + if (content) return content; - describe('sum test', () => { - it('adds 1 + 2 to equal 3', () => { - expect(1 + 2).toBe(3); - }); + return dedent` + import { describe, it, expect } from 'vitest'; + + describe('sum test', () => { + it('adds 1 + 2 to equal 3', () => { + expect(1 + 2).toBe(3); }); - `; - } - }, - { - name: ({ typescript }) => `vite.config.${typescript ? 'ts' : 'js'}`, - content: ({ content }) => { - const { ast, generateCode } = parseScript(content); + }); + `; + }); - // find `defineConfig` import declaration for "vite" - const importDecls = ast.body.filter((n) => n.type === 'ImportDeclaration'); - const defineConfigImportDecl = importDecls.find( - (importDecl) => - (importDecl.source.value === 'vite' || importDecl.source.value === 'vitest/config') && - importDecl.importKind === 'value' && - importDecl.specifiers?.some( - (specifier) => - specifier.type === 'ImportSpecifier' && specifier.imported.name === 'defineConfig' - ) - ); + sv.file(`vite.config.${ext}`, (content) => { + const { ast, generateCode } = parseScript(content); - // we'll need to replace the "vite" import for a "vitest/config" import. - // if `defineConfig` is the only specifier in that "vite" import, remove the entire import declaration - if (defineConfigImportDecl?.specifiers?.length === 1) { - const idxToRemove = ast.body.indexOf(defineConfigImportDecl); - ast.body.splice(idxToRemove, 1); - } else { - // otherwise, just remove the `defineConfig` specifier - const idxToRemove = defineConfigImportDecl?.specifiers?.findIndex( - (s) => s.type === 'ImportSpecifier' && s.imported.name === 'defineConfig' - ); - if (idxToRemove) defineConfigImportDecl?.specifiers?.splice(idxToRemove, 1); - } + // find `defineConfig` import declaration for "vite" + const importDecls = ast.body.filter((n) => n.type === 'ImportDeclaration'); + const defineConfigImportDecl = importDecls.find( + (importDecl) => + (importDecl.source.value === 'vite' || importDecl.source.value === 'vitest/config') && + importDecl.importKind === 'value' && + importDecl.specifiers?.some( + (specifier) => + specifier.type === 'ImportSpecifier' && specifier.imported.name === 'defineConfig' + ) + ); - const config = common.expressionFromString('defineConfig({})'); - const defaultExport = exports.defaultExport(ast, config); + // we'll need to replace the "vite" import for a "vitest/config" import. + // if `defineConfig` is the only specifier in that "vite" import, remove the entire import declaration + if (defineConfigImportDecl?.specifiers?.length === 1) { + const idxToRemove = ast.body.indexOf(defineConfigImportDecl); + ast.body.splice(idxToRemove, 1); + } else { + // otherwise, just remove the `defineConfig` specifier + const idxToRemove = defineConfigImportDecl?.specifiers?.findIndex( + (s) => s.type === 'ImportSpecifier' && s.imported.name === 'defineConfig' + ); + if (idxToRemove) defineConfigImportDecl?.specifiers?.splice(idxToRemove, 1); + } - const test = object.create({ - include: common.expressionFromString("['src/**/*.{test,spec}.{js,ts}']") - }); + const config = common.expressionFromString('defineConfig({})'); + const defaultExport = exports.defaultExport(ast, config); - // uses the `defineConfig` helper - if ( - defaultExport.value.type === 'CallExpression' && - defaultExport.value.arguments[0]?.type === 'ObjectExpression' - ) { - // if the previous `defineConfig` was aliased, reuse the alias for the "vitest/config" import - const importSpecifier = defineConfigImportDecl?.specifiers?.find( - (sp) => sp.type === 'ImportSpecifier' && sp.imported.name === 'defineConfig' - ); - const defineConfigAlias = (importSpecifier?.local?.name ?? 'defineConfig') as string; - imports.addNamed(ast, 'vitest/config', { defineConfig: defineConfigAlias }); + const test = object.create({ + include: common.expressionFromString("['src/**/*.{test,spec}.{js,ts}']") + }); - object.properties(defaultExport.value.arguments[0], { test }); - } else if (defaultExport.value.type === 'ObjectExpression') { - // if the config is just an object expression, just add the property - object.properties(defaultExport.value, { test }); - } else { - // unexpected config shape - log.warn('Unexpected vite config for vitest adder. Could not update.'); - } + // uses the `defineConfig` helper + if ( + defaultExport.value.type === 'CallExpression' && + defaultExport.value.arguments[0]?.type === 'ObjectExpression' + ) { + // if the previous `defineConfig` was aliased, reuse the alias for the "vitest/config" import + const importSpecifier = defineConfigImportDecl?.specifiers?.find( + (sp) => sp.type === 'ImportSpecifier' && sp.imported.name === 'defineConfig' + ); + const defineConfigAlias = (importSpecifier?.local?.name ?? 'defineConfig') as string; + imports.addNamed(ast, 'vitest/config', { defineConfig: defineConfigAlias }); - return generateCode(); + object.properties(defaultExport.value.arguments[0], { test }); + } else if (defaultExport.value.type === 'ObjectExpression') { + // if the config is just an object expression, just add the property + object.properties(defaultExport.value, { test }); + } else { + // unexpected config shape + log.warn('Unexpected vite config for vitest adder. Could not update.'); } - } - ] + + return generateCode(); + }); + } }); diff --git a/packages/adders/vitest.config.ts b/packages/adders/vitest.config.ts index 9f43ca88..c1ac6663 100644 --- a/packages/adders/vitest.config.ts +++ b/packages/adders/vitest.config.ts @@ -1,9 +1,9 @@ import { env } from 'node:process'; -import { defineConfig } from 'vitest/config'; +import { defineProject } from 'vitest/config'; const ONE_MINUTE = 1000 * 60; -export default defineConfig({ +export default defineProject({ test: { name: 'adders', include: ['_tests/**/test.{js,ts}'], diff --git a/packages/cli/commands/add/index.ts b/packages/cli/commands/add/index.ts index de05a29b..97d12501 100644 --- a/packages/cli/commands/add/index.ts +++ b/packages/cli/commands/add/index.ts @@ -1,27 +1,26 @@ import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; +import pc from 'picocolors'; import * as v from 'valibot'; -import { exec } from 'tinyexec'; -import { Command, Option } from 'commander'; -import * as p from '@sveltejs/clack-prompts'; import * as pkg from 'empathic/package'; -import { resolveCommand, type AgentName } from 'package-manager-detector'; -import pc from 'picocolors'; +import * as p from '@sveltejs/clack-prompts'; +import { Command, Option } from 'commander'; import { officialAdders, getAdderDetails, communityAdderIds, getCommunityAdder } from '@sveltejs/adders'; -import type { AdderWithoutExplicitArgs, OptionValues } from '@sveltejs/cli-core'; +import type { AgentName } from 'package-manager-detector'; +import type { AdderWithoutExplicitArgs, OptionValues, PackageManager } from '@sveltejs/cli-core'; import * as common from '../../utils/common.ts'; import { createWorkspace } from './workspace.ts'; -import { createOrUpdateFiles } from './processor.ts'; -import { getGlobalPreconditions } from './preconditions.ts'; -import { formatFiles, getHighlighter, installPackages } from './utils.ts'; +import { formatFiles, getHighlighter } from './utils.ts'; import { Directive, downloadPackage, getPackageJSON } from './fetch-packages.ts'; import { installDependencies, packageManagerPrompt } from '../../utils/package-manager.ts'; +import { getGlobalPreconditions } from './preconditions.ts'; +import { type AddonMap, applyAddons, setupAddons } from '../../lib/install.ts'; const AddersSchema = v.array(v.string()); const AdderOptionFlagsSchema = v.object({ @@ -102,6 +101,11 @@ export async function runAddCommand( type: 'official', adder: getAdderDetails(id) })); + + type AdderId = string; + type QuestionValues = OptionValues; + type AdderOption = Record; + const official: AdderOption = {}; const community: AdderOption = {}; @@ -235,8 +239,8 @@ export async function runAddCommand( 'The Svelte maintainers have not reviewed community adders for malicious code. Use at your discretion.' ); - const paddingName = getPadding(pkgs.map(({ pkg }) => pkg.name)); - const paddingVersion = getPadding(pkgs.map(({ pkg }) => `(v${pkg.version})`)); + const paddingName = common.getPadding(pkgs.map(({ pkg }) => pkg.name)); + const paddingVersion = common.getPadding(pkgs.map(({ pkg }) => `(v${pkg.version})`)); const packageInfos = pkgs.map(({ pkg, repo: _repo }) => { const name = pc.yellowBright(pkg.name.padEnd(paddingName)); @@ -267,23 +271,16 @@ export async function runAddCommand( } } + // prepare official adders + let workspace = createWorkspace({ cwd: options.cwd }); + const adderSetupResults = setupAddons(officialAdders, workspace); + // prompt which adders to apply if (selectedAdders.length === 0) { - const workspace = createWorkspace({ cwd: options.cwd }); - const projectType = workspace.kit ? 'kit' : 'svelte'; const adderOptions = officialAdders - .map((adder) => { - // we'll only display adders within their respective project types - if (projectType === 'kit' && !adder.environments.kit) return; - if (projectType === 'svelte' && !adder.environments.svelte) return; - - return { - label: adder.id, - value: adder.id, - hint: adder.homepage - }; - }) - .filter((a) => !!a); + // only display supported adders relative to the current environment + .filter(({ id }) => adderSetupResults[id].unsupported.length === 0) + .map(({ id, homepage }) => ({ label: id, value: id, hint: homepage })); const selected = await p.multiselect({ message: `What would you like to add to your project? ${pc.dim('(use arrow keys / space bar)')}`, @@ -295,27 +292,25 @@ export async function runAddCommand( process.exit(1); } - selected.forEach((id) => selectedAdders.push({ type: 'official', adder: getAdderDetails(id) })); + for (const id of selected) { + const adder = officialAdders.find((adder) => adder.id === id)!; + selectedAdders.push({ type: 'official', adder }); + } } // add inter-adder dependencies for (const { adder } of selectedAdders) { - const dependents = - adder.dependsOn?.filter((dep) => !selectedAdders.some((a) => a.adder.id === dep)) ?? []; - - const workspace = createWorkspace({ cwd: options.cwd }); - for (const depId of dependents) { - const dependent = officialAdders.find((a) => a.id === depId) as AdderWithoutExplicitArgs; - if (!dependent) throw new Error(`Adder '${adder.id}' depends on an invalid '${depId}'`); - - // check if the dependent adder has already been installed - let installed = false; - installed = dependent.packages.every( - // we'll skip the conditions since we don't have any options to supply it - (p) => p.condition !== undefined || !!workspace.dependencyVersion(p.name) - ); + workspace = createWorkspace(workspace); + + const setupResult = adderSetupResults[adder.id]; + const missingDependencies = setupResult.dependsOn.filter( + (depId) => !selectedAdders.some((a) => a.adder.id === depId) + ); - if (installed) continue; + for (const depId of missingDependencies) { + // TODO: this will have to be adjusted when we work on community add-ons + const dependency = officialAdders.find((a) => a.id === depId); + if (!dependency) throw new Error(`'${adder.id}' depends on an invalid add-on: '${depId}'`); // prompt to install the dependent const install = await p.confirm({ @@ -325,17 +320,15 @@ export async function runAddCommand( p.cancel('Operation cancelled.'); process.exit(1); } - selectedAdders.push({ type: 'official', adder: dependent }); + selectedAdders.push({ type: 'official', adder: dependency }); } } // run precondition checks if (options.preconditions && selectedAdders.length > 0) { // add global checks - const { kit } = createWorkspace({ cwd: options.cwd }); - const projectType = kit ? 'kit' : 'svelte'; const adders = selectedAdders.map(({ adder }) => adder); - const { preconditions } = getGlobalPreconditions(options.cwd, projectType, adders); + const { preconditions } = getGlobalPreconditions(options.cwd, adders, adderSetupResults); const fails: Array<{ name: string; message?: string }> = []; for (const condition of preconditions) { @@ -425,13 +418,27 @@ export async function runAddCommand( if (selectedAdders.length === 0) return { packageManager: null }; // prompt for package manager - let packageManager: AgentName | undefined; + let packageManager: PackageManager | undefined; if (options.install) { packageManager = await packageManagerPrompt(options.cwd); + if (packageManager) workspace.packageManager = packageManager; } // apply adders - const filesToFormat = await runAdders({ cwd: options.cwd, packageManager, official, community }); + const officialDetails = Object.keys(official).map((id) => getAdderDetails(id)); + const commDetails = Object.keys(community).map( + (id) => communityDetails.find((a) => a.id === id)! + ); + const details = officialDetails.concat(commDetails); + + const addonMap: AddonMap = Object.assign({}, ...details.map((a) => ({ [a.id]: a }))); + const filesToFormat = await applyAddons({ + workspace, + adderSetupResults, + addons: addonMap, + options: official + }); + p.log.success('Successfully setup add-ons'); // install dependencies @@ -440,7 +447,7 @@ export async function runAddCommand( } // format modified/created files with prettier (if available) - const workspace = createWorkspace({ cwd: options.cwd, packageManager }); + workspace = createWorkspace(workspace); if (filesToFormat.length > 0 && packageManager && !!workspace.dependencyVersion('prettier')) { const { start, stop } = p.spinner(); start('Formatting modified files'); @@ -479,91 +486,6 @@ export async function runAddCommand( return { nextSteps, packageManager }; } -type AdderId = string; -type QuestionValues = OptionValues; -export type AdderOption = Record; - -export type InstallAdderOptions = { - cwd: string; - packageManager?: AgentName; - official?: AdderOption; - community?: AdderOption; -}; - -/** - * @returns a list of paths of modified files - */ -async function runAdders({ - cwd, - official = {}, - community = {}, - packageManager -}: InstallAdderOptions): Promise { - const adderDetails = Object.keys(official).map((id) => getAdderDetails(id)); - const commDetails = Object.keys(community).map( - (id) => communityDetails.find((a) => a.id === id)! - ); - const details = adderDetails.concat(commDetails); - - // adders might specify that they should be executed after another adder. - // this orders the adders to (ideally) have adders without dependencies run first - // and adders with dependencies runs later on, based on the adders they depend on. - // based on https://stackoverflow.com/a/72030336/16075084 - details.sort((a, b) => { - if (!a.dependsOn && !b.dependsOn) return 0; - if (!a.dependsOn) return -1; - if (!b.dependsOn) return 1; - - return a.dependsOn.includes(b.id) ? 1 : b.dependsOn.includes(a.id) ? -1 : 0; - }); - - // apply adders - const filesToFormat = new Set(); - for (const config of details) { - const adderId = config.id; - const workspace = createWorkspace({ cwd, packageManager }); - - workspace.options = official[adderId] ?? community[adderId]!; - - // execute adders - await config.preInstall?.(workspace); - const pkgPath = installPackages(config, workspace); - filesToFormat.add(pkgPath); - const changedFiles = createOrUpdateFiles(config.files, workspace); - changedFiles.forEach((file) => filesToFormat.add(file)); - await config.postInstall?.(workspace); - - if (config.scripts && config.scripts.length > 0) { - for (const script of config.scripts) { - if (script.condition?.(workspace) === false) continue; - - const { command, args } = resolveCommand(workspace.packageManager, 'execute', script.args)!; - const adderPrefix = details.length > 1 ? `${config.id}: ` : ''; - p.log.step( - `${adderPrefix}Running external command ${pc.gray(`(${command} ${args.join(' ')})`)}` - ); - - // adding --yes as the first parameter helps avoiding the "Need to install the following packages:" message - if (workspace.packageManager === 'npm') args.unshift('--yes'); - - try { - await exec(command, args, { - nodeOptions: { cwd: workspace.cwd, stdio: script.stdio }, - throwOnError: true - }); - } catch (error) { - const typedError = error as Error; - throw new Error( - `Failed to execute scripts '${script.description}': ` + typedError.message - ); - } - } - } - } - - return Array.from(filesToFormat); -} - /** * Dedupes and transforms aliases into their respective adder id */ @@ -648,8 +570,3 @@ function getOptionChoices(details: AdderWithoutExplicitArgs) { } return { choices, defaults, groups }; } - -function getPadding(lines: string[]) { - const lengths = lines.map((s) => s.length); - return Math.max(...lengths); -} diff --git a/packages/cli/commands/add/preconditions.ts b/packages/cli/commands/add/preconditions.ts index 50f90098..90694299 100644 --- a/packages/cli/commands/add/preconditions.ts +++ b/packages/cli/commands/add/preconditions.ts @@ -1,11 +1,12 @@ import { exec } from 'tinyexec'; -import type { AdderWithoutExplicitArgs, Precondition } from '@sveltejs/cli-core'; +import type { AdderSetupResult, AdderWithoutExplicitArgs, Precondition } from '@sveltejs/cli-core'; +import { UnsupportedError } from '../../utils/errors.ts'; type PreconditionCheck = { name: string; preconditions: Precondition[] }; export function getGlobalPreconditions( cwd: string, - projectType: 'svelte' | 'kit', - adders: AdderWithoutExplicitArgs[] + adders: AdderWithoutExplicitArgs[], + adderSetupResult: Record ): PreconditionCheck { return { name: 'global checks', @@ -36,29 +37,17 @@ export function getGlobalPreconditions( } }, { - name: 'supported environments', + name: 'unsupported adders', run: () => { - const addersForInvalidEnvironment = adders.filter((a) => { - const supportedEnvironments = a.environments; - if (projectType === 'kit' && !supportedEnvironments.kit) return true; - if (projectType === 'svelte' && !supportedEnvironments.svelte) return true; + const reasons = adders.flatMap((a) => + adderSetupResult[a.id].unsupported.map((reason) => ({ id: a.id, reason })) + ); - return false; - }); - - if (addersForInvalidEnvironment.length === 0) { + if (reasons.length === 0) { return { success: true, message: undefined }; } - const messages = addersForInvalidEnvironment.map((a) => { - if (projectType === 'kit') { - return `'${a.id}' does not support SvelteKit`; - } else { - return `'${a.id}' requires SvelteKit`; - } - }); - - throw new Error(messages.join('\n')); + throw new UnsupportedError(reasons); } } ] diff --git a/packages/cli/commands/add/processor.ts b/packages/cli/commands/add/processor.ts deleted file mode 100644 index 46c3e7c8..00000000 --- a/packages/cli/commands/add/processor.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { fileExists, readFile, writeFile } from './utils.ts'; -import type { Workspace, OptionDefinition, FileType } from '@sveltejs/cli-core'; - -/** - * @param files - * @param workspace - * @returns a list of paths of changed or created files - */ -export function createOrUpdateFiles( - files: Array>, - workspace: Workspace -): string[] { - const changedFiles: string[] = []; - for (const fileDetails of files) { - try { - if (fileDetails.condition && !fileDetails.condition(workspace)) { - continue; - } - - const exists = fileExists(workspace.cwd, fileDetails.name(workspace)); - let content = exists ? readFile(workspace.cwd, fileDetails.name(workspace)) : ''; - // process file - content = fileDetails.content({ content, ...workspace }); - if (!content) continue; - - writeFile(workspace, fileDetails.name(workspace), content); - changedFiles.push(fileDetails.name(workspace)); - } catch (e) { - if (e instanceof Error) { - throw new Error(`Unable to process '${fileDetails.name(workspace)}'. Reason: ${e.message}`); - } - throw e; - } - } - return changedFiles; -} diff --git a/packages/cli/commands/add/utils.ts b/packages/cli/commands/add/utils.ts index 55051644..2c2900c5 100644 --- a/packages/cli/commands/add/utils.ts +++ b/packages/cli/commands/add/utils.ts @@ -3,8 +3,8 @@ import path from 'node:path'; import pc from 'picocolors'; import { exec } from 'tinyexec'; import { parseJson } from '@sveltejs/cli-core/parsers'; -import type { Adder, Highlighter, Workspace } from '@sveltejs/cli-core'; import { resolveCommand, type AgentName } from 'package-manager-detector'; +import type { Highlighter, Workspace } from '@sveltejs/cli-core'; export type Package = { name: string; @@ -57,20 +57,19 @@ export function readFile(cwd: string, filePath: string): string { return text; } -export function installPackages(config: Adder, workspace: Workspace): string { +export function installPackages( + dependencies: Array<{ pkg: string; version: string; dev: boolean }>, + workspace: Workspace +): string { const { data, generateCode } = getPackageJson(workspace.cwd); - for (const dependency of config.packages) { - if (dependency.condition && !dependency.condition(workspace)) { - continue; - } - + for (const dependency of dependencies) { if (dependency.dev) { data.devDependencies ??= {}; - data.devDependencies[dependency.name] = dependency.version; + data.devDependencies[dependency.pkg] = dependency.version; } else { data.dependencies ??= {}; - data.dependencies[dependency.name] = dependency.version; + data.dependencies[dependency.pkg] = dependency.version; } } diff --git a/packages/cli/commands/add/workspace.ts b/packages/cli/commands/add/workspace.ts index ac4d396f..a73b6358 100644 --- a/packages/cli/commands/add/workspace.ts +++ b/packages/cli/commands/add/workspace.ts @@ -3,14 +3,22 @@ import path from 'node:path'; import * as find from 'empathic/find'; import { common, object, type AstTypes } from '@sveltejs/cli-core/js'; import { parseScript } from '@sveltejs/cli-core/parsers'; -import type { Workspace } from '@sveltejs/cli-core'; -import type { AgentName } from 'package-manager-detector'; +import { detectSync } from 'package-manager-detector'; +import type { OptionValues, PackageManager, Workspace } from '@sveltejs/cli-core'; import { TESTING } from '../../utils/env.ts'; import { commonFilePaths, getPackageJson, readFile } from './utils.ts'; import { getUserAgent } from '../../utils/package-manager.ts'; -type CreateWorkspaceOptions = { cwd: string; packageManager?: AgentName }; -export function createWorkspace({ cwd, packageManager }: CreateWorkspaceOptions): Workspace { +type CreateWorkspaceOptions = { + cwd: string; + packageManager?: PackageManager; + options?: OptionValues; +}; +export function createWorkspace({ + cwd, + options = {}, + packageManager = detectSync({ cwd })?.name ?? getUserAgent() ?? 'npm' +}: CreateWorkspaceOptions): Workspace { const resolvedCwd = path.resolve(cwd); const viteConfigPath = path.join(resolvedCwd, commonFilePaths.viteConfigTS); let usesTypescript = fs.existsSync(viteConfigPath); @@ -43,12 +51,12 @@ export function createWorkspace({ cwd, packageManager }: CreateWorkspaceOptions) } return { - kit: dependencies['@sveltejs/kit'] ? parseKitOptions(resolvedCwd) : undefined, - packageManager: packageManager ?? getUserAgent() ?? 'npm', cwd: resolvedCwd, - dependencyVersion: (pkg) => dependencies[pkg], + options, + packageManager, typescript: usesTypescript, - options: {} + kit: dependencies['@sveltejs/kit'] ? parseKitOptions(resolvedCwd) : undefined, + dependencyVersion: (pkg) => dependencies[pkg] }; } diff --git a/packages/cli/lib/install.ts b/packages/cli/lib/install.ts index 555da7e8..79c646d6 100644 --- a/packages/cli/lib/install.ts +++ b/packages/cli/lib/install.ts @@ -1,16 +1,27 @@ -import { exec } from 'tinyexec'; +import type { + Adder, + Workspace, + PackageManager, + OptionValues, + Question, + SvApi, + AdderSetupResult, + AdderWithoutExplicitArgs +} from '@sveltejs/cli-core'; +import pc from 'picocolors'; +import * as p from '@sveltejs/clack-prompts'; +import { exec, NonZeroExitError } from 'tinyexec'; import { resolveCommand } from 'package-manager-detector'; -import type { Adder, Workspace, PackageManager, OptionValues, Question } from '@sveltejs/cli-core'; -import { installPackages } from '../commands/add/utils.ts'; +import { TESTING } from '../utils/env.ts'; import { createWorkspace } from '../commands/add/workspace.ts'; -import { createOrUpdateFiles } from '../commands/add/processor.ts'; +import { fileExists, installPackages, readFile, writeFile } from '../commands/add/utils.ts'; type Addon = Adder; export type InstallOptions = { cwd: string; addons: Addons; options: OptionMap; - packageManager: PackageManager; + packageManager?: PackageManager; }; export type AddonMap = Record; @@ -24,71 +35,144 @@ export async function installAddon({ options, packageManager = 'npm' }: InstallOptions): Promise { + const workspace = createWorkspace({ cwd, packageManager }); + const adderSetupResults = setupAddons(Object.values(addons), workspace); + + return await applyAddons({ addons, workspace, options, adderSetupResults }); +} + +export type ApplyAddonOptions = { + addons: AddonMap; + options: OptionMap; + workspace: Workspace; + adderSetupResults: Record; +}; +export async function applyAddons({ + addons, + workspace, + adderSetupResults, + options +}: ApplyAddonOptions): Promise { const filesToFormat = new Set(); const mapped = Object.entries(addons).map(([, addon]) => addon); - const ordered = orderAddons(mapped); + const ordered = orderAddons(mapped, adderSetupResults); for (const addon of ordered) { - const workspace = createWorkspace({ cwd, packageManager }); - workspace.options = options[addon.id]; + workspace = createWorkspace({ ...workspace, options: options[addon.id] }); - const files = await runAddon(workspace, addon); + const files = await runAddon({ workspace, addon, multiple: ordered.length > 1 }); files.forEach((f) => filesToFormat.add(f)); } return Array.from(filesToFormat); } -async function runAddon( - workspace: Workspace, - addon: Adder> -): Promise { +export function setupAddons( + addons: AdderWithoutExplicitArgs[], + workspace: Workspace +): Record { + const adderSetupResults: Record = {}; + + for (const addon of addons) { + const setupResult: AdderSetupResult = { unsupported: [], dependsOn: [] }; + addon.setup?.({ + ...workspace, + dependsOn: (name) => setupResult.dependsOn.push(name), + unsupported: (reason) => setupResult.unsupported.push(reason) + }); + adderSetupResults[addon.id] = setupResult; + } + + return adderSetupResults; +} + +type RunAddon = { + workspace: Workspace; + addon: Adder>; + multiple: boolean; +}; +async function runAddon({ addon, multiple, workspace }: RunAddon): Promise { const files = new Set(); // apply default adder options - for (const [, question] of Object.entries(addon.options)) { + for (const [id, question] of Object.entries(addon.options)) { // we'll only apply defaults to options that don't explicitly fail their conditions if (question.condition?.(workspace.options) !== false) { - workspace.options ??= question.default; + workspace.options[id] ??= question.default; } } - await addon.preInstall?.(workspace); - const pkgPath = installPackages(addon, workspace); - files.add(pkgPath); - const changedFiles = createOrUpdateFiles(addon.files, workspace); - changedFiles.forEach((file) => files.add(file)); - await addon.postInstall?.(workspace); - - for (const script of addon.scripts ?? []) { - if (script.condition?.(workspace) === false) continue; - - try { - const { args, command } = resolveCommand(workspace.packageManager, 'execute', script.args)!; + const dependencies: Array<{ pkg: string; version: string; dev: boolean }> = []; + const sv: SvApi = { + file: (path, content) => { + try { + const exists = fileExists(workspace.cwd, path); + let fileContent = exists ? readFile(workspace.cwd, path) : ''; + // process file + fileContent = content(fileContent); + if (!fileContent) return fileContent; + + writeFile(workspace, path, fileContent); + files.add(path); + } catch (e) { + if (e instanceof Error) { + throw new Error(`Unable to process '${path}'. Reason: ${e.message}`); + } + throw e; + } + }, + execute: async (commandArgs, stdio) => { + const { command, args } = resolveCommand(workspace.packageManager, 'execute', commandArgs)!; + + const adderPrefix = multiple ? `${addon.id}: ` : ''; + const executedCommand = `${command} ${args.join(' ')}`; + if (!TESTING) { + p.log.step(`${adderPrefix}Running external command ${pc.gray(`(${executedCommand})`)}`); + } + + // adding --yes as the first parameter helps avoiding the "Need to install the following packages:" message if (workspace.packageManager === 'npm') args.unshift('--yes'); - await exec(command, args, { - nodeOptions: { cwd: workspace.cwd, stdio: 'pipe' }, - throwOnError: true - }); - } catch (error) { - const typedError = error as Error; - throw new Error(`Failed to execute scripts '${script.description}': ` + typedError.message); + + try { + await exec(command, args, { + nodeOptions: { cwd: workspace.cwd, stdio: TESTING ? 'pipe' : stdio }, + throwOnError: true + }); + } catch (error) { + const typedError = error as NonZeroExitError; + throw new Error(`Failed to execute scripts '${executedCommand}': ${typedError.message}`, { + cause: typedError.output + }); + } + }, + dependency: (pkg, version) => { + dependencies.push({ pkg, version, dev: false }); + }, + devDependency: (pkg, version) => { + dependencies.push({ pkg, version, dev: true }); } - } + }; + await addon.run({ ...workspace, sv }); + + const pkgPath = installPackages(dependencies, workspace); + files.add(pkgPath); return Array.from(files); } // sorts them to their execution order -function orderAddons(addons: Addon[]) { +function orderAddons(addons: Addon[], setupResults: Record) { return Array.from(addons).sort((a, b) => { - if (!a.dependsOn && !b.dependsOn) return 0; - if (!a.dependsOn) return -1; - if (!b.dependsOn) return 1; + const aDeps = setupResults[a.id].dependsOn; + const bDeps = setupResults[b.id].dependsOn; + + if (!aDeps && !bDeps) return 0; + if (!aDeps) return -1; + if (!bDeps) return 1; - if (a.dependsOn.includes(b.id)) return 1; - if (b.dependsOn.includes(a.id)) return -1; + if (aDeps.includes(b.id)) return 1; + if (bDeps.includes(a.id)) return -1; return 0; }); diff --git a/packages/cli/utils/common.ts b/packages/cli/utils/common.ts index bfcbfd16..68ef9593 100644 --- a/packages/cli/utils/common.ts +++ b/packages/cli/utils/common.ts @@ -2,6 +2,7 @@ import pc from 'picocolors'; import pkg from '../package.json'; import * as p from '@sveltejs/clack-prompts'; import type { Argument, HelpConfiguration, Option } from 'commander'; +import { UnsupportedError } from './errors.ts'; const NO_PREFIX = '--no-'; let options: readonly Option[] = []; @@ -70,9 +71,22 @@ export async function runCommand(action: MaybePromise): Promise { await action(); p.outro("You're all set!"); } catch (e) { - p.cancel('Operation failed.'); - if (e instanceof Error) { - console.error(e.stack ?? e); + if (e instanceof UnsupportedError) { + const padding = getPadding(e.reasons.map((r) => r.id)); + const message = e.reasons + .map((r) => ` ${r.id.padEnd(padding)} ${pc.red(r.reason)}`) + .join('\n'); + p.log.error(`${e.name}\n\n${message}`); + p.log.message(); + } else if (e instanceof Error) { + p.log.error(e.stack ?? String(e)); + p.log.message(); } + p.cancel('Operation failed.'); } } + +export function getPadding(lines: string[]) { + const lengths = lines.map((s) => s.length); + return Math.max(...lengths); +} diff --git a/packages/cli/utils/errors.ts b/packages/cli/utils/errors.ts new file mode 100644 index 00000000..1a33b141 --- /dev/null +++ b/packages/cli/utils/errors.ts @@ -0,0 +1,9 @@ +type Reason = { id: string; reason: string }; +export class UnsupportedError extends Error { + name = 'Unsupported Environment'; + reasons: Reason[] = []; + constructor(reasons: Reason[]) { + super(); + this.reasons = reasons; + } +} diff --git a/packages/cli/utils/package-manager.ts b/packages/cli/utils/package-manager.ts index c0226505..ffb3ad1b 100644 --- a/packages/cli/utils/package-manager.ts +++ b/packages/cli/utils/package-manager.ts @@ -1,5 +1,5 @@ import process from 'node:process'; -import { exec } from 'tinyexec'; +import { exec, NonZeroExitError } from 'tinyexec'; import * as p from '@sveltejs/clack-prompts'; import { AGENTS, @@ -41,6 +41,12 @@ export async function installDependencies(agent: AgentName, cwd: string): Promis spinner.stop('Successfully installed dependencies'); } catch (error) { spinner.stop('Failed to install dependencies', 2); + + if (error instanceof NonZeroExitError) { + const stderr = error.output?.stderr; + if (stderr) p.log.error(stderr); + } + throw error; } } diff --git a/packages/core/adder/config.ts b/packages/core/adder/config.ts index f19cb146..ffc8abae 100644 --- a/packages/core/adder/config.ts +++ b/packages/core/adder/config.ts @@ -6,11 +6,6 @@ export type ConditionDefinition = ( Workspace: Workspace ) => boolean; -export type Environments = { - svelte: boolean; - kit: boolean; -}; - export type PackageDefinition = { name: string; version: string; @@ -25,18 +20,25 @@ export type Scripts = { condition?: ConditionDefinition; }; +export type SvApi = { + file: (path: string, edit: (content: string) => string) => void; + dependency: (pkg: string, version: string) => void; + devDependency: (pkg: string, version: string) => void; + execute: (args: string[], stdio: 'inherit' | 'pipe') => Promise; +}; + export type Adder = { id: string; alias?: string; - environments: Environments; homepage?: string; options: Args; - dependsOn?: string[]; - packages: Array>; - scripts?: Array>; - files: Array>; - preInstall?: (workspace: Workspace) => MaybePromise; - postInstall?: (workspace: Workspace) => MaybePromise; + setup?: ( + workspace: Workspace & { + dependsOn: (name: string) => void; + unsupported: (reason: string) => void; + } + ) => MaybePromise; + run: (workspace: Workspace & { sv: SvApi }) => MaybePromise; nextSteps?: ( data: { highlighter: Highlighter; @@ -56,6 +58,8 @@ export function defineAdder(config: Adder): return config; } +export type AdderSetupResult = { dependsOn: string[]; unsupported: string[] }; + export type AdderWithoutExplicitArgs = Adder>; export type AdderConfigWithoutExplicitArgs = Adder>; diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 5940c172..8b52c7a6 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -1,6 +1,6 @@ -import { defineConfig } from 'vitest/config'; +import { defineProject } from 'vitest/config'; -export default defineConfig({ +export default defineProject({ test: { name: 'core', include: ['./tests/**/index.ts'] diff --git a/packages/create/vitest.config.ts b/packages/create/vitest.config.ts index f29b5427..a3de89ca 100644 --- a/packages/create/vitest.config.ts +++ b/packages/create/vitest.config.ts @@ -1,7 +1,7 @@ import { env } from 'node:process'; -import { defineConfig } from 'vitest/config'; +import { defineProject } from 'vitest/config'; -export default defineConfig({ +export default defineProject({ test: { name: 'create', include: ['test/*.ts'], diff --git a/packages/migrate/vitest.config.ts b/packages/migrate/vitest.config.ts index 22afa1cf..13a0f18e 100644 --- a/packages/migrate/vitest.config.ts +++ b/packages/migrate/vitest.config.ts @@ -1,6 +1,6 @@ -import { defineConfig } from 'vitest/config'; +import { defineProject } from 'vitest/config'; -export default defineConfig({ +export default defineProject({ test: { name: 'migrate' }