@openload28/create-tt 1.0.0-dev.20260930.435 → 1.0.0-dev.20261014.555

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +13 -1
  2. package/package.json +1 -1
  3. package/src/installer.js +153 -34
package/README.md CHANGED
@@ -24,13 +24,25 @@ the existing TypeScript config and declares the content mapper. A config with
24
24
  project `references` (such as Vite's solution-style `tsconfig.json`) gets a
25
25
  `*.tt.json` counterpart for every referenced config inside the project, the
26
26
  generated configs reference each other the way the originals do, and the
27
- generated scripts check them with `tsc -b`. The initializer sets `typescript` to
27
+ generated scripts check them with `tsc -b`. TypeScript does not let a
28
+ referenced project disable emit (TS6310), so a generated config that another
29
+ compiled config references emits declarations only, into
30
+ `node_modules/.cache/tt/`, instead of setting `noEmit`; every other generated
31
+ config sets `noEmit`. Without a bundler, the generated `tt:build` runs
32
+ `ttc -o .tt-build` over the source roots the configurations include (the
33
+ directory part of each `include` pattern before its first wildcard, and each
34
+ `files` entry); several roots each keep their own path under `.tt-build`, so
35
+ relative imports between them still resolve. The initializer sets `typescript` to
28
36
  the TypeScript 7.1 build tt is verified with; when the project named another
29
37
  version, it replaces it and prints the change. Existing scripts and config files
30
38
  stay intact. Re-running init accepts unchanged generated configs;
31
39
  if a generated config has been customized, init stops before writing any project
32
40
  files.
33
41
  Use `--no-install` in CI or when dependencies will be installed later.
42
+ The package name is the directory name made valid for a new npm package
43
+ (lowercase, no leading `.` or `_`, at most 214 characters, and `my-tt-app` in
44
+ place of a reserved or core-module name). After `--`, every argument is the
45
+ directory, even one that starts with `-`.
34
46
  New projects use Bun for dependency installation and scripts. Existing projects
35
47
  keep the package manager declared in `package.json` or selected by their lockfile.
36
48
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openload28/create-tt",
3
- "version": "1.0.0-dev.20260930.435",
3
+ "version": "1.0.0-dev.20261014.555",
4
4
  "description": "Create a new tt project or add tt to an existing TypeScript project",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
package/src/installer.js CHANGED
@@ -1,7 +1,8 @@
1
- import { existsSync } from 'node:fs'
1
+ import { existsSync, statSync } from 'node:fs'
2
2
  import { lstat, mkdir, readFile, readdir, readlink, realpath, writeFile } from 'node:fs/promises'
3
3
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
4
4
  import { spawnSync } from 'node:child_process'
5
+ import { builtinModules } from 'node:module'
5
6
 
6
7
  const ownManifest = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'))
7
8
  const ttChannel = dependencyChannel(ownManifest.version)
@@ -158,9 +159,9 @@ export async function initializeExisting(options) {
158
159
  const typeConfig = 'tsconfig.tt.json'
159
160
  const generated = []
160
161
  const realRoot = await realpath(root)
161
- const references = existsSync(join(root, 'tsconfig.json'))
162
+ const { references, roots } = existsSync(join(root, 'tsconfig.json'))
162
163
  ? await typeConfigGraph(realRoot, await projectConfig(realRoot, join(root, 'tsconfig.json')), generated)
163
- : (generated.push([typeConfig, `${JSON.stringify(tsconfig(), null, ' ')}\n`]), false)
164
+ : defaultTypeConfig(realRoot, typeConfig, generated)
164
165
  const typeCheck = `tsc ${references ? '-b' : '-p'} ${typeConfig} --runExternalCode`
165
166
  manifest.scripts['tt:check'] ??= typeCheck
166
167
 
@@ -180,7 +181,7 @@ export async function initializeExisting(options) {
180
181
  manifest.scripts['tt:build'] ??= `${typeCheck} && ${adapter.commands.build} --config ${adapter.wrapper}`
181
182
  }
182
183
  } else {
183
- manifest.scripts['tt:build'] ??= 'ttc -o .tt-build src'
184
+ manifest.scripts['tt:build'] ??= buildScript(roots)
184
185
  }
185
186
 
186
187
  // Validate the complete output set before changing any project files.
@@ -199,36 +200,142 @@ export async function initializeExisting(options) {
199
200
  return { root, packageManager, mode: 'init', bundler: bundler ?? 'none', files, manualModule, updated }
200
201
  }
201
202
 
202
- async function typeConfigGraph(root, configPath, generated, visited = new Set()) {
203
- visited.add(configPath)
203
+ async function typeConfigGraph(root, configPath, generated) {
204
+ const projects = new Map()
205
+ await readConfigGraph(root, configPath, projects)
206
+ const emitting = new Set(
207
+ [...projects.values()]
208
+ .filter((project) => !solutionStyle(project.config))
209
+ .flatMap((project) => project.references.map((reference) => reference.target).filter(Boolean)),
210
+ )
211
+ for (const [path, { config, references }] of projects) {
212
+ const directory = dirname(path)
213
+ const counterpart = typeConfigPath(path)
214
+ const content = tsconfig(
215
+ `./${basename(path)}`,
216
+ emitting.has(path) ? declarationOutput(root, counterpart, config) : undefined,
217
+ )
218
+ if (Array.isArray(config.references)) {
219
+ content.references = references.map(({ reference, target }) =>
220
+ target ? { ...reference, path: relativePath(directory, typeConfigPath(target)) } : reference)
221
+ }
222
+ generated.push([relative(root, counterpart), `${JSON.stringify(content, null, ' ')}\n`])
223
+ }
224
+ const inputs = []
225
+ for (const [path, { config }] of projects) {
226
+ if (!solutionStyle(config)) inputs.push(...await configInputs(path, config))
227
+ }
228
+ return {
229
+ references: Array.isArray(projects.get(configPath).config.references),
230
+ roots: sourceRoots(root, inputs),
231
+ }
232
+ }
233
+
234
+ function defaultTypeConfig(root, typeConfig, generated) {
235
+ const config = tsconfig()
236
+ generated.push([typeConfig, `${JSON.stringify(config, null, ' ')}\n`])
237
+ return {
238
+ references: false,
239
+ roots: sourceRoots(root, config.include.map((entry) => inputBase(root, entry))),
240
+ }
241
+ }
242
+
243
+ async function configInputs(path, config) {
244
+ const { files, include } = await declaredInputs(path, config, new Set())
245
+ if (!files && !include) return [dirname(path)]
246
+ return [...(files ?? []), ...(include ?? [])]
247
+ }
248
+
249
+ async function declaredInputs(path, config, seen) {
250
+ seen.add(path)
251
+ const directory = dirname(path)
252
+ const own = (key) => Array.isArray(config[key])
253
+ ? config[key].filter((entry) => typeof entry === 'string').map((entry) => inputBase(directory, entry))
254
+ : undefined
255
+ let files = own('files')
256
+ let include = own('include')
257
+ const bases = (Array.isArray(config.extends) ? config.extends : [config.extends])
258
+ .filter((base) => typeof base === 'string' && (base.startsWith('./') || base.startsWith('../') || isAbsolute(base)))
259
+ .map((base) => resolve(directory, base))
260
+ .map((base) => (existsSync(base) || base.endsWith('.json') ? base : `${base}.json`))
261
+ .reverse()
262
+ for (const base of bases) {
263
+ if ((files && include) || seen.has(base) || !existsSync(base)) continue
264
+ let inherited
265
+ try {
266
+ inherited = parseJsonc(await readFile(base, 'utf8'))
267
+ } catch (error) {
268
+ throw new Error(`cannot read ${base}: ${error.message}`)
269
+ }
270
+ const from = await declaredInputs(base, inherited, seen)
271
+ files ??= from.files
272
+ include ??= from.include
273
+ }
274
+ return { files, include }
275
+ }
276
+
277
+ function inputBase(directory, entry) {
278
+ const segments = entry.split(/[\\/]/)
279
+ const wildcard = segments.findIndex((segment) => /[*?]/.test(segment))
280
+ return resolve(directory, ...(wildcard === -1 ? segments : segments.slice(0, wildcard)))
281
+ }
282
+
283
+ function sourceRoots(root, inputs) {
284
+ const unique = [...new Set(inputs)].filter((input) => input === root || insideRoot(root, input))
285
+ const existing = unique.filter((input) => existsSync(input))
286
+ const chosen = existing.length > 0 ? existing : unique
287
+ const posix = (path) => relative(root, path).split('\\').join('/') || '.'
288
+ return chosen
289
+ .filter((input) => !chosen.some((other) => other !== input && (other === root || insideRoot(other, input))))
290
+ .map((input) => ({
291
+ input: posix(input),
292
+ mirror: posix(existsSync(input) && !statSync(input).isDirectory() ? dirname(input) : input),
293
+ }))
294
+ .sort((left, right) => (left.input < right.input ? -1 : left.input > right.input ? 1 : 0))
295
+ }
296
+
297
+ function buildScript(roots) {
298
+ if (roots.length === 1) return `ttc -o .tt-build ${shellQuote(roots[0].input)}`
299
+ return roots
300
+ .map(({ input, mirror }) => `ttc -o ${shellQuote(mirror === '.' ? '.tt-build' : `.tt-build/${mirror}`)} ${shellQuote(input)}`)
301
+ .join(' && ')
302
+ }
303
+
304
+ async function readConfigGraph(root, configPath, projects) {
204
305
  let config
205
306
  try {
206
307
  config = parseJsonc(await readFile(configPath, 'utf8'))
207
308
  } catch (error) {
208
309
  throw new Error(`cannot read ${configPath}: ${error.message}`)
209
310
  }
311
+ const project = { config, references: [] }
312
+ projects.set(configPath, project)
210
313
  const directory = dirname(configPath)
211
- const counterpart = configPath.replace(/\.json$/, '') + '.tt.json'
212
- const slot = generated.push(undefined) - 1
213
- const content = tsconfig(`./${basename(configPath)}`)
214
- const hasReferences = Array.isArray(config.references)
215
- if (hasReferences) {
216
- const references = []
217
- for (const reference of config.references) {
218
- const lexical = typeof reference?.path === 'string' && referencedConfig(directory, reference.path)
219
- const target = lexical && existsSync(lexical) && await realpath(lexical)
220
- if (!target || !insideRoot(root, target)) {
221
- references.push(reference)
222
- continue
223
- }
224
- if (!visited.has(target)) await typeConfigGraph(root, target, generated, visited)
225
- const referenced = target.replace(/\.json$/, '') + '.tt.json'
226
- references.push({ ...reference, path: relativePath(directory, referenced) })
227
- }
228
- content.references = references
314
+ for (const reference of Array.isArray(config.references) ? config.references : []) {
315
+ const lexical = typeof reference?.path === 'string' && referencedConfig(directory, reference.path)
316
+ const resolved = lexical && existsSync(lexical) && await realpath(lexical)
317
+ const target = resolved && insideRoot(root, resolved) ? resolved : undefined
318
+ project.references.push({ reference, target })
319
+ if (target && !projects.has(target)) await readConfigGraph(root, target, projects)
320
+ }
321
+ }
322
+
323
+ function typeConfigPath(configPath) {
324
+ return configPath.replace(/\.json$/, '') + '.tt.json'
325
+ }
326
+
327
+ function solutionStyle(config) {
328
+ return Array.isArray(config.files) && config.files.length === 0 && config.include === undefined
329
+ }
330
+
331
+ function declarationOutput(root, counterpart, config) {
332
+ const directory = dirname(counterpart)
333
+ const cache = relativePath(directory, join(root, 'node_modules', '.cache', 'tt', relative(root, directory)))
334
+ const options = { noEmit: false, emitDeclarationOnly: true, outDir: cache, declarationDir: cache }
335
+ if (config.compilerOptions?.tsBuildInfoFile !== undefined) {
336
+ options.tsBuildInfoFile = `${cache}/${basename(counterpart, '.json')}.tsbuildinfo`
229
337
  }
230
- generated[slot] = [relative(root, counterpart), `${JSON.stringify(content, null, ' ')}\n`]
231
- return hasReferences
338
+ return options
232
339
  }
233
340
 
234
341
  async function projectConfig(root, path) {
@@ -316,14 +423,18 @@ function parseArguments(argv) {
316
423
  install: true,
317
424
  bundler: 'auto',
318
425
  }
426
+ let positional = false
319
427
  while (args.length) {
320
428
  const arg = args.shift()
321
- if (arg === '--help' || arg === '-h') options.help = true
429
+ if (positional || !arg.startsWith('-')) {
430
+ if (options.directory) throw new Error(`unknown argument: ${arg}`)
431
+ options.directory = arg
432
+ } else if (arg === '--') positional = true
433
+ else if (arg === '--help' || arg === '-h') options.help = true
322
434
  else if (arg === '--no-install') options.install = false
323
435
  else if (arg === '--bundler') options.bundler = requiredValue(arg, args)
324
436
  else if (arg === '--package-manager') options.packageManager = requiredValue(arg, args)
325
437
  else if (arg === '--registry') options.registry = registryUrl(requiredValue(arg, args))
326
- else if (!arg.startsWith('-') && !options.directory) options.directory = arg
327
438
  else throw new Error(`unknown argument: ${arg}`)
328
439
  }
329
440
  if (!['auto', 'none', ...Object.keys(bundlers)].includes(options.bundler)) {
@@ -410,10 +521,10 @@ export default typeof base === 'function'
410
521
  `
411
522
  }
412
523
 
413
- function tsconfig(extendsConfig) {
524
+ function tsconfig(extendsConfig, compilerOptions = { noEmit: true }) {
414
525
  const config = {
415
526
  ...(extendsConfig
416
- ? { extends: extendsConfig, compilerOptions: { noEmit: true } }
527
+ ? { extends: extendsConfig, compilerOptions }
417
528
  : {
418
529
  compilerOptions: {
419
530
  target: 'ES2022',
@@ -432,9 +543,16 @@ function tsconfig(extendsConfig) {
432
543
  return config
433
544
  }
434
545
 
435
- function packageName(value) {
436
- const normalized = value.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '')
437
- return normalized || 'my-tt-app'
546
+ const RESERVED_NAMES = new Set(['node_modules', 'favicon.ico', ...builtinModules])
547
+
548
+ export function packageName(value) {
549
+ const normalized = value
550
+ .toLowerCase()
551
+ .replace(/[^a-z0-9._-]+/g, '-')
552
+ .replace(/^[._-]+/, '')
553
+ .slice(0, 214)
554
+ .replace(/-+$/, '')
555
+ return normalized === '' || RESERVED_NAMES.has(normalized) ? 'my-tt-app' : normalized
438
556
  }
439
557
 
440
558
  function indentation(source) {
@@ -487,4 +605,5 @@ Options:
487
605
  --package-manager <npm|pnpm|yarn|bun>
488
606
  --registry <url> install from an npm-compatible private/local registry
489
607
  --no-install
490
- -h, --help`
608
+ -h, --help
609
+ -- every argument after it is the directory, even one starting with -`