diff --git a/community-adder-template/src/config/adder.js b/community-adder-template/src/config/adder.js index 0729f3b7..e4d320c7 100644 --- a/community-adder-template/src/config/adder.js +++ b/community-adder-template/src/config/adder.js @@ -4,12 +4,10 @@ import { imports } from '@svelte-cli/core/js'; import { parseScript } from '@svelte-cli/core/parsers'; export const adder = defineAdder({ - metadata: { - id: 'community-adder-template', - name: 'Community Adder Template', - description: 'An adder template demo', - environments: { kit: true, svelte: true } - }, + id: 'community-adder-template', + name: 'Community Adder Template', + description: 'An adder template demo', + environments: { kit: true, svelte: true }, options, packages: [], files: [ diff --git a/packages/adders/_config/official.ts b/packages/adders/_config/official.ts index 24b6436f..d79e3715 100644 --- a/packages/adders/_config/official.ts +++ b/packages/adders/_config/official.ts @@ -27,7 +27,7 @@ export const adderCategories: AdderCategories = getCategoriesById(); function getCategoriesById(): AdderCategories { const adderCategories: any = {}; for (const [key, adders] of Object.entries(categories)) { - adderCategories[key] = adders.map((a) => a.metadata.id); + adderCategories[key] = adders.map((a) => a.id); } return adderCategories; } @@ -37,7 +37,7 @@ export const adderIds: string[] = Object.values(adderCategories).flatMap((x) => const adderDetails = Object.values(categories).flat(); export function getAdderDetails(name: string): AdderWithoutExplicitArgs { - const details = adderDetails.find((a) => a.metadata.id === name); + const details = adderDetails.find((a) => a.id === name); if (!details) { throw new Error(`Invalid adder name: ${name}`); } diff --git a/packages/adders/drizzle/config/adder.ts b/packages/adders/drizzle/config/adder.ts deleted file mode 100644 index af4b0216..00000000 --- a/packages/adders/drizzle/config/adder.ts +++ /dev/null @@ -1,403 +0,0 @@ -import { options as availableOptions } from './options.ts'; -import { common, exports, functions, imports, object, variables } from '@svelte-cli/core/js'; -import { defineAdder, dedent, type FileEditor } from '@svelte-cli/core'; -import { parseJson, parseScript } from '@svelte-cli/core/parsers'; - -const PORTS = { - mysql: '3306', - postgresql: '5432', - sqlite: '' -} as const; - -export const adder = defineAdder({ - metadata: { - id: 'drizzle', - name: 'Drizzle', - description: 'Headless ORM for NodeJS, TypeScript and JavaScript', - environments: { svelte: false, kit: true }, - website: { - logo: './drizzle.svg', - keywords: ['drizzle', 'drizzle-orm', 'drizzle-kit', 'database', 'orm'], - documentation: 'https://orm.drizzle.team/docs/overview' - } - }, - options: availableOptions, - packages: [ - { name: 'drizzle-orm', version: '^0.33.0', dev: false }, - { name: 'drizzle-kit', version: '^0.22.0', dev: true }, - // 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' - }, - // 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' - }, - // 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' - } - ], - 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 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; - - const imageName = options.database === 'mysql' ? 'mysql' : 'postgres'; - const port = PORTS[options.database]; - - const USER = 'root'; - const PASSWORD = 'mysecretpassword'; - const DB_NAME = 'local'; - - let dbSpecificContent = ''; - if (options.mysql === 'mysql2') { - dbSpecificContent = ` - MYSQL_ROOT_PASSWORD: ${PASSWORD} - MYSQL_DATABASE: ${DB_NAME} - `; - } - if (options.postgresql === 'postgres.js') { - dbSpecificContent = ` - POSTGRES_USER: ${USER} - POSTGRES_PASSWORD: ${PASSWORD} - POSTGRES_DB: ${DB_NAME} - `; - } - - content = dedent` - services: - db: - image: ${imageName} - restart: always - ports: - - ${port}:${port} - environment: ${dbSpecificContent} - `; - - 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 }) => { - 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' }); - - 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(); - } - }, - { - 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); - - 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); - - 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( - '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' }); - - 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('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('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('postgres(env.DATABASE_URL)'); - } - - if (!clientExpression) throw new Error('unreachable state...'); - const clientIdentifier = variables.declaration(ast, 'const', 'client', clientExpression); - common.addStatement(ast, clientIdentifier); - - const drizzleCall = functions.callByIdentifier('drizzle', ['client']); - const db = variables.declaration(ast, 'const', 'db', drizzleCall); - exports.namedExport(ast, 'db', db); - - return generateCode(); - } - } - ], - nextSteps: ({ options, highlighter }) => { - const steps = [ - `You will need to set ${highlighter.env('DATABASE_URL')} in your production environment` - ]; - if (options.docker) { - steps.push(`Run ${highlighter.command('npm run db:start')} to start the docker container`); - } - steps.push(`To update your DB schema, run ${highlighter.command('npm run db:push')}`); - - return steps; - } -}); - -function generateEnvFileContent({ content, options }: FileEditor) { - const DB_URL_KEY = 'DATABASE_URL'; - if (options.docker) { - // we'll prefill with the default docker db credentials - const protocol = options.database === 'mysql' ? 'mysql' : 'postgres'; - const port = PORTS[options.database]; - content = addEnvVar( - content, - DB_URL_KEY, - `"${protocol}://root:mysecretpassword@localhost:${port}/local"` - ); - return content; - } - if (options.sqlite === 'better-sqlite3' || options.sqlite === 'libsql') { - const dbFile = options.sqlite === 'libsql' ? 'file:local.db' : 'local.db'; - content = addEnvVar(content, DB_URL_KEY, dbFile); - return content; - } - - content = addEnvComment(content, 'Replace with your DB credentials!'); - if (options.sqlite === 'turso') { - content = addEnvVar(content, DB_URL_KEY, '"libsql://db-name-user.turso.io"'); - content = addEnvVar(content, 'DATABASE_AUTH_TOKEN', '""'); - content = addEnvComment(content, 'A local DB can also be used in dev as well'); - content = addEnvComment(content, `${DB_URL_KEY}="file:local.db"`); - } - if (options.database === 'mysql') { - content = addEnvVar(content, DB_URL_KEY, '"mysql://user:password@host:port/db-name"'); - } - if (options.database === 'postgresql') { - content = addEnvVar(content, DB_URL_KEY, '"postgres://user:password@host:port/db-name"'); - } - return content; -} - -function addEnvVar(content: string, key: string, value: string) { - if (!content.includes(key + '=')) { - content = appendEnvContent(content, `${key}=${value}`); - } - return content; -} - -function addEnvComment(content: string, comment: string) { - const commented = `# ${comment}`; - if (!content.includes(commented)) { - content = appendEnvContent(content, commented); - } - return content; -} - -function appendEnvContent(existing: string, content: string) { - const withNewLine = !existing.length || existing.endsWith('\n') ? existing : existing + '\n'; - return withNewLine + content + '\n'; -} diff --git a/packages/adders/drizzle/config/options.ts b/packages/adders/drizzle/config/options.ts deleted file mode 100644 index 832d30d1..00000000 --- a/packages/adders/drizzle/config/options.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { defineAdderOptions } from '@svelte-cli/core'; - -export const options = defineAdderOptions({ - database: { - question: 'Which database would you like to use?', - type: 'select', - default: 'sqlite', - options: [ - { value: 'postgresql', label: 'PostgreSQL' }, - { value: 'mysql', label: 'MySQL' }, - { value: 'sqlite', label: 'SQLite' } - ] - }, - postgresql: { - question: 'Which PostgreSQL client would you like to use?', - type: 'select', - group: 'client', - default: 'postgres.js', - options: [ - { value: 'postgres.js', label: 'Postgres.JS', hint: 'recommended for most users' }, - { value: 'neon', label: 'Neon', hint: 'popular hosted platform' } - ], - condition: ({ database }) => database === 'postgresql' - }, - mysql: { - question: 'Which MySQL client would you like to use?', - type: 'select', - group: 'client', - default: 'mysql2', - options: [ - { value: 'mysql2', hint: 'recommended for most users' }, - { value: 'planetscale', label: 'PlanetScale', hint: 'popular hosted platform' } - ], - condition: ({ database }) => database === 'mysql' - }, - sqlite: { - question: 'Which SQLite client would you like to use?', - type: 'select', - group: 'client', - default: 'libsql', - options: [ - { value: 'better-sqlite3', hint: 'for traditional Node environments' }, - { value: 'libsql', label: 'libSQL', hint: 'for serverless environments' }, - { value: 'turso', label: 'Turso', hint: 'popular hosted platform' } - ], - condition: ({ database }) => database === 'sqlite' - }, - docker: { - question: 'Do you want to run the database locally with docker-compose?', - default: false, - type: 'boolean', - condition: ({ database, mysql, postgresql }) => - (database === 'mysql' && mysql === 'mysql2') || - (database === 'postgresql' && postgresql === 'postgres.js') - } -}); diff --git a/packages/adders/drizzle/index.ts b/packages/adders/drizzle/index.ts index 70c45e9f..31376fc3 100644 --- a/packages/adders/drizzle/index.ts +++ b/packages/adders/drizzle/index.ts @@ -1,3 +1,451 @@ -import { adder } from './config/adder.ts'; +import { common, exports, functions, imports, object, variables } from '@svelte-cli/core/js'; +import { defineAdder, defineAdderOptions, dedent, type FileEditor } from '@svelte-cli/core'; +import { parseJson, parseScript } from '@svelte-cli/core/parsers'; -export default adder; +const PORTS = { + mysql: '3306', + postgresql: '5432', + sqlite: '' +} as const; + +export const options = defineAdderOptions({ + database: { + question: 'Which database would you like to use?', + type: 'select', + default: 'sqlite', + options: [ + { value: 'postgresql', label: 'PostgreSQL' }, + { value: 'mysql', label: 'MySQL' }, + { value: 'sqlite', label: 'SQLite' } + ] + }, + postgresql: { + question: 'Which PostgreSQL client would you like to use?', + type: 'select', + group: 'client', + default: 'postgres.js', + options: [ + { value: 'postgres.js', label: 'Postgres.JS', hint: 'recommended for most users' }, + { value: 'neon', label: 'Neon', hint: 'popular hosted platform' } + ], + condition: ({ database }) => database === 'postgresql' + }, + mysql: { + question: 'Which MySQL client would you like to use?', + type: 'select', + group: 'client', + default: 'mysql2', + options: [ + { value: 'mysql2', hint: 'recommended for most users' }, + { value: 'planetscale', label: 'PlanetScale', hint: 'popular hosted platform' } + ], + condition: ({ database }) => database === 'mysql' + }, + sqlite: { + question: 'Which SQLite client would you like to use?', + type: 'select', + group: 'client', + default: 'libsql', + options: [ + { value: 'better-sqlite3', hint: 'for traditional Node environments' }, + { value: 'libsql', label: 'libSQL', hint: 'for serverless environments' }, + { value: 'turso', label: 'Turso', hint: 'popular hosted platform' } + ], + condition: ({ database }) => database === 'sqlite' + }, + docker: { + question: 'Do you want to run the database locally with docker-compose?', + default: false, + type: 'boolean', + condition: ({ database, mysql, postgresql }) => + (database === 'mysql' && mysql === 'mysql2') || + (database === 'postgresql' && postgresql === 'postgres.js') + } +}); + +export default defineAdder({ + id: 'drizzle', + name: 'Drizzle', + description: 'Headless ORM for NodeJS, TypeScript and JavaScript', + environments: { svelte: false, kit: true }, + documentation: 'https://orm.drizzle.team/docs/overview', + options, + packages: [ + { name: 'drizzle-orm', version: '^0.33.0', dev: false }, + { name: 'drizzle-kit', version: '^0.22.0', dev: true }, + // 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' + }, + // 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' + }, + // 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' + } + ], + 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 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; + + const imageName = options.database === 'mysql' ? 'mysql' : 'postgres'; + const port = PORTS[options.database]; + + const USER = 'root'; + const PASSWORD = 'mysecretpassword'; + const DB_NAME = 'local'; + + let dbSpecificContent = ''; + if (options.mysql === 'mysql2') { + dbSpecificContent = ` + MYSQL_ROOT_PASSWORD: ${PASSWORD} + MYSQL_DATABASE: ${DB_NAME} + `; + } + if (options.postgresql === 'postgres.js') { + dbSpecificContent = ` + POSTGRES_USER: ${USER} + POSTGRES_PASSWORD: ${PASSWORD} + POSTGRES_DB: ${DB_NAME} + `; + } + + content = dedent` + services: + db: + image: ${imageName} + restart: always + ports: + - ${port}:${port} + environment: ${dbSpecificContent} + `; + + 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 }) => { + 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' }); + + 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(); + } + }, + { + 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); + + 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); + + 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( + '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' }); + + 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('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('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('postgres(env.DATABASE_URL)'); + } + + if (!clientExpression) throw new Error('unreachable state...'); + const clientIdentifier = variables.declaration(ast, 'const', 'client', clientExpression); + common.addStatement(ast, clientIdentifier); + + const drizzleCall = functions.callByIdentifier('drizzle', ['client']); + const db = variables.declaration(ast, 'const', 'db', drizzleCall); + exports.namedExport(ast, 'db', db); + + return generateCode(); + } + } + ], + nextSteps: ({ options, highlighter }) => { + const steps = [ + `You will need to set ${highlighter.env('DATABASE_URL')} in your production environment` + ]; + if (options.docker) { + steps.push(`Run ${highlighter.command('npm run db:start')} to start the docker container`); + } + steps.push(`To update your DB schema, run ${highlighter.command('npm run db:push')}`); + + return steps; + } +}); + +function generateEnvFileContent({ content, options: opts }: FileEditor) { + const DB_URL_KEY = 'DATABASE_URL'; + if (opts.docker) { + // we'll prefill with the default docker db credentials + const protocol = opts.database === 'mysql' ? 'mysql' : 'postgres'; + const port = PORTS[opts.database]; + content = addEnvVar( + content, + DB_URL_KEY, + `"${protocol}://root:mysecretpassword@localhost:${port}/local"` + ); + return content; + } + if (opts.sqlite === 'better-sqlite3' || opts.sqlite === 'libsql') { + const dbFile = opts.sqlite === 'libsql' ? 'file:local.db' : 'local.db'; + content = addEnvVar(content, DB_URL_KEY, dbFile); + return content; + } + + content = addEnvComment(content, 'Replace with your DB credentials!'); + if (opts.sqlite === 'turso') { + content = addEnvVar(content, DB_URL_KEY, '"libsql://db-name-user.turso.io"'); + content = addEnvVar(content, 'DATABASE_AUTH_TOKEN', '""'); + content = addEnvComment(content, 'A local DB can also be used in dev as well'); + content = addEnvComment(content, `${DB_URL_KEY}="file:local.db"`); + } + if (opts.database === 'mysql') { + content = addEnvVar(content, DB_URL_KEY, '"mysql://user:password@host:port/db-name"'); + } + if (opts.database === 'postgresql') { + content = addEnvVar(content, DB_URL_KEY, '"postgres://user:password@host:port/db-name"'); + } + return content; +} + +function addEnvVar(content: string, key: string, value: string) { + if (!content.includes(key + '=')) { + content = appendEnvContent(content, `${key}=${value}`); + } + return content; +} + +function addEnvComment(content: string, comment: string) { + const commented = `# ${comment}`; + if (!content.includes(commented)) { + content = appendEnvContent(content, commented); + } + return content; +} + +function appendEnvContent(existing: string, content: string) { + const withNewLine = !existing.length || existing.endsWith('\n') ? existing : existing + '\n'; + return withNewLine + content + '\n'; +} diff --git a/packages/adders/drizzle/drizzle.svg b/packages/adders/drizzle/logo.svg similarity index 100% rename from packages/adders/drizzle/drizzle.svg rename to packages/adders/drizzle/logo.svg diff --git a/packages/adders/drizzle/tests/tests.ts b/packages/adders/drizzle/tests.ts similarity index 98% rename from packages/adders/drizzle/tests/tests.ts rename to packages/adders/drizzle/tests.ts index cca889f9..3a755d35 100644 --- a/packages/adders/drizzle/tests/tests.ts +++ b/packages/adders/drizzle/tests.ts @@ -1,4 +1,4 @@ -import { options } from '../config/options.ts'; +import { options } from './index.ts'; import { defineAdderTests } from '@svelte-cli/core'; import { parseSvelte, parseJson } from '@svelte-cli/core/parsers'; diff --git a/packages/adders/eslint/config/adder.ts b/packages/adders/eslint/config/adder.ts deleted file mode 100644 index 6c0139c4..00000000 --- a/packages/adders/eslint/config/adder.ts +++ /dev/null @@ -1,162 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { options } from './options.ts'; -import { addEslintConfigPrettier } from '../../common.ts'; -import { defineAdder, log } from '@svelte-cli/core'; -import { - array, - common, - exports, - functions, - imports, - object, - type AstKinds, - type AstTypes -} from '@svelte-cli/core/js'; -import { parseJson, parseScript } from '@svelte-cli/core/parsers'; - -export const adder = defineAdder({ - metadata: { - id: 'eslint', - name: 'ESLint', - description: 'A configurable JavaScript linter', - environments: { svelte: true, kit: true }, - website: { - logo: './eslint.svg', - keywords: ['eslint', 'code', 'linter'], - documentation: '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(); - } - }, - { - 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(); - } - }, - { - 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({ - languageOptions: object.create({ - globals: globalsObjLiteral - }) - }); - 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); - } - - 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(); - } - }, - { - name: () => 'eslint.config.js', - condition: ({ dependencyVersion }) => Boolean(dependencyVersion('prettier')), - content: addEslintConfigPrettier - } - ] -}); diff --git a/packages/adders/eslint/config/options.ts b/packages/adders/eslint/config/options.ts deleted file mode 100644 index 279c2c09..00000000 --- a/packages/adders/eslint/config/options.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { defineAdderOptions } from '@svelte-cli/core'; - -export const options = defineAdderOptions({}); diff --git a/packages/adders/eslint/index.ts b/packages/adders/eslint/index.ts index 70c45e9f..fb7fe8f9 100644 --- a/packages/adders/eslint/index.ts +++ b/packages/adders/eslint/index.ts @@ -1,3 +1,155 @@ -import { adder } from './config/adder.ts'; +import fs from 'node:fs'; +import path from 'node:path'; +import { addEslintConfigPrettier } from '../common.ts'; +import { defineAdder, log } from '@svelte-cli/core'; +import { + array, + common, + exports, + functions, + imports, + object, + type AstKinds, + type AstTypes +} from '@svelte-cli/core/js'; +import { parseJson, parseScript } from '@svelte-cli/core/parsers'; -export default adder; +export default defineAdder({ + id: 'eslint', + name: 'ESLint', + description: 'A configurable JavaScript linter', + environments: { svelte: true, kit: true }, + documentation: '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(); + } + }, + { + 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(); + } + }, + { + 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({ + languageOptions: object.create({ + globals: globalsObjLiteral + }) + }); + 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); + } + + 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(); + } + }, + { + name: () => 'eslint.config.js', + condition: ({ dependencyVersion }) => Boolean(dependencyVersion('prettier')), + content: addEslintConfigPrettier + } + ] +}); diff --git a/packages/adders/eslint/eslint.svg b/packages/adders/eslint/logo.svg similarity index 100% rename from packages/adders/eslint/eslint.svg rename to packages/adders/eslint/logo.svg diff --git a/packages/adders/eslint/tests/tests.ts b/packages/adders/eslint/tests.ts similarity index 70% rename from packages/adders/eslint/tests/tests.ts rename to packages/adders/eslint/tests.ts index e5f84777..b37ea6ce 100644 --- a/packages/adders/eslint/tests/tests.ts +++ b/packages/adders/eslint/tests.ts @@ -1,9 +1,8 @@ import { defineAdderTests } from '@svelte-cli/core'; -import { options } from '../config/options.ts'; export const tests = defineAdderTests({ files: [], - options, + options: {}, optionValues: [], tests: [] }); diff --git a/packages/adders/lucia/config/adder.ts b/packages/adders/lucia/config/adder.ts deleted file mode 100644 index 7fcfbf3a..00000000 --- a/packages/adders/lucia/config/adder.ts +++ /dev/null @@ -1,609 +0,0 @@ -import { options } from './options.ts'; -import { colors, dedent, defineAdder, log, Walker } from '@svelte-cli/core'; -import { common, exports, imports, variables, object, functions } from '@svelte-cli/core/js'; -// eslint-disable-next-line no-duplicate-imports -import type { AstTypes } from '@svelte-cli/core/js'; -import { addHooksHandle, addGlobalAppInterface, hasTypeProp } from '../../common.ts'; -import { parseScript } from '@svelte-cli/core/parsers'; - -const LUCIA_ADAPTER = { - mysql: 'DrizzleMySQLAdapter', - postgresql: 'DrizzlePostgreSQLAdapter', - sqlite: 'DrizzleSQLiteAdapter' -} as const; - -const TABLE_TYPE = { - mysql: 'mysqlTable', - postgresql: 'pgTable', - sqlite: 'sqliteTable' -}; - -type Dialect = keyof typeof LUCIA_ADAPTER; - -let drizzleDialect: Dialect; -let schemaPath: string; - -export const adder = defineAdder({ - metadata: { - id: 'lucia', - name: 'Lucia', - description: 'An auth library that abstracts away the complexity of handling sessions', - environments: { svelte: false, kit: true }, - website: { - logo: './lucia.webp', - keywords: ['lucia', 'lucia-auth', 'auth', 'authentication'], - documentation: 'https://lucia-auth.com' - } - }, - options, - packages: [ - { name: 'lucia', version: '^3.2.0', dev: false }, - { name: '@lucia-auth/adapter-drizzle', version: '^1.1.0', dev: false }, - // password hashing for demo - { - name: '@node-rs/argon2', - version: '^1.1.0', - condition: ({ options }) => options.demo, - dev: false - } - ], - 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'); - } - return generateCode(); - } - }, - { - name: () => schemaPath, - content: ({ content, options }) => { - const { ast, generateCode } = parseScript(content); - const createTable = (name: string) => functions.call(TABLE_TYPE[drizzleDialect], [name]); - - const userDecl = variables.declaration(ast, 'const', 'user', createTable('user')); - const sessionDecl = variables.declaration(ast, 'const', 'session', createTable('session')); - - const user = exports.namedExport(ast, 'user', userDecl); - const session = exports.namedExport(ast, 'session', sessionDecl); - - const userTable = getCallExpression(user); - const sessionTable = getCallExpression(session); - - if (!userTable || !sessionTable) { - throw new Error('failed to find call expression of `user` or `session`'); - } - - if (userTable.arguments.length === 1) { - userTable.arguments.push(object.createEmpty()); - } - if (sessionTable.arguments.length === 1) { - sessionTable.arguments.push(object.createEmpty()); - } - - 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') { - 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').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)` - ), - expiresAt: common.expressionFromString(`datetime('expires_at').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()` - ) - }); - } - return generateCode(); - } - }, - { - name: ({ kit, typescript }) => `${kit?.libDirectory}/server/auth.${typescript ? 'ts' : 'js'}`, - content: ({ content, typescript, options }) => { - const { ast, generateCode } = parseScript(content); - const adapter = LUCIA_ADAPTER[drizzleDialect]; - - imports.addNamed(ast, '$lib/server/db/schema.js', { user: 'user', session: 'session' }); - imports.addNamed(ast, '$lib/server/db', { db: 'db' }); - imports.addNamed(ast, 'lucia', { Lucia: 'Lucia' }); - imports.addNamed(ast, '@lucia-auth/adapter-drizzle', { [adapter]: adapter }); - imports.addNamed(ast, '$app/environment', { dev: 'dev' }); - - // adapter - const adapterDecl = common.statementFromString( - `const adapter = new ${adapter}(db, session, user);` - ); - common.addStatement(ast, adapterDecl); - - // lucia export - const luciaInit = common.expressionFromString(` - new Lucia(adapter, { - sessionCookie: { - attributes: { - secure: !dev - } - }, - ${options.demo ? 'getUserAttributes: (attributes) => ({ username: attributes.username })' : ''} - })`); - const luciaDecl = variables.declaration(ast, 'const', 'lucia', luciaInit); - exports.namedExport(ast, 'lucia', luciaDecl); - - // module declaration - if (typescript && !/declare module ["']lucia["']/.test(content)) { - const moduleDecl = common.statementFromString(` - declare module 'lucia' { - interface Register { - Lucia: typeof lucia; - // attributes that are already included are omitted - DatabaseUserAttributes: Omit; - DatabaseSessionAttributes: Omit; - } - }`); - common.addStatement(ast, moduleDecl); - } - return generateCode(); - } - }, - { - name: () => 'src/app.d.ts', - condition: ({ typescript }) => typescript, - content: ({ content }) => { - const { ast, generateCode } = parseScript(content); - - const locals = addGlobalAppInterface(ast, 'Locals'); - if (!locals) { - throw new Error('Failed detecting `locals` interface in `src/app.d.ts`'); - } - - const user = locals.body.body.find((prop) => hasTypeProp('user', prop)); - const session = locals.body.body.find((prop) => hasTypeProp('session', prop)); - - if (!user) { - locals.body.body.push(createLuciaType('user')); - } - if (!session) { - 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.addNamed(ast, '$lib/server/auth.js', { lucia: 'lucia' }); - addHooksHandle(ast, typescript, 'auth', getAuthHandleContent()); - return generateCode(); - } - }, - // DEMO - // login/register - { - name: ({ kit, typescript }) => - `${kit!.routesDirectory}/demo/login/+page.server.${typescript ? 'ts' : 'js'}`, - condition: ({ options }) => options.demo, - content({ content, typescript }) { - if (content) { - log.warn( - `Existing ${colors.yellow('/demo/login/+page.server.[js|ts]')} file. Could not update.` - ); - return content; - } - - const ts = (str: string, opt = '') => (typescript ? str : opt); - return dedent` - import { fail, redirect } from '@sveltejs/kit'; - import { hash, verify } from '@node-rs/argon2'; - import { eq } from 'drizzle-orm'; - import { generateId } from 'lucia'; - import { lucia } from '$lib/server/auth'; - import { db } from '$lib/server/db'; - import { user } from '$lib/server/db/schema.js'; - ${ts(`import type { Actions, PageServerLoad } from './$types';`)} - - export const load${ts(': PageServerLoad')} = async (event) => { - if (event.locals.user) { - return redirect(302, '/demo'); - } - return {}; - }; - - export const actions${ts(': Actions')} = { - login: async (event) => { - const formData = await event.request.formData(); - const username = formData.get('username'); - const password = formData.get('password'); - - if (!validateUsername(username)) { - return fail(400, { - message: 'Invalid username', - }); - } - if (!validatePassword(password)) { - return fail(400, { - message: 'Invalid password', - }); - } - - const results = await db - .select() - .from(user) - .where(eq(user.username, username)); - - const existingUser = results.at(0); - if (!existingUser) { - return fail(400, { - message: 'Incorrect username or password', - }); - } - - const validPassword = await verify(existingUser.passwordHash, password, { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1, - }); - if (!validPassword) { - return fail(400, { - message: 'Incorrect username or password', - }); - } - - const session = await lucia.createSession(existingUser.id, {}); - const sessionCookie = lucia.createSessionCookie(session.id); - event.cookies.set(sessionCookie.name, sessionCookie.value, { - path: '/', - ...sessionCookie.attributes, - }); - - return redirect(302, '/demo'); - }, - register: async (event) => { - const formData = await event.request.formData(); - const username = formData.get('username'); - const password = formData.get('password'); - - if (!validateUsername(username)) { - return fail(400, { - message: 'Invalid username', - }); - } - if (!validatePassword(password)) { - return fail(400, { - message: 'Invalid password', - }); - } - - const passwordHash = await hash(password, { - // recommended minimum parameters - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1, - }); - const userId = generateId(15); - - try { - await db.insert(user).values({ - id: userId, - username, - passwordHash, - }); - - const session = await lucia.createSession(userId, {}); - const sessionCookie = lucia.createSessionCookie(session.id); - event.cookies.set(sessionCookie.name, sessionCookie.value, { - path: '/', - ...sessionCookie.attributes, - }); - } catch (e) { - return fail(500, { - message: 'An error has occurred', - }); - } - return redirect(302, '/demo'); - }, - }; - - function validateUsername(username${ts(': unknown): username is string', ')')} { - return ( - typeof username === 'string' && - username.length >= 3 && - username.length <= 31 && - /^[a-z0-9_-]+$/.test(username) - ); - } - - function validatePassword(password${ts(': unknown): password is string', ')')} { - return ( - typeof password === 'string' && - password.length >= 6 && - password.length <= 255 - ); - } - `; - } - }, - { - name: ({ kit }) => `${kit!.routesDirectory}/demo/login/+page.svelte`, - condition: ({ options }) => options.demo, - content({ content, typescript }) { - if (content) { - log.warn(`Existing ${colors.yellow('/demo/login/+page.svelte')} file. Could not update.`); - return content; - } - - const ts = (str: string) => (typescript ? str : ''); - return dedent` - - -

Login/Register

-
- - - - -
-

{form?.message ?? ''}

- `; - } - }, - // logout - { - name: ({ kit, typescript }) => - `${kit!.routesDirectory}/demo/+page.server.${typescript ? 'ts' : 'js'}`, - condition: ({ options }) => options.demo, - content({ content, typescript }) { - if (content) { - log.warn( - `Existing ${colors.yellow('/demo/+page.server.[js|ts]')} file. Could not update.` - ); - return content; - } - - const ts = (str: string) => (typescript ? str : ''); - return dedent` - import { lucia } from '$lib/server/auth'; - import { fail, redirect } from '@sveltejs/kit'; - ${ts(`import type { Actions, PageServerLoad } from './$types';`)} - - export const load${ts(': PageServerLoad')} = async (event) => { - if (!event.locals.user) { - return redirect(302, '/demo/login'); - } - return { - user: event.locals.user, - }; - }; - - export const actions${ts(': Actions')} = { - logout: async (event) => { - if (!event.locals.session) { - return fail(401); - } - await lucia.invalidateSession(event.locals.session.id); - const sessionCookie = lucia.createBlankSessionCookie(); - event.cookies.set(sessionCookie.name, sessionCookie.value, { - path: '/', - ...sessionCookie.attributes, - }); - return redirect(302, '/demo/login'); - }, - }; - `; - } - }, - { - name: ({ kit }) => `${kit!.routesDirectory}/demo/+page.svelte`, - condition: ({ options }) => options.demo, - content({ content, typescript }) { - if (content) { - log.warn(`Existing ${colors.yellow('/demo/+page.svelte')} file. Could not update.`); - return content; - } - - const ts = (str: string) => (typescript ? str : ''); - return dedent` - - -

Hi, {data.user.username}!

-

Your user ID is {data.user.id}.

-
- -
- `; - } - } - ], - nextSteps: ({ highlighter, options }) => { - const steps = [`Run ${highlighter.command('npm run db:push')} to update your database`]; - if (options.demo) { - steps.push(`Visit ${highlighter.route('/demo')} route to view the demo`); - } - - return steps; - } -}); - -function createLuciaType(name: string): AstTypes.TSInterfaceBody['body'][number] { - return { - type: 'TSPropertySignature', - key: { - type: 'Identifier', - name - }, - typeAnnotation: { - type: 'TSTypeAnnotation', - typeAnnotation: { - type: 'TSUnionType', - types: [ - { - type: 'TSImportType', - argument: { type: 'StringLiteral', value: 'lucia' }, - qualifier: { - type: 'Identifier', - // capitalize first letter - name: `${name[0]!.toUpperCase()}${name.slice(1)}` - } - }, - { - type: 'TSNullKeyword' - } - ] - } - } - }; -} - -function getAuthHandleContent() { - return ` - async ({ event, resolve }) => { - const sessionId = event.cookies.get(lucia.sessionCookieName); - if (!sessionId) { - event.locals.user = null; - event.locals.session = null; - return resolve(event); - } - - const { session, user } = await lucia.validateSession(sessionId); - if (!session) { - event.cookies.delete(lucia.sessionCookieName, { path: '/' }); - } - - if (session?.fresh) { - const sessionCookie = lucia.createSessionCookie(session.id); - - event.cookies.set(sessionCookie.name, sessionCookie.value, { - path: '/', - ...sessionCookie.attributes, - }); - } - - event.locals.user = user; - event.locals.session = session; - - return resolve(event); - };`; -} - -function getCallExpression(ast: AstTypes.ASTNode): AstTypes.CallExpression | undefined { - let callExpression; - - // prettier-ignore - Walker.walk(ast, {}, { - CallExpression(node) { - callExpression ??= node; - }, - }); - - return callExpression; -} diff --git a/packages/adders/lucia/config/options.ts b/packages/adders/lucia/config/options.ts deleted file mode 100644 index 7803d6e8..00000000 --- a/packages/adders/lucia/config/options.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineAdderOptions, colors } from '@svelte-cli/core'; - -export const options = defineAdderOptions({ - demo: { - type: 'boolean', - default: false, - question: `Do you want to include a demo? ${colors.dim('(includes a login/register page)')}` - } -}); diff --git a/packages/adders/lucia/index.ts b/packages/adders/lucia/index.ts index 70c45e9f..a98136df 100644 --- a/packages/adders/lucia/index.ts +++ b/packages/adders/lucia/index.ts @@ -1,3 +1,610 @@ -import { adder } from './config/adder.ts'; +import { colors, dedent, defineAdder, defineAdderOptions, log, Walker } from '@svelte-cli/core'; +import { common, exports, imports, variables, object, functions } from '@svelte-cli/core/js'; +// eslint-disable-next-line no-duplicate-imports +import type { AstTypes } from '@svelte-cli/core/js'; +import { addHooksHandle, addGlobalAppInterface, hasTypeProp } from '../common.ts'; +import { parseScript } from '@svelte-cli/core/parsers'; -export default adder; +const LUCIA_ADAPTER = { + mysql: 'DrizzleMySQLAdapter', + postgresql: 'DrizzlePostgreSQLAdapter', + sqlite: 'DrizzleSQLiteAdapter' +} as const; + +const TABLE_TYPE = { + mysql: 'mysqlTable', + postgresql: 'pgTable', + sqlite: 'sqliteTable' +}; + +type Dialect = keyof typeof LUCIA_ADAPTER; + +let drizzleDialect: Dialect; +let schemaPath: string; + +export const options = defineAdderOptions({ + demo: { + type: 'boolean', + default: false, + question: `Do you want to include a demo? ${colors.dim('(includes a login/register page)')}` + } +}); + +export default defineAdder({ + id: 'lucia', + name: 'Lucia', + description: 'An auth library that abstracts away the complexity of handling sessions', + environments: { svelte: false, kit: true }, + documentation: 'https://lucia-auth.com', + options, + packages: [ + { name: 'lucia', version: '^3.2.0', dev: false }, + { name: '@lucia-auth/adapter-drizzle', version: '^1.1.0', dev: false }, + // password hashing for demo + { + name: '@node-rs/argon2', + version: '^1.1.0', + condition: ({ options }) => options.demo, + dev: false + } + ], + 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'); + } + return generateCode(); + } + }, + { + name: () => schemaPath, + content: ({ content, options }) => { + const { ast, generateCode } = parseScript(content); + const createTable = (name: string) => functions.call(TABLE_TYPE[drizzleDialect], [name]); + + const userDecl = variables.declaration(ast, 'const', 'user', createTable('user')); + const sessionDecl = variables.declaration(ast, 'const', 'session', createTable('session')); + + const user = exports.namedExport(ast, 'user', userDecl); + const session = exports.namedExport(ast, 'session', sessionDecl); + + const userTable = getCallExpression(user); + const sessionTable = getCallExpression(session); + + if (!userTable || !sessionTable) { + throw new Error('failed to find call expression of `user` or `session`'); + } + + if (userTable.arguments.length === 1) { + userTable.arguments.push(object.createEmpty()); + } + if (sessionTable.arguments.length === 1) { + sessionTable.arguments.push(object.createEmpty()); + } + + 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') { + 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').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)` + ), + expiresAt: common.expressionFromString(`datetime('expires_at').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()` + ) + }); + } + return generateCode(); + } + }, + { + name: ({ kit, typescript }) => `${kit?.libDirectory}/server/auth.${typescript ? 'ts' : 'js'}`, + content: ({ content, typescript, options }) => { + const { ast, generateCode } = parseScript(content); + const adapter = LUCIA_ADAPTER[drizzleDialect]; + + imports.addNamed(ast, '$lib/server/db/schema.js', { user: 'user', session: 'session' }); + imports.addNamed(ast, '$lib/server/db', { db: 'db' }); + imports.addNamed(ast, 'lucia', { Lucia: 'Lucia' }); + imports.addNamed(ast, '@lucia-auth/adapter-drizzle', { [adapter]: adapter }); + imports.addNamed(ast, '$app/environment', { dev: 'dev' }); + + // adapter + const adapterDecl = common.statementFromString( + `const adapter = new ${adapter}(db, session, user);` + ); + common.addStatement(ast, adapterDecl); + + // lucia export + const luciaInit = common.expressionFromString(` + new Lucia(adapter, { + sessionCookie: { + attributes: { + secure: !dev + } + }, + ${options.demo ? 'getUserAttributes: (attributes) => ({ username: attributes.username })' : ''} + })`); + const luciaDecl = variables.declaration(ast, 'const', 'lucia', luciaInit); + exports.namedExport(ast, 'lucia', luciaDecl); + + // module declaration + if (typescript && !/declare module ["']lucia["']/.test(content)) { + const moduleDecl = common.statementFromString(` + declare module 'lucia' { + interface Register { + Lucia: typeof lucia; + // attributes that are already included are omitted + DatabaseUserAttributes: Omit; + DatabaseSessionAttributes: Omit; + } + }`); + common.addStatement(ast, moduleDecl); + } + return generateCode(); + } + }, + { + name: () => 'src/app.d.ts', + condition: ({ typescript }) => typescript, + content: ({ content }) => { + const { ast, generateCode } = parseScript(content); + + const locals = addGlobalAppInterface(ast, 'Locals'); + if (!locals) { + throw new Error('Failed detecting `locals` interface in `src/app.d.ts`'); + } + + const user = locals.body.body.find((prop) => hasTypeProp('user', prop)); + const session = locals.body.body.find((prop) => hasTypeProp('session', prop)); + + if (!user) { + locals.body.body.push(createLuciaType('user')); + } + if (!session) { + 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.addNamed(ast, '$lib/server/auth.js', { lucia: 'lucia' }); + addHooksHandle(ast, typescript, 'auth', getAuthHandleContent()); + return generateCode(); + } + }, + // DEMO + // login/register + { + name: ({ kit, typescript }) => + `${kit!.routesDirectory}/demo/login/+page.server.${typescript ? 'ts' : 'js'}`, + condition: ({ options }) => options.demo, + content({ content, typescript }) { + if (content) { + log.warn( + `Existing ${colors.yellow('/demo/login/+page.server.[js|ts]')} file. Could not update.` + ); + return content; + } + + const ts = (str: string, opt = '') => (typescript ? str : opt); + return dedent` + import { fail, redirect } from '@sveltejs/kit'; + import { hash, verify } from '@node-rs/argon2'; + import { eq } from 'drizzle-orm'; + import { generateId } from 'lucia'; + import { lucia } from '$lib/server/auth'; + import { db } from '$lib/server/db'; + import { user } from '$lib/server/db/schema.js'; + ${ts(`import type { Actions, PageServerLoad } from './$types';`)} + + export const load${ts(': PageServerLoad')} = async (event) => { + if (event.locals.user) { + return redirect(302, '/demo'); + } + return {}; + }; + + export const actions${ts(': Actions')} = { + login: async (event) => { + const formData = await event.request.formData(); + const username = formData.get('username'); + const password = formData.get('password'); + + if (!validateUsername(username)) { + return fail(400, { + message: 'Invalid username', + }); + } + if (!validatePassword(password)) { + return fail(400, { + message: 'Invalid password', + }); + } + + const results = await db + .select() + .from(user) + .where(eq(user.username, username)); + + const existingUser = results.at(0); + if (!existingUser) { + return fail(400, { + message: 'Incorrect username or password', + }); + } + + const validPassword = await verify(existingUser.passwordHash, password, { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1, + }); + if (!validPassword) { + return fail(400, { + message: 'Incorrect username or password', + }); + } + + const session = await lucia.createSession(existingUser.id, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + event.cookies.set(sessionCookie.name, sessionCookie.value, { + path: '/', + ...sessionCookie.attributes, + }); + + return redirect(302, '/demo'); + }, + register: async (event) => { + const formData = await event.request.formData(); + const username = formData.get('username'); + const password = formData.get('password'); + + if (!validateUsername(username)) { + return fail(400, { + message: 'Invalid username', + }); + } + if (!validatePassword(password)) { + return fail(400, { + message: 'Invalid password', + }); + } + + const passwordHash = await hash(password, { + // recommended minimum parameters + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1, + }); + const userId = generateId(15); + + try { + await db.insert(user).values({ + id: userId, + username, + passwordHash, + }); + + const session = await lucia.createSession(userId, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + event.cookies.set(sessionCookie.name, sessionCookie.value, { + path: '/', + ...sessionCookie.attributes, + }); + } catch (e) { + return fail(500, { + message: 'An error has occurred', + }); + } + return redirect(302, '/demo'); + }, + }; + + function validateUsername(username${ts(': unknown): username is string', ')')} { + return ( + typeof username === 'string' && + username.length >= 3 && + username.length <= 31 && + /^[a-z0-9_-]+$/.test(username) + ); + } + + function validatePassword(password${ts(': unknown): password is string', ')')} { + return ( + typeof password === 'string' && + password.length >= 6 && + password.length <= 255 + ); + } + `; + } + }, + { + name: ({ kit }) => `${kit!.routesDirectory}/demo/login/+page.svelte`, + condition: ({ options }) => options.demo, + content({ content, typescript }) { + if (content) { + log.warn(`Existing ${colors.yellow('/demo/login/+page.svelte')} file. Could not update.`); + return content; + } + + const ts = (str: string) => (typescript ? str : ''); + return dedent` + + +

Login/Register

+
+ + + + +
+

{form?.message ?? ''}

+ `; + } + }, + // logout + { + name: ({ kit, typescript }) => + `${kit!.routesDirectory}/demo/+page.server.${typescript ? 'ts' : 'js'}`, + condition: ({ options }) => options.demo, + content({ content, typescript }) { + if (content) { + log.warn( + `Existing ${colors.yellow('/demo/+page.server.[js|ts]')} file. Could not update.` + ); + return content; + } + + const ts = (str: string) => (typescript ? str : ''); + return dedent` + import { lucia } from '$lib/server/auth'; + import { fail, redirect } from '@sveltejs/kit'; + ${ts(`import type { Actions, PageServerLoad } from './$types';`)} + + export const load${ts(': PageServerLoad')} = async (event) => { + if (!event.locals.user) { + return redirect(302, '/demo/login'); + } + return { + user: event.locals.user, + }; + }; + + export const actions${ts(': Actions')} = { + logout: async (event) => { + if (!event.locals.session) { + return fail(401); + } + await lucia.invalidateSession(event.locals.session.id); + const sessionCookie = lucia.createBlankSessionCookie(); + event.cookies.set(sessionCookie.name, sessionCookie.value, { + path: '/', + ...sessionCookie.attributes, + }); + return redirect(302, '/demo/login'); + }, + }; + `; + } + }, + { + name: ({ kit }) => `${kit!.routesDirectory}/demo/+page.svelte`, + condition: ({ options }) => options.demo, + content({ content, typescript }) { + if (content) { + log.warn(`Existing ${colors.yellow('/demo/+page.svelte')} file. Could not update.`); + return content; + } + + const ts = (str: string) => (typescript ? str : ''); + return dedent` + + +

Hi, {data.user.username}!

+

Your user ID is {data.user.id}.

+
+ +
+ `; + } + } + ], + nextSteps: ({ highlighter, options }) => { + const steps = [`Run ${highlighter.command('npm run db:push')} to update your database`]; + if (options.demo) { + steps.push(`Visit ${highlighter.route('/demo')} route to view the demo`); + } + + return steps; + } +}); + +function createLuciaType(name: string): AstTypes.TSInterfaceBody['body'][number] { + return { + type: 'TSPropertySignature', + key: { + type: 'Identifier', + name + }, + typeAnnotation: { + type: 'TSTypeAnnotation', + typeAnnotation: { + type: 'TSUnionType', + types: [ + { + type: 'TSImportType', + argument: { type: 'StringLiteral', value: 'lucia' }, + qualifier: { + type: 'Identifier', + // capitalize first letter + name: `${name[0]!.toUpperCase()}${name.slice(1)}` + } + }, + { + type: 'TSNullKeyword' + } + ] + } + } + }; +} + +function getAuthHandleContent() { + return ` + async ({ event, resolve }) => { + const sessionId = event.cookies.get(lucia.sessionCookieName); + if (!sessionId) { + event.locals.user = null; + event.locals.session = null; + return resolve(event); + } + + const { session, user } = await lucia.validateSession(sessionId); + if (!session) { + event.cookies.delete(lucia.sessionCookieName, { path: '/' }); + } + + if (session?.fresh) { + const sessionCookie = lucia.createSessionCookie(session.id); + + event.cookies.set(sessionCookie.name, sessionCookie.value, { + path: '/', + ...sessionCookie.attributes, + }); + } + + event.locals.user = user; + event.locals.session = session; + + return resolve(event); + };`; +} + +function getCallExpression(ast: AstTypes.ASTNode): AstTypes.CallExpression | undefined { + let callExpression; + + // prettier-ignore + Walker.walk(ast, {}, { + CallExpression(node) { + callExpression ??= node; + }, + }); + + return callExpression; +} diff --git a/packages/adders/lucia/lucia.webp b/packages/adders/lucia/logo.webp similarity index 100% rename from packages/adders/lucia/lucia.webp rename to packages/adders/lucia/logo.webp diff --git a/packages/adders/playwright/tests/tests.ts b/packages/adders/lucia/tests.ts similarity index 75% rename from packages/adders/playwright/tests/tests.ts rename to packages/adders/lucia/tests.ts index e5f84777..326b88e3 100644 --- a/packages/adders/playwright/tests/tests.ts +++ b/packages/adders/lucia/tests.ts @@ -1,5 +1,5 @@ import { defineAdderTests } from '@svelte-cli/core'; -import { options } from '../config/options.ts'; +import { options } from './index.ts'; export const tests = defineAdderTests({ files: [], diff --git a/packages/adders/mdsvex/config/adder.ts b/packages/adders/mdsvex/config/adder.ts deleted file mode 100644 index 01e34ba5..00000000 --- a/packages/adders/mdsvex/config/adder.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { options } from './options.ts'; -import { defineAdder } from '@svelte-cli/core'; -import { array, exports, functions, imports, object } from '@svelte-cli/core/js'; -import { parseScript } from '@svelte-cli/core/parsers'; - -export const adder = defineAdder({ - metadata: { - id: 'mdsvex', - name: 'mdsvex', - description: 'svelte in markdown', - environments: { svelte: true, kit: true }, - website: { - logo: './mdsvex.svg', - keywords: ['mdsvex', 'svelte', 'markdown'], - documentation: 'https://mdsvex.pngwn.io/docs' - } - }, - options, - packages: [{ name: 'mdsvex', version: '^0.11.2', dev: true }], - files: [ - { - name: () => 'svelte.config.js', - content: ({ content }) => { - const { ast, generateCode } = parseScript(content); - - imports.addNamed(ast, 'mdsvex', { mdsvex: 'mdsvex' }); - - const { value: exportDefault } = exports.defaultExport(ast, object.createEmpty()); - - // preprocess - let preprocessorArray = object.property(exportDefault, 'preprocess', array.createEmpty()); - const isArray = preprocessorArray.type === 'ArrayExpression'; - - if (!isArray) { - const previousElement = preprocessorArray; - preprocessorArray = array.createEmpty(); - array.push(preprocessorArray, previousElement); - object.overrideProperty(exportDefault, 'preprocess', preprocessorArray); - } - - const mdsvexCall = functions.call('mdsvex', []); - array.push(preprocessorArray, mdsvexCall); - - // extensions - const extensionsArray = object.property(exportDefault, 'extensions', array.createEmpty()); - array.push(extensionsArray, '.svelte'); - array.push(extensionsArray, '.svx'); - - return generateCode(); - } - } - ] -}); diff --git a/packages/adders/mdsvex/config/options.ts b/packages/adders/mdsvex/config/options.ts deleted file mode 100644 index 279c2c09..00000000 --- a/packages/adders/mdsvex/config/options.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { defineAdderOptions } from '@svelte-cli/core'; - -export const options = defineAdderOptions({}); diff --git a/packages/adders/mdsvex/index.ts b/packages/adders/mdsvex/index.ts index 70c45e9f..82633cf8 100644 --- a/packages/adders/mdsvex/index.ts +++ b/packages/adders/mdsvex/index.ts @@ -1,3 +1,46 @@ -import { adder } from './config/adder.ts'; +import { defineAdder } from '@svelte-cli/core'; +import { array, exports, functions, imports, object } from '@svelte-cli/core/js'; +import { parseScript } from '@svelte-cli/core/parsers'; -export default adder; +export default defineAdder({ + id: 'mdsvex', + name: 'mdsvex', + description: 'svelte in markdown', + environments: { svelte: true, kit: true }, + documentation: 'https://mdsvex.pngwn.io/docs', + options: {}, + packages: [{ name: 'mdsvex', version: '^0.11.2', dev: true }], + files: [ + { + name: () => 'svelte.config.js', + content: ({ content }) => { + const { ast, generateCode } = parseScript(content); + + imports.addNamed(ast, 'mdsvex', { mdsvex: 'mdsvex' }); + + const { value: exportDefault } = exports.defaultExport(ast, object.createEmpty()); + + // preprocess + let preprocessorArray = object.property(exportDefault, 'preprocess', array.createEmpty()); + const isArray = preprocessorArray.type === 'ArrayExpression'; + + if (!isArray) { + const previousElement = preprocessorArray; + preprocessorArray = array.createEmpty(); + array.push(preprocessorArray, previousElement); + object.overrideProperty(exportDefault, 'preprocess', preprocessorArray); + } + + const mdsvexCall = functions.call('mdsvex', []); + array.push(preprocessorArray, mdsvexCall); + + // extensions + const extensionsArray = object.property(exportDefault, 'extensions', array.createEmpty()); + array.push(extensionsArray, '.svelte'); + array.push(extensionsArray, '.svx'); + + return generateCode(); + } + } + ] +}); diff --git a/packages/adders/mdsvex/mdsvex.svg b/packages/adders/mdsvex/logo.svg similarity index 100% rename from packages/adders/mdsvex/mdsvex.svg rename to packages/adders/mdsvex/logo.svg diff --git a/packages/adders/mdsvex/tests/tests.ts b/packages/adders/mdsvex/tests.ts similarity index 96% rename from packages/adders/mdsvex/tests/tests.ts rename to packages/adders/mdsvex/tests.ts index 2dfe6c49..86afde98 100644 --- a/packages/adders/mdsvex/tests/tests.ts +++ b/packages/adders/mdsvex/tests.ts @@ -1,5 +1,4 @@ import { defineAdderTests, type OptionDefinition, type FileEditor } from '@svelte-cli/core'; -import { options } from '../config/options.ts'; import { imports } from '@svelte-cli/core/js'; import * as html from '@svelte-cli/core/html'; import { parseSvelte } from '@svelte-cli/core/parsers'; @@ -27,7 +26,7 @@ export const tests = defineAdderTests({ condition: ({ kit }) => !kit } ], - options, + options: {}, optionValues: [], tests: [ { diff --git a/packages/adders/playwright/config/adder.ts b/packages/adders/playwright/config/adder.ts deleted file mode 100644 index b65eff11..00000000 --- a/packages/adders/playwright/config/adder.ts +++ /dev/null @@ -1,93 +0,0 @@ -import fs from 'node:fs'; -import { join } from 'node:path'; -import { options } from './options.ts'; -import { dedent, defineAdder, log } from '@svelte-cli/core'; -import { common, exports, imports, object } from '@svelte-cli/core/js'; -import { parseJson, parseScript } from '@svelte-cli/core/parsers'; - -export const adder = defineAdder({ - metadata: { - id: 'playwright', - name: 'Playwright', - description: 'A testing framework for end-to-end testing', - environments: { svelte: true, kit: true }, - website: { - logo: './playwright.svg', - keywords: ['test', 'testing', 'end-to-end', 'e2e', 'integration'], - documentation: '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; - - 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(); - }); - `; - } - }, - { - 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); - - 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(); - } - } - ] -}); diff --git a/packages/adders/playwright/config/options.ts b/packages/adders/playwright/config/options.ts deleted file mode 100644 index 279c2c09..00000000 --- a/packages/adders/playwright/config/options.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { defineAdderOptions } from '@svelte-cli/core'; - -export const options = defineAdderOptions({}); diff --git a/packages/adders/playwright/index.ts b/packages/adders/playwright/index.ts index 70c45e9f..8cd57d7b 100644 --- a/packages/adders/playwright/index.ts +++ b/packages/adders/playwright/index.ts @@ -1,3 +1,86 @@ -import { adder } from './config/adder.ts'; +import fs from 'node:fs'; +import { join } from 'node:path'; +import { dedent, defineAdder, log } from '@svelte-cli/core'; +import { common, exports, imports, object } from '@svelte-cli/core/js'; +import { parseJson, parseScript } from '@svelte-cli/core/parsers'; -export default adder; +export default defineAdder({ + id: 'playwright', + name: 'Playwright', + description: 'A testing framework for end-to-end testing', + environments: { svelte: true, kit: true }, + documentation: '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; + + 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(); + }); + `; + } + }, + { + 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); + + 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(); + } + } + ] +}); diff --git a/packages/adders/playwright/playwright.svg b/packages/adders/playwright/logo.svg similarity index 100% rename from packages/adders/playwright/playwright.svg rename to packages/adders/playwright/logo.svg diff --git a/packages/adders/lucia/tests/tests.ts b/packages/adders/playwright/tests.ts similarity index 70% rename from packages/adders/lucia/tests/tests.ts rename to packages/adders/playwright/tests.ts index e5f84777..b37ea6ce 100644 --- a/packages/adders/lucia/tests/tests.ts +++ b/packages/adders/playwright/tests.ts @@ -1,9 +1,8 @@ import { defineAdderTests } from '@svelte-cli/core'; -import { options } from '../config/options.ts'; export const tests = defineAdderTests({ files: [], - options, + options: {}, optionValues: [], tests: [] }); diff --git a/packages/adders/prettier/config/adder.ts b/packages/adders/prettier/config/adder.ts deleted file mode 100644 index 2708622b..00000000 --- a/packages/adders/prettier/config/adder.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { dedent, defineAdder, log, colors } from '@svelte-cli/core'; -import { options } from './options.ts'; -import { addEslintConfigPrettier } from '../../common.ts'; -import { parseJson } from '@svelte-cli/core/parsers'; - -export const adder = defineAdder({ - metadata: { - id: 'prettier', - name: 'Prettier', - description: 'An opinionated code formatter', - environments: { svelte: true, kit: true }, - website: { - logo: './prettier.svg', - keywords: ['prettier', 'code', 'formatter', 'formatting'], - documentation: '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 - `; - } - }, - { - 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 ??= []; - - 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(); - } - }, - { - name: () => 'package.json', - content: ({ content, dependencyVersion }) => { - const { data, generateCode } = parseJson(content); - - data.scripts ??= {}; - const scripts: Record = data.scripts; - const CHECK_CMD = 'prettier --check .'; - scripts['format'] ??= 'prettier --write .'; - - 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(); - } - }, - { - name: () => 'eslint.config.js', - condition: ({ dependencyVersion }) => { - // We only want this to execute when it's `false`, not falsy - - 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 - } - ] -}); - -const SUPPORTED_ESLINT_VERSION = '9'; - -function hasEslint(dependencyVersion: (pkg: string) => string | undefined): boolean { - const version = dependencyVersion('eslint'); - return !!version && version.startsWith(SUPPORTED_ESLINT_VERSION); -} diff --git a/packages/adders/prettier/config/options.ts b/packages/adders/prettier/config/options.ts deleted file mode 100644 index 279c2c09..00000000 --- a/packages/adders/prettier/config/options.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { defineAdderOptions } from '@svelte-cli/core'; - -export const options = defineAdderOptions({}); diff --git a/packages/adders/prettier/index.ts b/packages/adders/prettier/index.ts index 70c45e9f..34b28993 100644 --- a/packages/adders/prettier/index.ts +++ b/packages/adders/prettier/index.ts @@ -1,3 +1,107 @@ -import { adder } from './config/adder.ts'; +import { dedent, defineAdder, log, colors } from '@svelte-cli/core'; +import { addEslintConfigPrettier } from '../common.ts'; +import { parseJson } from '@svelte-cli/core/parsers'; -export default adder; +export default defineAdder({ + id: 'prettier', + name: 'Prettier', + description: 'An opinionated code formatter', + environments: { svelte: true, kit: true }, + documentation: '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 + `; + } + }, + { + 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 ??= []; + + 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(); + } + }, + { + name: () => 'package.json', + content: ({ content, dependencyVersion }) => { + const { data, generateCode } = parseJson(content); + + data.scripts ??= {}; + const scripts: Record = data.scripts; + const CHECK_CMD = 'prettier --check .'; + scripts['format'] ??= 'prettier --write .'; + + 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(); + } + }, + { + name: () => 'eslint.config.js', + condition: ({ dependencyVersion }) => { + // We only want this to execute when it's `false`, not falsy + + 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 + } + ] +}); + +const SUPPORTED_ESLINT_VERSION = '9'; + +function hasEslint(dependencyVersion: (pkg: string) => string | undefined): boolean { + const version = dependencyVersion('eslint'); + return !!version && version.startsWith(SUPPORTED_ESLINT_VERSION); +} diff --git a/packages/adders/prettier/prettier.svg b/packages/adders/prettier/logo.svg similarity index 100% rename from packages/adders/prettier/prettier.svg rename to packages/adders/prettier/logo.svg diff --git a/packages/adders/prettier/tests/tests.ts b/packages/adders/prettier/tests.ts similarity index 70% rename from packages/adders/prettier/tests/tests.ts rename to packages/adders/prettier/tests.ts index e5f84777..b37ea6ce 100644 --- a/packages/adders/prettier/tests/tests.ts +++ b/packages/adders/prettier/tests.ts @@ -1,9 +1,8 @@ import { defineAdderTests } from '@svelte-cli/core'; -import { options } from '../config/options.ts'; export const tests = defineAdderTests({ files: [], - options, + options: {}, optionValues: [], tests: [] }); diff --git a/packages/adders/routify/config/adder.ts b/packages/adders/routify/config/adder.ts deleted file mode 100644 index 89caadcd..00000000 --- a/packages/adders/routify/config/adder.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { defineAdder } from '@svelte-cli/core'; -import { options } from './options.ts'; -import { array, exports, functions, imports, object, variables } from '@svelte-cli/core/js'; -import * as html from '@svelte-cli/core/html'; -import { parseScript, parseSvelte } from '@svelte-cli/core/parsers'; - -export const adder = defineAdder({ - metadata: { - id: 'routify', - name: 'Routify', - description: 'The Router that Grows With You', - environments: { svelte: true, kit: false }, - website: { - logo: './routify.svg', - keywords: ['routify', 'svelte', 'router'], - documentation: 'https://routify.dev' - } - }, - options, - packages: [{ name: '@roxi/routify', version: 'next', dev: true }], - files: [ - { - name: ({ typescript }) => `vite.config.${typescript ? 'ts' : 'js'}`, - content: ({ content }) => { - const { ast, generateCode } = parseScript(content); - const vitePluginName = 'routify'; - imports.addDefault(ast, '@roxi/routify/vite-plugin', 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.createEmpty(); - functions.argumentByIndex(pluginFunctionCall, 0, pluginConfig); - - array.push(pluginsArray, pluginFunctionCall); - return generateCode(); - } - }, - { - name: () => 'src/App.svelte', - content: ({ content }) => { - const { script, template, generateCode } = parseSvelte(content); - imports.addNamed(script.ast, '@roxi/routify', { - Router: 'Router', - createRouter: 'createRouter' - }); - imports.addDefault(script.ast, '../.routify/routes.default.js', 'routes'); - - const routesObject = object.createEmpty(); - const routesIdentifier = variables.identifier('routes'); - object.property(routesObject, 'routes', routesIdentifier); - const createRouterFunction = functions.call('createRouter', []); - createRouterFunction.arguments.push(routesObject); - const routerVariableDeclaration = variables.declaration( - script.ast, - 'const', - 'router', - createRouterFunction - ); - exports.namedExport(script.ast, 'router', routerVariableDeclaration); - - const router = html.element('Router', { '{router}': '' }); - html.insertElement(template.ast.childNodes, router); - return generateCode({ script: script.generateCode(), template: template.generateCode() }); - } - }, - { - name: () => 'src/routes/index.svelte', - content: ({ content }) => { - const htmlString = `${routifyDemoHtml}

On index

`; - return content + htmlString; - } - }, - { - name: () => 'src/routes/demo.svelte', - content: ({ content }) => { - const htmlString = `${routifyDemoHtml}

On demo

`; - return content + htmlString; - } - } - ] -}); - -const routifyDemoHtml = ` -
- Index - Demo -
-`; diff --git a/packages/adders/routify/config/options.ts b/packages/adders/routify/config/options.ts deleted file mode 100644 index 279c2c09..00000000 --- a/packages/adders/routify/config/options.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { defineAdderOptions } from '@svelte-cli/core'; - -export const options = defineAdderOptions({}); diff --git a/packages/adders/routify/index.ts b/packages/adders/routify/index.ts index 70c45e9f..4e7e2763 100644 --- a/packages/adders/routify/index.ts +++ b/packages/adders/routify/index.ts @@ -1,3 +1,87 @@ -import { adder } from './config/adder.ts'; +import { defineAdder } from '@svelte-cli/core'; +import { array, exports, functions, imports, object, variables } from '@svelte-cli/core/js'; +import * as html from '@svelte-cli/core/html'; +import { parseScript, parseSvelte } from '@svelte-cli/core/parsers'; -export default adder; +export default defineAdder({ + id: 'routify', + name: 'Routify', + description: 'The Router that Grows With You', + environments: { svelte: true, kit: false }, + documentation: 'https://routify.dev', + options: {}, + packages: [{ name: '@roxi/routify', version: 'next', dev: true }], + files: [ + { + name: ({ typescript }) => `vite.config.${typescript ? 'ts' : 'js'}`, + content: ({ content }) => { + const { ast, generateCode } = parseScript(content); + const vitePluginName = 'routify'; + imports.addDefault(ast, '@roxi/routify/vite-plugin', 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.createEmpty(); + functions.argumentByIndex(pluginFunctionCall, 0, pluginConfig); + + array.push(pluginsArray, pluginFunctionCall); + return generateCode(); + } + }, + { + name: () => 'src/App.svelte', + content: ({ content }) => { + const { script, template, generateCode } = parseSvelte(content); + imports.addNamed(script.ast, '@roxi/routify', { + Router: 'Router', + createRouter: 'createRouter' + }); + imports.addDefault(script.ast, '../.routify/routes.default.js', 'routes'); + + const routesObject = object.createEmpty(); + const routesIdentifier = variables.identifier('routes'); + object.property(routesObject, 'routes', routesIdentifier); + const createRouterFunction = functions.call('createRouter', []); + createRouterFunction.arguments.push(routesObject); + const routerVariableDeclaration = variables.declaration( + script.ast, + 'const', + 'router', + createRouterFunction + ); + exports.namedExport(script.ast, 'router', routerVariableDeclaration); + + const router = html.element('Router', { '{router}': '' }); + html.insertElement(template.ast.childNodes, router); + return generateCode({ script: script.generateCode(), template: template.generateCode() }); + } + }, + { + name: () => 'src/routes/index.svelte', + content: ({ content }) => { + const htmlString = `${routifyDemoHtml}

On index

`; + return content + htmlString; + } + }, + { + name: () => 'src/routes/demo.svelte', + content: ({ content }) => { + const htmlString = `${routifyDemoHtml}

On demo

`; + return content + htmlString; + } + } + ] +}); + +const routifyDemoHtml = ` +
+ Index + Demo +
+`; diff --git a/packages/adders/routify/routify.svg b/packages/adders/routify/logo.svg similarity index 100% rename from packages/adders/routify/routify.svg rename to packages/adders/routify/logo.svg diff --git a/packages/adders/routify/tests/tests.ts b/packages/adders/routify/tests.ts similarity index 88% rename from packages/adders/routify/tests/tests.ts rename to packages/adders/routify/tests.ts index 7af25372..2a759c48 100644 --- a/packages/adders/routify/tests/tests.ts +++ b/packages/adders/routify/tests.ts @@ -1,9 +1,8 @@ import { defineAdderTests } from '@svelte-cli/core'; -import { options } from '../config/options.ts'; export const tests = defineAdderTests({ files: [], - options, + options: {}, optionValues: [], tests: [ { diff --git a/packages/adders/storybook/config/adder.ts b/packages/adders/storybook/config/adder.ts deleted file mode 100644 index e8596bd1..00000000 --- a/packages/adders/storybook/config/adder.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { defineAdder } from '@svelte-cli/core'; -import { options } from './options.ts'; - -export const adder = defineAdder({ - metadata: { - id: 'storybook', - name: 'Storybook', - description: 'Build UIs without the grunt work', - environments: { kit: true, svelte: true }, - website: { - logo: './storybook.svg', - keywords: [ - 'storybook', - 'styling', - 'testing', - 'documentation', - 'storybook-svelte-csf', - 'svelte-csf' - ], - documentation: 'https://storybook.js.org/docs/get-started' - } - }, - packages: [], - scripts: [ - { - description: 'applies storybook', - args: ['storybook@latest', 'init', '--skip-install', '--no-dev'], - stdio: 'inherit' - } - ], - options, - files: [] -}); diff --git a/packages/adders/storybook/config/options.ts b/packages/adders/storybook/config/options.ts deleted file mode 100644 index 279c2c09..00000000 --- a/packages/adders/storybook/config/options.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { defineAdderOptions } from '@svelte-cli/core'; - -export const options = defineAdderOptions({}); diff --git a/packages/adders/storybook/index.ts b/packages/adders/storybook/index.ts index 70c45e9f..543a5723 100644 --- a/packages/adders/storybook/index.ts +++ b/packages/adders/storybook/index.ts @@ -1,3 +1,19 @@ -import { adder } from './config/adder.ts'; +import { defineAdder } from '@svelte-cli/core'; -export default adder; +export default defineAdder({ + id: 'storybook', + name: 'Storybook', + description: 'Build UIs without the grunt work', + environments: { kit: true, svelte: true }, + documentation: 'https://storybook.js.org/docs/get-started', + options: {}, + packages: [], + scripts: [ + { + description: 'applies storybook', + args: ['storybook@latest', 'init', '--skip-install', '--no-dev'], + stdio: 'inherit' + } + ], + files: [] +}); diff --git a/packages/adders/storybook/storybook.svg b/packages/adders/storybook/logo.svg similarity index 100% rename from packages/adders/storybook/storybook.svg rename to packages/adders/storybook/logo.svg diff --git a/packages/adders/storybook/tests/tests.ts b/packages/adders/storybook/tests.ts similarity index 87% rename from packages/adders/storybook/tests/tests.ts rename to packages/adders/storybook/tests.ts index eb79e808..32ee2038 100644 --- a/packages/adders/storybook/tests/tests.ts +++ b/packages/adders/storybook/tests.ts @@ -1,10 +1,9 @@ import { defineAdderTests } from '@svelte-cli/core'; -import { options } from '../config/options.ts'; let port = 6006; export const tests = defineAdderTests({ - options, + options: {}, optionValues: [], get command() { return `storybook -p ${port++} --ci`; diff --git a/packages/adders/tailwindcss/config/adder.ts b/packages/adders/tailwindcss/config/adder.ts deleted file mode 100644 index f14aaa7d..00000000 --- a/packages/adders/tailwindcss/config/adder.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { options } from './options.ts'; -import { defineAdder } from '@svelte-cli/core'; -import { addImports } from '@svelte-cli/core/css'; -import { array, common, exports, functions, imports, object } from '@svelte-cli/core/js'; -import { parseCss, parseScript, parseJson, parseSvelte } from '@svelte-cli/core/parsers'; - -export const adder = defineAdder({ - metadata: { - id: 'tailwindcss', - alias: 'tailwind', - name: 'Tailwind CSS', - description: 'Rapidly build modern websites without ever leaving your HTML', - environments: { svelte: true, kit: true }, - website: { - logo: './tailwindcss.svg', - keywords: ['tailwind', 'postcss', 'autoprefixer'], - documentation: 'https://tailwindcss.com/docs' - } - }, - options, - packages: [ - { name: 'tailwindcss', version: '^3.4.9', dev: true }, - { name: 'autoprefixer', version: '^10.4.20', dev: true }, - { - name: '@tailwindcss/typography', - version: '^0.5.14', - dev: true, - condition: ({ options }) => options.plugins.includes('typography') - }, - { - name: 'prettier-plugin-tailwindcss', - version: '^0.6.5', - dev: true, - condition: ({ dependencyVersion }) => Boolean(dependencyVersion('prettier')) - } - ], - 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.typeAnnotateExpression(rootExport, 'Config'); - } - - const { astNode: exportDeclaration } = exports.defaultExport(ast, root ?? rootExport); - - if (!typescript) - common.addJsDocTypeComment(exportDeclaration, "import('tailwindcss').Config"); - - const contentArray = object.property(rootExport, 'content', array.createEmpty()); - array.push(contentArray, './src/**/*.{html,js,svelte,ts}'); - - const themeObject = object.property(rootExport, 'theme', object.createEmpty()); - object.property(themeObject, 'extend', object.createEmpty()); - - const pluginsArray = object.property(rootExport, 'plugins', array.createEmpty()); - - if (options.plugins.includes('typography')) { - const requireCall = functions.call('require', ['@tailwindcss/typography']); - array.push(pluginsArray, requireCall); - } - - return generateCode(); - } - }, - { - 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(); - } - }, - { - name: () => 'src/app.css', - content: ({ content }) => { - const { ast, generateCode } = parseCss(content); - const layerImports = ['base', 'components', 'utilities'].map( - (layer) => `"tailwindcss/${layer}"` - ); - const originalFirst = ast.first; - - const nodes = addImports(ast, layerImports); - - if ( - originalFirst !== ast.first && - originalFirst?.type === 'atrule' && - originalFirst.name === 'import' - ) { - originalFirst.raws.before = '\n'; - } - - // 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(); - } - }, - { - name: () => 'src/App.svelte', - content: ({ content, typescript }) => { - 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 }) => { - const { script, generateCode } = parseSvelte(content, { typescript }); - imports.addEmpty(script.ast, '../app.css'); - return generateCode({ - script: script.generateCode(), - template: content.length === 0 ? '' : undefined - }); - }, - condition: ({ kit }) => Boolean(kit) - }, - { - name: () => '.prettierrc', - content: ({ content }) => { - const { data, generateCode } = parseJson(content); - const PLUGIN_NAME = 'prettier-plugin-tailwindcss'; - - data.plugins ??= []; - const plugins: string[] = data.plugins; - - if (!plugins.includes(PLUGIN_NAME)) plugins.push(PLUGIN_NAME); - - return generateCode(); - }, - condition: ({ dependencyVersion }) => Boolean(dependencyVersion('prettier')) - } - ] -}); diff --git a/packages/adders/tailwindcss/config/options.ts b/packages/adders/tailwindcss/config/options.ts deleted file mode 100644 index 9ef1b98a..00000000 --- a/packages/adders/tailwindcss/config/options.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineAdderOptions } from '@svelte-cli/core'; - -export const options = defineAdderOptions({ - plugins: { - type: 'multiselect', - question: 'Which plugins would you like to add?', - options: [{ value: 'typography', label: 'Typography' }], - default: [] - } -}); diff --git a/packages/adders/tailwindcss/index.ts b/packages/adders/tailwindcss/index.ts index 70c45e9f..4ff99d16 100644 --- a/packages/adders/tailwindcss/index.ts +++ b/packages/adders/tailwindcss/index.ts @@ -1,3 +1,150 @@ -import { adder } from './config/adder.ts'; +import { defineAdder, defineAdderOptions } from '@svelte-cli/core'; +import { addImports } from '@svelte-cli/core/css'; +import { array, common, exports, functions, imports, object } from '@svelte-cli/core/js'; +import { parseCss, parseScript, parseJson, parseSvelte } from '@svelte-cli/core/parsers'; -export default adder; +export const options = defineAdderOptions({ + plugins: { + type: 'multiselect', + question: 'Which plugins would you like to add?', + options: [{ value: 'typography', label: 'Typography' }], + default: [] + } +}); + +export default defineAdder({ + id: 'tailwindcss', + alias: 'tailwind', + name: 'Tailwind CSS', + description: 'Rapidly build modern websites without ever leaving your HTML', + environments: { svelte: true, kit: true }, + documentation: 'https://tailwindcss.com/docs', + options, + packages: [ + { name: 'tailwindcss', version: '^3.4.9', dev: true }, + { name: 'autoprefixer', version: '^10.4.20', dev: true }, + { + name: '@tailwindcss/typography', + version: '^0.5.14', + dev: true, + condition: ({ options }) => options.plugins.includes('typography') + }, + { + name: 'prettier-plugin-tailwindcss', + version: '^0.6.5', + dev: true, + condition: ({ dependencyVersion }) => Boolean(dependencyVersion('prettier')) + } + ], + 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.typeAnnotateExpression(rootExport, 'Config'); + } + + const { astNode: exportDeclaration } = exports.defaultExport(ast, root ?? rootExport); + + if (!typescript) + common.addJsDocTypeComment(exportDeclaration, "import('tailwindcss').Config"); + + const contentArray = object.property(rootExport, 'content', array.createEmpty()); + array.push(contentArray, './src/**/*.{html,js,svelte,ts}'); + + const themeObject = object.property(rootExport, 'theme', object.createEmpty()); + object.property(themeObject, 'extend', object.createEmpty()); + + const pluginsArray = object.property(rootExport, 'plugins', array.createEmpty()); + + if (options.plugins.includes('typography')) { + const requireCall = functions.call('require', ['@tailwindcss/typography']); + array.push(pluginsArray, requireCall); + } + + return generateCode(); + } + }, + { + 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(); + } + }, + { + name: () => 'src/app.css', + content: ({ content }) => { + const { ast, generateCode } = parseCss(content); + const layerImports = ['base', 'components', 'utilities'].map( + (layer) => `"tailwindcss/${layer}"` + ); + const originalFirst = ast.first; + + const nodes = addImports(ast, layerImports); + + if ( + originalFirst !== ast.first && + originalFirst?.type === 'atrule' && + originalFirst.name === 'import' + ) { + originalFirst.raws.before = '\n'; + } + + // 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(); + } + }, + { + name: () => 'src/App.svelte', + content: ({ content, typescript }) => { + 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 }) => { + const { script, generateCode } = parseSvelte(content, { typescript }); + imports.addEmpty(script.ast, '../app.css'); + return generateCode({ + script: script.generateCode(), + template: content.length === 0 ? '' : undefined + }); + }, + condition: ({ kit }) => Boolean(kit) + }, + { + name: () => '.prettierrc', + content: ({ content }) => { + const { data, generateCode } = parseJson(content); + const PLUGIN_NAME = 'prettier-plugin-tailwindcss'; + + data.plugins ??= []; + const plugins: string[] = data.plugins; + + if (!plugins.includes(PLUGIN_NAME)) plugins.push(PLUGIN_NAME); + + return generateCode(); + }, + condition: ({ dependencyVersion }) => Boolean(dependencyVersion('prettier')) + } + ] +}); diff --git a/packages/adders/tailwindcss/tailwindcss.svg b/packages/adders/tailwindcss/logo.svg similarity index 100% rename from packages/adders/tailwindcss/tailwindcss.svg rename to packages/adders/tailwindcss/logo.svg diff --git a/packages/adders/tailwindcss/tests/tests.ts b/packages/adders/tailwindcss/tests.ts similarity index 97% rename from packages/adders/tailwindcss/tests/tests.ts rename to packages/adders/tailwindcss/tests.ts index 28af6ea8..8fb6438f 100644 --- a/packages/adders/tailwindcss/tests/tests.ts +++ b/packages/adders/tailwindcss/tests.ts @@ -1,5 +1,5 @@ import { defineAdderTests } from '@svelte-cli/core'; -import { options } from '../config/options.ts'; +import { options } from './index.ts'; const divId = 'myDiv'; const typographyDivId = 'myTypographyDiv'; diff --git a/packages/adders/vitest/config/adder.ts b/packages/adders/vitest/config/adder.ts deleted file mode 100644 index 9078e255..00000000 --- a/packages/adders/vitest/config/adder.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { options } from './options.ts'; -import { dedent, defineAdder, log } from '@svelte-cli/core'; -import { common, exports, imports, object } from '@svelte-cli/core/js'; -import { parseJson, parseScript } from '@svelte-cli/core/parsers'; - -export const adder = defineAdder({ - metadata: { - id: 'vitest', - name: 'Vitest', - description: 'A testing framework powered by Vite', - environments: { svelte: true, kit: true }, - website: { - logo: './vitest.svg', - keywords: ['test', 'testing', 'unit', 'unit-testing'], - documentation: '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; - - 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' - ) - ); - - // 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 config = common.expressionFromString('defineConfig({})'); - const defaultExport = exports.defaultExport(ast, config); - - const test = object.create({ - include: common.expressionFromString("['src/**/*.{test,spec}.{js,ts}']") - }); - - // 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'; - imports.addNamed(ast, 'vitest/config', { defineConfig: defineConfigAlias }); - - 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/options.ts b/packages/adders/vitest/config/options.ts deleted file mode 100644 index 279c2c09..00000000 --- a/packages/adders/vitest/config/options.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { defineAdderOptions } from '@svelte-cli/core'; - -export const options = defineAdderOptions({}); diff --git a/packages/adders/vitest/index.ts b/packages/adders/vitest/index.ts index 70c45e9f..7e6be684 100644 --- a/packages/adders/vitest/index.ts +++ b/packages/adders/vitest/index.ts @@ -1,3 +1,107 @@ -import { adder } from './config/adder.ts'; +import { dedent, defineAdder, log } from '@svelte-cli/core'; +import { common, exports, imports, object } from '@svelte-cli/core/js'; +import { parseJson, parseScript } from '@svelte-cli/core/parsers'; -export default adder; +export default defineAdder({ + id: 'vitest', + name: 'Vitest', + description: 'A testing framework powered by Vite', + environments: { svelte: true, kit: true }, + documentation: '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; + + 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' + ) + ); + + // 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 config = common.expressionFromString('defineConfig({})'); + const defaultExport = exports.defaultExport(ast, config); + + const test = object.create({ + include: common.expressionFromString("['src/**/*.{test,spec}.{js,ts}']") + }); + + // 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'; + imports.addNamed(ast, 'vitest/config', { defineConfig: defineConfigAlias }); + + 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/vitest.svg b/packages/adders/vitest/logo.svg similarity index 100% rename from packages/adders/vitest/vitest.svg rename to packages/adders/vitest/logo.svg diff --git a/packages/adders/vitest/tests.ts b/packages/adders/vitest/tests.ts new file mode 100644 index 00000000..b37ea6ce --- /dev/null +++ b/packages/adders/vitest/tests.ts @@ -0,0 +1,8 @@ +import { defineAdderTests } from '@svelte-cli/core'; + +export const tests = defineAdderTests({ + files: [], + options: {}, + optionValues: [], + tests: [] +}); diff --git a/packages/adders/vitest/tests/tests.ts b/packages/adders/vitest/tests/tests.ts deleted file mode 100644 index e5f84777..00000000 --- a/packages/adders/vitest/tests/tests.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineAdderTests } from '@svelte-cli/core'; -import { options } from '../config/options.ts'; - -export const tests = defineAdderTests({ - files: [], - options, - optionValues: [], - tests: [] -}); diff --git a/packages/cli/commands/add/index.ts b/packages/cli/commands/add/index.ts index 3db66e1f..3a21e86d 100644 --- a/packages/cli/commands/add/index.ts +++ b/packages/cli/commands/add/index.ts @@ -39,7 +39,7 @@ const OptionsSchema = v.strictObject({ type Options = v.InferOutput; const adderDetails = adderIds.map((id) => getAdderDetails(id)); -const aliases = adderDetails.map((c) => c.metadata.alias).filter((v) => v !== undefined); +const aliases = adderDetails.map((c) => c.alias).filter((v) => v !== undefined); const addersOptions = getAdderOptionFlags(); const communityDetails: AdderWithoutExplicitArgs[] = []; @@ -266,7 +266,7 @@ export async function runAddCommand(options: Options, adders: string[]): Promise start('Downloading community adder packages'); const details = await Promise.all(pkgs.map(async (opts) => downloadPackage(opts))); for (const adder of details) { - const id = adder.metadata.id; + const id = adder.id; community[id] ??= {}; communityDetails.push(adder); selectedAdders.push({ type: 'community', adder }); @@ -289,13 +289,13 @@ export async function runAddCommand(options: Options, adders: string[]): Promise .map((id) => { const config = getAdderDetails(id); // we'll only display adders within their respective project types - if (projectType === 'kit' && !config.metadata.environments.kit) return; - if (projectType === 'svelte' && !config.metadata.environments.svelte) return; + if (projectType === 'kit' && !config.environments.kit) return; + if (projectType === 'svelte' && !config.environments.svelte) return; return { - label: config.metadata.name, - value: config.metadata.id, - hint: config.metadata.website?.documentation + label: config.name, + value: config.id, + hint: config.documentation }; }) .filter((c) => !!c); @@ -322,14 +322,13 @@ export async function runAddCommand(options: Options, adders: string[]): Promise // add inter-adder dependencies for (const { adder } of selectedAdders) { - const name = adder.metadata.name; + const name = adder.name; const dependents = - adder.dependsOn?.filter((dep) => !selectedAdders.some((a) => a.adder.metadata.id === dep)) ?? - []; + adder.dependsOn?.filter((dep) => !selectedAdders.some((a) => a.adder.id === dep)) ?? []; const workspace = createWorkspace(options.cwd); for (const depId of dependents) { - const dependent = adderDetails.find((a) => a.metadata.id === depId); + const dependent = adderDetails.find((a) => a.id === depId); if (!dependent) throw new Error(`Adder '${name}' depends on an invalid '${depId}'`); // check if the dependent adder has already been installed @@ -387,8 +386,8 @@ export async function runAddCommand(options: Options, adders: string[]): Promise // ask remaining questions for (const { adder, type } of selectedAdders) { - const adderId = adder.metadata.id; - const questionPrefix = selectedAdders.length > 1 ? `${adder.metadata.name}: ` : ''; + const adderId = adder.id; + const questionPrefix = selectedAdders.length > 1 ? `${adder.name}: ` : ''; let values: QuestionValues = {}; if (type === 'official') { @@ -476,15 +475,14 @@ export async function runAddCommand(options: Options, adders: string[]): Promise const nextStepsMsg = selectedAdders .filter(({ adder }) => adder.nextSteps) .map(({ adder }) => { - const metadata = adder.metadata; let adderMessage = ''; if (selectedAdders.length > 1) { - adderMessage = `${pc.green(metadata.name)}:\n`; + adderMessage = `${pc.green(adder.name)}:\n`; } const adderNextSteps = adder.nextSteps!({ ...workspace, - options: official[metadata.id]!, + options: official[adder.id]!, highlighter }); adderMessage += `- ${adderNextSteps.join('\n- ')}`; @@ -516,7 +514,7 @@ export async function installAdders({ }: InstallAdderOptions): Promise { const adderDetails = Object.keys(official).map((id) => getAdderDetails(id)); const commDetails = Object.keys(community).map( - (id) => communityDetails.find((a) => a.metadata.id === id)! + (id) => communityDetails.find((a) => a.id === id)! ); const details = adderDetails.concat(commDetails); @@ -528,13 +526,13 @@ export async function installAdders({ if (!a.dependsOn) return -1; if (!b.dependsOn) return 1; - return a.dependsOn.includes(b.metadata.id) ? 1 : b.dependsOn.includes(a.metadata.id) ? -1 : 0; + 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.metadata.id; + const adderId = config.id; const workspace = createWorkspace(cwd); workspace.options = official[adderId] ?? community[adderId]!; @@ -546,7 +544,7 @@ export async function installAdders({ changedFiles.forEach((file) => filesToFormat.add(file)); if (config.scripts && config.scripts.length > 0) { - const name = config.metadata.name; + const name = config.name; p.log.step(`Running external command ${pc.gray(`(${name})`)}`); for (const script of config.scripts) { @@ -580,8 +578,8 @@ function transformAliases(ids: string[]): string[] { const set = new Set(); for (const id of ids) { if (aliases.includes(id)) { - const adder = adderDetails.find((a) => a.metadata.alias === id)!; - set.add(adder.metadata.id); + const adder = adderDetails.find((a) => a.alias === id)!; + set.add(adder.id); } else { set.add(id); } diff --git a/packages/cli/commands/add/workspace.ts b/packages/cli/commands/add/workspace.ts index 25c42ead..4d92707c 100644 --- a/packages/cli/commands/add/workspace.ts +++ b/packages/cli/commands/add/workspace.ts @@ -20,7 +20,7 @@ export function createEmptyWorkspace() { export function createWorkspace(cwd: string): Workspace { const workspace = createEmptyWorkspace(); - workspace.cwd = cwd; + workspace.cwd = path.resolve(cwd); let usesTypescript = fs.existsSync(path.join(cwd, commonFilePaths.viteConfigTS)); @@ -36,8 +36,14 @@ export function createWorkspace(cwd: string): Wor let directory = workspace.cwd; const root = findRoot(workspace.cwd); while (directory && directory !== root) { - const { data: packageJson } = getPackageJson(workspace.cwd); - dependencies = { ...packageJson.devDependencies, ...packageJson.dependencies, ...dependencies }; + if (fs.existsSync(path.join(directory, commonFilePaths.packageJson))) { + const { data: packageJson } = getPackageJson(directory); + dependencies = { + ...packageJson.devDependencies, + ...packageJson.dependencies, + ...dependencies + }; + } directory = path.dirname(directory); } // removes the version ranges (e.g. `^` is removed from: `^9.0.0`) @@ -58,12 +64,14 @@ function findRoot(cwd: string): string { const { root } = path.parse(cwd); let directory = cwd; while (directory && directory !== root) { - if (fs.existsSync(path.join(directory, 'pnpm-workspace.yaml'))) { - return directory; - } - const { data } = getPackageJson(directory); - if (data.workspaces) { - return directory; + if (fs.existsSync(path.join(directory, commonFilePaths.packageJson))) { + if (fs.existsSync(path.join(directory, 'pnpm-workspace.yaml'))) { + return directory; + } + const { data } = getPackageJson(directory); + if (data.workspaces) { + return directory; + } } directory = path.dirname(directory); } diff --git a/packages/cli/common.ts b/packages/cli/common.ts index dc67b1e7..734e4c2e 100644 --- a/packages/cli/common.ts +++ b/packages/cli/common.ts @@ -157,7 +157,7 @@ export function getGlobalPreconditions( name: 'supported environments', run: () => { const addersForInvalidEnvironment = adders.filter((a) => { - const supportedEnvironments = a.metadata.environments; + const supportedEnvironments = a.environments; if (projectType === 'kit' && !supportedEnvironments.kit) return true; if (projectType === 'svelte' && !supportedEnvironments.svelte) return true; @@ -169,7 +169,7 @@ export function getGlobalPreconditions( } const messages = addersForInvalidEnvironment.map( - (a) => `"${a.metadata.name}" does not support "${projectType}"` + (a) => `"${a.name}" does not support "${projectType}"` ); return { success: false, message: messages.join(' / ') }; } diff --git a/packages/core/adder/config.ts b/packages/core/adder/config.ts index c3ce8cd5..24687fa6 100644 --- a/packages/core/adder/config.ts +++ b/packages/core/adder/config.ts @@ -6,26 +6,11 @@ export type ConditionDefinition = ( Workspace: Workspace ) => boolean; -export type WebsiteMetadata = { - logo: string; - keywords: string[]; - documentation: string; -}; - -export type AdderConfigEnvironments = { +export type Environments = { svelte: boolean; kit: boolean; }; -export type AdderConfigMetadata = { - id: string; - alias?: string; - name: string; - description: string; - environments: AdderConfigEnvironments; - website?: WebsiteMetadata; -}; - export type PackageDefinition = { name: string; version: string; @@ -41,7 +26,12 @@ export type Scripts = { }; export type Adder = { - metadata: AdderConfigMetadata; + id: string; + alias?: string; + name: string; + description: string; + environments: Environments; + documentation?: string; options: Args; dependsOn?: string[]; packages: Array>;