From 6b29bf8542693995debc5e141adfd98e9399375e Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Thu, 6 Aug 2026 16:22:08 +0800 Subject: [PATCH 01/28] chore(deps): upgrade Rslib to 1.0.0-beta.2 (#220) --- .../migrate-to-rstack-cli/references/rslib.md | 2 +- examples/lib-node/rstack.config.ts | 3 ++- examples/lib-react/rstack.config.ts | 3 ++- packages/rstack/rslib.config.ts | 15 ++++++++++- packages/rstack/src/fmt/workerPool.ts | 2 +- .../config/define-app-lib/rstack.config.ts | 1 - .../tests/config/define-lib/rstack.config.ts | 1 - .../define-test-projects-lib/rstack.config.ts | 1 - pnpm-lock.yaml | 26 +++++++++---------- pnpm-workspace.yaml | 2 +- website/docs/en/guide/cli/lib.mdx | 8 ++---- website/docs/en/guide/configuration.mdx | 8 ++---- website/docs/en/guide/monorepo.mdx | 8 ++---- website/docs/zh/guide/cli/lib.mdx | 8 ++---- website/docs/zh/guide/configuration.mdx | 8 ++---- website/docs/zh/guide/monorepo.mdx | 8 ++---- 16 files changed, 46 insertions(+), 58 deletions(-) diff --git a/.agents/skills/migrate-to-rstack-cli/references/rslib.md b/.agents/skills/migrate-to-rstack-cli/references/rslib.md index 88481137..10f3db28 100644 --- a/.agents/skills/migrate-to-rstack-cli/references/rslib.md +++ b/.agents/skills/migrate-to-rstack-cli/references/rslib.md @@ -18,7 +18,7 @@ Read this reference when the project uses `@rslib/core`, `rslib.config.*`, `rsli import { define } from 'rstack'; define.lib({ - lib: [{ dts: true }], + dts: true, }); ``` diff --git a/examples/lib-node/rstack.config.ts b/examples/lib-node/rstack.config.ts index 7febc0b6..ccafc8da 100644 --- a/examples/lib-node/rstack.config.ts +++ b/examples/lib-node/rstack.config.ts @@ -2,7 +2,8 @@ import { define } from 'rstack'; define.lib({ - lib: [{ syntax: ['node 22'], dts: true }], + dts: true, + syntax: ['node 22'], }); define.lint(async () => { diff --git a/examples/lib-react/rstack.config.ts b/examples/lib-react/rstack.config.ts index 1b26f747..31913350 100644 --- a/examples/lib-react/rstack.config.ts +++ b/examples/lib-react/rstack.config.ts @@ -4,12 +4,13 @@ import { define } from 'rstack'; define.lib(async () => { const { pluginReact } = await import('@rsbuild/plugin-react'); return { + bundle: false, + dts: true, source: { entry: { index: ['./src/**'], }, }, - lib: [{ bundle: false, dts: true }], output: { target: 'web', }, diff --git a/packages/rstack/rslib.config.ts b/packages/rstack/rslib.config.ts index fdc4b9b5..1449f5fe 100644 --- a/packages/rstack/rslib.config.ts +++ b/packages/rstack/rslib.config.ts @@ -4,7 +4,8 @@ import pkgJson from './package.json' with { type: 'json' }; const fullyMinifiedChunks = /(?:fmt(?:Plugins)?|sortPackageJsonPlugin|staged)\.js$/; export default defineConfig({ - lib: [{ syntax: 'es2023', dts: true }], + dts: true, + syntax: 'es2023', source: { entry: { index: './src/index.ts', @@ -50,4 +51,16 @@ export default defineConfig({ ], }, }, + tools: { + rspack: { + module: { + parser: { + javascript: { + // @rstest/adapter-rslib resolves extended tsconfig paths from a runtime base. + createRequire: false, + }, + }, + }, + }, + }, }); diff --git a/packages/rstack/src/fmt/workerPool.ts b/packages/rstack/src/fmt/workerPool.ts index 1531a864..155f4e66 100644 --- a/packages/rstack/src/fmt/workerPool.ts +++ b/packages/rstack/src/fmt/workerPool.ts @@ -28,7 +28,7 @@ const getFmtWorkerUrl = (): URL => { const workerPath = new URL(import.meta.url).pathname.endsWith('.ts') ? '../../dist/fmtWorker.js' : './fmtWorker.js'; - return new URL(workerPath, import.meta.url); + return new URL(/* rspackIgnore: true */ workerPath, import.meta.url); }; /** Creates and starts every worker before formatting can begin. */ diff --git a/packages/rstack/tests/config/define-app-lib/rstack.config.ts b/packages/rstack/tests/config/define-app-lib/rstack.config.ts index 9b484917..869d5dba 100644 --- a/packages/rstack/tests/config/define-app-lib/rstack.config.ts +++ b/packages/rstack/tests/config/define-app-lib/rstack.config.ts @@ -9,7 +9,6 @@ define.app({ }); define.lib({ - lib: [{}], source: { define: { RSTACK_INHERITED_CONFIG: JSON.stringify('lib'), diff --git a/packages/rstack/tests/config/define-lib/rstack.config.ts b/packages/rstack/tests/config/define-lib/rstack.config.ts index 6fb084ed..05c7cd9f 100644 --- a/packages/rstack/tests/config/define-lib/rstack.config.ts +++ b/packages/rstack/tests/config/define-lib/rstack.config.ts @@ -1,7 +1,6 @@ import { define } from 'rstack'; define.lib({ - lib: [{}], source: { define: { DEFINE_LIB_TEST_VALUE: JSON.stringify('define.lib works'), diff --git a/packages/rstack/tests/config/define-test-projects-lib/rstack.config.ts b/packages/rstack/tests/config/define-test-projects-lib/rstack.config.ts index cd94309b..04c6a253 100644 --- a/packages/rstack/tests/config/define-test-projects-lib/rstack.config.ts +++ b/packages/rstack/tests/config/define-test-projects-lib/rstack.config.ts @@ -7,7 +7,6 @@ define.lib(() => { libConfigCalls += 1; return { - lib: [{}], source: { define: { RSTACK_INHERITED_CONFIG: JSON.stringify('lib'), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7886b530..de0dfd06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,8 +17,8 @@ catalogs: specifier: ^2.0.1 version: 2.0.1 '@rslib/core': - specifier: ~1.0.0-beta.1 - version: 1.0.0-beta.1 + specifier: ~1.0.0-beta.2 + version: 1.0.0-beta.2 '@rslint/core': specifier: ~0.7.3 version: 0.7.3 @@ -329,7 +329,7 @@ importers: version: 2.1.10 '@rslib/core': specifier: 'catalog:' - version: 1.0.0-beta.1(typescript@7.0.2) + version: 1.0.0-beta.2(typescript@7.0.2) '@rslint/core': specifier: 'catalog:' version: 0.7.3 @@ -360,7 +360,7 @@ importers: version: 0.11.5(@rsbuild/core@2.1.10)(@rstest/core@0.11.5) '@rstest/adapter-rslib': specifier: 'catalog:' - version: 0.11.5(@rslib/core@1.0.0-beta.1)(@rstest/core@0.11.5)(typescript@7.0.2) + version: 0.11.5(@rslib/core@1.0.0-beta.2)(@rstest/core@0.11.5)(typescript@7.0.2) '@types/micromatch': specifier: 'catalog:' version: 4.0.10 @@ -663,8 +663,8 @@ packages: '@rsbuild/core': optional: true - '@rslib/core@1.0.0-beta.1': - resolution: {integrity: sha512-HHPZ+wTUKT/3bEhw2y0JB0O62wMuljkSHVZelLbSGhBflCaUt+D3LohBcIxYW6bhzPbUp4gkJXo1pdTpxiuNLQ==} + '@rslib/core@1.0.0-beta.2': + resolution: {integrity: sha512-A0j3MBP8Kga8Qrh7znO2UKf00Sga37PJCiTGUqVVBHb+u58fNoGXZtFAgeH6qKjf5eU1wYt4WptXX/1blOAfRw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -1972,8 +1972,8 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} - rsbuild-plugin-dts@1.0.0-beta.1: - resolution: {integrity: sha512-hAEjOXhfIHR4erqjsqjRvA9Yqzc6Thry06tHheXDJUVwmR3VbN8BSMDC8kBZzyKdypDF0IJ98FmLQRiC194sMA==} + rsbuild-plugin-dts@1.0.0-beta.2: + resolution: {integrity: sha512-xOYa/kw/y29kKFFNjd+CIemlq+CB8E7LhqNkIzg7HT9dYNBVBpZvnnL6OEsOgjeuqzKpGSCRgZN/dDoVOk4VhQ==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: '@microsoft/api-extractor': ^7 @@ -2505,10 +2505,10 @@ snapshots: optionalDependencies: '@rsbuild/core': 2.1.10 - '@rslib/core@1.0.0-beta.1(typescript@7.0.2)': + '@rslib/core@1.0.0-beta.2(typescript@7.0.2)': dependencies: '@rsbuild/core': 2.1.10 - rsbuild-plugin-dts: 1.0.0-beta.1(@rsbuild/core@2.1.10)(typescript@7.0.2) + rsbuild-plugin-dts: 1.0.0-beta.2(@rsbuild/core@2.1.10)(typescript@7.0.2) optionalDependencies: typescript: 7.0.2 transitivePeerDependencies: @@ -2700,9 +2700,9 @@ snapshots: '@rsbuild/core': 2.1.10 '@rstest/core': 0.11.5(happy-dom@20.11.1) - '@rstest/adapter-rslib@0.11.5(@rslib/core@1.0.0-beta.1)(@rstest/core@0.11.5)(typescript@7.0.2)': + '@rstest/adapter-rslib@0.11.5(@rslib/core@1.0.0-beta.2)(@rstest/core@0.11.5)(typescript@7.0.2)': dependencies: - '@rslib/core': 1.0.0-beta.1(typescript@7.0.2) + '@rslib/core': 1.0.0-beta.2(typescript@7.0.2) '@rstest/core': 0.11.5(happy-dom@20.11.1) optionalDependencies: typescript: 7.0.2 @@ -4095,7 +4095,7 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 - rsbuild-plugin-dts@1.0.0-beta.1(@rsbuild/core@2.1.10)(typescript@7.0.2): + rsbuild-plugin-dts@1.0.0-beta.2(@rsbuild/core@2.1.10)(typescript@7.0.2): dependencies: '@ast-grep/napi': 0.37.0 '@rsbuild/core': 2.1.10 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 97dec285..c724c9bc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -15,7 +15,7 @@ catalog: '@rsbuild/core': '~2.1.10' '@rsbuild/plugin-react': '^2.1.0' '@rsbuild/plugin-sass': '^2.0.1' - '@rslib/core': '~1.0.0-beta.1' + '@rslib/core': '~1.0.0-beta.2' '@rslint/core': '~0.7.3' '@rspress/core': '^2.0.19' '@rspress/plugin-client-redirects': '^2.0.19' diff --git a/website/docs/en/guide/cli/lib.mdx b/website/docs/en/guide/cli/lib.mdx index 8fbdee53..7c24a343 100644 --- a/website/docs/en/guide/cli/lib.mdx +++ b/website/docs/en/guide/cli/lib.mdx @@ -58,11 +58,7 @@ Configure library builds through [`define.lib()`](../configuration#define-lib) i import { define } from 'rstack'; define.lib({ - lib: [ - { - dts: true, - format: 'esm', - }, - ], + dts: true, + format: 'esm', }); ``` diff --git a/website/docs/en/guide/configuration.mdx b/website/docs/en/guide/configuration.mdx index 8e3b1e2a..e9db58fe 100644 --- a/website/docs/en/guide/configuration.mdx +++ b/website/docs/en/guide/configuration.mdx @@ -103,12 +103,8 @@ Defines the [Rslib configuration](https://rslib.rs/config/) for a library. It ac import { define } from 'rstack'; define.lib({ - lib: [ - { - dts: true, - format: 'esm', - }, - ], + dts: true, + format: 'esm', }); ``` diff --git a/website/docs/en/guide/monorepo.mdx b/website/docs/en/guide/monorepo.mdx index 200d1a0f..141e96b3 100644 --- a/website/docs/en/guide/monorepo.mdx +++ b/website/docs/en/guide/monorepo.mdx @@ -146,12 +146,8 @@ A library can define its build, test, and documentation configuration in one fil import { define } from 'rstack'; define.lib({ - lib: [ - { - format: 'esm', - dts: true, - }, - ], + dts: true, + format: 'esm', }); define.test({ diff --git a/website/docs/zh/guide/cli/lib.mdx b/website/docs/zh/guide/cli/lib.mdx index 1c5a521f..994c658c 100644 --- a/website/docs/zh/guide/cli/lib.mdx +++ b/website/docs/zh/guide/cli/lib.mdx @@ -58,11 +58,7 @@ rs lib mf-dev import { define } from 'rstack'; define.lib({ - lib: [ - { - dts: true, - format: 'esm', - }, - ], + dts: true, + format: 'esm', }); ``` diff --git a/website/docs/zh/guide/configuration.mdx b/website/docs/zh/guide/configuration.mdx index ce34d1f8..f4cbf631 100644 --- a/website/docs/zh/guide/configuration.mdx +++ b/website/docs/zh/guide/configuration.mdx @@ -103,12 +103,8 @@ define.app({ import { define } from 'rstack'; define.lib({ - lib: [ - { - dts: true, - format: 'esm', - }, - ], + dts: true, + format: 'esm', }); ``` diff --git a/website/docs/zh/guide/monorepo.mdx b/website/docs/zh/guide/monorepo.mdx index 91f88a1c..66fbedb1 100644 --- a/website/docs/zh/guide/monorepo.mdx +++ b/website/docs/zh/guide/monorepo.mdx @@ -146,12 +146,8 @@ define.test({ import { define } from 'rstack'; define.lib({ - lib: [ - { - format: 'esm', - dts: true, - }, - ], + dts: true, + format: 'esm', }); define.test({ From a4dae03a9b65cd82ecbda8ee1a7024ccfa9e97ae Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Thu, 6 Aug 2026 18:27:07 +0800 Subject: [PATCH 02/28] docs: add formatting and setup command examples (#222) --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 017d2e3c..7edb06d2 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,9 @@ bun add -d rstack "test": "rs test", "lint": "rs lint", "lib": "rs lib", - "doc": "rs doc" + "doc": "rs doc", + "format": "rs fmt", + "prepare": "rs setup" } } ``` @@ -74,6 +76,7 @@ pnpm test pnpm lint pnpm lib pnpm doc +pnpm format ``` ## Credits From 6ff1ff210b8d111f6dfd5314a5f4254973c59c63 Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Thu, 6 Aug 2026 18:30:41 +0800 Subject: [PATCH 03/28] feat(fmt): add project cache directory (#221) --- packages/rstack/src/fmt/discoverPaths.ts | 10 +++++- packages/rstack/src/projectCache.ts | 35 +++++++++++++++++++ packages/rstack/tests/fmt/discovery.test.ts | 13 +++++++ packages/rstack/tests/projectCache.test.ts | 38 +++++++++++++++++++++ 4 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 packages/rstack/src/projectCache.ts create mode 100644 packages/rstack/tests/projectCache.test.ts diff --git a/packages/rstack/src/fmt/discoverPaths.ts b/packages/rstack/src/fmt/discoverPaths.ts index 7ed432ae..e77f12dd 100644 --- a/packages/rstack/src/fmt/discoverPaths.ts +++ b/packages/rstack/src/fmt/discoverPaths.ts @@ -10,7 +10,15 @@ import { type RelativePathResolver, } from './pathHelpers.ts'; -const defaultIgnoredDirNames = new Set(['.git', '.sl', '.svn', '.hg', '.jj', 'node_modules']); +const defaultIgnoredDirNames = new Set([ + '.git', + '.sl', + '.svn', + '.hg', + '.jj', + '.rstack', + 'node_modules', +]); interface DiscoverFmtPathsOptions { /** Absolute directory used to resolve input paths. */ diff --git a/packages/rstack/src/projectCache.ts b/packages/rstack/src/projectCache.ts new file mode 100644 index 00000000..5959b1ce --- /dev/null +++ b/packages/rstack/src/projectCache.ts @@ -0,0 +1,35 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +const cacheGitignore = '*\n'; + +type ProjectCacheResult = + { status: 'available'; path: string } | { status: 'unavailable'; path: string; error: unknown }; + +/** Returns the disposable cache directory for a resolved Rstack project root. */ +const getProjectCacheDir = (rootPath: string): string => path.join(rootPath, '.rstack', 'cache'); + +/** Creates the project cache directory without making cache failures fatal. */ +const ensureProjectCacheDir = async (rootPath: string): Promise => { + const cachePath = getProjectCacheDir(rootPath); + const ignorePath = path.join(cachePath, '.gitignore'); + + try { + if ((await readFile(ignorePath, 'utf8')) === cacheGitignore) { + return { status: 'available', path: cachePath }; + } + } catch { + // Create or repair the marker below. + } + + try { + await mkdir(cachePath, { recursive: true }); + await writeFile(ignorePath, cacheGitignore); + return { status: 'available', path: cachePath }; + } catch (error) { + return { status: 'unavailable', path: cachePath, error }; + } +}; + +export { ensureProjectCacheDir, getProjectCacheDir }; +export type { ProjectCacheResult }; diff --git a/packages/rstack/tests/fmt/discovery.test.ts b/packages/rstack/tests/fmt/discovery.test.ts index 9ee6443f..71b902b4 100644 --- a/packages/rstack/tests/fmt/discovery.test.ts +++ b/packages/rstack/tests/fmt/discovery.test.ts @@ -47,6 +47,19 @@ test('applies config ignore patterns outside the config root', async () => { }); }); +test('excludes .rstack from discovery', async () => { + await withTempProject(async (rootPath) => { + const cacheFile = writeProjectFile(rootPath, '.rstack/cache/fmt-v1.json', '{}'); + writeProjectFile(rootPath, 'index.ts'); + + const discoveredFiles = await discover(rootPath); + const explicitFile = await discover(rootPath, [cacheFile]); + + expect(relativePaths(rootPath, discoveredFiles)).toEqual(['index.ts']); + expect(explicitFile).toEqual([]); + }); +}); + test('keeps files re-included by a CLI ignore file during directory traversal', async () => { await withTempProject(async (rootPath) => { writeProjectFile(rootPath, '.prettierignore', 'generated/*\n!generated/keep.ts\n'); diff --git a/packages/rstack/tests/projectCache.test.ts b/packages/rstack/tests/projectCache.test.ts new file mode 100644 index 00000000..eff83131 --- /dev/null +++ b/packages/rstack/tests/projectCache.test.ts @@ -0,0 +1,38 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { expect, test } from 'rstack/test'; +import { ensureProjectCacheDir, getProjectCacheDir } from '../src/projectCache.ts'; +import { withTempProject, writeProjectFile } from './fmt/helpers.ts'; + +test('creates and repairs an ignored project cache only when requested', async () => { + await withTempProject(async (rootPath) => { + const cachePath = getProjectCacheDir(rootPath); + const ignorePath = path.join(cachePath, '.gitignore'); + + expect(cachePath).toBe(path.join(rootPath, '.rstack', 'cache')); + expect(existsSync(cachePath)).toBe(false); + + await expect(ensureProjectCacheDir(rootPath)).resolves.toEqual({ + status: 'available', + path: cachePath, + }); + expect(readFileSync(ignorePath, 'utf8')).toBe('*\n'); + + writeFileSync(ignorePath, 'stale\n'); + await ensureProjectCacheDir(rootPath); + expect(readFileSync(ignorePath, 'utf8')).toBe('*\n'); + }); +}); + +test('reports an unavailable project cache without throwing', async () => { + await withTempProject(async (rootPath) => { + writeProjectFile(rootPath, '.rstack', 'not a directory'); + + const result = await ensureProjectCacheDir(rootPath); + + expect(result).toMatchObject({ + status: 'unavailable', + path: getProjectCacheDir(rootPath), + }); + }); +}); From f2949b525a7f6da2d44e77e8cc1d2907eba0498d Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Thu, 6 Aug 2026 21:31:39 +0800 Subject: [PATCH 04/28] feat(fmt): add persistent cache store (#223) --- packages/rstack/src/fmt/cacheStore.ts | 174 +++++++++++++++++++ packages/rstack/tests/fmt/cacheStore.test.ts | 109 ++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 packages/rstack/src/fmt/cacheStore.ts create mode 100644 packages/rstack/tests/fmt/cacheStore.test.ts diff --git a/packages/rstack/src/fmt/cacheStore.ts b/packages/rstack/src/fmt/cacheStore.ts new file mode 100644 index 00000000..1eca9562 --- /dev/null +++ b/packages/rstack/src/fmt/cacheStore.ts @@ -0,0 +1,174 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +const fmtCacheFileName = 'fmt-v1.json'; +const fmtCacheVersion = 1; + +type FmtCacheState = 'clean' | 'dirty'; +type FmtCacheEntry = readonly [contentHash: string, optionsHash: string, state: FmtCacheState]; + +interface FmtCacheFile { + version: typeof fmtCacheVersion; + namespace: string; + files: Record; +} + +interface FmtCacheStore { + get(filePath: string): FmtCacheEntry | undefined; + set(filePath: string, entry: FmtCacheEntry): void; + /** Persists changed entries and returns whether the cache file was replaced. */ + save(): Promise; +} + +const createEmptyCache = (namespace: string): FmtCacheFile => ({ + version: fmtCacheVersion, + namespace, + files: Object.create(null) as Record, +}); + +const parseCacheEntry = (value: unknown): FmtCacheEntry | undefined => { + if ( + !Array.isArray(value) || + value.length !== 3 || + typeof value[0] !== 'string' || + typeof value[1] !== 'string' || + (value[2] !== 'clean' && value[2] !== 'dirty') + ) { + return; + } + + return [value[0], value[1], value[2]]; +}; + +const parseCacheFile = (content: string): FmtCacheFile | undefined => { + let value: unknown; + try { + value = JSON.parse(content); + } catch { + return; + } + + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + !('version' in value) || + value.version !== fmtCacheVersion || + !('namespace' in value) || + typeof value.namespace !== 'string' || + !('files' in value) || + typeof value.files !== 'object' || + value.files === null || + Array.isArray(value.files) + ) { + return; + } + + const files = Object.create(null) as Record; + for (const [filePath, rawEntry] of Object.entries(value.files)) { + const entry = parseCacheEntry(rawEntry); + if (!entry) { + return; + } + files[filePath] = entry; + } + + return { + version: fmtCacheVersion, + namespace: value.namespace, + files, + }; +}; + +const serializeCache = (cache: FmtCacheFile): string => `${JSON.stringify(cache)}\n`; + +const isFileNotFoundError = (error: unknown): error is NodeJS.ErrnoException => + error instanceof Error && 'code' in error && error.code === 'ENOENT'; + +const getTemporaryPath = (filePath: string): string => + path.join( + path.dirname(filePath), + `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`, + ); + +class FmtCacheStoreImpl implements FmtCacheStore { + readonly #filePath: string; + readonly #cache: FmtCacheFile; + #savedContent: string | undefined; + #changed: boolean; + + constructor( + filePath: string, + cache: FmtCacheFile, + savedContent: string | undefined, + changed: boolean, + ) { + this.#filePath = filePath; + this.#cache = cache; + this.#savedContent = savedContent; + this.#changed = changed; + } + + get(filePath: string): FmtCacheEntry | undefined { + return this.#cache.files[filePath]; + } + + set(filePath: string, entry: FmtCacheEntry): void { + const current = this.#cache.files[filePath]; + if (current?.[0] === entry[0] && current[1] === entry[1] && current[2] === entry[2]) { + return; + } + + this.#cache.files[filePath] = [entry[0], entry[1], entry[2]]; + this.#changed = true; + } + + async save(): Promise { + if (!this.#changed) { + return false; + } + + const content = serializeCache(this.#cache); + if (content === this.#savedContent) { + this.#changed = false; + return false; + } + + const temporaryPath = getTemporaryPath(this.#filePath); + try { + await mkdir(path.dirname(this.#filePath), { recursive: true }); + await writeFile(temporaryPath, content); + await rename(temporaryPath, this.#filePath); + this.#savedContent = content; + this.#changed = false; + return true; + } catch { + return false; + } finally { + await rm(temporaryPath, { force: true }).catch(() => undefined); + } + } +} + +const loadFmtCacheStore = async (filePath: string, namespace: string): Promise => { + const emptyCache = createEmptyCache(namespace); + + try { + const content = await readFile(filePath, 'utf8'); + const cache = parseCacheFile(content); + if (!cache) { + return new FmtCacheStoreImpl(filePath, emptyCache, undefined, true); + } + + return cache.namespace === namespace + ? new FmtCacheStoreImpl(filePath, cache, content, false) + : new FmtCacheStoreImpl(filePath, emptyCache, undefined, true); + } catch (error) { + const missing = isFileNotFoundError(error); + return new FmtCacheStoreImpl(filePath, emptyCache, undefined, !missing); + } +}; + +export { fmtCacheFileName, fmtCacheVersion, loadFmtCacheStore }; +export type { FmtCacheEntry, FmtCacheFile, FmtCacheState, FmtCacheStore }; diff --git a/packages/rstack/tests/fmt/cacheStore.test.ts b/packages/rstack/tests/fmt/cacheStore.test.ts new file mode 100644 index 00000000..dcee1a6b --- /dev/null +++ b/packages/rstack/tests/fmt/cacheStore.test.ts @@ -0,0 +1,109 @@ +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { expect, test } from 'rstack/test'; +import { fmtCacheVersion, loadFmtCacheStore, type FmtCacheFile } from '../../src/fmt/cacheStore.ts'; +import { withTempProject } from './helpers.ts'; + +const namespace = 'test-namespace'; +const firstEntry = ['content-a', 'options-a', 'clean'] as const; +const secondEntry = ['content-b', 'options-b', 'dirty'] as const; + +const readCache = (filePath: string): FmtCacheFile => + JSON.parse(readFileSync(filePath, 'utf8')) as FmtCacheFile; + +test('writes entries that can be loaded by another store', async () => { + await withTempProject(async (rootPath) => { + const cachePath = path.join(rootPath, 'cache', 'fmt-v1.json'); + const store = await loadFmtCacheStore(cachePath, namespace); + + expect(await store.save()).toBe(false); + expect(existsSync(cachePath)).toBe(false); + + store.set('src/a.ts', firstEntry); + expect(await store.save()).toBe(true); + expect(await store.save()).toBe(false); + + const loaded = await loadFmtCacheStore(cachePath, namespace); + expect(loaded.get('src/a.ts')).toEqual(firstEntry); + }); +}); + +test('preserves unvisited entries and skips unchanged updates', async () => { + await withTempProject(async (rootPath) => { + const cachePath = path.join(rootPath, 'fmt-v1.json'); + writeFileSync( + cachePath, + `${JSON.stringify({ + version: fmtCacheVersion, + namespace, + files: { + 'src/a.ts': firstEntry, + 'src/b.ts': secondEntry, + }, + })}\n`, + ); + + const store = await loadFmtCacheStore(cachePath, namespace); + store.set('src/a.ts', secondEntry); + store.set('src/a.ts', firstEntry); + expect(await store.save()).toBe(false); + + store.set('src/a.ts', secondEntry); + expect(await store.save()).toBe(true); + expect(readCache(cachePath).files).toEqual({ + 'src/a.ts': secondEntry, + 'src/b.ts': secondEntry, + }); + }); +}); + +test('discards invalid data and entries from another namespace', async () => { + await withTempProject(async (rootPath) => { + const cachePath = path.join(rootPath, 'fmt-v1.json'); + const invalidContents = [ + '{invalid', + JSON.stringify({ version: 2, namespace, files: {} }), + JSON.stringify({ + version: fmtCacheVersion, + namespace, + files: { 'src/a.ts': ['content', 'options', 'unknown'] }, + }), + ]; + + for (const content of invalidContents) { + writeFileSync(cachePath, content); + const store = await loadFmtCacheStore(cachePath, namespace); + expect(store.get('src/a.ts')).toBeUndefined(); + } + + writeFileSync( + cachePath, + JSON.stringify({ + version: fmtCacheVersion, + namespace: 'old-namespace', + files: { 'src/a.ts': firstEntry }, + }), + ); + const store = await loadFmtCacheStore(cachePath, namespace); + expect(store.get('src/a.ts')).toBeUndefined(); + expect(await store.save()).toBe(true); + expect(readCache(cachePath)).toEqual({ + version: fmtCacheVersion, + namespace, + files: {}, + }); + }); +}); + +test('does not throw or leave temporary files when persistence fails', async () => { + await withTempProject(async (rootPath) => { + const cachePath = path.join(rootPath, 'fmt-v1.json'); + mkdirSync(cachePath); + + const store = await loadFmtCacheStore(cachePath, namespace); + store.set('src/a.ts', firstEntry); + + await expect(store.save()).resolves.toBe(false); + expect(readdirSync(rootPath).filter((name) => name.endsWith('.tmp'))).toEqual([]); + }); +}); From 279b050dc9bb3fc7d29c02d006806b7e3114bf80 Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Thu, 6 Aug 2026 22:15:30 +0800 Subject: [PATCH 05/28] feat(fmt): add cache identity helpers (#224) --- packages/rstack/package.json | 1 + packages/rstack/rslib.config.ts | 2 + packages/rstack/src/fmt/cacheIdentity.ts | 55 ++++++++++++ .../rstack/tests/fmt/cacheIdentity.test.ts | 87 +++++++++++++++++++ pnpm-lock.yaml | 11 +++ pnpm-workspace.yaml | 1 + 6 files changed, 157 insertions(+) create mode 100644 packages/rstack/src/fmt/cacheIdentity.ts create mode 100644 packages/rstack/tests/fmt/cacheIdentity.test.ts diff --git a/packages/rstack/package.json b/packages/rstack/package.json index f16ec98b..0bd3b037 100644 --- a/packages/rstack/package.json +++ b/packages/rstack/package.json @@ -82,6 +82,7 @@ "@rstest/adapter-rslib": "catalog:", "@types/micromatch": "catalog:", "@types/node": "catalog:", + "fast-json-stable-stringify": "catalog:", "ignore": "catalog:", "import-meta-resolve": "catalog:", "is-binary-path": "catalog:", diff --git a/packages/rstack/rslib.config.ts b/packages/rstack/rslib.config.ts index 1449f5fe..bf5cf0ec 100644 --- a/packages/rstack/rslib.config.ts +++ b/packages/rstack/rslib.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from '@rslib/core'; +import prettierPkgJson from 'prettier/package.json' with { type: 'json' }; import pkgJson from './package.json' with { type: 'json' }; const fullyMinifiedChunks = /(?:fmt(?:Plugins)?|sortPackageJsonPlugin|staged)\.js$/; @@ -22,6 +23,7 @@ export default defineConfig({ fmtWorker: './src/fmt/worker.ts', }, define: { + PRETTIER_VERSION: JSON.stringify(prettierPkgJson.version), RSTACK_VERSION: JSON.stringify(pkgJson.version), }, }, diff --git a/packages/rstack/src/fmt/cacheIdentity.ts b/packages/rstack/src/fmt/cacheIdentity.ts new file mode 100644 index 00000000..824c0cf9 --- /dev/null +++ b/packages/rstack/src/fmt/cacheIdentity.ts @@ -0,0 +1,55 @@ +import { createHash } from 'node:crypto'; +import { isAbsolute } from 'node:path'; +import stableStringify from 'fast-json-stable-stringify'; +import { fmtCacheVersion } from './cacheStore.ts'; +import { createRelativePathResolver, toPosixPath } from './pathHelpers.ts'; +import type { ResolvedFmtOptions } from './types.ts'; + +declare const PRETTIER_VERSION: string; +declare const RSTACK_VERSION: string; + +type CacheKeyResolver = (filePath: string) => string | undefined; +type OptionsHasher = (options: ResolvedFmtOptions) => string | undefined; + +const sha256 = (content: string | Uint8Array): string => + createHash('sha256').update(content).digest('hex'); + +/** Identifies formatter behavior shared by all cache entries in this process. */ +const cacheNamespace: string = JSON.stringify([fmtCacheVersion, RSTACK_VERSION, PRETTIER_VERSION]); + +/** Creates project-relative POSIX cache keys without repeating path setup. */ +const createCacheKeyResolver = (rootPath: string): CacheKeyResolver => { + const resolveRelativePath = createRelativePathResolver(rootPath); + + return (filePath) => { + const relativePath = resolveRelativePath(filePath); + return isAbsolute(relativePath) ? undefined : toPosixPath(relativePath); + }; +}; + +/** Hashes final per-file options and memoizes option objects shared by many files. */ +const createOptionsHasher = (): OptionsHasher => { + const hashes = new WeakMap(); + + return (options) => { + const cached = hashes.get(options); + if (cached !== undefined) { + return cached ?? undefined; + } + + let hash: string | undefined; + try { + // A resolved plugin path does not identify the plugin implementation. + if (!options.plugins?.length) { + hash = sha256(stableStringify(options)); + } + } catch { + // Circular or unreadable options cannot be cached. + } + + hashes.set(options, hash ?? null); + return hash; + }; +}; + +export { cacheNamespace, createCacheKeyResolver, createOptionsHasher, sha256 }; diff --git a/packages/rstack/tests/fmt/cacheIdentity.test.ts b/packages/rstack/tests/fmt/cacheIdentity.test.ts new file mode 100644 index 00000000..b1b13f6d --- /dev/null +++ b/packages/rstack/tests/fmt/cacheIdentity.test.ts @@ -0,0 +1,87 @@ +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import prettierPkgJson from 'prettier/package.json' with { type: 'json' }; +import { expect, test } from 'rstack/test'; +import pkgJson from '../../package.json' with { type: 'json' }; +import { + cacheNamespace, + createCacheKeyResolver, + createOptionsHasher, + sha256, +} from '../../src/fmt/cacheIdentity.ts'; +import { fmtCacheVersion } from '../../src/fmt/cacheStore.ts'; +import type { ResolvedFmtOptions } from '../../src/fmt/types.ts'; + +const rootPath = path.join(import.meta.dirname, 'project'); + +const asOptions = (value: Record): ResolvedFmtOptions => + value as ResolvedFmtOptions; + +test('creates stable SHA-256 option hashes', () => { + const hashOptions = createOptionsHasher(); + const left: ResolvedFmtOptions = { + singleQuote: true, + semi: false, + }; + const right: ResolvedFmtOptions = { + semi: false, + singleQuote: true, + }; + + expect(hashOptions(left)).toBe(hashOptions(right)); + expect(hashOptions(left)).toHaveLength(64); + expect(sha256('abc')).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'); +}); + +test('invalidates hashes when final formatter options change', () => { + const hashOptions = createOptionsHasher(); + const hashes = [ + hashOptions({ singleQuote: false }), + hashOptions({ singleQuote: true }), + hashOptions({ parser: 'typescript' }), + hashOptions({ sortPackageJson: true }), + hashOptions({ singleQuote: true, semi: false }), + ]; + + expect(hashes.every(Boolean)).toBe(true); + expect(new Set(hashes).size).toBe(hashes.length); +}); + +test('bypasses user plugins and unserializable options', () => { + const hashOptions = createOptionsHasher(); + const cyclic: Record = {}; + const unreadable = new Proxy( + {}, + { + get: () => { + throw new Error('unreadable'); + }, + }, + ); + cyclic.self = cyclic; + + expect(hashOptions({ plugins: [path.resolve('plugin.mjs')] })).toBeUndefined(); + expect(hashOptions({ plugins: [pathToFileURL(path.resolve('plugin.mjs'))] })).toBeUndefined(); + + expect(hashOptions(asOptions({ custom: cyclic }))).toBeUndefined(); + expect(hashOptions(asOptions(unreadable))).toBeUndefined(); +}); + +test('includes formatter implementation versions in the namespace', () => { + expect(JSON.parse(cacheNamespace)).toEqual([ + fmtCacheVersion, + pkgJson.version, + prettierPkgJson.version, + ]); +}); + +test('creates config-root-relative POSIX cache keys', () => { + const resolveKey = createCacheKeyResolver(rootPath); + const firstPath = path.join(rootPath, 'src/nested/index.ts'); + const secondPath = path.join(rootPath, 'src/other.ts'); + + expect(resolveKey(firstPath)).toBe('src/nested/index.ts'); + expect(resolveKey(secondPath)).toBe('src/other.ts'); + expect(resolveKey(firstPath)).not.toBe(resolveKey(secondPath)); + expect(resolveKey(path.join(rootPath, '../shared/index.ts'))).toBe('../shared/index.ts'); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de0dfd06..be9bdd9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,6 +76,9 @@ catalogs: cspell-ban-words: specifier: ^0.0.4 version: 0.0.4 + fast-json-stable-stringify: + specifier: 2.1.0 + version: 2.1.0 happy-dom: specifier: ^20.11.1 version: 20.11.1 @@ -367,6 +370,9 @@ importers: '@types/node': specifier: 'catalog:' version: 24.13.3 + fast-json-stable-stringify: + specifier: 'catalog:' + version: 2.1.0 ignore: specifier: 'catalog:' version: 7.0.6 @@ -1416,6 +1422,9 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -3153,6 +3162,8 @@ snapshots: extend@3.0.2: {} + fast-json-stable-stringify@2.1.0: {} + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c724c9bc..a12d531e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -35,6 +35,7 @@ catalog: '@types/react-dom': '^19.2.4' '@shikijs/transformers': '^4.4.1' 'cspell-ban-words': '^0.0.4' + 'fast-json-stable-stringify': '2.1.0' 'happy-dom': '^20.11.1' 'heading-case': '^1.1.4' ignore: 7.0.6 From 30e8fbd2e7d20b8aba0bfe8f3af2deef622d125e Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Thu, 6 Aug 2026 22:44:33 +0800 Subject: [PATCH 06/28] feat(fmt): cache check and list results (#225) --- packages/rstack/src/fmt/runner.ts | 101 +++++++--- packages/rstack/src/fmt/types.ts | 23 +++ packages/rstack/src/fmt/worker.ts | 53 ++++-- packages/rstack/src/fmt/workerPool.ts | 6 +- packages/rstack/tests/fmt/runnerCache.test.ts | 176 ++++++++++++++++++ packages/rstack/tests/fmt/worker.test.ts | 35 +++- 6 files changed, 354 insertions(+), 40 deletions(-) create mode 100644 packages/rstack/tests/fmt/runnerCache.test.ts diff --git a/packages/rstack/src/fmt/runner.ts b/packages/rstack/src/fmt/runner.ts index 0f9c92cf..e4f81b5e 100644 --- a/packages/rstack/src/fmt/runner.ts +++ b/packages/rstack/src/fmt/runner.ts @@ -1,4 +1,8 @@ +import { cacheNamespace, createCacheKeyResolver, createOptionsHasher } from './cacheIdentity.ts'; +import { loadFmtCacheStore } from './cacheStore.ts'; +import type { FmtCacheEntry, FmtCacheStore } from './cacheStore.ts'; import type { + FmtFileCache, FmtExitCode, FmtFileRequest, FmtFileResult, @@ -11,6 +15,18 @@ import type { FmtWorkerPool } from './workerPool.ts'; type FormatFile = FmtWorkerPool['formatFile']; type FmtFileOutcome = FmtFileResult | 'unchanged' | 'unsupported'; +interface FmtFileRun { + outcome: FmtFileOutcome; + key?: string; + entry?: FmtCacheEntry; +} + +interface RunCache { + store: FmtCacheStore; + resolveKey: ReturnType; + hashOptions: ReturnType; +} + interface FmtWorkerPoolResult { files: FmtFileResult[]; processedFileCount: number; @@ -32,22 +48,47 @@ const runFmtFile = async ( file: FmtFileRequest, shouldWrite: boolean, formatFile: FormatFile, -): Promise => { - try { - const result = await formatFile(file, shouldWrite); - if (result === 'unchanged' || result === 'unsupported') { - return result; + cache?: RunCache, +): Promise => { + let key: string | undefined; + let fileCache: FmtFileCache | undefined; + + if (cache) { + key = cache.resolveKey(file.path); + if (key !== undefined) { + const optionsHash = cache.hashOptions(file.options); + if (optionsHash === undefined) { + key = undefined; + } else { + fileCache = { + entry: cache.store.get(key), + optionsHash, + }; + } } + } - return { - path: file.path, - status: shouldWrite ? 'written' : 'different', - }; + try { + const result = await formatFile(file, shouldWrite, fileCache); + const outcome: FmtFileOutcome = + result.status === 'changed' + ? { + path: file.path, + status: shouldWrite ? 'written' : 'different', + } + : result.status; + + if (key !== undefined && result.cacheEntry) { + return { outcome, key, entry: result.cacheEntry }; + } + return { outcome }; } catch (error) { return { - path: file.path, - status: 'error', - error, + outcome: { + path: file.path, + status: 'error', + error, + }, }; } }; @@ -57,7 +98,8 @@ const runPriorityFmtFiles = async ( files: FmtFileRequest[], shouldWrite: boolean, formatFile: FormatFile, -): Promise => { + cache?: RunCache, +): Promise => { const priority: number[] = []; const rest: number[] = []; @@ -67,9 +109,9 @@ const runPriorityFmtFiles = async ( const order = priority.concat(rest); const outcomes = await Promise.all( - order.map((index) => runFmtFile(files[index], shouldWrite, formatFile)), + order.map((index) => runFmtFile(files[index], shouldWrite, formatFile, cache)), ); - const results = new Array(files.length); + const results = new Array(files.length); for (let index = 0; index < order.length; index++) { results[order[index]] = outcomes[index]; } @@ -81,6 +123,7 @@ const runFmtFilesInWorkerPool = async ( files: FmtFileRequest[], shouldWrite: boolean, maxWorkers?: number, + cache?: RunCache, ): Promise => { const { createFmtWorkerPool } = await import('./workerPool.ts'); const workerPool = await createFmtWorkerPool(files.length, maxWorkers); @@ -88,21 +131,24 @@ const runFmtFilesInWorkerPool = async ( try { const results = workerPool.workerCount >= minPriorityWorkers - ? await runPriorityFmtFiles(files, shouldWrite, workerPool.formatFile) + ? await runPriorityFmtFiles(files, shouldWrite, workerPool.formatFile, cache) : await Promise.all( - files.map((file) => runFmtFile(file, shouldWrite, workerPool.formatFile)), + files.map((file) => runFmtFile(file, shouldWrite, workerPool.formatFile, cache)), ); const processedFiles: FmtFileResult[] = []; let processedFileCount = 0; - for (const result of results) { - if (result === 'unsupported') { + for (const { outcome, key, entry } of results) { + if (key !== undefined && entry) { + cache?.store.set(key, entry); + } + if (outcome === 'unsupported') { continue; } processedFileCount++; - if (result !== 'unchanged') { - processedFiles.push(result); + if (outcome !== 'unchanged') { + processedFiles.push(outcome); } } @@ -133,12 +179,23 @@ const runFmtFiles = async ({ files, mode, maxWorkers, + cache, }: RunFmtFilesOptions): Promise => { const shouldWrite = mode === 'write'; + let runCache: RunCache | undefined; + if (files.length > 0 && cache && !shouldWrite) { + runCache = { + store: await loadFmtCacheStore(cache.filePath, cacheNamespace), + resolveKey: createCacheKeyResolver(cache.rootPath), + hashOptions: createOptionsHasher(), + }; + } + const result = files.length === 0 ? { files: [], processedFileCount: 0 } - : await runFmtFilesInWorkerPool(files, shouldWrite, maxWorkers); + : await runFmtFilesInWorkerPool(files, shouldWrite, maxWorkers, runCache); + await runCache?.store.save().catch(() => false); return { ...result, diff --git a/packages/rstack/src/fmt/types.ts b/packages/rstack/src/fmt/types.ts index e93a4bdc..f3cabb92 100644 --- a/packages/rstack/src/fmt/types.ts +++ b/packages/rstack/src/fmt/types.ts @@ -1,4 +1,5 @@ import type { Config as PrettierConfig, Options as PrettierOptions } from 'prettier'; +import type { FmtCacheEntry } from './cacheStore.ts'; /** Plugin objects cannot cross worker boundaries and are not planned for support. */ type FmtPluginSpecifier = string | URL; @@ -71,6 +72,23 @@ interface FmtFileRequest { options: ResolvedFmtOptions; } +interface FmtCacheContext { + /** Persistent cache file to load and update. */ + filePath: string; + /** Root used to create portable per-file cache keys. */ + rootPath: string; +} + +interface FmtFileCache { + entry: FmtCacheEntry | undefined; + optionsHash: string; +} + +interface FmtWorkerResult { + status: 'changed' | 'unchanged' | 'unsupported'; + cacheEntry?: FmtCacheEntry; +} + type FmtMode = 'write' | 'check' | 'list-different'; type FmtExitCode = 0 | 1 | 2; @@ -81,6 +99,8 @@ interface RunFmtFilesOptions { mode: FmtMode; /** Maximum number of formatting workers. */ maxWorkers?: number; + /** Internal persistent cache context. Currently used only by check and list modes. */ + cache?: FmtCacheContext; } interface SuccessfulFmtFileResult { @@ -106,14 +126,17 @@ interface FmtRunResult { export type { DiscoverFmtFilesOptions, + FmtCacheContext, FmtConfig, FmtConfigDefinition, FmtExitCode, FmtFileResult, FmtFileRequest, + FmtFileCache, FmtMode, FmtPluginSpecifier, FmtRunResult, + FmtWorkerResult, ResolvedFmtConfig, ResolvedFmtOptions, RunFmtFilesOptions, diff --git a/packages/rstack/src/fmt/worker.ts b/packages/rstack/src/fmt/worker.ts index 1b861e70..d695bc54 100644 --- a/packages/rstack/src/fmt/worker.ts +++ b/packages/rstack/src/fmt/worker.ts @@ -1,39 +1,64 @@ // Derived from @prettier/cli, see THIRD_PARTY_NOTICES.md +import { createHash } from 'node:crypto'; import { readFileSync, writeFileSync } from 'node:fs'; -import { formatFmtSource } from './format.ts'; -import type { FmtFileRequest } from './types.ts'; - -type FormatFileResult = 'changed' | 'unchanged' | 'unsupported'; +import type { FmtCacheEntry } from './cacheStore.ts'; +import type { FmtFileCache, FmtFileRequest, FmtWorkerResult } from './types.ts'; interface FormatFileTask { file: FmtFileRequest; shouldWrite: boolean; + cache?: FmtFileCache; } +const hashContent = (content: Uint8Array): string => + createHash('sha256').update(content).digest('hex'); + /** * Use synchronous direct I/O inside the dedicated worker to avoid libuv * scheduling overhead. This prioritizes throughput over crash-safe replacement. */ -const formatFile = async ({ file, shouldWrite }: FormatFileTask): Promise => { - const result = await formatFmtSource(file, () => readFileSync(file.path, 'utf8')); +const formatFile = async ({ + file, + shouldWrite, + cache, +}: FormatFileTask): Promise => { + let source: string | undefined; + let contentHash: string | undefined; + + if (cache && !shouldWrite) { + const content = readFileSync(file.path); + contentHash = hashContent(content); + source = content.toString('utf8'); + + const { entry, optionsHash } = cache; + if (entry?.[0] === contentHash && entry[1] === optionsHash) { + return { status: entry[2] === 'clean' ? 'unchanged' : 'changed' }; + } + } + + const { formatFmtSource } = await import('./format.ts'); + const result = await formatFmtSource(file, () => (source ??= readFileSync(file.path, 'utf8'))); if (result.status === 'unsupported') { - return 'unsupported'; + return { status: 'unsupported' }; } - const { source, formatted } = result; - if (source === formatted) { - return 'unchanged'; + const unchanged = result.source === result.formatted; + + if (!unchanged && shouldWrite) { + writeFileSync(file.path, result.formatted, 'utf8'); } - if (shouldWrite) { - writeFileSync(file.path, formatted, 'utf8'); + const status = unchanged ? 'unchanged' : 'changed'; + if (!cache || contentHash === undefined) { + return { status }; } - return 'changed'; + const cacheEntry: FmtCacheEntry = [contentHash, cache.optionsHash, unchanged ? 'clean' : 'dirty']; + return { status, cacheEntry }; }; -/** Confirms that the worker module and its runtime dependencies are ready. */ +/** Confirms that the worker module is ready. Formatter dependencies load only on a cache miss. */ const initializeFmtWorker = (): true => true; export { formatFile, initializeFmtWorker }; diff --git a/packages/rstack/src/fmt/workerPool.ts b/packages/rstack/src/fmt/workerPool.ts index 155f4e66..e350a149 100644 --- a/packages/rstack/src/fmt/workerPool.ts +++ b/packages/rstack/src/fmt/workerPool.ts @@ -2,7 +2,7 @@ import { availableParallelism } from 'node:os'; import Tinypool from 'tinypool'; -import type { FmtFileRequest } from './types.ts'; +import type { FmtFileCache, FmtFileRequest } from './types.ts'; type FmtWorkerMethods = typeof import('./worker.ts'); @@ -11,6 +11,7 @@ interface FmtWorkerPool { formatFile: ( file: FmtFileRequest, shouldWrite: boolean, + cache?: FmtFileCache, ) => ReturnType; terminate: () => Promise; } @@ -57,7 +58,8 @@ const createFmtWorkerPool = async ( return { workerCount, - formatFile: (file, shouldWrite) => pool.run({ file, shouldWrite }, { name: 'formatFile' }), + formatFile: (file, shouldWrite, cache) => + pool.run({ file, shouldWrite, cache }, { name: 'formatFile' }), terminate: () => pool.destroy(), }; }; diff --git a/packages/rstack/tests/fmt/runnerCache.test.ts b/packages/rstack/tests/fmt/runnerCache.test.ts new file mode 100644 index 00000000..b41edb19 --- /dev/null +++ b/packages/rstack/tests/fmt/runnerCache.test.ts @@ -0,0 +1,176 @@ +import { existsSync, readFileSync, statSync, utimesSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { expect, test } from 'rstack/test'; +import { cacheNamespace, createOptionsHasher, sha256 } from '../../src/fmt/cacheIdentity.ts'; +import { loadFmtCacheStore } from '../../src/fmt/cacheStore.ts'; +import { runFmtFiles } from '../../src/fmt/runner.ts'; +import type { + FmtCacheContext, + FmtFileRequest, + FmtMode, + ResolvedFmtOptions, +} from '../../src/fmt/types.ts'; +import { withTempProject } from './helpers.ts'; + +const createRequest = ( + filePath: string, + options: ResolvedFmtOptions = { parser: 'typescript' }, +): FmtFileRequest => ({ + path: filePath, + options, +}); + +const createCache = (rootPath: string): FmtCacheContext => ({ + filePath: path.join(rootPath, 'cache', 'fmt-v1.json'), + rootPath, +}); + +const run = (files: FmtFileRequest[], mode: FmtMode, cache: FmtCacheContext) => + runFmtFiles({ files, mode, cache }); + +for (const mode of ['check', 'list-different'] as const) { + test(`${mode} persists clean and dirty results`, async () => { + await withTempProject(async (rootPath) => { + const cleanPath = path.join(rootPath, 'clean.ts'); + const dirtyPath = path.join(rootPath, 'dirty.ts'); + const cache = createCache(rootPath); + writeFileSync(cleanPath, 'const clean = 1;\n'); + writeFileSync(dirtyPath, 'const dirty=1'); + + const files = [createRequest(cleanPath), createRequest(dirtyPath)]; + const first = await run(files, mode, cache); + + expect(first).toMatchObject({ + exitCode: 1, + files: [{ path: dirtyPath, status: 'different' }], + processedFileCount: 2, + }); + + const store = await loadFmtCacheStore(cache.filePath, cacheNamespace); + expect(store.get('clean.ts')).toEqual([ + sha256(readFileSync(cleanPath)), + expect.any(String), + 'clean', + ]); + expect(store.get('dirty.ts')).toEqual([ + sha256(readFileSync(dirtyPath)), + expect.any(String), + 'dirty', + ]); + + await expect(run(files, mode, cache)).resolves.toMatchObject(first); + }); + }); +} + +test('uses content hashes instead of file metadata', async () => { + await withTempProject(async (rootPath) => { + const filePath = path.join(rootPath, 'index.ts'); + const cache = createCache(rootPath); + const timestamp = new Date('2020-01-01T00:00:00.000Z'); + const clean = 'const value = 1;\n'; + const dirty = 'const value= 1;\n'; + writeFileSync(filePath, clean); + utimesSync(filePath, timestamp, timestamp); + + await run([createRequest(filePath)], 'check', cache); + const firstStore = await loadFmtCacheStore(cache.filePath, cacheNamespace); + const firstEntry = firstStore.get('index.ts'); + + writeFileSync(filePath, dirty); + utimesSync(filePath, timestamp, timestamp); + expect(statSync(filePath)).toMatchObject({ + mtimeMs: timestamp.getTime(), + size: Buffer.byteLength(clean), + }); + + await expect(run([createRequest(filePath)], 'check', cache)).resolves.toMatchObject({ + exitCode: 1, + files: [{ path: filePath, status: 'different' }], + }); + + const secondStore = await loadFmtCacheStore(cache.filePath, cacheNamespace); + const secondEntry = secondStore.get('index.ts'); + expect(secondEntry).toEqual([sha256(readFileSync(filePath)), expect.any(String), 'dirty']); + expect(secondEntry?.[0]).not.toBe(firstEntry?.[0]); + }); +}); + +test('invalidates entries when final options change', async () => { + await withTempProject(async (rootPath) => { + const filePath = path.join(rootPath, 'index.ts'); + const cache = createCache(rootPath); + writeFileSync(filePath, 'const value = "text";\n'); + + const initial = createRequest(filePath, { parser: 'typescript', singleQuote: false }); + await run([initial], 'check', cache); + + const changed = createRequest(filePath, { parser: 'typescript', singleQuote: true }); + await expect(run([changed], 'check', cache)).resolves.toMatchObject({ + exitCode: 1, + files: [{ path: filePath, status: 'different' }], + }); + + const store = await loadFmtCacheStore(cache.filePath, cacheNamespace); + expect(store.get('index.ts')).toEqual([ + sha256(readFileSync(filePath)), + createOptionsHasher()(changed.options), + 'dirty', + ]); + }); +}); + +test('preserves entries outside the formatted subset', async () => { + await withTempProject(async (rootPath) => { + const firstPath = path.join(rootPath, 'first.ts'); + const secondPath = path.join(rootPath, 'second.ts'); + const cache = createCache(rootPath); + writeFileSync(firstPath, 'const first = 1;\n'); + writeFileSync(secondPath, 'const second = 2;\n'); + + await run([createRequest(firstPath), createRequest(secondPath)], 'check', cache); + const firstStore = await loadFmtCacheStore(cache.filePath, cacheNamespace); + const secondEntry = firstStore.get('second.ts'); + + writeFileSync(firstPath, 'const first=1'); + await run([createRequest(firstPath)], 'check', cache); + + const secondStore = await loadFmtCacheStore(cache.filePath, cacheNamespace); + expect(secondStore.get('second.ts')).toEqual(secondEntry); + }); +}); + +test('does not cache formatting errors', async () => { + await withTempProject(async (rootPath) => { + const validPath = path.join(rootPath, 'valid.ts'); + const invalidPath = path.join(rootPath, 'invalid.ts'); + const cache = createCache(rootPath); + writeFileSync(validPath, 'const valid = 1;\n'); + writeFileSync(invalidPath, 'const invalid = ;'); + + await run([createRequest(validPath)], 'check', cache); + await expect(run([createRequest(invalidPath)], 'check', cache)).resolves.toMatchObject({ + exitCode: 2, + files: [{ path: invalidPath, status: 'error' }], + }); + + const store = await loadFmtCacheStore(cache.filePath, cacheNamespace); + expect(store.get('valid.ts')).toBeDefined(); + expect(store.get('invalid.ts')).toBeUndefined(); + }); +}); + +test('does not apply the cache in write mode yet', async () => { + await withTempProject(async (rootPath) => { + const filePath = path.join(rootPath, 'index.ts'); + const cache = createCache(rootPath); + writeFileSync(filePath, 'const value=1'); + + await expect(run([createRequest(filePath)], 'write', cache)).resolves.toMatchObject({ + exitCode: 0, + files: [{ path: filePath, status: 'written' }], + }); + expect(readFileSync(filePath, 'utf8')).toBe('const value = 1;\n'); + expect(existsSync(cache.filePath)).toBe(false); + }); +}); diff --git a/packages/rstack/tests/fmt/worker.test.ts b/packages/rstack/tests/fmt/worker.test.ts index 78730aa0..7c823d6d 100644 --- a/packages/rstack/tests/fmt/worker.test.ts +++ b/packages/rstack/tests/fmt/worker.test.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs'; import { expect, test } from 'rstack/test'; +import { sha256 } from '../../src/fmt/cacheIdentity.ts'; import { formatFile } from '../../src/fmt/worker.ts'; import { withTempProject, writeProjectFile } from './helpers.ts'; @@ -17,7 +18,7 @@ test('writes formatted files', async () => { }, shouldWrite: true, }), - ).resolves.toBe('changed'); + ).resolves.toEqual({ status: 'changed' }); expect(readFileSync(filePath, 'utf8')).toBe('const value = 1;\n'); }); @@ -36,8 +37,38 @@ test('infers the parser for an explicitly provided node_modules file', async () }, shouldWrite: false, }), - ).resolves.toBe('changed'); + ).resolves.toEqual({ status: 'changed' }); expect(readFileSync(filePath, 'utf8')).toBe(source); }); }); + +test('returns cached states before resolving the parser', async () => { + await withTempProject(async (rootPath) => { + const source = 'const value=1'; + const filePath = writeProjectFile(rootPath, 'example.ts', source); + const contentHash = sha256(source); + const optionsHash = 'options'; + + for (const [state, status] of [ + ['clean', 'unchanged'], + ['dirty', 'changed'], + ] as const) { + await expect( + formatFile({ + file: { + path: filePath, + options: { + parser: 'unknown-parser', + }, + }, + shouldWrite: false, + cache: { + entry: [contentHash, optionsHash, state], + optionsHash, + }, + }), + ).resolves.toEqual({ status }); + } + }); +}); From e473259aeb8c4f8d27475ca363b169a85a272b5e Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Fri, 7 Aug 2026 08:30:50 +0800 Subject: [PATCH 07/28] fix(fmt): avoid reading unsupported cache misses (#226) --- packages/rstack/src/fmt/worker.ts | 26 +++++++++++++++++------- packages/rstack/tests/fmt/worker.test.ts | 19 +++++++++++++++++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/packages/rstack/src/fmt/worker.ts b/packages/rstack/src/fmt/worker.ts index d695bc54..330e9b20 100644 --- a/packages/rstack/src/fmt/worker.ts +++ b/packages/rstack/src/fmt/worker.ts @@ -25,20 +25,28 @@ const formatFile = async ({ }: FormatFileTask): Promise => { let source: string | undefined; let contentHash: string | undefined; + const fileCache = shouldWrite ? undefined : cache; + + const readSource = (): string => { + if (!fileCache) { + return readFileSync(file.path, 'utf8'); + } - if (cache && !shouldWrite) { const content = readFileSync(file.path); contentHash = hashContent(content); - source = content.toString('utf8'); + return content.toString('utf8'); + }; - const { entry, optionsHash } = cache; - if (entry?.[0] === contentHash && entry[1] === optionsHash) { + if (fileCache?.entry && fileCache.entry[1] === fileCache.optionsHash) { + source = readSource(); + const { entry } = fileCache; + if (entry[0] === contentHash) { return { status: entry[2] === 'clean' ? 'unchanged' : 'changed' }; } } const { formatFmtSource } = await import('./format.ts'); - const result = await formatFmtSource(file, () => (source ??= readFileSync(file.path, 'utf8'))); + const result = await formatFmtSource(file, () => (source ??= readSource())); if (result.status === 'unsupported') { return { status: 'unsupported' }; } @@ -50,11 +58,15 @@ const formatFile = async ({ } const status = unchanged ? 'unchanged' : 'changed'; - if (!cache || contentHash === undefined) { + if (!fileCache || contentHash === undefined) { return { status }; } - const cacheEntry: FmtCacheEntry = [contentHash, cache.optionsHash, unchanged ? 'clean' : 'dirty']; + const cacheEntry: FmtCacheEntry = [ + contentHash, + fileCache.optionsHash, + unchanged ? 'clean' : 'dirty', + ]; return { status, cacheEntry }; }; diff --git a/packages/rstack/tests/fmt/worker.test.ts b/packages/rstack/tests/fmt/worker.test.ts index 7c823d6d..c7f5ba5d 100644 --- a/packages/rstack/tests/fmt/worker.test.ts +++ b/packages/rstack/tests/fmt/worker.test.ts @@ -1,3 +1,4 @@ +import path from 'node:path'; import { readFileSync } from 'node:fs'; import { expect, test } from 'rstack/test'; import { sha256 } from '../../src/fmt/cacheIdentity.ts'; @@ -72,3 +73,21 @@ test('returns cached states before resolving the parser', async () => { } }); }); + +test('resolves parser support before reading on a cache miss', async () => { + await withTempProject(async (rootPath) => { + await expect( + formatFile({ + file: { + path: path.join(rootPath, 'missing.unknown'), + options: {}, + }, + shouldWrite: false, + cache: { + entry: undefined, + optionsHash: 'options', + }, + }), + ).resolves.toEqual({ status: 'unsupported' }); + }); +}); From fabe1b10f3de2ba1ad6540a5f7a3ad32ee2c06fe Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Fri, 7 Aug 2026 09:35:16 +0800 Subject: [PATCH 08/28] feat(fmt): cache write results (#227) --- packages/rstack/src/fmt/runner.ts | 2 +- packages/rstack/src/fmt/types.ts | 2 +- packages/rstack/src/fmt/worker.ts | 27 +++++---- packages/rstack/tests/fmt/runnerCache.test.ts | 58 +++++++++++++++++-- packages/rstack/tests/fmt/worker.test.ts | 9 +-- 5 files changed, 76 insertions(+), 22 deletions(-) diff --git a/packages/rstack/src/fmt/runner.ts b/packages/rstack/src/fmt/runner.ts index e4f81b5e..230aa228 100644 --- a/packages/rstack/src/fmt/runner.ts +++ b/packages/rstack/src/fmt/runner.ts @@ -183,7 +183,7 @@ const runFmtFiles = async ({ }: RunFmtFilesOptions): Promise => { const shouldWrite = mode === 'write'; let runCache: RunCache | undefined; - if (files.length > 0 && cache && !shouldWrite) { + if (files.length > 0 && cache) { runCache = { store: await loadFmtCacheStore(cache.filePath, cacheNamespace), resolveKey: createCacheKeyResolver(cache.rootPath), diff --git a/packages/rstack/src/fmt/types.ts b/packages/rstack/src/fmt/types.ts index f3cabb92..cf258e41 100644 --- a/packages/rstack/src/fmt/types.ts +++ b/packages/rstack/src/fmt/types.ts @@ -99,7 +99,7 @@ interface RunFmtFilesOptions { mode: FmtMode; /** Maximum number of formatting workers. */ maxWorkers?: number; - /** Internal persistent cache context. Currently used only by check and list modes. */ + /** Internal persistent cache context. */ cache?: FmtCacheContext; } diff --git a/packages/rstack/src/fmt/worker.ts b/packages/rstack/src/fmt/worker.ts index 330e9b20..8b42b922 100644 --- a/packages/rstack/src/fmt/worker.ts +++ b/packages/rstack/src/fmt/worker.ts @@ -11,7 +11,7 @@ interface FormatFileTask { cache?: FmtFileCache; } -const hashContent = (content: Uint8Array): string => +const hashContent = (content: string | Uint8Array): string => createHash('sha256').update(content).digest('hex'); /** @@ -25,10 +25,9 @@ const formatFile = async ({ }: FormatFileTask): Promise => { let source: string | undefined; let contentHash: string | undefined; - const fileCache = shouldWrite ? undefined : cache; - const readSource = (): string => { - if (!fileCache) { + const readSource = (shouldHash = !shouldWrite): string => { + if (!cache || !shouldHash) { return readFileSync(file.path, 'utf8'); } @@ -37,10 +36,10 @@ const formatFile = async ({ return content.toString('utf8'); }; - if (fileCache?.entry && fileCache.entry[1] === fileCache.optionsHash) { - source = readSource(); - const { entry } = fileCache; - if (entry[0] === contentHash) { + if (cache?.entry && cache.entry[1] === cache.optionsHash) { + source = readSource(true); + const { entry } = cache; + if (entry[0] === contentHash && (!shouldWrite || entry[2] === 'clean')) { return { status: entry[2] === 'clean' ? 'unchanged' : 'changed' }; } } @@ -58,14 +57,18 @@ const formatFile = async ({ } const status = unchanged ? 'unchanged' : 'changed'; - if (!fileCache || contentHash === undefined) { + if (!cache) { return { status }; } + const cacheHash = + shouldWrite && !unchanged + ? hashContent(result.formatted) + : (contentHash ?? hashContent(result.source)); const cacheEntry: FmtCacheEntry = [ - contentHash, - fileCache.optionsHash, - unchanged ? 'clean' : 'dirty', + cacheHash, + cache.optionsHash, + shouldWrite || unchanged ? 'clean' : 'dirty', ]; return { status, cacheEntry }; }; diff --git a/packages/rstack/tests/fmt/runnerCache.test.ts b/packages/rstack/tests/fmt/runnerCache.test.ts index b41edb19..781c35e1 100644 --- a/packages/rstack/tests/fmt/runnerCache.test.ts +++ b/packages/rstack/tests/fmt/runnerCache.test.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, statSync, utimesSync, writeFileSync } from 'node:fs'; +import { readFileSync, statSync, utimesSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { expect, test } from 'rstack/test'; import { cacheNamespace, createOptionsHasher, sha256 } from '../../src/fmt/cacheIdentity.ts'; @@ -160,17 +160,67 @@ test('does not cache formatting errors', async () => { }); }); -test('does not apply the cache in write mode yet', async () => { +test('write persists clean results for misses and hits', async () => { + await withTempProject(async (rootPath) => { + const cleanPath = path.join(rootPath, 'clean.ts'); + const dirtyPath = path.join(rootPath, 'dirty.ts'); + const cache = createCache(rootPath); + writeFileSync(cleanPath, 'const clean = 1;\n'); + writeFileSync(dirtyPath, 'const dirty=1'); + + const files = [createRequest(cleanPath), createRequest(dirtyPath)]; + await expect(run(files, 'write', cache)).resolves.toMatchObject({ + exitCode: 0, + files: [{ path: dirtyPath, status: 'written' }], + processedFileCount: 2, + }); + + const store = await loadFmtCacheStore(cache.filePath, cacheNamespace); + expect(store.get('clean.ts')).toEqual([ + sha256(readFileSync(cleanPath)), + expect.any(String), + 'clean', + ]); + expect(store.get('dirty.ts')).toEqual([ + sha256(readFileSync(dirtyPath)), + expect.any(String), + 'clean', + ]); + + const timestamps = files.map((file) => statSync(file.path).mtimeMs); + await expect(run(files, 'write', cache)).resolves.toMatchObject({ + exitCode: 0, + files: [], + processedFileCount: 2, + }); + expect(files.map((file) => statSync(file.path).mtimeMs)).toEqual(timestamps); + }); +}); + +test('write converts a dirty entry to clean', async () => { await withTempProject(async (rootPath) => { const filePath = path.join(rootPath, 'index.ts'); const cache = createCache(rootPath); + const file = createRequest(filePath); writeFileSync(filePath, 'const value=1'); - await expect(run([createRequest(filePath)], 'write', cache)).resolves.toMatchObject({ + await run([file], 'check', cache); + + await expect(run([file], 'write', cache)).resolves.toMatchObject({ exitCode: 0, files: [{ path: filePath, status: 'written' }], }); expect(readFileSync(filePath, 'utf8')).toBe('const value = 1;\n'); - expect(existsSync(cache.filePath)).toBe(false); + + const store = await loadFmtCacheStore(cache.filePath, cacheNamespace); + expect(store.get('index.ts')).toEqual([ + sha256(readFileSync(filePath)), + expect.any(String), + 'clean', + ]); + await expect(run([file], 'check', cache)).resolves.toMatchObject({ + exitCode: 0, + files: [], + }); }); }); diff --git a/packages/rstack/tests/fmt/worker.test.ts b/packages/rstack/tests/fmt/worker.test.ts index c7f5ba5d..4b71eac7 100644 --- a/packages/rstack/tests/fmt/worker.test.ts +++ b/packages/rstack/tests/fmt/worker.test.ts @@ -51,9 +51,10 @@ test('returns cached states before resolving the parser', async () => { const contentHash = sha256(source); const optionsHash = 'options'; - for (const [state, status] of [ - ['clean', 'unchanged'], - ['dirty', 'changed'], + for (const [state, shouldWrite, status] of [ + ['clean', false, 'unchanged'], + ['dirty', false, 'changed'], + ['clean', true, 'unchanged'], ] as const) { await expect( formatFile({ @@ -63,7 +64,7 @@ test('returns cached states before resolving the parser', async () => { parser: 'unknown-parser', }, }, - shouldWrite: false, + shouldWrite, cache: { entry: [contentHash, optionsHash, state], optionsHash, From faee25173826d7a11a8f3bb3f89da6f05e4320ac Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Fri, 7 Aug 2026 10:20:21 +0800 Subject: [PATCH 09/28] feat(fmt): enable cache by default (#228) --- packages/rstack/src/fmt/cli.ts | 21 +++++ packages/rstack/tests/cli/fmt/index.test.ts | 87 ++++++++++++++++++- .../tests/fmt/__snapshots__/cli.test.ts.snap | 1 + packages/rstack/tests/fmt/cli.test.ts | 18 ++-- website/docs/en/guide/cli/fmt.mdx | 12 +++ website/docs/en/guide/formatting.mdx | 15 ++++ website/docs/zh/guide/cli/fmt.mdx | 12 +++ website/docs/zh/guide/formatting.mdx | 15 ++++ 8 files changed, 170 insertions(+), 11 deletions(-) diff --git a/packages/rstack/src/fmt/cli.ts b/packages/rstack/src/fmt/cli.ts index a01416df..f1ad9bb2 100644 --- a/packages/rstack/src/fmt/cli.ts +++ b/packages/rstack/src/fmt/cli.ts @@ -1,7 +1,10 @@ +import path from 'node:path'; import { performance } from 'node:perf_hooks'; import { color, logger } from 'rslog'; import { parseArgs } from '../cli/args.ts'; import { loadRstackConfig } from '../config.ts'; +import { ensureProjectCacheDir } from '../projectCache.ts'; +import { fmtCacheFileName } from './cacheStore.ts'; import { resolveFmtConfig } from './config.ts'; import { discoverFmtFiles } from './discovery.ts'; import { createRelativePathResolver, toPosixPath } from './pathHelpers.ts'; @@ -9,6 +12,7 @@ import { runFmtFiles } from './runner.ts'; import type { FmtMode, FmtRunResult, ResolvedFmtConfig } from './types.ts'; interface ParsedFmtCLIArgs { + cache: boolean; mode: FmtMode; patterns: string[]; ignorePaths: string[]; @@ -34,6 +38,7 @@ ${color.cyan('Options')}: -l, --list-different Print paths of unformatted files --ignore-path Path to an additional ignore file (repeatable) -u, --ignore-unknown Ignore unknown files + --no-cache Disable the formatting cache --no-error-on-unmatched-pattern Do not error when no files match --with-node-modules Process files inside node_modules --parallel-workers Number of parallel workers @@ -62,6 +67,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => { 'list-different': { type: 'boolean', short: 'l' }, 'ignore-path': { type: 'string', multiple: true }, 'ignore-unknown': { type: 'boolean', short: 'u' }, + 'no-cache': { type: 'boolean' }, 'no-error-on-unmatched-pattern': { type: 'boolean' }, 'with-node-modules': { type: 'boolean' }, 'parallel-workers': { type: 'string' }, @@ -81,6 +87,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => { } const mode = check ? 'check' : listDifferent ? 'list-different' : 'write'; + const cache = !(values.noCache ?? false); const ignorePaths = values.ignorePath ?? []; const ignoreUnknown = values.ignoreUnknown ?? false; const noErrorOnUnmatchedPattern = values.noErrorOnUnmatchedPattern ?? false; @@ -103,6 +110,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => { } return { + cache, mode, patterns: positionals, ignorePaths, @@ -238,6 +246,7 @@ const runFmtCLI = async (args: string[]): Promise => { // exit code identifies "rs fmt refused to run". try { const { + cache, help, ignorePaths, ignoreUnknown, @@ -287,6 +296,17 @@ const runFmtCLI = async (args: string[]): Promise => { return; } + let cacheContext; + if (cache) { + const cacheDir = await ensureProjectCacheDir(config.rootPath); + if (cacheDir.status === 'available') { + cacheContext = { + filePath: path.join(cacheDir.path, fmtCacheFileName), + rootPath: config.rootPath, + }; + } + } + if (mode === 'check') { logger.start('Checking formatting...'); } @@ -295,6 +315,7 @@ const runFmtCLI = async (args: string[]): Promise => { files, mode, maxWorkers, + cache: cacheContext, }); if (result.processedFileCount === 0) { diff --git a/packages/rstack/tests/cli/fmt/index.test.ts b/packages/rstack/tests/cli/fmt/index.test.ts index 166cc6b7..d5ab131b 100644 --- a/packages/rstack/tests/cli/fmt/index.test.ts +++ b/packages/rstack/tests/cli/fmt/index.test.ts @@ -1,5 +1,5 @@ import { spawnSync } from 'node:child_process'; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { afterEach, beforeEach, expect, test } from 'rstack/test'; import { RSTACK_BIN_PATH } from '#test-helpers'; @@ -33,19 +33,19 @@ const writeFixturePlugin = (): void => { ); }; -const runCLI = (args: string[], input?: string) => { +const runCLI = (args: string[], input?: string, cwd = projectPath) => { const env: NodeJS.ProcessEnv = { ...process.env, NO_COLOR: '1' }; delete env.FORCE_COLOR; return spawnSync(process.execPath, [RSTACK_BIN_PATH, ...args], { - cwd: projectPath, + cwd, encoding: 'utf8', env, input, }); }; -const runFmt = (args: string[] = []) => runCLI(['fmt', ...args]); +const runFmt = (args: string[] = [], cwd = projectPath) => runCLI(['fmt', ...args], undefined, cwd); const runFmtStdin = (args: string[], input: string) => runCLI(['fmt', ...args], input); @@ -164,6 +164,83 @@ test('summarizes write mode when no files change', () => { expect(result.stderr).toBe(''); }); +test.each([ + ['write', []], + ['check', ['--check']], + ['list-different', ['--list-different']], +] as const)('uses the default cache in %s mode', (_, args) => { + writeProjectFile('index.ts', 'const value = 1;\n'); + + const result = runFmt([...args, 'index.ts']); + + expect(result.status).toBe(0); + expect(readProjectFile('.rstack/cache/.gitignore')).toBe('*\n'); + expect(JSON.parse(readProjectFile('.rstack/cache/fmt-v1.json'))).toMatchObject({ + version: 1, + files: { + 'index.ts': [expect.any(String), expect.any(String), 'clean'], + }, + }); +}); + +test('--no-cache bypasses cache reads and writes', () => { + writeProjectFile('index.ts', 'const value=1'); + + const first = runFmt(['--no-cache', 'index.ts']); + + expect(first.status).toBe(0); + expect(existsSync(path.join(projectPath, '.rstack'))).toBe(false); + + writeProjectFile('.rstack/cache/fmt-v1.json', 'stale'); + writeProjectFile('index.ts', 'const value=2'); + const second = runFmt(['--no-cache', 'index.ts']); + + expect(second.status).toBe(0); + expect(readProjectFile('index.ts')).toBe('const value = 2;\n'); + expect(readProjectFile('.rstack/cache/fmt-v1.json')).toBe('stale'); + expect(existsSync(path.join(projectPath, '.rstack/cache/.gitignore'))).toBe(false); +}); + +test('uses an explicit config root cache from a subdirectory', () => { + const appPath = path.join(projectPath, 'packages/app'); + writeProjectFile('packages/app/index.ts', 'const value=1'); + + const result = runFmt(['index.ts', '--config', '../../rstack.config.ts'], appPath); + + expect(result.status).toBe(0); + expect(readProjectFile('packages/app/index.ts')).toBe('const value = 1;\n'); + expect(existsSync(path.join(projectPath, '.rstack/cache/fmt-v1.json'))).toBe(true); + expect(existsSync(path.join(appPath, '.rstack'))).toBe(false); + expect(JSON.parse(readProjectFile('.rstack/cache/fmt-v1.json'))).toMatchObject({ + files: { + 'packages/app/index.ts': [expect.any(String), expect.any(String), 'clean'], + }, + }); +}); + +test('recovers from a corrupted cache', () => { + writeProjectFile('index.ts', 'const value = 1;\n'); + const first = runFmt(['--check', 'index.ts']); + writeProjectFile('.rstack/cache/fmt-v1.json', '{'); + + const second = runFmt(['--check', 'index.ts']); + + expect(second.status).toBe(0); + expect(normalizeDuration(second.stdout)).toBe(normalizeDuration(first.stdout)); + expect(second.stderr).toBe(first.stderr); + expect(JSON.parse(readProjectFile('.rstack/cache/fmt-v1.json'))).toMatchObject({ version: 1 }); +}); + +test('formats without a writable cache directory', () => { + writeProjectFile('.rstack', 'not a directory'); + writeProjectFile('index.ts', 'const value=1'); + + const result = runFmt(['index.ts']); + + expect(result.status).toBe(0); + expect(readProjectFile('index.ts')).toBe('const value = 1;\n'); +}); + test('does not sort package.json by default', () => { writeProjectFile('package.json', packageJsonSource); @@ -461,6 +538,7 @@ test('formats stdin for the given filepath', () => { expect(result.status).toBe(0); expect(result.stdout).toBe('const message = "hello";\n'); expect(result.stderr).toBe(''); + expect(existsSync(path.join(projectPath, '.rstack'))).toBe(false); }); test('applies define.fmt options and overrides to stdin', () => { @@ -622,6 +700,7 @@ test('returns exit code 2 when no files match', () => { ); expect(result.stderr).not.toContain('\n at '); } + expect(existsSync(path.join(projectPath, '.rstack'))).toBe(false); }); test('allows no files to match with --no-error-on-unmatched-pattern', () => { diff --git a/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap b/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap index d0d098a5..3dda6d66 100644 --- a/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap +++ b/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap @@ -12,6 +12,7 @@ Options: -l, --list-different Print paths of unformatted files --ignore-path Path to an additional ignore file (repeatable) -u, --ignore-unknown Ignore unknown files + --no-cache Disable the formatting cache --no-error-on-unmatched-pattern Do not error when no files match --with-node-modules Process files inside node_modules --parallel-workers Number of parallel workers diff --git a/packages/rstack/tests/fmt/cli.test.ts b/packages/rstack/tests/fmt/cli.test.ts index 4512e433..dd8cf664 100644 --- a/packages/rstack/tests/fmt/cli.test.ts +++ b/packages/rstack/tests/fmt/cli.test.ts @@ -19,6 +19,7 @@ test.each([ test('uses write mode by default', () => { expect(parseFmtCLIArgs([])).toEqual({ + cache: true, mode: 'write', patterns: [], ignorePaths: [], @@ -38,6 +39,7 @@ test.each([ ['--list-different', 'list-different'], ] as const)('parses %s mode', (option, mode) => { expect(parseFmtCLIArgs([option])).toEqual({ + cache: true, mode, patterns: [], ignorePaths: [], @@ -51,6 +53,7 @@ test.each([ test('configures parallel worker count', () => { expect(parseFmtCLIArgs(['--parallel-workers', '3'])).toEqual({ + cache: true, mode: 'write', patterns: [], ignorePaths: [], @@ -75,6 +78,7 @@ test('preserves file paths and globs', () => { const patterns = ['src/file with spaces.ts', 'src/**/*.{js,ts}', '!src/generated/**']; expect(parseFmtCLIArgs([patterns[0], '--check', ...patterns.slice(1)])).toEqual({ + cache: true, mode: 'check', patterns, ignorePaths: [], @@ -88,6 +92,7 @@ test('preserves file paths and globs', () => { test('treats arguments after the terminator as paths', () => { expect(parseFmtCLIArgs(['--check', '--', '--write', '--help'])).toEqual({ + cache: true, mode: 'check', patterns: ['--write', '--help'], ignorePaths: [], @@ -118,12 +123,17 @@ test.each(['-u', '--ignore-unknown', '--ignoreUnknown'])('parses %s', (option) = expect(parseFmtCLIArgs([option]).ignoreUnknown).toBe(true); }); +test('parses --no-cache', () => { + expect(parseFmtCLIArgs(['--no-cache']).cache).toBe(false); +}); + test('parses --with-node-modules', () => { expect(parseFmtCLIArgs(['--with-node-modules']).withNodeModules).toBe(true); }); test('parses --stdin-filepath', () => { expect(parseFmtCLIArgs(['--stdin-filepath', 'src/index.ts'])).toEqual({ + cache: true, mode: 'write', patterns: [], ignorePaths: [], @@ -138,6 +148,7 @@ test('parses --stdin-filepath', () => { test('accepts a worker count with --stdin-filepath', () => { expect(parseFmtCLIArgs(['--stdin-filepath', 'index.ts', '--parallel-workers', '2'])).toEqual({ + cache: true, mode: 'write', patterns: [], ignorePaths: [], @@ -182,10 +193,3 @@ test.each([ 'The --write, --check, and --list-different options cannot be used together.', ); }); - -test.each(['--unknown', '--no-cache', '--no-parallel'])( - 'rejects unsupported option %s', - (option) => { - expect(() => parseFmtCLIArgs([option])).toThrow(); - }, -); diff --git a/website/docs/en/guide/cli/fmt.mdx b/website/docs/en/guide/cli/fmt.mdx index 0bb50761..783b9b7a 100644 --- a/website/docs/en/guide/cli/fmt.mdx +++ b/website/docs/en/guide/cli/fmt.mdx @@ -121,6 +121,18 @@ rs fmt -l The option uses the same exit codes as `--check` and cannot be combined with `--write` or `--check`. +### `--no-cache` + +Disable the persistent formatting cache for the current invocation: + +```bash +rs fmt --no-cache +``` + +Without this option, `rs fmt` stores cache data in `.rstack/cache` under the Rstack configuration root. `--no-cache` prevents the command from reading, creating, or updating that cache. Stdin formatting never uses the persistent cache. + +See [Cache](../formatting#cache) for cache behavior and cleanup guidance. + ### `--no-error-on-unmatched-pattern` Exit successfully without diagnostics when no files match the provided paths or globs, including when all matching files are ignored: diff --git a/website/docs/en/guide/formatting.mdx b/website/docs/en/guide/formatting.mdx index 27c1ce68..e0f008b7 100644 --- a/website/docs/en/guide/formatting.mdx +++ b/website/docs/en/guide/formatting.mdx @@ -6,6 +6,7 @@ Rstack CLI includes a formatter built on [Prettier](https://prettier.io/). Compa - **Parallel formatting**: Files are formatted concurrently in a worker pool. - **Yuku parser**: The high-performance [Yuku](https://yuku.fyi/) parser is used by default for JavaScript, JSX, and TypeScript files. +- **Persistent cache**: Content-based results let later runs skip formatting unchanged files. `rs fmt` supports Prettier options and plugins and adds built-in capabilities such as [sorting package.json fields](#sort-package-json). @@ -173,6 +174,20 @@ define.fmt({ }); ``` +## Cache + +`rs fmt` uses a persistent cache by default for file-based `--write`, `--check`, and `--list-different` runs. Cache entries use file content and final formatting options, so changing either causes the file to be formatted again. Files that use custom Prettier plugins currently bypass the cache. + +The default cache file is `.rstack/cache/fmt-v1.json` under the Rstack configuration root. When a command runs from a subdirectory, it continues to use the cache next to the resolved `rstack.config.*` file. Stdin formatting does not use this cache. + +Use [`--no-cache`](./cli/fmt#--no-cache) to run without reading, creating, or updating the cache: + +```bash +rs fmt --no-cache +``` + +You can safely delete `.rstack/cache` to clear cached results. Do not treat the entire `.rstack` directory as disposable because it may also contain user-maintained Git hook scripts. + ## Prettier plugins To add formatting capabilities that are not built into Rstack, install the corresponding [Prettier plugin](https://prettier.io/docs/plugins) and add it to `plugins`. Plugins can be referenced by package name, file path, or URL. Package names and relative paths are resolved from the directory containing the Rstack configuration file. diff --git a/website/docs/zh/guide/cli/fmt.mdx b/website/docs/zh/guide/cli/fmt.mdx index 290e6158..bae72136 100644 --- a/website/docs/zh/guide/cli/fmt.mdx +++ b/website/docs/zh/guide/cli/fmt.mdx @@ -121,6 +121,18 @@ rs fmt -l 此选项与 `--check` 使用相同的退出状态码,且不能与 `--write` 或 `--check` 同时使用。 +### `--no-cache` + +在当前调用中关闭持久化格式化缓存: + +```bash +rs fmt --no-cache +``` + +默认情况下,`rs fmt` 会将缓存数据保存在 Rstack 配置根目录下的 `.rstack/cache` 中。`--no-cache` 会阻止命令读取、创建或更新该缓存。stdin 格式化始终不会使用持久化缓存。 + +缓存行为和清理方式请参考[缓存](../formatting#cache)。 + ### `--no-error-on-unmatched-pattern` 如果传入的路径或 glob 没有匹配任何文件(包括所有匹配文件均被忽略的情况),则不输出诊断信息并成功退出: diff --git a/website/docs/zh/guide/formatting.mdx b/website/docs/zh/guide/formatting.mdx index 3b397051..7a6a09eb 100644 --- a/website/docs/zh/guide/formatting.mdx +++ b/website/docs/zh/guide/formatting.mdx @@ -6,6 +6,7 @@ Rstack CLI 提供了基于 [Prettier](https://prettier.io/) 的格式化工具 - **并行格式化**:通过 worker 池并行格式化文件。 - **Yuku 解析器**:默认使用高性能的 [Yuku](https://yuku.fyi/) 解析器处理 JavaScript、JSX 和 TypeScript 文件。 +- **持久化缓存**:基于文件内容缓存结果,后续运行可以跳过未变化文件的格式化。 `rs fmt` 兼容 Prettier 的选项和插件,并提供更多内置能力,例如支持[排序 package.json 字段](#sort-package-json)。 @@ -173,6 +174,20 @@ define.fmt({ }); ``` +## 缓存 \{#cache} + +`rs fmt` 默认会在基于文件的 `--write`、`--check` 和 `--list-different` 调用中使用持久化缓存。缓存条目基于文件内容和最终格式化选项;任意一项发生变化时,文件都会重新格式化。使用自定义 Prettier 插件的文件目前会绕过缓存。 + +默认缓存文件位于 Rstack 配置根目录下的 `.rstack/cache/fmt-v1.json`。从子目录运行命令时,仍会使用解析到的 `rstack.config.*` 文件旁的缓存。stdin 格式化不会使用该缓存。 + +使用 [`--no-cache`](./cli/fmt#--no-cache) 可以在运行时跳过缓存读取、创建和更新: + +```bash +rs fmt --no-cache +``` + +可以安全删除 `.rstack/cache` 来清理缓存结果。不要将整个 `.rstack` 目录视为可随意删除的内容,因为其中还可能包含用户维护的 Git hook 脚本。 + ## Prettier 插件 \{#prettier-plugins} 如果需要使用 Rstack 未内置的格式化能力,可以安装相应的 [Prettier 插件](https://prettier.io/docs/plugins),并添加到 `plugins` 中。插件支持通过包名、文件路径或 URL 引用,其中包名和相对路径基于 Rstack 配置文件所在的目录解析。 From fe9c028b21c190075fa65cb1125f95b668ca2e03 Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Fri, 7 Aug 2026 13:08:42 +0800 Subject: [PATCH 10/28] feat(fmt): use dedicated cache directory (#229) --- packages/rstack/src/fmt/cacheStore.ts | 2 +- packages/rstack/src/fmt/cli.ts | 2 +- packages/rstack/tests/cli/fmt/index.test.ts | 12 +++++++----- website/docs/en/guide/cli/fmt.mdx | 2 +- website/docs/en/guide/formatting.mdx | 2 +- website/docs/zh/guide/cli/fmt.mdx | 2 +- website/docs/zh/guide/formatting.mdx | 2 +- 7 files changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/rstack/src/fmt/cacheStore.ts b/packages/rstack/src/fmt/cacheStore.ts index 1eca9562..e0ab7372 100644 --- a/packages/rstack/src/fmt/cacheStore.ts +++ b/packages/rstack/src/fmt/cacheStore.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'; import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; -const fmtCacheFileName = 'fmt-v1.json'; +const fmtCacheFileName = 'v1.json'; const fmtCacheVersion = 1; type FmtCacheState = 'clean' | 'dirty'; diff --git a/packages/rstack/src/fmt/cli.ts b/packages/rstack/src/fmt/cli.ts index f1ad9bb2..be253279 100644 --- a/packages/rstack/src/fmt/cli.ts +++ b/packages/rstack/src/fmt/cli.ts @@ -301,7 +301,7 @@ const runFmtCLI = async (args: string[]): Promise => { const cacheDir = await ensureProjectCacheDir(config.rootPath); if (cacheDir.status === 'available') { cacheContext = { - filePath: path.join(cacheDir.path, fmtCacheFileName), + filePath: path.join(cacheDir.path, 'fmt', fmtCacheFileName), rootPath: config.rootPath, }; } diff --git a/packages/rstack/tests/cli/fmt/index.test.ts b/packages/rstack/tests/cli/fmt/index.test.ts index d5ab131b..23b7e0d6 100644 --- a/packages/rstack/tests/cli/fmt/index.test.ts +++ b/packages/rstack/tests/cli/fmt/index.test.ts @@ -170,17 +170,19 @@ test.each([ ['list-different', ['--list-different']], ] as const)('uses the default cache in %s mode', (_, args) => { writeProjectFile('index.ts', 'const value = 1;\n'); + writeProjectFile('.rstack/cache/fmt-v1.json', 'legacy'); const result = runFmt([...args, 'index.ts']); expect(result.status).toBe(0); expect(readProjectFile('.rstack/cache/.gitignore')).toBe('*\n'); - expect(JSON.parse(readProjectFile('.rstack/cache/fmt-v1.json'))).toMatchObject({ + expect(JSON.parse(readProjectFile('.rstack/cache/fmt/v1.json'))).toMatchObject({ version: 1, files: { 'index.ts': [expect.any(String), expect.any(String), 'clean'], }, }); + expect(readProjectFile('.rstack/cache/fmt-v1.json')).toBe('legacy'); }); test('--no-cache bypasses cache reads and writes', () => { @@ -209,9 +211,9 @@ test('uses an explicit config root cache from a subdirectory', () => { expect(result.status).toBe(0); expect(readProjectFile('packages/app/index.ts')).toBe('const value = 1;\n'); - expect(existsSync(path.join(projectPath, '.rstack/cache/fmt-v1.json'))).toBe(true); + expect(existsSync(path.join(projectPath, '.rstack/cache/fmt/v1.json'))).toBe(true); expect(existsSync(path.join(appPath, '.rstack'))).toBe(false); - expect(JSON.parse(readProjectFile('.rstack/cache/fmt-v1.json'))).toMatchObject({ + expect(JSON.parse(readProjectFile('.rstack/cache/fmt/v1.json'))).toMatchObject({ files: { 'packages/app/index.ts': [expect.any(String), expect.any(String), 'clean'], }, @@ -221,14 +223,14 @@ test('uses an explicit config root cache from a subdirectory', () => { test('recovers from a corrupted cache', () => { writeProjectFile('index.ts', 'const value = 1;\n'); const first = runFmt(['--check', 'index.ts']); - writeProjectFile('.rstack/cache/fmt-v1.json', '{'); + writeProjectFile('.rstack/cache/fmt/v1.json', '{'); const second = runFmt(['--check', 'index.ts']); expect(second.status).toBe(0); expect(normalizeDuration(second.stdout)).toBe(normalizeDuration(first.stdout)); expect(second.stderr).toBe(first.stderr); - expect(JSON.parse(readProjectFile('.rstack/cache/fmt-v1.json'))).toMatchObject({ version: 1 }); + expect(JSON.parse(readProjectFile('.rstack/cache/fmt/v1.json'))).toMatchObject({ version: 1 }); }); test('formats without a writable cache directory', () => { diff --git a/website/docs/en/guide/cli/fmt.mdx b/website/docs/en/guide/cli/fmt.mdx index 783b9b7a..6861b890 100644 --- a/website/docs/en/guide/cli/fmt.mdx +++ b/website/docs/en/guide/cli/fmt.mdx @@ -129,7 +129,7 @@ Disable the persistent formatting cache for the current invocation: rs fmt --no-cache ``` -Without this option, `rs fmt` stores cache data in `.rstack/cache` under the Rstack configuration root. `--no-cache` prevents the command from reading, creating, or updating that cache. Stdin formatting never uses the persistent cache. +Without this option, `rs fmt` stores cache data in `.rstack/cache/fmt` under the Rstack configuration root. `--no-cache` prevents the command from reading, creating, or updating that cache. Stdin formatting never uses the persistent cache. See [Cache](../formatting#cache) for cache behavior and cleanup guidance. diff --git a/website/docs/en/guide/formatting.mdx b/website/docs/en/guide/formatting.mdx index e0f008b7..4064d652 100644 --- a/website/docs/en/guide/formatting.mdx +++ b/website/docs/en/guide/formatting.mdx @@ -178,7 +178,7 @@ define.fmt({ `rs fmt` uses a persistent cache by default for file-based `--write`, `--check`, and `--list-different` runs. Cache entries use file content and final formatting options, so changing either causes the file to be formatted again. Files that use custom Prettier plugins currently bypass the cache. -The default cache file is `.rstack/cache/fmt-v1.json` under the Rstack configuration root. When a command runs from a subdirectory, it continues to use the cache next to the resolved `rstack.config.*` file. Stdin formatting does not use this cache. +The default cache directory is `.rstack/cache/fmt` under the Rstack configuration root. When a command runs from a subdirectory, it continues to use the cache next to the resolved `rstack.config.*` file. Stdin formatting does not use this cache. Use [`--no-cache`](./cli/fmt#--no-cache) to run without reading, creating, or updating the cache: diff --git a/website/docs/zh/guide/cli/fmt.mdx b/website/docs/zh/guide/cli/fmt.mdx index bae72136..e810c263 100644 --- a/website/docs/zh/guide/cli/fmt.mdx +++ b/website/docs/zh/guide/cli/fmt.mdx @@ -129,7 +129,7 @@ rs fmt -l rs fmt --no-cache ``` -默认情况下,`rs fmt` 会将缓存数据保存在 Rstack 配置根目录下的 `.rstack/cache` 中。`--no-cache` 会阻止命令读取、创建或更新该缓存。stdin 格式化始终不会使用持久化缓存。 +默认情况下,`rs fmt` 会将缓存数据保存在 Rstack 配置根目录下的 `.rstack/cache/fmt` 中。`--no-cache` 会阻止命令读取、创建或更新该缓存。stdin 格式化始终不会使用持久化缓存。 缓存行为和清理方式请参考[缓存](../formatting#cache)。 diff --git a/website/docs/zh/guide/formatting.mdx b/website/docs/zh/guide/formatting.mdx index 7a6a09eb..71f580bc 100644 --- a/website/docs/zh/guide/formatting.mdx +++ b/website/docs/zh/guide/formatting.mdx @@ -178,7 +178,7 @@ define.fmt({ `rs fmt` 默认会在基于文件的 `--write`、`--check` 和 `--list-different` 调用中使用持久化缓存。缓存条目基于文件内容和最终格式化选项;任意一项发生变化时,文件都会重新格式化。使用自定义 Prettier 插件的文件目前会绕过缓存。 -默认缓存文件位于 Rstack 配置根目录下的 `.rstack/cache/fmt-v1.json`。从子目录运行命令时,仍会使用解析到的 `rstack.config.*` 文件旁的缓存。stdin 格式化不会使用该缓存。 +默认缓存目录位于 Rstack 配置根目录下的 `.rstack/cache/fmt`。从子目录运行命令时,仍会使用解析到的 `rstack.config.*` 文件旁的缓存。stdin 格式化不会使用该缓存。 使用 [`--no-cache`](./cli/fmt#--no-cache) 可以在运行时跳过缓存读取、创建和更新: From 78f6d7b254f7f022b6919e2e947b11d1df03459f Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Fri, 7 Aug 2026 15:08:47 +0800 Subject: [PATCH 11/28] feat(fmt): support custom cache directories (#230) --- packages/rstack/src/fmt/cli.ts | 19 +++++++- packages/rstack/src/fmt/discovery.ts | 16 ++++++- packages/rstack/src/fmt/types.ts | 2 + packages/rstack/tests/cli/fmt/index.test.ts | 44 ++++++++++++++++++- .../tests/fmt/__snapshots__/cli.test.ts.snap | 1 + packages/rstack/tests/fmt/cli.test.ts | 17 +++++++ packages/rstack/tests/fmt/discovery.test.ts | 24 ++++++++++ website/docs/en/guide/cli/fmt.mdx | 12 +++++ website/docs/en/guide/formatting.mdx | 2 + website/docs/zh/guide/cli/fmt.mdx | 12 +++++ website/docs/zh/guide/formatting.mdx | 2 + 11 files changed, 146 insertions(+), 5 deletions(-) diff --git a/packages/rstack/src/fmt/cli.ts b/packages/rstack/src/fmt/cli.ts index be253279..7b1dd4d3 100644 --- a/packages/rstack/src/fmt/cli.ts +++ b/packages/rstack/src/fmt/cli.ts @@ -13,6 +13,7 @@ import type { FmtMode, FmtRunResult, ResolvedFmtConfig } from './types.ts'; interface ParsedFmtCLIArgs { cache: boolean; + cacheLocation?: string; mode: FmtMode; patterns: string[]; ignorePaths: string[]; @@ -39,6 +40,7 @@ ${color.cyan('Options')}: --ignore-path Path to an additional ignore file (repeatable) -u, --ignore-unknown Ignore unknown files --no-cache Disable the formatting cache + --cache-location Path to the formatting cache directory --no-error-on-unmatched-pattern Do not error when no files match --with-node-modules Process files inside node_modules --parallel-workers Number of parallel workers @@ -68,6 +70,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => { 'ignore-path': { type: 'string', multiple: true }, 'ignore-unknown': { type: 'boolean', short: 'u' }, 'no-cache': { type: 'boolean' }, + 'cache-location': { type: 'string' }, 'no-error-on-unmatched-pattern': { type: 'boolean' }, 'with-node-modules': { type: 'boolean' }, 'parallel-workers': { type: 'string' }, @@ -88,6 +91,11 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => { const mode = check ? 'check' : listDifferent ? 'list-different' : 'write'; const cache = !(values.noCache ?? false); + const cacheLocation = cache ? values.cacheLocation : undefined; + if (cacheLocation === '') { + throw new Error('The --cache-location option requires a path.'); + } + const ignorePaths = values.ignorePath ?? []; const ignoreUnknown = values.ignoreUnknown ?? false; const noErrorOnUnmatchedPattern = values.noErrorOnUnmatchedPattern ?? false; @@ -111,6 +119,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => { return { cache, + cacheLocation, mode, patterns: positionals, ignorePaths, @@ -247,6 +256,7 @@ const runFmtCLI = async (args: string[]): Promise => { try { const { cache, + cacheLocation, help, ignorePaths, ignoreUnknown, @@ -277,11 +287,13 @@ const runFmtCLI = async (args: string[]): Promise => { return; } + const cacheDirPath = cacheLocation ? path.resolve(cwd, cacheLocation) : undefined; const config = await loadFmtConfig(cwd); const files = await discoverFmtFiles({ cwd, patterns, config, + excludedDirPath: cacheDirPath, ignorePaths, withNodeModules, }); @@ -297,7 +309,12 @@ const runFmtCLI = async (args: string[]): Promise => { } let cacheContext; - if (cache) { + if (cacheDirPath) { + cacheContext = { + filePath: path.join(cacheDirPath, fmtCacheFileName), + rootPath: config.rootPath, + }; + } else if (cache) { const cacheDir = await ensureProjectCacheDir(config.rootPath); if (cacheDir.status === 'available') { cacheContext = { diff --git a/packages/rstack/src/fmt/discovery.ts b/packages/rstack/src/fmt/discovery.ts index d4e012e8..40431b72 100644 --- a/packages/rstack/src/fmt/discovery.ts +++ b/packages/rstack/src/fmt/discovery.ts @@ -1,3 +1,4 @@ +import path from 'node:path'; import { createFmtOptionsResolver, type FmtOptionsResolver } from './config.ts'; import { discoverFmtPaths } from './discoverPaths.ts'; import { createIgnoreMatcher } from './ignore.ts'; @@ -11,26 +12,37 @@ const createFileRequest = ( options: resolveOptions(filePath), }); +const createDirMatcher = (dirPath: string): ((filePath: string) => boolean) => { + const prefix = dirPath.endsWith(path.sep) ? dirPath : `${dirPath}${path.sep}`; + return (filePath) => filePath === dirPath || filePath.startsWith(prefix); +}; + /** Discovers worker-ready files without automatically reading Prettier config or ignore files. */ const discoverFmtFiles = async ({ cwd, + excludedDirPath, patterns, ignorePaths, withNodeModules, config, }: DiscoverFmtFilesOptions): Promise => { const isIgnored = await createIgnoreMatcher({ config, cwd, ignorePaths }); + const isExcluded = excludedDirPath ? createDirMatcher(excludedDirPath) : undefined; + const shouldIgnore = isExcluded + ? (filePath: string, isDirectory = false) => + isExcluded(filePath) || isIgnored(filePath, isDirectory) + : isIgnored; const candidates = await discoverFmtPaths({ cwd, patterns, withNodeModules, - isIgnored, + isIgnored: shouldIgnore, }); if (candidates.length === 0) { return []; } - const filePaths = candidates.filter((filePath) => !isIgnored(filePath)); + const filePaths = candidates.filter((filePath) => !shouldIgnore(filePath)); const resolveOptions = createFmtOptionsResolver(config); const files = filePaths.map((filePath) => createFileRequest(filePath, resolveOptions)); if (!files.some((file) => file.options.plugins?.length)) { diff --git a/packages/rstack/src/fmt/types.ts b/packages/rstack/src/fmt/types.ts index cf258e41..23e2dd6f 100644 --- a/packages/rstack/src/fmt/types.ts +++ b/packages/rstack/src/fmt/types.ts @@ -55,6 +55,8 @@ interface ResolvedFmtConfig { interface DiscoverFmtFilesOptions { /** Absolute directory used to resolve input paths. */ cwd: string; + /** Absolute directory to exclude from formatting. */ + excludedDirPath?: string; /** Files, directories, and positive or negative globs. Defaults to the current directory. */ patterns?: string[]; /** Ignore files resolved from `cwd`; each file's patterns are relative to its own directory. */ diff --git a/packages/rstack/tests/cli/fmt/index.test.ts b/packages/rstack/tests/cli/fmt/index.test.ts index 23b7e0d6..265b14ca 100644 --- a/packages/rstack/tests/cli/fmt/index.test.ts +++ b/packages/rstack/tests/cli/fmt/index.test.ts @@ -187,10 +187,18 @@ test.each([ test('--no-cache bypasses cache reads and writes', () => { writeProjectFile('index.ts', 'const value=1'); - - const first = runFmt(['--no-cache', 'index.ts']); + writeProjectFile('custom-cache/v1.json', '{"value":true}'); + + const first = runFmt([ + '--no-cache', + '--cache-location', + 'custom-cache', + 'index.ts', + 'custom-cache/v1.json', + ]); expect(first.status).toBe(0); + expect(readProjectFile('custom-cache/v1.json')).toBe('{ "value": true }\n'); expect(existsSync(path.join(projectPath, '.rstack'))).toBe(false); writeProjectFile('.rstack/cache/fmt-v1.json', 'stale'); @@ -203,6 +211,38 @@ test('--no-cache bypasses cache reads and writes', () => { expect(existsSync(path.join(projectPath, '.rstack/cache/.gitignore'))).toBe(false); }); +test.each(['relative', 'absolute'] as const)('uses a %s custom cache location', (kind) => { + const cacheDir = path.join(projectPath, 'custom-cache'); + const cacheLocation = kind === 'relative' ? path.relative(projectPath, cacheDir) : cacheDir; + const cachePath = path.join(cacheDir, 'v1.json'); + writeProjectFile('index.ts', 'const value = 1;\n'); + + const result = runFmt(['--cache-location', cacheLocation, 'index.ts']); + + expect(result.status).toBe(0); + expect(JSON.parse(readFileSync(cachePath, 'utf8'))).toMatchObject({ + version: 1, + files: { + 'index.ts': [expect.any(String), expect.any(String), 'clean'], + }, + }); + expect(existsSync(path.join(projectPath, 'custom-cache/.gitignore'))).toBe(false); + expect(existsSync(path.join(projectPath, '.rstack'))).toBe(false); +}); + +test('excludes the custom cache directory from formatting', () => { + const cacheLocation = 'custom-cache'; + writeProjectFile('index.ts', 'const value = 1;\n'); + writeProjectFile('custom-cache/nested/ignored.ts', 'const value=2'); + expect(runFmt(['--cache-location', cacheLocation, 'index.ts']).status).toBe(0); + + const result = runFmt(['--cache-location', cacheLocation, '.']); + + expect(result.status).toBe(0); + expectWriteSummary(result.stdout, 2, 0); + expect(readProjectFile('custom-cache/nested/ignored.ts')).toBe('const value=2'); +}); + test('uses an explicit config root cache from a subdirectory', () => { const appPath = path.join(projectPath, 'packages/app'); writeProjectFile('packages/app/index.ts', 'const value=1'); diff --git a/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap b/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap index 3dda6d66..75708741 100644 --- a/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap +++ b/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap @@ -13,6 +13,7 @@ Options: --ignore-path Path to an additional ignore file (repeatable) -u, --ignore-unknown Ignore unknown files --no-cache Disable the formatting cache + --cache-location Path to the formatting cache directory --no-error-on-unmatched-pattern Do not error when no files match --with-node-modules Process files inside node_modules --parallel-workers Number of parallel workers diff --git a/packages/rstack/tests/fmt/cli.test.ts b/packages/rstack/tests/fmt/cli.test.ts index dd8cf664..5bd45d27 100644 --- a/packages/rstack/tests/fmt/cli.test.ts +++ b/packages/rstack/tests/fmt/cli.test.ts @@ -127,6 +127,23 @@ test('parses --no-cache', () => { expect(parseFmtCLIArgs(['--no-cache']).cache).toBe(false); }); +test('parses --cache-location', () => { + expect(parseFmtCLIArgs(['--cache-location', '.cache/fmt']).cacheLocation).toBe('.cache/fmt'); +}); + +test('--no-cache ignores --cache-location', () => { + expect(parseFmtCLIArgs(['--no-cache', '--cache-location='])).toMatchObject({ + cache: false, + cacheLocation: undefined, + }); +}); + +test('rejects an empty cache location', () => { + expect(() => parseFmtCLIArgs(['--cache-location='])).toThrow( + 'The --cache-location option requires a path.', + ); +}); + test('parses --with-node-modules', () => { expect(parseFmtCLIArgs(['--with-node-modules']).withNodeModules).toBe(true); }); diff --git a/packages/rstack/tests/fmt/discovery.test.ts b/packages/rstack/tests/fmt/discovery.test.ts index 71b902b4..171fbaae 100644 --- a/packages/rstack/tests/fmt/discovery.test.ts +++ b/packages/rstack/tests/fmt/discovery.test.ts @@ -60,6 +60,30 @@ test('excludes .rstack from discovery', async () => { }); }); +test('excludes a custom cache directory', async () => { + await withTempProject(async (rootPath) => { + const cacheDir = path.join(rootPath, 'custom-cache'); + const cacheFile = writeProjectFile(rootPath, 'custom-cache/v1.json', '{}'); + writeProjectFile(rootPath, 'custom-cache/nested/ignored.ts'); + writeProjectFile(rootPath, 'index.ts'); + + const discoveredFiles = await discoverFmtFiles({ + cwd: rootPath, + excludedDirPath: cacheDir, + config: normalizeFmtConfig(undefined, rootPath), + }); + const explicitFile = await discoverFmtFiles({ + cwd: rootPath, + excludedDirPath: cacheDir, + patterns: [cacheFile], + config: normalizeFmtConfig(undefined, rootPath), + }); + + expect(relativePaths(rootPath, discoveredFiles)).toEqual(['index.ts']); + expect(explicitFile).toEqual([]); + }); +}); + test('keeps files re-included by a CLI ignore file during directory traversal', async () => { await withTempProject(async (rootPath) => { writeProjectFile(rootPath, '.prettierignore', 'generated/*\n!generated/keep.ts\n'); diff --git a/website/docs/en/guide/cli/fmt.mdx b/website/docs/en/guide/cli/fmt.mdx index 6861b890..e2552f1e 100644 --- a/website/docs/en/guide/cli/fmt.mdx +++ b/website/docs/en/guide/cli/fmt.mdx @@ -133,6 +133,18 @@ Without this option, `rs fmt` stores cache data in `.rstack/cache/fmt` under the See [Cache](../formatting#cache) for cache behavior and cleanup guidance. +### `--cache-location ` + +Store the persistent cache in a custom directory: + +```bash +rs fmt --cache-location .cache/rs-fmt +``` + +Relative paths are resolved from the current working directory, while absolute paths are used as-is. The directory is created as needed and excluded from file discovery. Unlike the default cache location, a custom directory does not receive an automatic `.gitignore`; exclude it from version control or manage it through your CI cache configuration. + +When both options are provided, `--no-cache` takes precedence and the custom directory is not excluded from file discovery. + ### `--no-error-on-unmatched-pattern` Exit successfully without diagnostics when no files match the provided paths or globs, including when all matching files are ignored: diff --git a/website/docs/en/guide/formatting.mdx b/website/docs/en/guide/formatting.mdx index 4064d652..2710f6af 100644 --- a/website/docs/en/guide/formatting.mdx +++ b/website/docs/en/guide/formatting.mdx @@ -180,6 +180,8 @@ define.fmt({ The default cache directory is `.rstack/cache/fmt` under the Rstack configuration root. When a command runs from a subdirectory, it continues to use the cache next to the resolved `rstack.config.*` file. Stdin formatting does not use this cache. +Use [`--cache-location `](./cli/fmt#--cache-location-path) to store the cache in a different directory. Relative paths are resolved from the current working directory. Custom directories are excluded from file discovery but are not automatically ignored by Git. + Use [`--no-cache`](./cli/fmt#--no-cache) to run without reading, creating, or updating the cache: ```bash diff --git a/website/docs/zh/guide/cli/fmt.mdx b/website/docs/zh/guide/cli/fmt.mdx index e810c263..adf80c2b 100644 --- a/website/docs/zh/guide/cli/fmt.mdx +++ b/website/docs/zh/guide/cli/fmt.mdx @@ -133,6 +133,18 @@ rs fmt --no-cache 缓存行为和清理方式请参考[缓存](../formatting#cache)。 +### `--cache-location ` + +将持久化缓存保存到自定义目录: + +```bash +rs fmt --cache-location .cache/rs-fmt +``` + +相对路径基于当前工作目录解析,绝对路径则原样使用。目录会在需要时自动创建,并从文件发现中排除。与默认缓存位置不同,自定义目录不会自动生成 `.gitignore`;请将其排除在版本控制之外,或通过 CI 缓存配置进行管理。 + +同时使用两个选项时,优先使用 `--no-cache`,且不会从文件发现中排除自定义目录。 + ### `--no-error-on-unmatched-pattern` 如果传入的路径或 glob 没有匹配任何文件(包括所有匹配文件均被忽略的情况),则不输出诊断信息并成功退出: diff --git a/website/docs/zh/guide/formatting.mdx b/website/docs/zh/guide/formatting.mdx index 71f580bc..61d96b34 100644 --- a/website/docs/zh/guide/formatting.mdx +++ b/website/docs/zh/guide/formatting.mdx @@ -180,6 +180,8 @@ define.fmt({ 默认缓存目录位于 Rstack 配置根目录下的 `.rstack/cache/fmt`。从子目录运行命令时,仍会使用解析到的 `rstack.config.*` 文件旁的缓存。stdin 格式化不会使用该缓存。 +使用 [`--cache-location `](./cli/fmt#--cache-location-path) 可以将缓存保存到其他目录。相对路径基于当前工作目录解析。自定义目录会从文件发现中排除,但不会被 Git 自动忽略。 + 使用 [`--no-cache`](./cli/fmt#--no-cache) 可以在运行时跳过缓存读取、创建和更新: ```bash From 3c652be7e12945215e69160a492f1e72bae4f5cf Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Fri, 7 Aug 2026 15:31:19 +0800 Subject: [PATCH 12/28] feat(fmt): add plugin fingerprint resolver (#231) --- packages/rstack/src/fmt/plugins.ts | 86 +++++++++++++++++++++-- packages/rstack/tests/fmt/plugins.test.ts | 56 ++++++++++++++- 2 files changed, 137 insertions(+), 5 deletions(-) diff --git a/packages/rstack/src/fmt/plugins.ts b/packages/rstack/src/fmt/plugins.ts index 17ebcc24..fea4734b 100644 --- a/packages/rstack/src/fmt/plugins.ts +++ b/packages/rstack/src/fmt/plugins.ts @@ -1,11 +1,14 @@ -import { isAbsolute, join, resolve as resolvePath } from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { readFile, realpath } from 'node:fs/promises'; +import { isAbsolute, join, relative, resolve as resolvePath, sep } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { moduleResolve } from 'import-meta-resolve'; import type { Options as PrettierOptions } from 'prettier'; +import { toPosixPath } from './pathHelpers.ts'; import type { FmtPluginSpecifier, ResolvedFmtOptions } from './types.ts'; type FmtPlugin = NonNullable[number]; type FmtPluginResolver = (options: ResolvedFmtOptions) => ResolvedFmtOptions; +type FingerprintResolver = (plugin: FmtPluginSpecifier) => Promise; const resolveModuleUrl = (specifier: string, parentUrl: URL): string => moduleResolve(specifier, parentUrl).href; @@ -13,6 +16,81 @@ const resolveModuleUrl = (specifier: string, parentUrl: URL): string => const isFmtPluginSpecifier = (plugin: FmtPlugin): plugin is FmtPluginSpecifier => typeof plugin === 'string' || plugin instanceof URL; +const getPackageRoot = (entryPath: string): string | undefined => { + const marker = `${sep}node_modules${sep}`; + const index = entryPath.lastIndexOf(marker); + if (index === -1) { + return undefined; + } + const start = index + marker.length; + + let end = entryPath.indexOf(sep, start); + if (end === -1) { + return undefined; + } + if (entryPath[start] === '@') { + end = entryPath.indexOf(sep, end + 1); + if (end === -1) { + return undefined; + } + } + + return entryPath.slice(0, end); +}; + +const fingerprintPlugin = async (pluginUrl: string): Promise => { + try { + const url = new URL(pluginUrl); + if (url.protocol !== 'file:') { + return undefined; + } + + // Resolving symlinks keeps workspace and other local plugins out of the + // package-version fast path when their implementation can change in place. + const entryPath = await realpath(fileURLToPath(url)); + const packageRoot = getPackageRoot(entryPath); + if (packageRoot === undefined) { + return undefined; + } + + const pkg: unknown = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')); + if ( + typeof pkg !== 'object' || + pkg === null || + !('name' in pkg) || + typeof pkg.name !== 'string' || + !('version' in pkg) || + typeof pkg.version !== 'string' + ) { + return undefined; + } + + return JSON.stringify([ + 'package', + pkg.name, + pkg.version, + toPosixPath(relative(packageRoot, entryPath)), + ]); + } catch { + return undefined; + } +}; + +/** Creates a memoized identity resolver for installed package plugins. */ +const createFingerprintResolver = (): FingerprintResolver => { + const cache = new Map>(); + + return (plugin) => { + const pluginUrl = plugin instanceof URL ? plugin.href : plugin; + let fingerprint = cache.get(pluginUrl); + if (fingerprint === undefined) { + fingerprint = fingerprintPlugin(pluginUrl); + cache.set(pluginUrl, fingerprint); + } + return fingerprint; + }; +}; + /** Creates a project-root resolver for plugins in final per-file options. */ const createFmtPluginResolver = (rootPath: string): FmtPluginResolver => { const parentUrl = pathToFileURL(join(rootPath, 'index.js')); @@ -65,5 +143,5 @@ const createFmtPluginResolver = (rootPath: string): FmtPluginResolver => { }; }; -export { createFmtPluginResolver }; -export type { FmtPluginResolver }; +export { createFingerprintResolver, createFmtPluginResolver }; +export type { FingerprintResolver, FmtPluginResolver }; diff --git a/packages/rstack/tests/fmt/plugins.test.ts b/packages/rstack/tests/fmt/plugins.test.ts index cac6da5b..7d2a34f7 100644 --- a/packages/rstack/tests/fmt/plugins.test.ts +++ b/packages/rstack/tests/fmt/plugins.test.ts @@ -1,6 +1,6 @@ import { pathToFileURL } from 'node:url'; import { expect, test } from 'rstack/test'; -import { createFmtPluginResolver } from '../../src/fmt/plugins.ts'; +import { createFingerprintResolver, createFmtPluginResolver } from '../../src/fmt/plugins.ts'; import { withTempProject, writeProjectFile } from './helpers.ts'; test('resolves plugin specifiers from the config root', async () => { @@ -60,3 +60,57 @@ test('rejects imported plugin objects', () => { 'Prettier plugin objects are not supported. Use a package name, path, or URL instead.', ); }); + +test('fingerprints installed package plugins once', async () => { + await withTempProject(async (rootPath) => { + const entry = writeProjectFile(rootPath, 'node_modules/prettier-plugin-fixture/dist/index.mjs'); + const packageJsonPath = 'node_modules/prettier-plugin-fixture/package.json'; + writeProjectFile( + rootPath, + packageJsonPath, + JSON.stringify({ name: 'prettier-plugin-fixture', version: '1.2.3' }), + ); + + const resolveFingerprint = createFingerprintResolver(); + const pluginUrl = pathToFileURL(entry); + const fingerprint = JSON.stringify([ + 'package', + 'prettier-plugin-fixture', + '1.2.3', + 'dist/index.mjs', + ]); + + await expect(resolveFingerprint(pluginUrl)).resolves.toBe(fingerprint); + + writeProjectFile( + rootPath, + packageJsonPath, + JSON.stringify({ name: 'prettier-plugin-fixture', version: '2.0.0' }), + ); + await expect(resolveFingerprint(pluginUrl)).resolves.toBe(fingerprint); + }); +}); + +test('skips plugins without stable package metadata', async () => { + await withTempProject(async (rootPath) => { + const localPlugin = writeProjectFile(rootPath, 'plugins/local.mjs'); + const unversionedPlugin = writeProjectFile( + rootPath, + 'node_modules/prettier-plugin-fixture/index.mjs', + ); + writeProjectFile( + rootPath, + 'node_modules/prettier-plugin-fixture/package.json', + JSON.stringify({ name: 'prettier-plugin-fixture' }), + ); + + const resolveFingerprint = createFingerprintResolver(); + await expect( + Promise.all([ + resolveFingerprint(pathToFileURL(localPlugin)), + resolveFingerprint(pathToFileURL(unversionedPlugin)), + resolveFingerprint('data:text/javascript,export default {}'), + ]), + ).resolves.toEqual([undefined, undefined, undefined]); + }); +}); From af8e2c4eacfdc5f8b2cf590f3de99b96e92f7d04 Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Fri, 7 Aug 2026 15:52:39 +0800 Subject: [PATCH 13/28] feat(fmt): cache files using package plugins (#232) --- packages/rstack/src/fmt/cacheIdentity.ts | 22 ++++++-- packages/rstack/src/fmt/runner.ts | 46 ++++++++++++++++- .../rstack/tests/fmt/cacheIdentity.test.ts | 10 ++++ packages/rstack/tests/fmt/runnerCache.test.ts | 51 ++++++++++++++++++- website/docs/en/guide/formatting.mdx | 2 +- website/docs/zh/guide/formatting.mdx | 2 +- 6 files changed, 124 insertions(+), 9 deletions(-) diff --git a/packages/rstack/src/fmt/cacheIdentity.ts b/packages/rstack/src/fmt/cacheIdentity.ts index 824c0cf9..189eb7e4 100644 --- a/packages/rstack/src/fmt/cacheIdentity.ts +++ b/packages/rstack/src/fmt/cacheIdentity.ts @@ -10,6 +10,7 @@ declare const RSTACK_VERSION: string; type CacheKeyResolver = (filePath: string) => string | undefined; type OptionsHasher = (options: ResolvedFmtOptions) => string | undefined; +type PluginFingerprints = ReadonlyMap; const sha256 = (content: string | Uint8Array): string => createHash('sha256').update(content).digest('hex'); @@ -28,7 +29,7 @@ const createCacheKeyResolver = (rootPath: string): CacheKeyResolver => { }; /** Hashes final per-file options and memoizes option objects shared by many files. */ -const createOptionsHasher = (): OptionsHasher => { +const createOptionsHasher = (pluginFingerprints?: PluginFingerprints): OptionsHasher => { const hashes = new WeakMap(); return (options) => { @@ -39,10 +40,23 @@ const createOptionsHasher = (): OptionsHasher => { let hash: string | undefined; try { - // A resolved plugin path does not identify the plugin implementation. - if (!options.plugins?.length) { - hash = sha256(stableStringify(options)); + const { plugins } = options; + let value = options; + if (plugins?.length) { + const fingerprints: string[] = []; + for (const plugin of plugins) { + const key = + plugin instanceof URL ? plugin.href : typeof plugin === 'string' ? plugin : undefined; + const fingerprint = key === undefined ? undefined : pluginFingerprints?.get(key); + if (fingerprint === undefined) { + hashes.set(options, null); + return undefined; + } + fingerprints.push(fingerprint); + } + value = { ...options, plugins: fingerprints }; } + hash = sha256(stableStringify(value)); } catch { // Circular or unreadable options cannot be cached. } diff --git a/packages/rstack/src/fmt/runner.ts b/packages/rstack/src/fmt/runner.ts index 230aa228..cde24b5e 100644 --- a/packages/rstack/src/fmt/runner.ts +++ b/packages/rstack/src/fmt/runner.ts @@ -6,6 +6,7 @@ import type { FmtExitCode, FmtFileRequest, FmtFileResult, + FmtPluginSpecifier, FmtRunResult, RunFmtFilesOptions, } from './types.ts'; @@ -43,6 +44,43 @@ const minPriorityWorkers = 8; const isMarkdown = (file: FmtFileRequest): boolean => file.path.endsWith('.md') || file.path.endsWith('.mdx'); +/** Resolves each distinct plugin once before the synchronous per-file cache path. */ +const loadPluginFingerprints = async ( + files: FmtFileRequest[], +): Promise | undefined> => { + const plugins = new Map(); + for (const file of files) { + try { + for (const plugin of file.options.plugins ?? []) { + if (typeof plugin === 'string' || plugin instanceof URL) { + plugins.set(plugin instanceof URL ? plugin.href : plugin, plugin); + } + } + } catch { + // Unreadable options cannot be cached by the options hasher. + } + } + if (plugins.size === 0) { + return undefined; + } + + const { createFingerprintResolver } = await import( + /* rspackChunkName: 'fmtPlugins' */ + './plugins.ts' + ); + const resolveFingerprint = createFingerprintResolver(); + const entries = await Promise.all( + Array.from(plugins, async ([key, plugin]) => [key, await resolveFingerprint(plugin)] as const), + ); + const fingerprints = new Map(); + for (const [key, fingerprint] of entries) { + if (fingerprint !== undefined) { + fingerprints.set(key, fingerprint); + } + } + return fingerprints; +}; + /** Converts a formatter outcome into the shared per-file result. */ const runFmtFile = async ( file: FmtFileRequest, @@ -184,10 +222,14 @@ const runFmtFiles = async ({ const shouldWrite = mode === 'write'; let runCache: RunCache | undefined; if (files.length > 0 && cache) { + const [store, fingerprints] = await Promise.all([ + loadFmtCacheStore(cache.filePath, cacheNamespace), + loadPluginFingerprints(files), + ]); runCache = { - store: await loadFmtCacheStore(cache.filePath, cacheNamespace), + store, resolveKey: createCacheKeyResolver(cache.rootPath), - hashOptions: createOptionsHasher(), + hashOptions: createOptionsHasher(fingerprints), }; } diff --git a/packages/rstack/tests/fmt/cacheIdentity.test.ts b/packages/rstack/tests/fmt/cacheIdentity.test.ts index b1b13f6d..e0d48789 100644 --- a/packages/rstack/tests/fmt/cacheIdentity.test.ts +++ b/packages/rstack/tests/fmt/cacheIdentity.test.ts @@ -47,6 +47,16 @@ test('invalidates hashes when final formatter options change', () => { expect(new Set(hashes).size).toBe(hashes.length); }); +test('includes plugin fingerprints in option hashes', () => { + const plugin = pathToFileURL(path.resolve('plugin.mjs')).href; + const first = createOptionsHasher(new Map([[plugin, 'plugin@1']])); + const second = createOptionsHasher(new Map([[plugin, 'plugin@2']])); + + expect(first({ plugins: [plugin] })).toHaveLength(64); + expect(first({ plugins: [new URL(plugin)] })).toBe(first({ plugins: [plugin] })); + expect(first({ plugins: [plugin] })).not.toBe(second({ plugins: [plugin] })); +}); + test('bypasses user plugins and unserializable options', () => { const hashOptions = createOptionsHasher(); const cyclic: Record = {}; diff --git a/packages/rstack/tests/fmt/runnerCache.test.ts b/packages/rstack/tests/fmt/runnerCache.test.ts index 781c35e1..05c9aee3 100644 --- a/packages/rstack/tests/fmt/runnerCache.test.ts +++ b/packages/rstack/tests/fmt/runnerCache.test.ts @@ -1,5 +1,6 @@ import { readFileSync, statSync, utimesSync, writeFileSync } from 'node:fs'; import path from 'node:path'; +import { pathToFileURL } from 'node:url'; import { expect, test } from 'rstack/test'; import { cacheNamespace, createOptionsHasher, sha256 } from '../../src/fmt/cacheIdentity.ts'; import { loadFmtCacheStore } from '../../src/fmt/cacheStore.ts'; @@ -10,7 +11,7 @@ import type { FmtMode, ResolvedFmtOptions, } from '../../src/fmt/types.ts'; -import { withTempProject } from './helpers.ts'; +import { withTempProject, writeProjectFile } from './helpers.ts'; const createRequest = ( filePath: string, @@ -120,6 +121,54 @@ test('invalidates entries when final options change', async () => { }); }); +test('caches only plugins with stable fingerprints', async () => { + await withTempProject(async (rootPath) => { + const filePath = writeProjectFile(rootPath, 'data.fixture', '{"value":true}'); + const pluginEntry = writeProjectFile( + rootPath, + 'node_modules/prettier-plugin-fixture/index.mjs', + `export default { + languages: [{ name: 'Fixture JSON', parsers: ['json'], extensions: ['.fixture'] }], +}; +`, + ); + const packageJsonPath = 'node_modules/prettier-plugin-fixture/package.json'; + const writePackageJson = (version?: string) => + writeProjectFile( + rootPath, + packageJsonPath, + JSON.stringify({ + name: 'prettier-plugin-fixture', + exports: './index.mjs', + ...(version ? { version } : {}), + }), + ); + const cache = createCache(rootPath); + const file = createRequest(filePath, { plugins: [pathToFileURL(pluginEntry).href] }); + + writePackageJson(); + await run([file], 'check', cache); + expect((await loadFmtCacheStore(cache.filePath, cacheNamespace)).get('data.fixture')).toBe( + undefined, + ); + + writePackageJson('1.0.0'); + await run([file], 'check', cache); + const firstHash = (await loadFmtCacheStore(cache.filePath, cacheNamespace)).get( + 'data.fixture', + )?.[1]; + expect(firstHash).toHaveLength(64); + + writePackageJson('2.0.0'); + await run([file], 'check', cache); + const secondHash = (await loadFmtCacheStore(cache.filePath, cacheNamespace)).get( + 'data.fixture', + )?.[1]; + expect(secondHash).toHaveLength(64); + expect(secondHash).not.toBe(firstHash); + }); +}); + test('preserves entries outside the formatted subset', async () => { await withTempProject(async (rootPath) => { const firstPath = path.join(rootPath, 'first.ts'); diff --git a/website/docs/en/guide/formatting.mdx b/website/docs/en/guide/formatting.mdx index 2710f6af..c8519104 100644 --- a/website/docs/en/guide/formatting.mdx +++ b/website/docs/en/guide/formatting.mdx @@ -176,7 +176,7 @@ define.fmt({ ## Cache -`rs fmt` uses a persistent cache by default for file-based `--write`, `--check`, and `--list-different` runs. Cache entries use file content and final formatting options, so changing either causes the file to be formatted again. Files that use custom Prettier plugins currently bypass the cache. +`rs fmt` uses a persistent cache by default for file-based `--write`, `--check`, and `--list-different` runs. Cache entries use file content and final formatting options, so changing either causes the file to be formatted again. Installed Prettier plugins are identified by their package name, version, and entry point; local, linked, or unversioned plugins bypass the cache. The default cache directory is `.rstack/cache/fmt` under the Rstack configuration root. When a command runs from a subdirectory, it continues to use the cache next to the resolved `rstack.config.*` file. Stdin formatting does not use this cache. diff --git a/website/docs/zh/guide/formatting.mdx b/website/docs/zh/guide/formatting.mdx index 61d96b34..cd33ca50 100644 --- a/website/docs/zh/guide/formatting.mdx +++ b/website/docs/zh/guide/formatting.mdx @@ -176,7 +176,7 @@ define.fmt({ ## 缓存 \{#cache} -`rs fmt` 默认会在基于文件的 `--write`、`--check` 和 `--list-different` 调用中使用持久化缓存。缓存条目基于文件内容和最终格式化选项;任意一项发生变化时,文件都会重新格式化。使用自定义 Prettier 插件的文件目前会绕过缓存。 +`rs fmt` 默认会在基于文件的 `--write`、`--check` 和 `--list-different` 调用中使用持久化缓存。缓存条目基于文件内容和最终格式化选项;任意一项发生变化时,文件都会重新格式化。已安装的 Prettier 插件通过包名、版本和入口进行识别;本地插件、链接插件或缺少版本信息的插件会绕过缓存。 默认缓存目录位于 Rstack 配置根目录下的 `.rstack/cache/fmt`。从子目录运行命令时,仍会使用解析到的 `rstack.config.*` 文件旁的缓存。stdin 格式化不会使用该缓存。 From 7dcb51ddc37abb413cfe112c04c78c6a087612fc Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Fri, 7 Aug 2026 16:45:29 +0800 Subject: [PATCH 14/28] feat: add create-rstack package (#233) --- packages/create-rstack/LICENSE | 21 +++++ packages/create-rstack/README.md | 33 ++++++++ packages/create-rstack/bin.js | 2 + packages/create-rstack/package.json | 52 +++++++++++++ packages/create-rstack/rstack.config.ts | 12 +++ packages/create-rstack/src/index.ts | 43 +++++++++++ .../template-app-js/package.json | 14 ++++ .../template-app-js/rstack.config.js | 5 ++ .../template-app-js/src/index.css | 26 +++++++ .../template-app-js/src/index.js | 12 +++ .../template-app-ts/package.json | 16 ++++ .../template-app-ts/rstack.config.ts | 4 + .../template-app-ts/src/index.css | 26 +++++++ .../template-app-ts/src/index.ts | 12 +++ .../template-app-ts/tsconfig.json | 22 ++++++ .../create-rstack/template-common/AGENTS.md | 15 ++++ .../create-rstack/template-common/README.md | 34 +++++++++ .../create-rstack/template-common/gitignore | 13 ++++ packages/create-rstack/tests/create.test.ts | 76 +++++++++++++++++++ packages/create-rstack/tests/tsconfig.json | 10 +++ packages/create-rstack/tsconfig.json | 16 ++++ pnpm-lock.yaml | 25 ++++++ pnpm-workspace.yaml | 1 + 23 files changed, 490 insertions(+) create mode 100644 packages/create-rstack/LICENSE create mode 100644 packages/create-rstack/README.md create mode 100755 packages/create-rstack/bin.js create mode 100644 packages/create-rstack/package.json create mode 100644 packages/create-rstack/rstack.config.ts create mode 100644 packages/create-rstack/src/index.ts create mode 100644 packages/create-rstack/template-app-js/package.json create mode 100644 packages/create-rstack/template-app-js/rstack.config.js create mode 100644 packages/create-rstack/template-app-js/src/index.css create mode 100644 packages/create-rstack/template-app-js/src/index.js create mode 100644 packages/create-rstack/template-app-ts/package.json create mode 100644 packages/create-rstack/template-app-ts/rstack.config.ts create mode 100644 packages/create-rstack/template-app-ts/src/index.css create mode 100644 packages/create-rstack/template-app-ts/src/index.ts create mode 100644 packages/create-rstack/template-app-ts/tsconfig.json create mode 100644 packages/create-rstack/template-common/AGENTS.md create mode 100644 packages/create-rstack/template-common/README.md create mode 100644 packages/create-rstack/template-common/gitignore create mode 100644 packages/create-rstack/tests/create.test.ts create mode 100644 packages/create-rstack/tests/tsconfig.json create mode 100644 packages/create-rstack/tsconfig.json diff --git a/packages/create-rstack/LICENSE b/packages/create-rstack/LICENSE new file mode 100644 index 00000000..235ea7b4 --- /dev/null +++ b/packages/create-rstack/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Rstack contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/create-rstack/README.md b/packages/create-rstack/README.md new file mode 100644 index 00000000..34d9bbc7 --- /dev/null +++ b/packages/create-rstack/README.md @@ -0,0 +1,33 @@ +# create-rstack + +Create a new Rstack project. + +## Usage + +Using `npm create`: + +```bash +npm create rstack@latest +``` + +Using CLI flags: + +```bash +npx create-rstack --dir my-project --template app-ts + +# Using abbreviations +npx create-rstack -d my-project -t app-ts +``` + +## Templates + +- `app-js` - JavaScript application +- `app-ts` - TypeScript application + +## Documentation + +See the [Rstack documentation](https://rstack.rs). + +## License + +[MIT](https://github.com/rstackjs/rstack-cli/blob/main/LICENSE). diff --git a/packages/create-rstack/bin.js b/packages/create-rstack/bin.js new file mode 100755 index 00000000..1ae21c85 --- /dev/null +++ b/packages/create-rstack/bin.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import './dist/index.js'; diff --git a/packages/create-rstack/package.json b/packages/create-rstack/package.json new file mode 100644 index 00000000..7fbea590 --- /dev/null +++ b/packages/create-rstack/package.json @@ -0,0 +1,52 @@ +{ + "name": "create-rstack", + "version": "3.0.0", + "description": "Create a new Rstack project", + "homepage": "https://rstack.rs", + "bugs": { + "url": "https://github.com/rstackjs/rstack-cli/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/rstackjs/rstack-cli.git", + "directory": "packages/create-rstack" + }, + "license": "MIT", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "types": "./dist/index.d.ts", + "bin": { + "create-rstack": "./bin.js" + }, + "files": [ + "template-*/**", + "dist", + "bin.js" + ], + "scripts": { + "build": "rs lib", + "dev": "rs lib --watch", + "start": "node ./dist/index.js", + "test": "rs test" + }, + "dependencies": { + "@rstackjs/create-toolkit": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:", + "rstack": "workspace:*", + "typescript": "catalog:" + }, + "engines": { + "node": ">=22.12.0" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/create-rstack/rstack.config.ts b/packages/create-rstack/rstack.config.ts new file mode 100644 index 00000000..61659acf --- /dev/null +++ b/packages/create-rstack/rstack.config.ts @@ -0,0 +1,12 @@ +// Rstack configuration guide: https://rstack.rs/config +import { define } from 'rstack'; + +define.lib({ + syntax: 'es2023', +}); + +define.test({ + source: { + tsconfigPath: './tests/tsconfig.json', + }, +}); diff --git a/packages/create-rstack/src/index.ts b/packages/create-rstack/src/index.ts new file mode 100644 index 00000000..17e9833b --- /dev/null +++ b/packages/create-rstack/src/index.ts @@ -0,0 +1,43 @@ +import { + type Argv, + checkCancel, + create, + type ESLintTemplateName, + type RslintTemplateName, + select, +} from '@rstackjs/create-toolkit'; +import path from 'node:path'; + +const getTemplateName = async ({ template }: Argv): Promise => { + if (typeof template === 'string') { + const [type, language = 'js'] = template.split('-'); + return `${type}-${language}`; + } + + const language = checkCancel( + await select({ + message: 'Select language', + options: [ + { value: 'ts', label: 'TypeScript' }, + { value: 'js', label: 'JavaScript' }, + ], + }), + ); + + return `app-${language}`; +}; + +const mapESLintTemplate = (templateName: string): ESLintTemplateName => + templateName.endsWith('-ts') ? 'vanilla-ts' : 'vanilla-js'; + +const mapRslintTemplate = (templateName: string): RslintTemplateName => + templateName.endsWith('-ts') ? 'vanilla-ts' : 'vanilla-js'; + +await create({ + root: path.join(import.meta.dirname, '..'), + name: 'rstack', + templates: ['app-js', 'app-ts'], + getTemplateName, + mapESLintTemplate, + mapRslintTemplate, +}); diff --git a/packages/create-rstack/template-app-js/package.json b/packages/create-rstack/template-app-js/package.json new file mode 100644 index 00000000..0247d794 --- /dev/null +++ b/packages/create-rstack/template-app-js/package.json @@ -0,0 +1,14 @@ +{ + "name": "rstack-app-js", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "rs build", + "dev": "rs dev --open", + "preview": "rs preview" + }, + "devDependencies": { + "rstack": "^0.3.5" + } +} diff --git a/packages/create-rstack/template-app-js/rstack.config.js b/packages/create-rstack/template-app-js/rstack.config.js new file mode 100644 index 00000000..82c1574b --- /dev/null +++ b/packages/create-rstack/template-app-js/rstack.config.js @@ -0,0 +1,5 @@ +// @ts-check +// Rstack configuration guide: https://rstack.rs/config +import { define } from 'rstack'; + +define.app({}); diff --git a/packages/create-rstack/template-app-js/src/index.css b/packages/create-rstack/template-app-js/src/index.css new file mode 100644 index 00000000..164c0a6a --- /dev/null +++ b/packages/create-rstack/template-app-js/src/index.css @@ -0,0 +1,26 @@ +body { + margin: 0; + color: #fff; + font-family: Inter, Avenir, Helvetica, Arial, sans-serif; + background-image: linear-gradient(to bottom, #020917, #101725); +} + +.content { + display: flex; + min-height: 100vh; + line-height: 1.1; + text-align: center; + flex-direction: column; + justify-content: center; +} + +.content h1 { + font-size: 3.6rem; + font-weight: 700; +} + +.content p { + font-size: 1.2rem; + font-weight: 400; + opacity: 0.5; +} diff --git a/packages/create-rstack/template-app-js/src/index.js b/packages/create-rstack/template-app-js/src/index.js new file mode 100644 index 00000000..a1546df9 --- /dev/null +++ b/packages/create-rstack/template-app-js/src/index.js @@ -0,0 +1,12 @@ +import './index.css'; + +const rootElement = document.querySelector('#root'); + +if (rootElement) { + rootElement.innerHTML = ` +
+

Rstack

+

Start building amazing things with Rstack.

+
+ `; +} diff --git a/packages/create-rstack/template-app-ts/package.json b/packages/create-rstack/template-app-ts/package.json new file mode 100644 index 00000000..bd79bc97 --- /dev/null +++ b/packages/create-rstack/template-app-ts/package.json @@ -0,0 +1,16 @@ +{ + "name": "rstack-app-ts", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "rs build", + "dev": "rs dev --open", + "preview": "rs preview" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "rstack": "^0.3.5", + "typescript": "^7.0.2" + } +} diff --git a/packages/create-rstack/template-app-ts/rstack.config.ts b/packages/create-rstack/template-app-ts/rstack.config.ts new file mode 100644 index 00000000..6dd79648 --- /dev/null +++ b/packages/create-rstack/template-app-ts/rstack.config.ts @@ -0,0 +1,4 @@ +// Rstack configuration guide: https://rstack.rs/config +import { define } from 'rstack'; + +define.app({}); diff --git a/packages/create-rstack/template-app-ts/src/index.css b/packages/create-rstack/template-app-ts/src/index.css new file mode 100644 index 00000000..164c0a6a --- /dev/null +++ b/packages/create-rstack/template-app-ts/src/index.css @@ -0,0 +1,26 @@ +body { + margin: 0; + color: #fff; + font-family: Inter, Avenir, Helvetica, Arial, sans-serif; + background-image: linear-gradient(to bottom, #020917, #101725); +} + +.content { + display: flex; + min-height: 100vh; + line-height: 1.1; + text-align: center; + flex-direction: column; + justify-content: center; +} + +.content h1 { + font-size: 3.6rem; + font-weight: 700; +} + +.content p { + font-size: 1.2rem; + font-weight: 400; + opacity: 0.5; +} diff --git a/packages/create-rstack/template-app-ts/src/index.ts b/packages/create-rstack/template-app-ts/src/index.ts new file mode 100644 index 00000000..a1546df9 --- /dev/null +++ b/packages/create-rstack/template-app-ts/src/index.ts @@ -0,0 +1,12 @@ +import './index.css'; + +const rootElement = document.querySelector('#root'); + +if (rootElement) { + rootElement.innerHTML = ` +
+

Rstack

+

Start building amazing things with Rstack.

+
+ `; +} diff --git a/packages/create-rstack/template-app-ts/tsconfig.json b/packages/create-rstack/template-app-ts/tsconfig.json new file mode 100644 index 00000000..3fae4ab2 --- /dev/null +++ b/packages/create-rstack/template-app-ts/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "lib": ["DOM", "ES2020"], + "target": "ES2020", + "noEmit": true, + "skipLibCheck": true, + "types": ["rstack/types", "node"], + "useDefineForClassFields": true, + + /* modules */ + "moduleDetection": "force", + "moduleResolution": "bundler", + "verbatimModuleSyntax": true, + "resolveJsonModule": true, + "allowImportingTsExtensions": true, + + /* type checking */ + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["src"] +} diff --git a/packages/create-rstack/template-common/AGENTS.md b/packages/create-rstack/template-common/AGENTS.md new file mode 100644 index 00000000..6c887e17 --- /dev/null +++ b/packages/create-rstack/template-common/AGENTS.md @@ -0,0 +1,15 @@ +# AGENTS.md + +You are an expert in JavaScript, Rstack, and web application development. You write maintainable, performant, and accessible code. + +## Commands + +- `{{ packageManager }} run dev` - Start the development server +- `{{ packageManager }} run build` - Build the app for production +- `{{ packageManager }} run preview` - Preview the production build locally + +## Docs + +- Rstack: https://rstack.rs/llms.txt +- Rsbuild: https://rsbuild.rs/llms.txt +- Rspack: https://rspack.rs/llms.txt diff --git a/packages/create-rstack/template-common/README.md b/packages/create-rstack/template-common/README.md new file mode 100644 index 00000000..d62a167e --- /dev/null +++ b/packages/create-rstack/template-common/README.md @@ -0,0 +1,34 @@ +# Rstack project + +## Setup + +Install the dependencies: + +```bash +{{ packageManager }} install +``` + +## Get started + +Start the development server: + +```bash +{{ packageManager }} run dev +``` + +Build the app for production: + +```bash +{{ packageManager }} run build +``` + +Preview the production build locally: + +```bash +{{ packageManager }} run preview +``` + +## Learn more + +- [Rstack documentation](https://rstack.rs) +- [Rstack GitHub repository](https://github.com/rstackjs/rstack-cli) diff --git a/packages/create-rstack/template-common/gitignore b/packages/create-rstack/template-common/gitignore new file mode 100644 index 00000000..38d7344c --- /dev/null +++ b/packages/create-rstack/template-common/gitignore @@ -0,0 +1,13 @@ +# Local +.DS_Store +*.local +*.log* + +# Dist +node_modules +dist/ + +# IDE +.vscode/* +!.vscode/extensions.json +.idea diff --git a/packages/create-rstack/tests/create.test.ts b/packages/create-rstack/tests/create.test.ts new file mode 100644 index 00000000..ad12c492 --- /dev/null +++ b/packages/create-rstack/tests/create.test.ts @@ -0,0 +1,76 @@ +import { execFile } from 'node:child_process'; +import { access, mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, expect, test } from 'rstack/test'; + +const execFileAsync = promisify(execFile); +const packageRoot = path.resolve(import.meta.dirname, '..'); +const binPath = path.join(packageRoot, 'bin.js'); +const tempDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +const createProject = async (template: string) => { + const tempDirectory = await mkdtemp(path.join(tmpdir(), 'create-rstack-')); + const projectDirectory = path.join(tempDirectory, 'my-app'); + tempDirectories.push(tempDirectory); + + await execFileAsync(process.execPath, [binPath, projectDirectory, '--template', template], { + cwd: tempDirectory, + env: { + ...process.env, + npm_config_user_agent: 'pnpm/11.20.0', + }, + }); + + return projectDirectory; +}; + +test.each([ + { template: 'app-js', extension: 'js', hasTypeScript: false }, + { template: 'app-ts', extension: 'ts', hasTypeScript: true }, +])('creates the $template template', async ({ template, extension, hasTypeScript }) => { + const projectDirectory = await createProject(template); + const packageJson = JSON.parse( + await readFile(path.join(projectDirectory, 'package.json'), 'utf8'), + ); + + expect(packageJson).toMatchObject({ + name: 'my-app', + private: true, + scripts: { + build: 'rs build', + dev: 'rs dev --open', + preview: 'rs preview', + }, + devDependencies: { + rstack: '^0.3.5', + }, + }); + + await expect(access(path.join(projectDirectory, 'README.md'))).resolves.toBeUndefined(); + await expect(access(path.join(projectDirectory, '.gitignore'))).resolves.toBeUndefined(); + await expect( + access(path.join(projectDirectory, `rstack.config.${extension}`)), + ).resolves.toBeUndefined(); + await expect( + access(path.join(projectDirectory, `src/index.${extension}`)), + ).resolves.toBeUndefined(); + + const tsconfigPath = path.join(projectDirectory, 'tsconfig.json'); + if (hasTypeScript) { + await expect(access(tsconfigPath)).resolves.toBeUndefined(); + expect(packageJson.devDependencies).toMatchObject({ + '@types/node': '^24.13.3', + typescript: '^7.0.2', + }); + } else { + await expect(access(tsconfigPath)).rejects.toThrow(); + } +}); diff --git a/packages/create-rstack/tests/tsconfig.json b/packages/create-rstack/tests/tsconfig.json new file mode 100644 index 00000000..9696beec --- /dev/null +++ b/packages/create-rstack/tests/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": true, + "noEmit": true, + "rootDir": "..", + "types": ["node"] + }, + "include": ["./**/*.ts", "../src"] +} diff --git a/packages/create-rstack/tsconfig.json b/packages/create-rstack/tsconfig.json new file mode 100644 index 00000000..227d39da --- /dev/null +++ b/packages/create-rstack/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "target": "ES2023", + "types": ["node"], + "lib": ["ESNext"], + "declaration": true, + "isolatedDeclarations": true, + "skipLibCheck": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "rewriteRelativeImportExtensions": true + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be9bdd9c..55c5b963 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,6 +34,9 @@ catalogs: '@rstack-dev/doc-ui': specifier: 1.14.7 version: 1.14.7 + '@rstackjs/create-toolkit': + specifier: 2.1.4 + version: 2.1.4 '@rstackjs/load-config': specifier: ^0.1.2 version: 0.1.2 @@ -325,6 +328,22 @@ importers: specifier: 'catalog:' version: 7.0.2 + packages/create-rstack: + dependencies: + '@rstackjs/create-toolkit': + specifier: 'catalog:' + version: 2.1.4 + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 24.13.3 + rstack: + specifier: workspace:* + version: link:../rstack + typescript: + specifier: 'catalog:' + version: 7.0.2 + packages/rstack: dependencies: '@rsbuild/core': @@ -852,6 +871,10 @@ packages: '@rspress/core': optional: true + '@rstackjs/create-toolkit@2.1.4': + resolution: {integrity: sha512-kwPhfMdgWN7z/Op5UKMszhmZy7+hzqXeiQPli4uycYYTLxCVUGLlEW3etoGoZlrM2t5zLqXZQAgL60PTUR/ocw==} + engines: {node: ^20.19.0 || >=22.12.0} + '@rstackjs/load-config@0.1.2': resolution: {integrity: sha512-6hChPVosmh2rzEt1M1CvGpsaE/+gGlt51ci5pbyd4Bd1FXyH+Owlg99ECvdcWtD7zdDwDM3jGkQL05xn9oWSIA==} peerDependencies: @@ -2700,6 +2723,8 @@ snapshots: optionalDependencies: '@rspress/core': 2.0.19(micromark-util-types@2.0.2)(micromark@4.0.2)(supports-color@8.1.1) + '@rstackjs/create-toolkit@2.1.4': {} + '@rstackjs/load-config@0.1.2': {} '@rstackjs/test-utils@0.2.0': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a12d531e..b22118e4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -21,6 +21,7 @@ catalog: '@rspress/plugin-client-redirects': '^2.0.19' '@rspress/plugin-sitemap': '^2.0.19' '@rstack-dev/doc-ui': '1.14.7' + '@rstackjs/create-toolkit': '2.1.4' '@rstackjs/load-config': ^0.1.2 '@rstackjs/test-utils': ^0.2.0 '@rstest/adapter-rsbuild': '~0.11.5' From 1125377c115e5d491c8e1cc78612c4b04042ffd7 Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Fri, 7 Aug 2026 17:39:02 +0800 Subject: [PATCH 15/28] fix(fmt): reject unsafe cache locations (#234) --- packages/rstack/src/fmt/cli.ts | 11 +++++++++++ packages/rstack/tests/cli/fmt/index.test.ts | 10 ++++++++++ 2 files changed, 21 insertions(+) diff --git a/packages/rstack/src/fmt/cli.ts b/packages/rstack/src/fmt/cli.ts index 7b1dd4d3..55af2173 100644 --- a/packages/rstack/src/fmt/cli.ts +++ b/packages/rstack/src/fmt/cli.ts @@ -288,6 +288,17 @@ const runFmtCLI = async (args: string[]): Promise => { } const cacheDirPath = cacheLocation ? path.resolve(cwd, cacheLocation) : undefined; + if (cacheDirPath) { + const cacheDirPrefix = cacheDirPath.endsWith(path.sep) + ? cacheDirPath + : `${cacheDirPath}${path.sep}`; + if (cwd === cacheDirPath || cwd.startsWith(cacheDirPrefix)) { + throw new Error( + 'The --cache-location directory cannot be the current working directory or an ancestor.', + ); + } + } + const config = await loadFmtConfig(cwd); const files = await discoverFmtFiles({ cwd, diff --git a/packages/rstack/tests/cli/fmt/index.test.ts b/packages/rstack/tests/cli/fmt/index.test.ts index 265b14ca..289789a7 100644 --- a/packages/rstack/tests/cli/fmt/index.test.ts +++ b/packages/rstack/tests/cli/fmt/index.test.ts @@ -230,6 +230,16 @@ test.each(['relative', 'absolute'] as const)('uses a %s custom cache location', expect(existsSync(path.join(projectPath, '.rstack'))).toBe(false); }); +test.each(['.', '..'])('rejects a custom cache location at %s', (cacheLocation) => { + const result = runFmt(['--cache-location', cacheLocation, '.']); + + expect(result.status).toBe(2); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain( + 'The --cache-location directory cannot be the current working directory or an ancestor.', + ); +}); + test('excludes the custom cache directory from formatting', () => { const cacheLocation = 'custom-cache'; writeProjectFile('index.ts', 'const value = 1;\n'); From 5e6cdc153abec1afece9423f89a424131471bba3 Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Fri, 7 Aug 2026 17:59:34 +0800 Subject: [PATCH 16/28] fix(create-rstack): disable built-in tools (#235) --- packages/create-rstack/src/index.ts | 1 + pnpm-lock.yaml | 12 ++++++------ pnpm-workspace.yaml | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/create-rstack/src/index.ts b/packages/create-rstack/src/index.ts index 17e9833b..6054a8cf 100644 --- a/packages/create-rstack/src/index.ts +++ b/packages/create-rstack/src/index.ts @@ -37,6 +37,7 @@ await create({ root: path.join(import.meta.dirname, '..'), name: 'rstack', templates: ['app-js', 'app-ts'], + builtinTools: [], getTemplateName, mapESLintTemplate, mapRslintTemplate, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55c5b963..e7d4b033 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,8 +35,8 @@ catalogs: specifier: 1.14.7 version: 1.14.7 '@rstackjs/create-toolkit': - specifier: 2.1.4 - version: 2.1.4 + specifier: 2.1.5 + version: 2.1.5 '@rstackjs/load-config': specifier: ^0.1.2 version: 0.1.2 @@ -332,7 +332,7 @@ importers: dependencies: '@rstackjs/create-toolkit': specifier: 'catalog:' - version: 2.1.4 + version: 2.1.5 devDependencies: '@types/node': specifier: 'catalog:' @@ -871,8 +871,8 @@ packages: '@rspress/core': optional: true - '@rstackjs/create-toolkit@2.1.4': - resolution: {integrity: sha512-kwPhfMdgWN7z/Op5UKMszhmZy7+hzqXeiQPli4uycYYTLxCVUGLlEW3etoGoZlrM2t5zLqXZQAgL60PTUR/ocw==} + '@rstackjs/create-toolkit@2.1.5': + resolution: {integrity: sha512-55hmJqJJbyvdz9ET2NLXpQ+AF1+keb75duvLXMEU423sEPM1e8GdIE7aS3ODsXYKJ7CoYphDjVkcaDeB0TS5uw==} engines: {node: ^20.19.0 || >=22.12.0} '@rstackjs/load-config@0.1.2': @@ -2723,7 +2723,7 @@ snapshots: optionalDependencies: '@rspress/core': 2.0.19(micromark-util-types@2.0.2)(micromark@4.0.2)(supports-color@8.1.1) - '@rstackjs/create-toolkit@2.1.4': {} + '@rstackjs/create-toolkit@2.1.5': {} '@rstackjs/load-config@0.1.2': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b22118e4..8215985a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -21,7 +21,7 @@ catalog: '@rspress/plugin-client-redirects': '^2.0.19' '@rspress/plugin-sitemap': '^2.0.19' '@rstack-dev/doc-ui': '1.14.7' - '@rstackjs/create-toolkit': '2.1.4' + '@rstackjs/create-toolkit': '2.1.5' '@rstackjs/load-config': ^0.1.2 '@rstackjs/test-utils': ^0.2.0 '@rstest/adapter-rsbuild': '~0.11.5' From a01779f62a730a698864689b4bbc77ad6b22edca Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Fri, 7 Aug 2026 21:12:24 +0800 Subject: [PATCH 17/28] perf(fmt): avoid decoding cache hits (#236) --- packages/rstack/src/fmt/worker.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/rstack/src/fmt/worker.ts b/packages/rstack/src/fmt/worker.ts index 8b42b922..50c29fc2 100644 --- a/packages/rstack/src/fmt/worker.ts +++ b/packages/rstack/src/fmt/worker.ts @@ -24,20 +24,26 @@ const formatFile = async ({ cache, }: FormatFileTask): Promise => { let source: string | undefined; + let sourceBuffer: Buffer | undefined; let contentHash: string | undefined; const readSource = (shouldHash = !shouldWrite): string => { + if (sourceBuffer !== undefined) { + return sourceBuffer.toString('utf8'); + } + if (!cache || !shouldHash) { return readFileSync(file.path, 'utf8'); } - const content = readFileSync(file.path); - contentHash = hashContent(content); - return content.toString('utf8'); + sourceBuffer = readFileSync(file.path); + contentHash = hashContent(sourceBuffer); + return sourceBuffer.toString('utf8'); }; if (cache?.entry && cache.entry[1] === cache.optionsHash) { - source = readSource(true); + sourceBuffer = readFileSync(file.path); + contentHash = hashContent(sourceBuffer); const { entry } = cache; if (entry[0] === contentHash && (!shouldWrite || entry[2] === 'clean')) { return { status: entry[2] === 'clean' ? 'unchanged' : 'changed' }; From 09f06d85c17a24970aaecd393695a63280a4badf Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Fri, 7 Aug 2026 21:20:59 +0800 Subject: [PATCH 18/28] feat(create-rstack): add library templates (#237) --- packages/create-rstack/README.md | 2 + packages/create-rstack/src/index.ts | 14 ++++++- .../create-rstack/template-app-js/AGENTS.md | 12 ++++++ .../create-rstack/template-app-ts/AGENTS.md | 12 ++++++ .../create-rstack/template-common/AGENTS.md | 8 ---- .../create-rstack/template-lib-js/AGENTS.md | 14 +++++++ .../create-rstack/template-lib-js/README.md | 40 +++++++++++++++++++ .../template-lib-js/package.json | 30 ++++++++++++++ .../template-lib-js/rstack.config.js | 7 ++++ .../template-lib-js/src/index.js | 1 + .../template-lib-js/tests/index.test.js | 7 ++++ .../create-rstack/template-lib-ts/AGENTS.md | 14 +++++++ .../create-rstack/template-lib-ts/README.md | 40 +++++++++++++++++++ .../template-lib-ts/package.json | 34 ++++++++++++++++ .../template-lib-ts/rstack.config.ts | 7 ++++ .../template-lib-ts/src/index.ts | 1 + .../template-lib-ts/tests/index.test.ts | 7 ++++ .../template-lib-ts/tsconfig.json | 22 ++++++++++ packages/create-rstack/tests/create.test.ts | 29 ++++++++++++++ 19 files changed, 291 insertions(+), 10 deletions(-) create mode 100644 packages/create-rstack/template-app-js/AGENTS.md create mode 100644 packages/create-rstack/template-app-ts/AGENTS.md create mode 100644 packages/create-rstack/template-lib-js/AGENTS.md create mode 100644 packages/create-rstack/template-lib-js/README.md create mode 100644 packages/create-rstack/template-lib-js/package.json create mode 100644 packages/create-rstack/template-lib-js/rstack.config.js create mode 100644 packages/create-rstack/template-lib-js/src/index.js create mode 100644 packages/create-rstack/template-lib-js/tests/index.test.js create mode 100644 packages/create-rstack/template-lib-ts/AGENTS.md create mode 100644 packages/create-rstack/template-lib-ts/README.md create mode 100644 packages/create-rstack/template-lib-ts/package.json create mode 100644 packages/create-rstack/template-lib-ts/rstack.config.ts create mode 100644 packages/create-rstack/template-lib-ts/src/index.ts create mode 100644 packages/create-rstack/template-lib-ts/tests/index.test.ts create mode 100644 packages/create-rstack/template-lib-ts/tsconfig.json diff --git a/packages/create-rstack/README.md b/packages/create-rstack/README.md index 34d9bbc7..6b2da8f1 100644 --- a/packages/create-rstack/README.md +++ b/packages/create-rstack/README.md @@ -23,6 +23,8 @@ npx create-rstack -d my-project -t app-ts - `app-js` - JavaScript application - `app-ts` - TypeScript application +- `lib-js` - JavaScript Node.js library +- `lib-ts` - TypeScript Node.js library ## Documentation diff --git a/packages/create-rstack/src/index.ts b/packages/create-rstack/src/index.ts index 6054a8cf..9f7b851e 100644 --- a/packages/create-rstack/src/index.ts +++ b/packages/create-rstack/src/index.ts @@ -14,6 +14,16 @@ const getTemplateName = async ({ template }: Argv): Promise => { return `${type}-${language}`; } + const projectType = checkCancel( + await select({ + message: 'Select project type', + options: [ + { value: 'app', label: 'Web Application' }, + { value: 'lib', label: 'Library' }, + ], + }), + ); + const language = checkCancel( await select({ message: 'Select language', @@ -24,7 +34,7 @@ const getTemplateName = async ({ template }: Argv): Promise => { }), ); - return `app-${language}`; + return `${projectType}-${language}`; }; const mapESLintTemplate = (templateName: string): ESLintTemplateName => @@ -36,7 +46,7 @@ const mapRslintTemplate = (templateName: string): RslintTemplateName => await create({ root: path.join(import.meta.dirname, '..'), name: 'rstack', - templates: ['app-js', 'app-ts'], + templates: ['app-js', 'app-ts', 'lib-js', 'lib-ts'], builtinTools: [], getTemplateName, mapESLintTemplate, diff --git a/packages/create-rstack/template-app-js/AGENTS.md b/packages/create-rstack/template-app-js/AGENTS.md new file mode 100644 index 00000000..fb378511 --- /dev/null +++ b/packages/create-rstack/template-app-js/AGENTS.md @@ -0,0 +1,12 @@ +# AGENTS.md + +## Commands + +- `{{ packageManager }} run dev` - Start the development server +- `{{ packageManager }} run build` - Build the app for production +- `{{ packageManager }} run preview` - Preview the production build locally + +## Docs + +- Rsbuild: https://rsbuild.rs/llms.txt +- Rspack: https://rspack.rs/llms.txt diff --git a/packages/create-rstack/template-app-ts/AGENTS.md b/packages/create-rstack/template-app-ts/AGENTS.md new file mode 100644 index 00000000..fb378511 --- /dev/null +++ b/packages/create-rstack/template-app-ts/AGENTS.md @@ -0,0 +1,12 @@ +# AGENTS.md + +## Commands + +- `{{ packageManager }} run dev` - Start the development server +- `{{ packageManager }} run build` - Build the app for production +- `{{ packageManager }} run preview` - Preview the production build locally + +## Docs + +- Rsbuild: https://rsbuild.rs/llms.txt +- Rspack: https://rspack.rs/llms.txt diff --git a/packages/create-rstack/template-common/AGENTS.md b/packages/create-rstack/template-common/AGENTS.md index 6c887e17..9f8558e0 100644 --- a/packages/create-rstack/template-common/AGENTS.md +++ b/packages/create-rstack/template-common/AGENTS.md @@ -1,15 +1,7 @@ # AGENTS.md -You are an expert in JavaScript, Rstack, and web application development. You write maintainable, performant, and accessible code. - ## Commands -- `{{ packageManager }} run dev` - Start the development server -- `{{ packageManager }} run build` - Build the app for production -- `{{ packageManager }} run preview` - Preview the production build locally - ## Docs - Rstack: https://rstack.rs/llms.txt -- Rsbuild: https://rsbuild.rs/llms.txt -- Rspack: https://rspack.rs/llms.txt diff --git a/packages/create-rstack/template-lib-js/AGENTS.md b/packages/create-rstack/template-lib-js/AGENTS.md new file mode 100644 index 00000000..234cc720 --- /dev/null +++ b/packages/create-rstack/template-lib-js/AGENTS.md @@ -0,0 +1,14 @@ +# AGENTS.md + +## Commands + +- `{{ packageManager }} run build` - Build the library for production +- `{{ packageManager }} run dev` - Rebuild the library when source files change +- `{{ packageManager }} run test` - Run tests +- `{{ packageManager }} run test:watch` - Run tests in watch mode + +## Docs + +- Rslib: https://rslib.rs/llms.txt +- Rspack: https://rspack.rs/llms.txt +- Rstest: https://rstest.rs/llms.txt diff --git a/packages/create-rstack/template-lib-js/README.md b/packages/create-rstack/template-lib-js/README.md new file mode 100644 index 00000000..ab4b6927 --- /dev/null +++ b/packages/create-rstack/template-lib-js/README.md @@ -0,0 +1,40 @@ +# Rstack library + +## Setup + +Install the dependencies: + +```bash +{{ packageManager }} install +``` + +## Get started + +Build the library: + +```bash +{{ packageManager }} run build +``` + +Build the library in watch mode: + +```bash +{{ packageManager }} run dev +``` + +Run tests: + +```bash +{{ packageManager }} run test +``` + +Run tests in watch mode: + +```bash +{{ packageManager }} run test:watch +``` + +## Learn more + +- [Rstack documentation](https://rstack.rs) +- [Rslib documentation](https://rslib.rs) diff --git a/packages/create-rstack/template-lib-js/package.json b/packages/create-rstack/template-lib-js/package.json new file mode 100644 index 00000000..a1008819 --- /dev/null +++ b/packages/create-rstack/template-lib-js/package.json @@ -0,0 +1,30 @@ +{ + "name": "rstack-lib-js", + "version": "0.0.0", + "sideEffects": false, + "type": "module", + "exports": { + ".": { + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "rs lib", + "dev": "rs lib --watch", + "test": "rs test", + "test:watch": "rs test --watch" + }, + "devDependencies": { + "rstack": "^0.3.5" + }, + "engines": { + "node": ">=22.12.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/create-rstack/template-lib-js/rstack.config.js b/packages/create-rstack/template-lib-js/rstack.config.js new file mode 100644 index 00000000..437a6bd3 --- /dev/null +++ b/packages/create-rstack/template-lib-js/rstack.config.js @@ -0,0 +1,7 @@ +// @ts-check +// Rstack configuration guide: https://rstack.rs/config +import { define } from 'rstack'; + +define.lib({ + syntax: ['node 22'], +}); diff --git a/packages/create-rstack/template-lib-js/src/index.js b/packages/create-rstack/template-lib-js/src/index.js new file mode 100644 index 00000000..f8beddf5 --- /dev/null +++ b/packages/create-rstack/template-lib-js/src/index.js @@ -0,0 +1 @@ +export const squared = (n) => n * n; diff --git a/packages/create-rstack/template-lib-js/tests/index.test.js b/packages/create-rstack/template-lib-js/tests/index.test.js new file mode 100644 index 00000000..912be5bc --- /dev/null +++ b/packages/create-rstack/template-lib-js/tests/index.test.js @@ -0,0 +1,7 @@ +import { expect, test } from 'rstack/test'; +import { squared } from '../src/index.js'; + +test('squared', () => { + expect(squared(2)).toBe(4); + expect(squared(12)).toBe(144); +}); diff --git a/packages/create-rstack/template-lib-ts/AGENTS.md b/packages/create-rstack/template-lib-ts/AGENTS.md new file mode 100644 index 00000000..234cc720 --- /dev/null +++ b/packages/create-rstack/template-lib-ts/AGENTS.md @@ -0,0 +1,14 @@ +# AGENTS.md + +## Commands + +- `{{ packageManager }} run build` - Build the library for production +- `{{ packageManager }} run dev` - Rebuild the library when source files change +- `{{ packageManager }} run test` - Run tests +- `{{ packageManager }} run test:watch` - Run tests in watch mode + +## Docs + +- Rslib: https://rslib.rs/llms.txt +- Rspack: https://rspack.rs/llms.txt +- Rstest: https://rstest.rs/llms.txt diff --git a/packages/create-rstack/template-lib-ts/README.md b/packages/create-rstack/template-lib-ts/README.md new file mode 100644 index 00000000..ab4b6927 --- /dev/null +++ b/packages/create-rstack/template-lib-ts/README.md @@ -0,0 +1,40 @@ +# Rstack library + +## Setup + +Install the dependencies: + +```bash +{{ packageManager }} install +``` + +## Get started + +Build the library: + +```bash +{{ packageManager }} run build +``` + +Build the library in watch mode: + +```bash +{{ packageManager }} run dev +``` + +Run tests: + +```bash +{{ packageManager }} run test +``` + +Run tests in watch mode: + +```bash +{{ packageManager }} run test:watch +``` + +## Learn more + +- [Rstack documentation](https://rstack.rs) +- [Rslib documentation](https://rslib.rs) diff --git a/packages/create-rstack/template-lib-ts/package.json b/packages/create-rstack/template-lib-ts/package.json new file mode 100644 index 00000000..7e2c33ee --- /dev/null +++ b/packages/create-rstack/template-lib-ts/package.json @@ -0,0 +1,34 @@ +{ + "name": "rstack-lib-ts", + "version": "0.0.0", + "sideEffects": false, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "types": "./dist/index.d.ts", + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "rs lib", + "dev": "rs lib --watch", + "test": "rs test", + "test:watch": "rs test --watch" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "rstack": "^0.3.5", + "typescript": "^7.0.2" + }, + "engines": { + "node": ">=22.12.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/create-rstack/template-lib-ts/rstack.config.ts b/packages/create-rstack/template-lib-ts/rstack.config.ts new file mode 100644 index 00000000..2cab1285 --- /dev/null +++ b/packages/create-rstack/template-lib-ts/rstack.config.ts @@ -0,0 +1,7 @@ +// Rstack configuration guide: https://rstack.rs/config +import { define } from 'rstack'; + +define.lib({ + syntax: ['node 22'], + dts: true, +}); diff --git a/packages/create-rstack/template-lib-ts/src/index.ts b/packages/create-rstack/template-lib-ts/src/index.ts new file mode 100644 index 00000000..fdf0e116 --- /dev/null +++ b/packages/create-rstack/template-lib-ts/src/index.ts @@ -0,0 +1 @@ +export const squared = (n: number): number => n * n; diff --git a/packages/create-rstack/template-lib-ts/tests/index.test.ts b/packages/create-rstack/template-lib-ts/tests/index.test.ts new file mode 100644 index 00000000..e7790bba --- /dev/null +++ b/packages/create-rstack/template-lib-ts/tests/index.test.ts @@ -0,0 +1,7 @@ +import { expect, test } from 'rstack/test'; +import { squared } from '../src/index'; + +test('squared', () => { + expect(squared(2)).toBe(4); + expect(squared(12)).toBe(144); +}); diff --git a/packages/create-rstack/template-lib-ts/tsconfig.json b/packages/create-rstack/template-lib-ts/tsconfig.json new file mode 100644 index 00000000..b21bfaf4 --- /dev/null +++ b/packages/create-rstack/template-lib-ts/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "lib": ["ES2022"], + "target": "ES2022", + "noEmit": true, + "skipLibCheck": true, + "types": ["rstack/types", "node"], + "useDefineForClassFields": true, + + /* modules */ + "moduleDetection": "force", + "moduleResolution": "bundler", + "verbatimModuleSyntax": true, + "resolveJsonModule": true, + "allowImportingTsExtensions": true, + + /* type checking */ + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["src"] +} diff --git a/packages/create-rstack/tests/create.test.ts b/packages/create-rstack/tests/create.test.ts index ad12c492..0d19f3d2 100644 --- a/packages/create-rstack/tests/create.test.ts +++ b/packages/create-rstack/tests/create.test.ts @@ -74,3 +74,32 @@ test.each([ await expect(access(tsconfigPath)).rejects.toThrow(); } }); + +test.each([ + { template: 'lib-js', extension: 'js', hasTypeScript: false }, + { template: 'lib-ts', extension: 'ts', hasTypeScript: true }, +])('creates the $template template', async ({ template, extension, hasTypeScript }) => { + const projectDirectory = await createProject(template); + const packageJson = JSON.parse( + await readFile(path.join(projectDirectory, 'package.json'), 'utf8'), + ); + + expect(packageJson.name).toBe('my-app'); + + await expect( + access(path.join(projectDirectory, `rstack.config.${extension}`)), + ).resolves.toBeUndefined(); + await expect( + access(path.join(projectDirectory, `src/index.${extension}`)), + ).resolves.toBeUndefined(); + await expect( + access(path.join(projectDirectory, `tests/index.test.${extension}`)), + ).resolves.toBeUndefined(); + + const tsconfigPath = path.join(projectDirectory, 'tsconfig.json'); + if (hasTypeScript) { + await expect(access(tsconfigPath)).resolves.toBeUndefined(); + } else { + await expect(access(tsconfigPath)).rejects.toThrow(); + } +}); From 49c5d832971c57e59be73260218e9f521962c3ad Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Fri, 7 Aug 2026 21:46:42 +0800 Subject: [PATCH 19/28] feat(create-rstack): add React app templates (#238) --- packages/create-rstack/README.md | 10 ++- packages/create-rstack/src/index.ts | 51 +++++++---- .../AGENTS.md | 0 .../template-app-react-js/package.json | 19 ++++ .../template-app-react-js/rstack.config.js | 11 +++ .../src/App.css} | 0 .../template-app-react-js/src/App.jsx | 12 +++ .../template-app-react-js/src/index.jsx | 10 +++ .../AGENTS.md | 0 .../template-app-react-ts/package.json | 23 +++++ .../template-app-react-ts/rstack.config.ts | 10 +++ .../src/App.css} | 0 .../template-app-react-ts/src/App.tsx | 12 +++ .../template-app-react-ts/src/env.d.ts | 9 ++ .../template-app-react-ts/src/index.tsx | 13 +++ .../template-app-react-ts/tsconfig.json | 23 +++++ .../template-app-vanilla-js/AGENTS.md | 12 +++ .../package.json | 2 +- .../rstack.config.js | 0 .../template-app-vanilla-js/src/index.css | 26 ++++++ .../src/index.js | 2 +- .../template-app-vanilla-ts/AGENTS.md | 12 +++ .../package.json | 2 +- .../rstack.config.ts | 0 .../template-app-vanilla-ts/src/index.css | 26 ++++++ .../src/index.ts | 2 +- .../tsconfig.json | 0 packages/create-rstack/tests/create.test.ts | 86 +++++++++++-------- 28 files changed, 309 insertions(+), 64 deletions(-) rename packages/create-rstack/{template-app-js => template-app-react-js}/AGENTS.md (100%) create mode 100644 packages/create-rstack/template-app-react-js/package.json create mode 100644 packages/create-rstack/template-app-react-js/rstack.config.js rename packages/create-rstack/{template-app-js/src/index.css => template-app-react-js/src/App.css} (100%) create mode 100644 packages/create-rstack/template-app-react-js/src/App.jsx create mode 100644 packages/create-rstack/template-app-react-js/src/index.jsx rename packages/create-rstack/{template-app-ts => template-app-react-ts}/AGENTS.md (100%) create mode 100644 packages/create-rstack/template-app-react-ts/package.json create mode 100644 packages/create-rstack/template-app-react-ts/rstack.config.ts rename packages/create-rstack/{template-app-ts/src/index.css => template-app-react-ts/src/App.css} (100%) create mode 100644 packages/create-rstack/template-app-react-ts/src/App.tsx create mode 100644 packages/create-rstack/template-app-react-ts/src/env.d.ts create mode 100644 packages/create-rstack/template-app-react-ts/src/index.tsx create mode 100644 packages/create-rstack/template-app-react-ts/tsconfig.json create mode 100644 packages/create-rstack/template-app-vanilla-js/AGENTS.md rename packages/create-rstack/{template-app-js => template-app-vanilla-js}/package.json (86%) rename packages/create-rstack/{template-app-js => template-app-vanilla-js}/rstack.config.js (100%) create mode 100644 packages/create-rstack/template-app-vanilla-js/src/index.css rename packages/create-rstack/{template-app-js => template-app-vanilla-js}/src/index.js (88%) create mode 100644 packages/create-rstack/template-app-vanilla-ts/AGENTS.md rename packages/create-rstack/{template-app-ts => template-app-vanilla-ts}/package.json (88%) rename packages/create-rstack/{template-app-ts => template-app-vanilla-ts}/rstack.config.ts (100%) create mode 100644 packages/create-rstack/template-app-vanilla-ts/src/index.css rename packages/create-rstack/{template-app-ts => template-app-vanilla-ts}/src/index.ts (88%) rename packages/create-rstack/{template-app-ts => template-app-vanilla-ts}/tsconfig.json (100%) diff --git a/packages/create-rstack/README.md b/packages/create-rstack/README.md index 6b2da8f1..453221e0 100644 --- a/packages/create-rstack/README.md +++ b/packages/create-rstack/README.md @@ -13,16 +13,18 @@ npm create rstack@latest Using CLI flags: ```bash -npx create-rstack --dir my-project --template app-ts +npx create-rstack --dir my-project --template app-vanilla-ts # Using abbreviations -npx create-rstack -d my-project -t app-ts +npx create-rstack -d my-project -t app-vanilla-ts ``` ## Templates -- `app-js` - JavaScript application -- `app-ts` - TypeScript application +- `app-vanilla-js` - JavaScript Vanilla application +- `app-vanilla-ts` - TypeScript Vanilla application +- `app-react-js` - JavaScript React application +- `app-react-ts` - TypeScript React application - `lib-js` - JavaScript Node.js library - `lib-ts` - TypeScript Node.js library diff --git a/packages/create-rstack/src/index.ts b/packages/create-rstack/src/index.ts index 9f7b851e..8590df3f 100644 --- a/packages/create-rstack/src/index.ts +++ b/packages/create-rstack/src/index.ts @@ -1,15 +1,18 @@ -import { - type Argv, - checkCancel, - create, - type ESLintTemplateName, - type RslintTemplateName, - select, -} from '@rstackjs/create-toolkit'; +import { type Argv, checkCancel, create, select } from '@rstackjs/create-toolkit'; import path from 'node:path'; const getTemplateName = async ({ template }: Argv): Promise => { if (typeof template === 'string') { + if (template === 'app' || template.startsWith('app-')) { + const [, framework = 'vanilla', language = 'js'] = template.split('-'); + + if (framework === 'js' || framework === 'ts') { + return `app-vanilla-${framework}`; + } + + return `app-${framework}-${language}`; + } + const [type, language = 'js'] = template.split('-'); return `${type}-${language}`; } @@ -24,6 +27,19 @@ const getTemplateName = async ({ template }: Argv): Promise => { }), ); + const framework = + projectType === 'app' + ? checkCancel( + await select({ + message: 'Select framework', + options: [ + { value: 'vanilla', label: 'Vanilla' }, + { value: 'react', label: 'React' }, + ], + }), + ) + : undefined; + const language = checkCancel( await select({ message: 'Select language', @@ -34,21 +50,20 @@ const getTemplateName = async ({ template }: Argv): Promise => { }), ); - return `${projectType}-${language}`; + return framework ? `${projectType}-${framework}-${language}` : `${projectType}-${language}`; }; -const mapESLintTemplate = (templateName: string): ESLintTemplateName => - templateName.endsWith('-ts') ? 'vanilla-ts' : 'vanilla-js'; - -const mapRslintTemplate = (templateName: string): RslintTemplateName => - templateName.endsWith('-ts') ? 'vanilla-ts' : 'vanilla-js'; - await create({ root: path.join(import.meta.dirname, '..'), name: 'rstack', - templates: ['app-js', 'app-ts', 'lib-js', 'lib-ts'], + templates: [ + 'app-vanilla-js', + 'app-vanilla-ts', + 'app-react-js', + 'app-react-ts', + 'lib-js', + 'lib-ts', + ], builtinTools: [], getTemplateName, - mapESLintTemplate, - mapRslintTemplate, }); diff --git a/packages/create-rstack/template-app-js/AGENTS.md b/packages/create-rstack/template-app-react-js/AGENTS.md similarity index 100% rename from packages/create-rstack/template-app-js/AGENTS.md rename to packages/create-rstack/template-app-react-js/AGENTS.md diff --git a/packages/create-rstack/template-app-react-js/package.json b/packages/create-rstack/template-app-react-js/package.json new file mode 100644 index 00000000..399515d4 --- /dev/null +++ b/packages/create-rstack/template-app-react-js/package.json @@ -0,0 +1,19 @@ +{ + "name": "rstack-app-react-js", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "rs build", + "dev": "rs dev --open", + "preview": "rs preview" + }, + "dependencies": { + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@rsbuild/plugin-react": "^2.1.0", + "rstack": "^0.3.5" + } +} diff --git a/packages/create-rstack/template-app-react-js/rstack.config.js b/packages/create-rstack/template-app-react-js/rstack.config.js new file mode 100644 index 00000000..370a7f9e --- /dev/null +++ b/packages/create-rstack/template-app-react-js/rstack.config.js @@ -0,0 +1,11 @@ +// @ts-check +// Rstack configuration guide: https://rstack.rs/config +import { define } from 'rstack'; + +define.app(async () => { + const { pluginReact } = await import('@rsbuild/plugin-react'); + + return { + plugins: [pluginReact()], + }; +}); diff --git a/packages/create-rstack/template-app-js/src/index.css b/packages/create-rstack/template-app-react-js/src/App.css similarity index 100% rename from packages/create-rstack/template-app-js/src/index.css rename to packages/create-rstack/template-app-react-js/src/App.css diff --git a/packages/create-rstack/template-app-react-js/src/App.jsx b/packages/create-rstack/template-app-react-js/src/App.jsx new file mode 100644 index 00000000..c23095d2 --- /dev/null +++ b/packages/create-rstack/template-app-react-js/src/App.jsx @@ -0,0 +1,12 @@ +import './App.css'; + +const App = () => { + return ( +
+

Rstack with React

+

Start building amazing things with Rstack.

+
+ ); +}; + +export default App; diff --git a/packages/create-rstack/template-app-react-js/src/index.jsx b/packages/create-rstack/template-app-react-js/src/index.jsx new file mode 100644 index 00000000..65a8dbf4 --- /dev/null +++ b/packages/create-rstack/template-app-react-js/src/index.jsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; + +const root = ReactDOM.createRoot(document.getElementById('root')); +root.render( + + + , +); diff --git a/packages/create-rstack/template-app-ts/AGENTS.md b/packages/create-rstack/template-app-react-ts/AGENTS.md similarity index 100% rename from packages/create-rstack/template-app-ts/AGENTS.md rename to packages/create-rstack/template-app-react-ts/AGENTS.md diff --git a/packages/create-rstack/template-app-react-ts/package.json b/packages/create-rstack/template-app-react-ts/package.json new file mode 100644 index 00000000..6e130b05 --- /dev/null +++ b/packages/create-rstack/template-app-react-ts/package.json @@ -0,0 +1,23 @@ +{ + "name": "rstack-app-react-ts", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "rs build", + "dev": "rs dev --open", + "preview": "rs preview" + }, + "dependencies": { + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@rsbuild/plugin-react": "^2.1.0", + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "rstack": "^0.3.5", + "typescript": "^7.0.2" + } +} diff --git a/packages/create-rstack/template-app-react-ts/rstack.config.ts b/packages/create-rstack/template-app-react-ts/rstack.config.ts new file mode 100644 index 00000000..698eb199 --- /dev/null +++ b/packages/create-rstack/template-app-react-ts/rstack.config.ts @@ -0,0 +1,10 @@ +// Rstack configuration guide: https://rstack.rs/config +import { define } from 'rstack'; + +define.app(async () => { + const { pluginReact } = await import('@rsbuild/plugin-react'); + + return { + plugins: [pluginReact()], + }; +}); diff --git a/packages/create-rstack/template-app-ts/src/index.css b/packages/create-rstack/template-app-react-ts/src/App.css similarity index 100% rename from packages/create-rstack/template-app-ts/src/index.css rename to packages/create-rstack/template-app-react-ts/src/App.css diff --git a/packages/create-rstack/template-app-react-ts/src/App.tsx b/packages/create-rstack/template-app-react-ts/src/App.tsx new file mode 100644 index 00000000..c23095d2 --- /dev/null +++ b/packages/create-rstack/template-app-react-ts/src/App.tsx @@ -0,0 +1,12 @@ +import './App.css'; + +const App = () => { + return ( +
+

Rstack with React

+

Start building amazing things with Rstack.

+
+ ); +}; + +export default App; diff --git a/packages/create-rstack/template-app-react-ts/src/env.d.ts b/packages/create-rstack/template-app-react-ts/src/env.d.ts new file mode 100644 index 00000000..011b5e7b --- /dev/null +++ b/packages/create-rstack/template-app-react-ts/src/env.d.ts @@ -0,0 +1,9 @@ +/** + * Imports the SVG file as a React component. + * @requires [@rsbuild/plugin-svgr](https://npmjs.com/package/@rsbuild/plugin-svgr) + */ +declare module '*.svg?react' { + import type React from 'react'; + const ReactComponent: React.FunctionComponent>; + export default ReactComponent; +} diff --git a/packages/create-rstack/template-app-react-ts/src/index.tsx b/packages/create-rstack/template-app-react-ts/src/index.tsx new file mode 100644 index 00000000..55f29bff --- /dev/null +++ b/packages/create-rstack/template-app-react-ts/src/index.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; + +const rootEl = document.getElementById('root'); +if (rootEl) { + const root = ReactDOM.createRoot(rootEl); + root.render( + + + , + ); +} diff --git a/packages/create-rstack/template-app-react-ts/tsconfig.json b/packages/create-rstack/template-app-react-ts/tsconfig.json new file mode 100644 index 00000000..dd3c6e37 --- /dev/null +++ b/packages/create-rstack/template-app-react-ts/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "lib": ["DOM", "ES2020"], + "jsx": "react-jsx", + "target": "ES2020", + "noEmit": true, + "skipLibCheck": true, + "types": ["rstack/types", "node"], + "useDefineForClassFields": true, + + /* modules */ + "moduleDetection": "force", + "moduleResolution": "bundler", + "verbatimModuleSyntax": true, + "resolveJsonModule": true, + "allowImportingTsExtensions": true, + + /* type checking */ + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["src"] +} diff --git a/packages/create-rstack/template-app-vanilla-js/AGENTS.md b/packages/create-rstack/template-app-vanilla-js/AGENTS.md new file mode 100644 index 00000000..fb378511 --- /dev/null +++ b/packages/create-rstack/template-app-vanilla-js/AGENTS.md @@ -0,0 +1,12 @@ +# AGENTS.md + +## Commands + +- `{{ packageManager }} run dev` - Start the development server +- `{{ packageManager }} run build` - Build the app for production +- `{{ packageManager }} run preview` - Preview the production build locally + +## Docs + +- Rsbuild: https://rsbuild.rs/llms.txt +- Rspack: https://rspack.rs/llms.txt diff --git a/packages/create-rstack/template-app-js/package.json b/packages/create-rstack/template-app-vanilla-js/package.json similarity index 86% rename from packages/create-rstack/template-app-js/package.json rename to packages/create-rstack/template-app-vanilla-js/package.json index 0247d794..6ca855dd 100644 --- a/packages/create-rstack/template-app-js/package.json +++ b/packages/create-rstack/template-app-vanilla-js/package.json @@ -1,5 +1,5 @@ { - "name": "rstack-app-js", + "name": "rstack-app-vanilla-js", "version": "1.0.0", "private": true, "type": "module", diff --git a/packages/create-rstack/template-app-js/rstack.config.js b/packages/create-rstack/template-app-vanilla-js/rstack.config.js similarity index 100% rename from packages/create-rstack/template-app-js/rstack.config.js rename to packages/create-rstack/template-app-vanilla-js/rstack.config.js diff --git a/packages/create-rstack/template-app-vanilla-js/src/index.css b/packages/create-rstack/template-app-vanilla-js/src/index.css new file mode 100644 index 00000000..164c0a6a --- /dev/null +++ b/packages/create-rstack/template-app-vanilla-js/src/index.css @@ -0,0 +1,26 @@ +body { + margin: 0; + color: #fff; + font-family: Inter, Avenir, Helvetica, Arial, sans-serif; + background-image: linear-gradient(to bottom, #020917, #101725); +} + +.content { + display: flex; + min-height: 100vh; + line-height: 1.1; + text-align: center; + flex-direction: column; + justify-content: center; +} + +.content h1 { + font-size: 3.6rem; + font-weight: 700; +} + +.content p { + font-size: 1.2rem; + font-weight: 400; + opacity: 0.5; +} diff --git a/packages/create-rstack/template-app-js/src/index.js b/packages/create-rstack/template-app-vanilla-js/src/index.js similarity index 88% rename from packages/create-rstack/template-app-js/src/index.js rename to packages/create-rstack/template-app-vanilla-js/src/index.js index a1546df9..5d84d19d 100644 --- a/packages/create-rstack/template-app-js/src/index.js +++ b/packages/create-rstack/template-app-vanilla-js/src/index.js @@ -5,7 +5,7 @@ const rootElement = document.querySelector('#root'); if (rootElement) { rootElement.innerHTML = `
-

Rstack

+

Vanilla Rstack

Start building amazing things with Rstack.

`; diff --git a/packages/create-rstack/template-app-vanilla-ts/AGENTS.md b/packages/create-rstack/template-app-vanilla-ts/AGENTS.md new file mode 100644 index 00000000..fb378511 --- /dev/null +++ b/packages/create-rstack/template-app-vanilla-ts/AGENTS.md @@ -0,0 +1,12 @@ +# AGENTS.md + +## Commands + +- `{{ packageManager }} run dev` - Start the development server +- `{{ packageManager }} run build` - Build the app for production +- `{{ packageManager }} run preview` - Preview the production build locally + +## Docs + +- Rsbuild: https://rsbuild.rs/llms.txt +- Rspack: https://rspack.rs/llms.txt diff --git a/packages/create-rstack/template-app-ts/package.json b/packages/create-rstack/template-app-vanilla-ts/package.json similarity index 88% rename from packages/create-rstack/template-app-ts/package.json rename to packages/create-rstack/template-app-vanilla-ts/package.json index bd79bc97..2b1fc111 100644 --- a/packages/create-rstack/template-app-ts/package.json +++ b/packages/create-rstack/template-app-vanilla-ts/package.json @@ -1,5 +1,5 @@ { - "name": "rstack-app-ts", + "name": "rstack-app-vanilla-ts", "version": "1.0.0", "private": true, "type": "module", diff --git a/packages/create-rstack/template-app-ts/rstack.config.ts b/packages/create-rstack/template-app-vanilla-ts/rstack.config.ts similarity index 100% rename from packages/create-rstack/template-app-ts/rstack.config.ts rename to packages/create-rstack/template-app-vanilla-ts/rstack.config.ts diff --git a/packages/create-rstack/template-app-vanilla-ts/src/index.css b/packages/create-rstack/template-app-vanilla-ts/src/index.css new file mode 100644 index 00000000..164c0a6a --- /dev/null +++ b/packages/create-rstack/template-app-vanilla-ts/src/index.css @@ -0,0 +1,26 @@ +body { + margin: 0; + color: #fff; + font-family: Inter, Avenir, Helvetica, Arial, sans-serif; + background-image: linear-gradient(to bottom, #020917, #101725); +} + +.content { + display: flex; + min-height: 100vh; + line-height: 1.1; + text-align: center; + flex-direction: column; + justify-content: center; +} + +.content h1 { + font-size: 3.6rem; + font-weight: 700; +} + +.content p { + font-size: 1.2rem; + font-weight: 400; + opacity: 0.5; +} diff --git a/packages/create-rstack/template-app-ts/src/index.ts b/packages/create-rstack/template-app-vanilla-ts/src/index.ts similarity index 88% rename from packages/create-rstack/template-app-ts/src/index.ts rename to packages/create-rstack/template-app-vanilla-ts/src/index.ts index a1546df9..5d84d19d 100644 --- a/packages/create-rstack/template-app-ts/src/index.ts +++ b/packages/create-rstack/template-app-vanilla-ts/src/index.ts @@ -5,7 +5,7 @@ const rootElement = document.querySelector('#root'); if (rootElement) { rootElement.innerHTML = `
-

Rstack

+

Vanilla Rstack

Start building amazing things with Rstack.

`; diff --git a/packages/create-rstack/template-app-ts/tsconfig.json b/packages/create-rstack/template-app-vanilla-ts/tsconfig.json similarity index 100% rename from packages/create-rstack/template-app-ts/tsconfig.json rename to packages/create-rstack/template-app-vanilla-ts/tsconfig.json diff --git a/packages/create-rstack/tests/create.test.ts b/packages/create-rstack/tests/create.test.ts index 0d19f3d2..ae8479f6 100644 --- a/packages/create-rstack/tests/create.test.ts +++ b/packages/create-rstack/tests/create.test.ts @@ -33,47 +33,57 @@ const createProject = async (template: string) => { }; test.each([ - { template: 'app-js', extension: 'js', hasTypeScript: false }, - { template: 'app-ts', extension: 'ts', hasTypeScript: true }, -])('creates the $template template', async ({ template, extension, hasTypeScript }) => { - const projectDirectory = await createProject(template); - const packageJson = JSON.parse( - await readFile(path.join(projectDirectory, 'package.json'), 'utf8'), - ); + { + template: 'app-vanilla-js', + configExtension: 'js', + sourceExtension: 'js', + hasTypeScript: false, + }, + { + template: 'app-vanilla-ts', + configExtension: 'ts', + sourceExtension: 'ts', + hasTypeScript: true, + }, + { + template: 'app-react-js', + configExtension: 'js', + sourceExtension: 'jsx', + hasTypeScript: false, + }, + { + template: 'app-react-ts', + configExtension: 'ts', + sourceExtension: 'tsx', + hasTypeScript: true, + }, +])( + 'creates the $template template', + async ({ template, configExtension, sourceExtension, hasTypeScript }) => { + const projectDirectory = await createProject(template); + const packageJson = JSON.parse( + await readFile(path.join(projectDirectory, 'package.json'), 'utf8'), + ); - expect(packageJson).toMatchObject({ - name: 'my-app', - private: true, - scripts: { - build: 'rs build', - dev: 'rs dev --open', - preview: 'rs preview', - }, - devDependencies: { - rstack: '^0.3.5', - }, - }); + expect(packageJson.name).toBe('my-app'); - await expect(access(path.join(projectDirectory, 'README.md'))).resolves.toBeUndefined(); - await expect(access(path.join(projectDirectory, '.gitignore'))).resolves.toBeUndefined(); - await expect( - access(path.join(projectDirectory, `rstack.config.${extension}`)), - ).resolves.toBeUndefined(); - await expect( - access(path.join(projectDirectory, `src/index.${extension}`)), - ).resolves.toBeUndefined(); + await expect(access(path.join(projectDirectory, 'README.md'))).resolves.toBeUndefined(); + await expect(access(path.join(projectDirectory, '.gitignore'))).resolves.toBeUndefined(); + await expect( + access(path.join(projectDirectory, `rstack.config.${configExtension}`)), + ).resolves.toBeUndefined(); + await expect( + access(path.join(projectDirectory, `src/index.${sourceExtension}`)), + ).resolves.toBeUndefined(); - const tsconfigPath = path.join(projectDirectory, 'tsconfig.json'); - if (hasTypeScript) { - await expect(access(tsconfigPath)).resolves.toBeUndefined(); - expect(packageJson.devDependencies).toMatchObject({ - '@types/node': '^24.13.3', - typescript: '^7.0.2', - }); - } else { - await expect(access(tsconfigPath)).rejects.toThrow(); - } -}); + const tsconfigPath = path.join(projectDirectory, 'tsconfig.json'); + if (hasTypeScript) { + await expect(access(tsconfigPath)).resolves.toBeUndefined(); + } else { + await expect(access(tsconfigPath)).rejects.toThrow(); + } + }, +); test.each([ { template: 'lib-js', extension: 'js', hasTypeScript: false }, From 20058524a54cc8d70d9de3b5a70cc8b222c59752 Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Fri, 7 Aug 2026 22:04:14 +0800 Subject: [PATCH 20/28] perf(fmt): avoid redundant discovery ignore checks (#240) --- packages/rstack/src/fmt/discoverPaths.ts | 6 ++++-- packages/rstack/src/fmt/discovery.ts | 5 ++--- packages/rstack/tests/fmt/discoverPaths.test.ts | 8 +++++++- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/rstack/src/fmt/discoverPaths.ts b/packages/rstack/src/fmt/discoverPaths.ts index e77f12dd..6b36951c 100644 --- a/packages/rstack/src/fmt/discoverPaths.ts +++ b/packages/rstack/src/fmt/discoverPaths.ts @@ -26,7 +26,7 @@ interface DiscoverFmtPathsOptions { patterns?: string[]; /** Whether files inside node_modules may be discovered. */ withNodeModules?: boolean; - /** Returns whether a scanned path can be excluded during traversal. */ + /** Returns whether a candidate path should be excluded. */ isIgnored?: (filePath: string, isDirectory: boolean) => boolean; } @@ -384,7 +384,9 @@ const discoverFmtPaths = async ({ } = await classifyPatterns(cwd, patterns, ignoredDirNames); const directoryRoots = getOutermostPaths(directories); const globMatchers = globs.map((pattern) => micromatch.matcher(pattern, { dot: true })); - const candidates = new Set(explicitFiles); + const candidates = new Set( + isIgnored ? explicitFiles.filter((filePath) => !isIgnored(filePath, false)) : explicitFiles, + ); const traversalRoots = getTraversalRoots(cwd, directoryRoots, globs); if (traversalRoots.length) { diff --git a/packages/rstack/src/fmt/discovery.ts b/packages/rstack/src/fmt/discovery.ts index 40431b72..8b40da10 100644 --- a/packages/rstack/src/fmt/discovery.ts +++ b/packages/rstack/src/fmt/discovery.ts @@ -32,17 +32,16 @@ const discoverFmtFiles = async ({ ? (filePath: string, isDirectory = false) => isExcluded(filePath) || isIgnored(filePath, isDirectory) : isIgnored; - const candidates = await discoverFmtPaths({ + const filePaths = await discoverFmtPaths({ cwd, patterns, withNodeModules, isIgnored: shouldIgnore, }); - if (candidates.length === 0) { + if (filePaths.length === 0) { return []; } - const filePaths = candidates.filter((filePath) => !shouldIgnore(filePath)); const resolveOptions = createFmtOptionsResolver(config); const files = filePaths.map((filePath) => createFileRequest(filePath, resolveOptions)); if (!files.some((file) => file.options.plugins?.length)) { diff --git a/packages/rstack/tests/fmt/discoverPaths.test.ts b/packages/rstack/tests/fmt/discoverPaths.test.ts index 5ad4aa0b..6210ba78 100644 --- a/packages/rstack/tests/fmt/discoverPaths.test.ts +++ b/packages/rstack/tests/fmt/discoverPaths.test.ts @@ -139,7 +139,7 @@ test('lets explicit files bypass gitignore', async () => { }); }); -test('applies an external ignore matcher during traversal', async () => { +test('applies an external ignore matcher to traversed and explicit paths', async () => { await withTempProject(async (rootPath) => { writeProjectFile(rootPath, 'generated/nested/output.ts'); const ignoredFilePath = writeProjectFile(rootPath, 'src/ignored.ts'); @@ -160,9 +160,15 @@ test('applies an external ignore matcher during traversal', async () => { patterns: ['generated'], isIgnored, }); + const explicitIgnoredFile = await discoverFmtPaths({ + cwd: rootPath, + patterns: [ignoredFilePath], + isIgnored, + }); expect(relativePaths(rootPath, files)).toEqual([path.join('src', 'index.ts')]); expect(ignoredRoot).toEqual([]); + expect(explicitIgnoredFile).toEqual([]); expect(checkedPaths).toContainEqual({ path: 'generated', isDirectory: true }); expect(checkedPaths).toContainEqual({ path: path.join('src', 'ignored.ts'), From 29095e98af1996dce764ee1f84343751fe9ce29b Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Sat, 8 Aug 2026 08:32:48 +0800 Subject: [PATCH 21/28] perf(fmt): cache unsupported parser results (#239) --- packages/rstack/src/fmt/cacheStore.ts | 24 ++++--- packages/rstack/src/fmt/runner.ts | 64 ++++++++++++++----- packages/rstack/src/fmt/worker.ts | 13 +++- packages/rstack/tests/fmt/cacheStore.test.ts | 13 ++++ packages/rstack/tests/fmt/runnerCache.test.ts | 34 ++++++++++ .../tests/fmt/runnerWorkerPreflight.test.ts | 32 ++++++++++ packages/rstack/tests/fmt/worker.test.ts | 20 ++++-- website/docs/en/guide/formatting.mdx | 2 +- website/docs/zh/guide/formatting.mdx | 2 +- 9 files changed, 166 insertions(+), 38 deletions(-) diff --git a/packages/rstack/src/fmt/cacheStore.ts b/packages/rstack/src/fmt/cacheStore.ts index e0ab7372..cd725df1 100644 --- a/packages/rstack/src/fmt/cacheStore.ts +++ b/packages/rstack/src/fmt/cacheStore.ts @@ -5,8 +5,10 @@ import path from 'node:path'; const fmtCacheFileName = 'v1.json'; const fmtCacheVersion = 1; -type FmtCacheState = 'clean' | 'dirty'; -type FmtCacheEntry = readonly [contentHash: string, optionsHash: string, state: FmtCacheState]; +type FmtCacheState = 'clean' | 'dirty' | 'unsupported'; +type FmtCacheEntry = + | readonly [contentHash: string, optionsHash: string, state: 'clean' | 'dirty'] + | readonly [contentHash: null, optionsHash: string, state: 'unsupported']; interface FmtCacheFile { version: typeof fmtCacheVersion; @@ -28,13 +30,14 @@ const createEmptyCache = (namespace: string): FmtCacheFile => ({ }); const parseCacheEntry = (value: unknown): FmtCacheEntry | undefined => { - if ( - !Array.isArray(value) || - value.length !== 3 || - typeof value[0] !== 'string' || - typeof value[1] !== 'string' || - (value[2] !== 'clean' && value[2] !== 'dirty') - ) { + if (!Array.isArray(value) || value.length !== 3 || typeof value[1] !== 'string') { + return; + } + + if (value[2] === 'unsupported') { + return value[0] === null ? [null, value[1], value[2]] : undefined; + } + if (typeof value[0] !== 'string' || (value[2] !== 'clean' && value[2] !== 'dirty')) { return; } @@ -120,7 +123,8 @@ class FmtCacheStoreImpl implements FmtCacheStore { return; } - this.#cache.files[filePath] = [entry[0], entry[1], entry[2]]; + this.#cache.files[filePath] = + entry[2] === 'unsupported' ? [null, entry[1], entry[2]] : [entry[0], entry[1], entry[2]]; this.#changed = true; } diff --git a/packages/rstack/src/fmt/runner.ts b/packages/rstack/src/fmt/runner.ts index cde24b5e..7b65f02a 100644 --- a/packages/rstack/src/fmt/runner.ts +++ b/packages/rstack/src/fmt/runner.ts @@ -22,6 +22,12 @@ interface FmtFileRun { entry?: FmtCacheEntry; } +interface FmtFileRunTask { + file: FmtFileRequest; + key?: string; + cache?: FmtFileCache; +} + interface RunCache { store: FmtCacheStore; resolveKey: ReturnType; @@ -81,13 +87,8 @@ const loadPluginFingerprints = async ( return fingerprints; }; -/** Converts a formatter outcome into the shared per-file result. */ -const runFmtFile = async ( - file: FmtFileRequest, - shouldWrite: boolean, - formatFile: FormatFile, - cache?: RunCache, -): Promise => { +/** Resolves the portable cache identity before work is dispatched. */ +const createFmtFileRunTask = (file: FmtFileRequest, cache?: RunCache): FmtFileRunTask => { let key: string | undefined; let fileCache: FmtFileCache | undefined; @@ -106,8 +107,29 @@ const runFmtFile = async ( } } + return { file, key, cache: fileCache }; +}; + +const isCachedUnsupported = ({ cache }: FmtFileRunTask): boolean => { + if (!cache?.entry) { + return false; + } + return cache.entry[1] === cache.optionsHash && cache.entry[2] === 'unsupported'; +}; + +/** Converts a formatter outcome into the shared per-file result. */ +const runFmtFile = async ( + task: FmtFileRunTask, + shouldWrite: boolean, + formatFile: FormatFile, +): Promise => { + if (isCachedUnsupported(task)) { + return { outcome: 'unsupported' }; + } + + const { file, key, cache } = task; try { - const result = await formatFile(file, shouldWrite, fileCache); + const result = await formatFile(file, shouldWrite, cache); const outcome: FmtFileOutcome = result.status === 'changed' ? { @@ -133,23 +155,22 @@ const runFmtFile = async ( /** Starts slower Markdown parsers first while preserving order within both priority groups. */ const runPriorityFmtFiles = async ( - files: FmtFileRequest[], + tasks: FmtFileRunTask[], shouldWrite: boolean, formatFile: FormatFile, - cache?: RunCache, ): Promise => { const priority: number[] = []; const rest: number[] = []; - for (let index = 0; index < files.length; index++) { - (isMarkdown(files[index]) ? priority : rest).push(index); + for (let index = 0; index < tasks.length; index++) { + (isMarkdown(tasks[index].file) ? priority : rest).push(index); } const order = priority.concat(rest); const outcomes = await Promise.all( - order.map((index) => runFmtFile(files[index], shouldWrite, formatFile, cache)), + order.map((index) => runFmtFile(tasks[index], shouldWrite, formatFile)), ); - const results = new Array(files.length); + const results = new Array(tasks.length); for (let index = 0; index < order.length; index++) { results[order[index]] = outcomes[index]; } @@ -163,15 +184,24 @@ const runFmtFilesInWorkerPool = async ( maxWorkers?: number, cache?: RunCache, ): Promise => { + const tasks = files.map((file) => createFmtFileRunTask(file, cache)); + const pendingFileCount = tasks.reduce( + (count, task) => count + (isCachedUnsupported(task) ? 0 : 1), + 0, + ); + if (pendingFileCount === 0) { + return { files: [], processedFileCount: 0 }; + } + const { createFmtWorkerPool } = await import('./workerPool.ts'); - const workerPool = await createFmtWorkerPool(files.length, maxWorkers); + const workerPool = await createFmtWorkerPool(pendingFileCount, maxWorkers); try { const results = workerPool.workerCount >= minPriorityWorkers - ? await runPriorityFmtFiles(files, shouldWrite, workerPool.formatFile, cache) + ? await runPriorityFmtFiles(tasks, shouldWrite, workerPool.formatFile) : await Promise.all( - files.map((file) => runFmtFile(file, shouldWrite, workerPool.formatFile, cache)), + tasks.map((task) => runFmtFile(task, shouldWrite, workerPool.formatFile)), ); const processedFiles: FmtFileResult[] = []; let processedFileCount = 0; diff --git a/packages/rstack/src/fmt/worker.ts b/packages/rstack/src/fmt/worker.ts index 50c29fc2..c48198d6 100644 --- a/packages/rstack/src/fmt/worker.ts +++ b/packages/rstack/src/fmt/worker.ts @@ -42,9 +42,13 @@ const formatFile = async ({ }; if (cache?.entry && cache.entry[1] === cache.optionsHash) { + const { entry } = cache; + if (entry[2] === 'unsupported') { + return { status: 'unsupported' }; + } + sourceBuffer = readFileSync(file.path); contentHash = hashContent(sourceBuffer); - const { entry } = cache; if (entry[0] === contentHash && (!shouldWrite || entry[2] === 'clean')) { return { status: entry[2] === 'clean' ? 'unchanged' : 'changed' }; } @@ -53,7 +57,12 @@ const formatFile = async ({ const { formatFmtSource } = await import('./format.ts'); const result = await formatFmtSource(file, () => (source ??= readSource())); if (result.status === 'unsupported') { - return { status: 'unsupported' }; + return cache + ? { + status: 'unsupported', + cacheEntry: [null, cache.optionsHash, 'unsupported'], + } + : { status: 'unsupported' }; } const unchanged = result.source === result.formatted; diff --git a/packages/rstack/tests/fmt/cacheStore.test.ts b/packages/rstack/tests/fmt/cacheStore.test.ts index dcee1a6b..fcedc8ce 100644 --- a/packages/rstack/tests/fmt/cacheStore.test.ts +++ b/packages/rstack/tests/fmt/cacheStore.test.ts @@ -7,6 +7,7 @@ import { withTempProject } from './helpers.ts'; const namespace = 'test-namespace'; const firstEntry = ['content-a', 'options-a', 'clean'] as const; const secondEntry = ['content-b', 'options-b', 'dirty'] as const; +const unsupportedEntry = [null, 'options-c', 'unsupported'] as const; const readCache = (filePath: string): FmtCacheFile => JSON.parse(readFileSync(filePath, 'utf8')) as FmtCacheFile; @@ -20,11 +21,13 @@ test('writes entries that can be loaded by another store', async () => { expect(existsSync(cachePath)).toBe(false); store.set('src/a.ts', firstEntry); + store.set('src/unknown.fixture', unsupportedEntry); expect(await store.save()).toBe(true); expect(await store.save()).toBe(false); const loaded = await loadFmtCacheStore(cachePath, namespace); expect(loaded.get('src/a.ts')).toEqual(firstEntry); + expect(loaded.get('src/unknown.fixture')).toEqual(unsupportedEntry); }); }); @@ -68,6 +71,16 @@ test('discards invalid data and entries from another namespace', async () => { namespace, files: { 'src/a.ts': ['content', 'options', 'unknown'] }, }), + JSON.stringify({ + version: fmtCacheVersion, + namespace, + files: { 'src/a.ts': ['content', 'options', 'unsupported'] }, + }), + JSON.stringify({ + version: fmtCacheVersion, + namespace, + files: { 'src/a.ts': [null, 'options', 'clean'] }, + }), ]; for (const content of invalidContents) { diff --git a/packages/rstack/tests/fmt/runnerCache.test.ts b/packages/rstack/tests/fmt/runnerCache.test.ts index 05c9aee3..ba924b7c 100644 --- a/packages/rstack/tests/fmt/runnerCache.test.ts +++ b/packages/rstack/tests/fmt/runnerCache.test.ts @@ -121,6 +121,40 @@ test('invalidates entries when final options change', async () => { }); }); +test('caches unsupported parser results until final options change', async () => { + await withTempProject(async (rootPath) => { + const filePath = writeProjectFile(rootPath, 'data.unknown', '{"value":true}'); + const cache = createCache(rootPath); + const unsupported = createRequest(filePath, {}); + + const first = await run([unsupported], 'check', cache); + expect(first).toEqual({ + exitCode: 2, + files: [], + processedFileCount: 0, + }); + expect((await loadFmtCacheStore(cache.filePath, cacheNamespace)).get('data.unknown')).toEqual([ + null, + createOptionsHasher()(unsupported.options), + 'unsupported', + ]); + + await expect(run([unsupported], 'check', cache)).resolves.toEqual(first); + + const supported = createRequest(filePath, { parser: 'json' }); + await expect(run([supported], 'check', cache)).resolves.toMatchObject({ + exitCode: 1, + files: [{ path: filePath, status: 'different' }], + processedFileCount: 1, + }); + expect((await loadFmtCacheStore(cache.filePath, cacheNamespace)).get('data.unknown')).toEqual([ + sha256(readFileSync(filePath)), + createOptionsHasher()(supported.options), + 'dirty', + ]); + }); +}); + test('caches only plugins with stable fingerprints', async () => { await withTempProject(async (rootPath) => { const filePath = writeProjectFile(rootPath, 'data.fixture', '{"value":true}'); diff --git a/packages/rstack/tests/fmt/runnerWorkerPreflight.test.ts b/packages/rstack/tests/fmt/runnerWorkerPreflight.test.ts index 127beaed..78bf9d76 100644 --- a/packages/rstack/tests/fmt/runnerWorkerPreflight.test.ts +++ b/packages/rstack/tests/fmt/runnerWorkerPreflight.test.ts @@ -1,5 +1,8 @@ import { readFileSync } from 'node:fs'; +import path from 'node:path'; import { beforeEach, expect, rs, test } from 'rstack/test'; +import { cacheNamespace, createOptionsHasher } from '../../src/fmt/cacheIdentity.ts'; +import { loadFmtCacheStore } from '../../src/fmt/cacheStore.ts'; import { runFmtFiles } from '../../src/fmt/runner.ts'; import type { FmtFileRequest } from '../../src/fmt/types.ts'; import { withTempProject, writeProjectFile } from './helpers.ts'; @@ -51,3 +54,32 @@ test('does not start the worker pool when there are no files', async () => { }); expect(mocks.createFmtWorkerPoolCalls).toEqual([]); }); + +test('does not start the worker pool when every parser result is cached as unsupported', async () => { + await withTempProject(async (rootPath) => { + const filePath = writeProjectFile(rootPath, 'example.unknown', 'plain text'); + const cachePath = path.join(rootPath, 'cache', 'fmt-v1.json'); + const file: FmtFileRequest = { path: filePath, options: {} }; + const optionsHash = createOptionsHasher()(file.options); + if (optionsHash === undefined) { + throw new Error('Expected cacheable formatter options.'); + } + + const store = await loadFmtCacheStore(cachePath, cacheNamespace); + store.set('example.unknown', [null, optionsHash, 'unsupported']); + await expect(store.save()).resolves.toBe(true); + + await expect( + runFmtFiles({ + files: [file], + mode: 'check', + cache: { filePath: cachePath, rootPath }, + }), + ).resolves.toEqual({ + exitCode: 2, + files: [], + processedFileCount: 0, + }); + expect(mocks.createFmtWorkerPoolCalls).toEqual([]); + }); +}); diff --git a/packages/rstack/tests/fmt/worker.test.ts b/packages/rstack/tests/fmt/worker.test.ts index 4b71eac7..11ebf0c4 100644 --- a/packages/rstack/tests/fmt/worker.test.ts +++ b/packages/rstack/tests/fmt/worker.test.ts @@ -48,25 +48,28 @@ test('returns cached states before resolving the parser', async () => { await withTempProject(async (rootPath) => { const source = 'const value=1'; const filePath = writeProjectFile(rootPath, 'example.ts', source); + const missingPath = path.join(rootPath, 'missing.unknown'); const contentHash = sha256(source); const optionsHash = 'options'; - for (const [state, shouldWrite, status] of [ - ['clean', false, 'unchanged'], - ['dirty', false, 'changed'], - ['clean', true, 'unchanged'], + for (const [entry, targetPath, shouldWrite, status] of [ + [[contentHash, optionsHash, 'clean'], filePath, false, 'unchanged'], + [[contentHash, optionsHash, 'dirty'], filePath, false, 'changed'], + [[contentHash, optionsHash, 'clean'], filePath, true, 'unchanged'], + [[null, optionsHash, 'unsupported'], missingPath, false, 'unsupported'], + [[null, optionsHash, 'unsupported'], missingPath, true, 'unsupported'], ] as const) { await expect( formatFile({ file: { - path: filePath, + path: targetPath, options: { parser: 'unknown-parser', }, }, shouldWrite, cache: { - entry: [contentHash, optionsHash, state], + entry, optionsHash, }, }), @@ -89,6 +92,9 @@ test('resolves parser support before reading on a cache miss', async () => { optionsHash: 'options', }, }), - ).resolves.toEqual({ status: 'unsupported' }); + ).resolves.toEqual({ + status: 'unsupported', + cacheEntry: [null, 'options', 'unsupported'], + }); }); }); diff --git a/website/docs/en/guide/formatting.mdx b/website/docs/en/guide/formatting.mdx index c8519104..d7d76052 100644 --- a/website/docs/en/guide/formatting.mdx +++ b/website/docs/en/guide/formatting.mdx @@ -176,7 +176,7 @@ define.fmt({ ## Cache -`rs fmt` uses a persistent cache by default for file-based `--write`, `--check`, and `--list-different` runs. Cache entries use file content and final formatting options, so changing either causes the file to be formatted again. Installed Prettier plugins are identified by their package name, version, and entry point; local, linked, or unversioned plugins bypass the cache. +`rs fmt` uses a persistent cache by default for file-based `--write`, `--check`, and `--list-different` runs. Formatting results use file content and final formatting options, so changing either causes the file to be formatted again. Unsupported parser lookups use the file path and final options because parser inference does not inspect file content. Installed Prettier plugins are identified by their package name, version, and entry point; local, linked, or unversioned plugins bypass the cache. The default cache directory is `.rstack/cache/fmt` under the Rstack configuration root. When a command runs from a subdirectory, it continues to use the cache next to the resolved `rstack.config.*` file. Stdin formatting does not use this cache. diff --git a/website/docs/zh/guide/formatting.mdx b/website/docs/zh/guide/formatting.mdx index cd33ca50..8a8e5adf 100644 --- a/website/docs/zh/guide/formatting.mdx +++ b/website/docs/zh/guide/formatting.mdx @@ -176,7 +176,7 @@ define.fmt({ ## 缓存 \{#cache} -`rs fmt` 默认会在基于文件的 `--write`、`--check` 和 `--list-different` 调用中使用持久化缓存。缓存条目基于文件内容和最终格式化选项;任意一项发生变化时,文件都会重新格式化。已安装的 Prettier 插件通过包名、版本和入口进行识别;本地插件、链接插件或缺少版本信息的插件会绕过缓存。 +`rs fmt` 默认会在基于文件的 `--write`、`--check` 和 `--list-different` 调用中使用持久化缓存。格式化结果基于文件内容和最终格式化选项;任意一项发生变化时,文件都会重新格式化。由于 parser 推断不会读取文件内容,不支持的 parser 查询结果仅基于文件路径和最终选项。已安装的 Prettier 插件通过包名、版本和入口进行识别;本地插件、链接插件或缺少版本信息的插件会绕过缓存。 默认缓存目录位于 Rstack 配置根目录下的 `.rstack/cache/fmt`。从子目录运行命令时,仍会使用解析到的 `rstack.config.*` 文件旁的缓存。stdin 格式化不会使用该缓存。 From 330e027e5308a44a9c855cab4eb3f7b382d0189b Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Sat, 8 Aug 2026 08:35:03 +0800 Subject: [PATCH 22/28] feat(create-rstack): add default template tooling (#241) --- .../template-app-react-js/package.json | 7 +++++-- .../template-app-react-js/rstack.config.js | 14 ++++++++++++++ .../template-app-react-ts/package.json | 7 +++++-- .../template-app-react-ts/rstack.config.ts | 15 +++++++++++++++ .../template-app-vanilla-js/package.json | 7 +++++-- .../template-app-vanilla-js/rstack.config.js | 14 +++++++++++++- .../template-app-vanilla-ts/package.json | 7 +++++-- .../template-app-vanilla-ts/rstack.config.ts | 14 +++++++++++++- .../create-rstack/template-lib-js/package.json | 2 ++ .../template-lib-js/rstack.config.js | 10 ++++++++++ .../create-rstack/template-lib-ts/package.json | 2 ++ .../template-lib-ts/rstack.config.ts | 10 ++++++++++ .../create-rstack/template-lib-ts/tsconfig.json | 1 + 13 files changed, 100 insertions(+), 10 deletions(-) diff --git a/packages/create-rstack/template-app-react-js/package.json b/packages/create-rstack/template-app-react-js/package.json index 399515d4..09dde494 100644 --- a/packages/create-rstack/template-app-react-js/package.json +++ b/packages/create-rstack/template-app-react-js/package.json @@ -5,8 +5,11 @@ "type": "module", "scripts": { "build": "rs build", - "dev": "rs dev --open", - "preview": "rs preview" + "dev": "rs dev", + "format": "rs fmt", + "lint": "rs lint", + "preview": "rs preview", + "test": "rs test" }, "dependencies": { "react": "^19.2.8", diff --git a/packages/create-rstack/template-app-react-js/rstack.config.js b/packages/create-rstack/template-app-react-js/rstack.config.js index 370a7f9e..a89f2994 100644 --- a/packages/create-rstack/template-app-react-js/rstack.config.js +++ b/packages/create-rstack/template-app-react-js/rstack.config.js @@ -9,3 +9,17 @@ define.app(async () => { plugins: [pluginReact()], }; }); + +define.test({ + // Configure Rstest +}); + +define.lint(async () => { + const { js, reactHooksPlugin, reactPlugin } = await import('rstack/lint'); + + return [ + js.configs.recommended, + reactPlugin.configs.recommended, + reactHooksPlugin.configs.recommended, + ]; +}); diff --git a/packages/create-rstack/template-app-react-ts/package.json b/packages/create-rstack/template-app-react-ts/package.json index 6e130b05..a8b73f0a 100644 --- a/packages/create-rstack/template-app-react-ts/package.json +++ b/packages/create-rstack/template-app-react-ts/package.json @@ -5,8 +5,11 @@ "type": "module", "scripts": { "build": "rs build", - "dev": "rs dev --open", - "preview": "rs preview" + "dev": "rs dev", + "format": "rs fmt", + "lint": "rs lint", + "preview": "rs preview", + "test": "rs test" }, "dependencies": { "react": "^19.2.8", diff --git a/packages/create-rstack/template-app-react-ts/rstack.config.ts b/packages/create-rstack/template-app-react-ts/rstack.config.ts index 698eb199..29ab420e 100644 --- a/packages/create-rstack/template-app-react-ts/rstack.config.ts +++ b/packages/create-rstack/template-app-react-ts/rstack.config.ts @@ -8,3 +8,18 @@ define.app(async () => { plugins: [pluginReact()], }; }); + +define.test({ + // Configure Rstest +}); + +define.lint(async () => { + const { js, ts, reactPlugin, reactHooksPlugin } = await import('rstack/lint'); + + return [ + js.configs.recommended, + ts.configs.recommended, + reactPlugin.configs.recommended, + reactHooksPlugin.configs.recommended, + ]; +}); diff --git a/packages/create-rstack/template-app-vanilla-js/package.json b/packages/create-rstack/template-app-vanilla-js/package.json index 6ca855dd..7dd220ef 100644 --- a/packages/create-rstack/template-app-vanilla-js/package.json +++ b/packages/create-rstack/template-app-vanilla-js/package.json @@ -5,8 +5,11 @@ "type": "module", "scripts": { "build": "rs build", - "dev": "rs dev --open", - "preview": "rs preview" + "dev": "rs dev", + "format": "rs fmt", + "lint": "rs lint", + "preview": "rs preview", + "test": "rs test" }, "devDependencies": { "rstack": "^0.3.5" diff --git a/packages/create-rstack/template-app-vanilla-js/rstack.config.js b/packages/create-rstack/template-app-vanilla-js/rstack.config.js index 82c1574b..fd6c52e0 100644 --- a/packages/create-rstack/template-app-vanilla-js/rstack.config.js +++ b/packages/create-rstack/template-app-vanilla-js/rstack.config.js @@ -2,4 +2,16 @@ // Rstack configuration guide: https://rstack.rs/config import { define } from 'rstack'; -define.app({}); +define.app({ + // Configure Rsbuild +}); + +define.test({ + // Configure Rstest +}); + +define.lint(async () => { + const { js } = await import('rstack/lint'); + + return [js.configs.recommended]; +}); diff --git a/packages/create-rstack/template-app-vanilla-ts/package.json b/packages/create-rstack/template-app-vanilla-ts/package.json index 2b1fc111..65121cbf 100644 --- a/packages/create-rstack/template-app-vanilla-ts/package.json +++ b/packages/create-rstack/template-app-vanilla-ts/package.json @@ -5,8 +5,11 @@ "type": "module", "scripts": { "build": "rs build", - "dev": "rs dev --open", - "preview": "rs preview" + "dev": "rs dev", + "format": "rs fmt", + "lint": "rs lint", + "preview": "rs preview", + "test": "rs test" }, "devDependencies": { "@types/node": "^24.13.3", diff --git a/packages/create-rstack/template-app-vanilla-ts/rstack.config.ts b/packages/create-rstack/template-app-vanilla-ts/rstack.config.ts index 6dd79648..3563b865 100644 --- a/packages/create-rstack/template-app-vanilla-ts/rstack.config.ts +++ b/packages/create-rstack/template-app-vanilla-ts/rstack.config.ts @@ -1,4 +1,16 @@ // Rstack configuration guide: https://rstack.rs/config import { define } from 'rstack'; -define.app({}); +define.app({ + // Configure Rsbuild +}); + +define.test({ + // Configure Rstest +}); + +define.lint(async () => { + const { js, ts } = await import('rstack/lint'); + + return [js.configs.recommended, ts.configs.recommended]; +}); diff --git a/packages/create-rstack/template-lib-js/package.json b/packages/create-rstack/template-lib-js/package.json index a1008819..76a93f30 100644 --- a/packages/create-rstack/template-lib-js/package.json +++ b/packages/create-rstack/template-lib-js/package.json @@ -15,6 +15,8 @@ "scripts": { "build": "rs lib", "dev": "rs lib --watch", + "format": "rs fmt", + "lint": "rs lint", "test": "rs test", "test:watch": "rs test --watch" }, diff --git a/packages/create-rstack/template-lib-js/rstack.config.js b/packages/create-rstack/template-lib-js/rstack.config.js index 437a6bd3..4d3e4b05 100644 --- a/packages/create-rstack/template-lib-js/rstack.config.js +++ b/packages/create-rstack/template-lib-js/rstack.config.js @@ -5,3 +5,13 @@ import { define } from 'rstack'; define.lib({ syntax: ['node 22'], }); + +define.test({ + // Configure Rstest +}); + +define.lint(async () => { + const { js } = await import('rstack/lint'); + + return [js.configs.recommended]; +}); diff --git a/packages/create-rstack/template-lib-ts/package.json b/packages/create-rstack/template-lib-ts/package.json index 7e2c33ee..46091aa6 100644 --- a/packages/create-rstack/template-lib-ts/package.json +++ b/packages/create-rstack/template-lib-ts/package.json @@ -17,6 +17,8 @@ "scripts": { "build": "rs lib", "dev": "rs lib --watch", + "format": "rs fmt", + "lint": "rs lint", "test": "rs test", "test:watch": "rs test --watch" }, diff --git a/packages/create-rstack/template-lib-ts/rstack.config.ts b/packages/create-rstack/template-lib-ts/rstack.config.ts index 2cab1285..bee703a8 100644 --- a/packages/create-rstack/template-lib-ts/rstack.config.ts +++ b/packages/create-rstack/template-lib-ts/rstack.config.ts @@ -5,3 +5,13 @@ define.lib({ syntax: ['node 22'], dts: true, }); + +define.test({ + // Configure Rstest +}); + +define.lint(async () => { + const { js, ts } = await import('rstack/lint'); + + return [js.configs.recommended, ts.configs.recommended]; +}); diff --git a/packages/create-rstack/template-lib-ts/tsconfig.json b/packages/create-rstack/template-lib-ts/tsconfig.json index b21bfaf4..1ca785bf 100644 --- a/packages/create-rstack/template-lib-ts/tsconfig.json +++ b/packages/create-rstack/template-lib-ts/tsconfig.json @@ -6,6 +6,7 @@ "skipLibCheck": true, "types": ["rstack/types", "node"], "useDefineForClassFields": true, + "rootDir": "src", /* modules */ "moduleDetection": "force", From ee18c6344e56cd083cc6f00b5937155f9db34de7 Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Sat, 8 Aug 2026 09:31:30 +0800 Subject: [PATCH 23/28] perf(fmt): use one-shot crypto hash (#243) --- packages/rstack/src/fmt/cacheIdentity.ts | 5 ++--- packages/rstack/src/fmt/worker.ts | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/rstack/src/fmt/cacheIdentity.ts b/packages/rstack/src/fmt/cacheIdentity.ts index 189eb7e4..0bbdbbe1 100644 --- a/packages/rstack/src/fmt/cacheIdentity.ts +++ b/packages/rstack/src/fmt/cacheIdentity.ts @@ -1,4 +1,4 @@ -import { createHash } from 'node:crypto'; +import { hash } from 'node:crypto'; import { isAbsolute } from 'node:path'; import stableStringify from 'fast-json-stable-stringify'; import { fmtCacheVersion } from './cacheStore.ts'; @@ -12,8 +12,7 @@ type CacheKeyResolver = (filePath: string) => string | undefined; type OptionsHasher = (options: ResolvedFmtOptions) => string | undefined; type PluginFingerprints = ReadonlyMap; -const sha256 = (content: string | Uint8Array): string => - createHash('sha256').update(content).digest('hex'); +const sha256 = (content: string | Uint8Array): string => hash('sha256', content, 'hex'); /** Identifies formatter behavior shared by all cache entries in this process. */ const cacheNamespace: string = JSON.stringify([fmtCacheVersion, RSTACK_VERSION, PRETTIER_VERSION]); diff --git a/packages/rstack/src/fmt/worker.ts b/packages/rstack/src/fmt/worker.ts index c48198d6..80264245 100644 --- a/packages/rstack/src/fmt/worker.ts +++ b/packages/rstack/src/fmt/worker.ts @@ -1,6 +1,6 @@ // Derived from @prettier/cli, see THIRD_PARTY_NOTICES.md -import { createHash } from 'node:crypto'; +import { hash } from 'node:crypto'; import { readFileSync, writeFileSync } from 'node:fs'; import type { FmtCacheEntry } from './cacheStore.ts'; import type { FmtFileCache, FmtFileRequest, FmtWorkerResult } from './types.ts'; @@ -11,8 +11,7 @@ interface FormatFileTask { cache?: FmtFileCache; } -const hashContent = (content: string | Uint8Array): string => - createHash('sha256').update(content).digest('hex'); +const hashContent = (content: string | Uint8Array): string => hash('sha256', content, 'hex'); /** * Use synchronous direct I/O inside the dedicated worker to avoid libuv From 3c0604d9c321a735fedc2887f9294c42c3ce44ac Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Sat, 8 Aug 2026 10:30:34 +0800 Subject: [PATCH 24/28] feat(create-rstack): add React library templates (#242) --- packages/create-rstack/README.md | 6 +- packages/create-rstack/rstack.config.ts | 1 + packages/create-rstack/src/index.ts | 39 +++++++--- .../AGENTS.md | 0 .../README.md | 0 .../package.json | 2 +- .../rstack.config.js | 0 .../src/index.js | 0 .../tests/index.test.js | 0 .../AGENTS.md | 0 .../README.md | 0 .../package.json | 2 +- .../rstack.config.ts | 0 .../src/index.ts | 0 .../tests/index.test.ts | 0 .../tsconfig.json | 0 .../template-lib-react-js/AGENTS.md | 14 ++++ .../template-lib-react-js/README.md | 40 ++++++++++ .../template-lib-react-js/package.json | 40 ++++++++++ .../template-lib-react-js/rstack.config.js | 34 +++++++++ .../template-lib-react-js/src/Button.jsx | 15 ++++ .../template-lib-react-js/src/button.css | 34 +++++++++ .../template-lib-react-js/src/index.jsx | 1 + .../tests/index.test.jsx | 11 +++ .../tests/rstest.setup.js | 4 + .../template-lib-react-ts/AGENTS.md | 14 ++++ .../template-lib-react-ts/README.md | 40 ++++++++++ .../template-lib-react-ts/package.json | 45 ++++++++++++ .../template-lib-react-ts/rstack.config.ts | 35 +++++++++ .../template-lib-react-ts/src/Button.tsx | 46 ++++++++++++ .../template-lib-react-ts/src/button.css | 34 +++++++++ .../template-lib-react-ts/src/index.tsx | 1 + .../tests/index.test.tsx | 11 +++ .../tests/rstest.setup.ts | 4 + .../template-lib-react-ts/tests/tsconfig.json | 8 ++ .../template-lib-react-ts/tsconfig.json | 24 ++++++ packages/create-rstack/tests/create.test.ts | 73 +++++++++++++------ 37 files changed, 538 insertions(+), 40 deletions(-) rename packages/create-rstack/{template-lib-js => template-lib-node-js}/AGENTS.md (100%) rename packages/create-rstack/{template-lib-js => template-lib-node-js}/README.md (100%) rename packages/create-rstack/{template-lib-js => template-lib-node-js}/package.json (94%) rename packages/create-rstack/{template-lib-js => template-lib-node-js}/rstack.config.js (100%) rename packages/create-rstack/{template-lib-js => template-lib-node-js}/src/index.js (100%) rename packages/create-rstack/{template-lib-js => template-lib-node-js}/tests/index.test.js (100%) rename packages/create-rstack/{template-lib-ts => template-lib-node-ts}/AGENTS.md (100%) rename packages/create-rstack/{template-lib-ts => template-lib-node-ts}/README.md (100%) rename packages/create-rstack/{template-lib-ts => template-lib-node-ts}/package.json (95%) rename packages/create-rstack/{template-lib-ts => template-lib-node-ts}/rstack.config.ts (100%) rename packages/create-rstack/{template-lib-ts => template-lib-node-ts}/src/index.ts (100%) rename packages/create-rstack/{template-lib-ts => template-lib-node-ts}/tests/index.test.ts (100%) rename packages/create-rstack/{template-lib-ts => template-lib-node-ts}/tsconfig.json (100%) create mode 100644 packages/create-rstack/template-lib-react-js/AGENTS.md create mode 100644 packages/create-rstack/template-lib-react-js/README.md create mode 100644 packages/create-rstack/template-lib-react-js/package.json create mode 100644 packages/create-rstack/template-lib-react-js/rstack.config.js create mode 100644 packages/create-rstack/template-lib-react-js/src/Button.jsx create mode 100644 packages/create-rstack/template-lib-react-js/src/button.css create mode 100644 packages/create-rstack/template-lib-react-js/src/index.jsx create mode 100644 packages/create-rstack/template-lib-react-js/tests/index.test.jsx create mode 100644 packages/create-rstack/template-lib-react-js/tests/rstest.setup.js create mode 100644 packages/create-rstack/template-lib-react-ts/AGENTS.md create mode 100644 packages/create-rstack/template-lib-react-ts/README.md create mode 100644 packages/create-rstack/template-lib-react-ts/package.json create mode 100644 packages/create-rstack/template-lib-react-ts/rstack.config.ts create mode 100644 packages/create-rstack/template-lib-react-ts/src/Button.tsx create mode 100644 packages/create-rstack/template-lib-react-ts/src/button.css create mode 100644 packages/create-rstack/template-lib-react-ts/src/index.tsx create mode 100644 packages/create-rstack/template-lib-react-ts/tests/index.test.tsx create mode 100644 packages/create-rstack/template-lib-react-ts/tests/rstest.setup.ts create mode 100644 packages/create-rstack/template-lib-react-ts/tests/tsconfig.json create mode 100644 packages/create-rstack/template-lib-react-ts/tsconfig.json diff --git a/packages/create-rstack/README.md b/packages/create-rstack/README.md index 453221e0..b62318ab 100644 --- a/packages/create-rstack/README.md +++ b/packages/create-rstack/README.md @@ -25,8 +25,10 @@ npx create-rstack -d my-project -t app-vanilla-ts - `app-vanilla-ts` - TypeScript Vanilla application - `app-react-js` - JavaScript React application - `app-react-ts` - TypeScript React application -- `lib-js` - JavaScript Node.js library -- `lib-ts` - TypeScript Node.js library +- `lib-node-js` - JavaScript Node.js library +- `lib-node-ts` - TypeScript Node.js library +- `lib-react-js` - JavaScript React library +- `lib-react-ts` - TypeScript React library ## Documentation diff --git a/packages/create-rstack/rstack.config.ts b/packages/create-rstack/rstack.config.ts index 61659acf..923fc17d 100644 --- a/packages/create-rstack/rstack.config.ts +++ b/packages/create-rstack/rstack.config.ts @@ -6,6 +6,7 @@ define.lib({ }); define.test({ + include: ['./tests/**/*.test.ts'], source: { tsconfigPath: './tests/tsconfig.json', }, diff --git a/packages/create-rstack/src/index.ts b/packages/create-rstack/src/index.ts index 8590df3f..81364077 100644 --- a/packages/create-rstack/src/index.ts +++ b/packages/create-rstack/src/index.ts @@ -13,6 +13,16 @@ const getTemplateName = async ({ template }: Argv): Promise => { return `app-${framework}-${language}`; } + if (template === 'lib' || template.startsWith('lib-')) { + const [, libraryType = 'node', language = 'js'] = template.split('-'); + + if (libraryType === 'js' || libraryType === 'ts') { + return `lib-node-${libraryType}`; + } + + return `lib-${libraryType}-${language}`; + } + const [type, language = 'js'] = template.split('-'); return `${type}-${language}`; } @@ -27,18 +37,21 @@ const getTemplateName = async ({ template }: Argv): Promise => { }), ); - const framework = - projectType === 'app' - ? checkCancel( - await select({ - message: 'Select framework', - options: [ + const templateType = checkCancel( + await select({ + message: projectType === 'app' ? 'Select framework' : 'Select library type', + options: + projectType === 'app' + ? [ { value: 'vanilla', label: 'Vanilla' }, { value: 'react', label: 'React' }, + ] + : [ + { value: 'node', label: 'Node.js' }, + { value: 'react', label: 'React' }, ], - }), - ) - : undefined; + }), + ); const language = checkCancel( await select({ @@ -50,7 +63,7 @@ const getTemplateName = async ({ template }: Argv): Promise => { }), ); - return framework ? `${projectType}-${framework}-${language}` : `${projectType}-${language}`; + return `${projectType}-${templateType}-${language}`; }; await create({ @@ -61,8 +74,10 @@ await create({ 'app-vanilla-ts', 'app-react-js', 'app-react-ts', - 'lib-js', - 'lib-ts', + 'lib-node-js', + 'lib-node-ts', + 'lib-react-js', + 'lib-react-ts', ], builtinTools: [], getTemplateName, diff --git a/packages/create-rstack/template-lib-js/AGENTS.md b/packages/create-rstack/template-lib-node-js/AGENTS.md similarity index 100% rename from packages/create-rstack/template-lib-js/AGENTS.md rename to packages/create-rstack/template-lib-node-js/AGENTS.md diff --git a/packages/create-rstack/template-lib-js/README.md b/packages/create-rstack/template-lib-node-js/README.md similarity index 100% rename from packages/create-rstack/template-lib-js/README.md rename to packages/create-rstack/template-lib-node-js/README.md diff --git a/packages/create-rstack/template-lib-js/package.json b/packages/create-rstack/template-lib-node-js/package.json similarity index 94% rename from packages/create-rstack/template-lib-js/package.json rename to packages/create-rstack/template-lib-node-js/package.json index 76a93f30..d586a11b 100644 --- a/packages/create-rstack/template-lib-js/package.json +++ b/packages/create-rstack/template-lib-node-js/package.json @@ -1,5 +1,5 @@ { - "name": "rstack-lib-js", + "name": "rstack-lib-node-js", "version": "0.0.0", "sideEffects": false, "type": "module", diff --git a/packages/create-rstack/template-lib-js/rstack.config.js b/packages/create-rstack/template-lib-node-js/rstack.config.js similarity index 100% rename from packages/create-rstack/template-lib-js/rstack.config.js rename to packages/create-rstack/template-lib-node-js/rstack.config.js diff --git a/packages/create-rstack/template-lib-js/src/index.js b/packages/create-rstack/template-lib-node-js/src/index.js similarity index 100% rename from packages/create-rstack/template-lib-js/src/index.js rename to packages/create-rstack/template-lib-node-js/src/index.js diff --git a/packages/create-rstack/template-lib-js/tests/index.test.js b/packages/create-rstack/template-lib-node-js/tests/index.test.js similarity index 100% rename from packages/create-rstack/template-lib-js/tests/index.test.js rename to packages/create-rstack/template-lib-node-js/tests/index.test.js diff --git a/packages/create-rstack/template-lib-ts/AGENTS.md b/packages/create-rstack/template-lib-node-ts/AGENTS.md similarity index 100% rename from packages/create-rstack/template-lib-ts/AGENTS.md rename to packages/create-rstack/template-lib-node-ts/AGENTS.md diff --git a/packages/create-rstack/template-lib-ts/README.md b/packages/create-rstack/template-lib-node-ts/README.md similarity index 100% rename from packages/create-rstack/template-lib-ts/README.md rename to packages/create-rstack/template-lib-node-ts/README.md diff --git a/packages/create-rstack/template-lib-ts/package.json b/packages/create-rstack/template-lib-node-ts/package.json similarity index 95% rename from packages/create-rstack/template-lib-ts/package.json rename to packages/create-rstack/template-lib-node-ts/package.json index 46091aa6..4805daf9 100644 --- a/packages/create-rstack/template-lib-ts/package.json +++ b/packages/create-rstack/template-lib-node-ts/package.json @@ -1,5 +1,5 @@ { - "name": "rstack-lib-ts", + "name": "rstack-lib-node-ts", "version": "0.0.0", "sideEffects": false, "type": "module", diff --git a/packages/create-rstack/template-lib-ts/rstack.config.ts b/packages/create-rstack/template-lib-node-ts/rstack.config.ts similarity index 100% rename from packages/create-rstack/template-lib-ts/rstack.config.ts rename to packages/create-rstack/template-lib-node-ts/rstack.config.ts diff --git a/packages/create-rstack/template-lib-ts/src/index.ts b/packages/create-rstack/template-lib-node-ts/src/index.ts similarity index 100% rename from packages/create-rstack/template-lib-ts/src/index.ts rename to packages/create-rstack/template-lib-node-ts/src/index.ts diff --git a/packages/create-rstack/template-lib-ts/tests/index.test.ts b/packages/create-rstack/template-lib-node-ts/tests/index.test.ts similarity index 100% rename from packages/create-rstack/template-lib-ts/tests/index.test.ts rename to packages/create-rstack/template-lib-node-ts/tests/index.test.ts diff --git a/packages/create-rstack/template-lib-ts/tsconfig.json b/packages/create-rstack/template-lib-node-ts/tsconfig.json similarity index 100% rename from packages/create-rstack/template-lib-ts/tsconfig.json rename to packages/create-rstack/template-lib-node-ts/tsconfig.json diff --git a/packages/create-rstack/template-lib-react-js/AGENTS.md b/packages/create-rstack/template-lib-react-js/AGENTS.md new file mode 100644 index 00000000..234cc720 --- /dev/null +++ b/packages/create-rstack/template-lib-react-js/AGENTS.md @@ -0,0 +1,14 @@ +# AGENTS.md + +## Commands + +- `{{ packageManager }} run build` - Build the library for production +- `{{ packageManager }} run dev` - Rebuild the library when source files change +- `{{ packageManager }} run test` - Run tests +- `{{ packageManager }} run test:watch` - Run tests in watch mode + +## Docs + +- Rslib: https://rslib.rs/llms.txt +- Rspack: https://rspack.rs/llms.txt +- Rstest: https://rstest.rs/llms.txt diff --git a/packages/create-rstack/template-lib-react-js/README.md b/packages/create-rstack/template-lib-react-js/README.md new file mode 100644 index 00000000..ab4b6927 --- /dev/null +++ b/packages/create-rstack/template-lib-react-js/README.md @@ -0,0 +1,40 @@ +# Rstack library + +## Setup + +Install the dependencies: + +```bash +{{ packageManager }} install +``` + +## Get started + +Build the library: + +```bash +{{ packageManager }} run build +``` + +Build the library in watch mode: + +```bash +{{ packageManager }} run dev +``` + +Run tests: + +```bash +{{ packageManager }} run test +``` + +Run tests in watch mode: + +```bash +{{ packageManager }} run test:watch +``` + +## Learn more + +- [Rstack documentation](https://rstack.rs) +- [Rslib documentation](https://rslib.rs) diff --git a/packages/create-rstack/template-lib-react-js/package.json b/packages/create-rstack/template-lib-react-js/package.json new file mode 100644 index 00000000..7580d09f --- /dev/null +++ b/packages/create-rstack/template-lib-react-js/package.json @@ -0,0 +1,40 @@ +{ + "name": "rstack-lib-react-js", + "version": "0.0.0", + "type": "module", + "exports": { + ".": { + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "rs lib", + "dev": "rs lib --watch", + "format": "rs fmt", + "lint": "rs lint", + "test": "rs test", + "test:watch": "rs test --watch" + }, + "devDependencies": { + "@rsbuild/plugin-react": "^2.1.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^16.3.2", + "@types/react-dom": "^19.2.4", + "happy-dom": "^20.11.1", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "rstack": "^0.3.5" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/create-rstack/template-lib-react-js/rstack.config.js b/packages/create-rstack/template-lib-react-js/rstack.config.js new file mode 100644 index 00000000..30b3b512 --- /dev/null +++ b/packages/create-rstack/template-lib-react-js/rstack.config.js @@ -0,0 +1,34 @@ +// @ts-check +// Rstack configuration guide: https://rstack.rs/config +import { define } from 'rstack'; + +define.lib(async () => { + const { pluginReact } = await import('@rsbuild/plugin-react'); + + return { + bundle: false, + source: { + entry: { + index: ['./src/**'], + }, + }, + output: { + target: 'web', + }, + plugins: [pluginReact()], + }; +}); + +define.test({ + setupFiles: ['./tests/rstest.setup.js'], +}); + +define.lint(async () => { + const { js, reactHooksPlugin, reactPlugin } = await import('rstack/lint'); + + return [ + js.configs.recommended, + reactPlugin.configs.recommended, + reactHooksPlugin.configs.recommended, + ]; +}); diff --git a/packages/create-rstack/template-lib-react-js/src/Button.jsx b/packages/create-rstack/template-lib-react-js/src/Button.jsx new file mode 100644 index 00000000..91960089 --- /dev/null +++ b/packages/create-rstack/template-lib-react-js/src/Button.jsx @@ -0,0 +1,15 @@ +import './button.css'; + +export const Button = ({ primary = false, size = 'medium', backgroundColor, label, ...props }) => { + const mode = primary ? 'demo-button--primary' : 'demo-button--secondary'; + return ( + + ); +}; diff --git a/packages/create-rstack/template-lib-react-js/src/button.css b/packages/create-rstack/template-lib-react-js/src/button.css new file mode 100644 index 00000000..257ef46f --- /dev/null +++ b/packages/create-rstack/template-lib-react-js/src/button.css @@ -0,0 +1,34 @@ +.demo-button { + font-weight: 700; + border: 0; + border-radius: 3em; + cursor: pointer; + display: inline-block; + line-height: 1; +} + +.demo-button--primary { + color: white; + background-color: #1ea7fd; +} + +.demo-button--secondary { + color: #333; + background-color: transparent; + box-shadow: rgba(0, 0, 0, 0.15) 0 0 0 1px inset; +} + +.demo-button--small { + font-size: 12px; + padding: 10px 16px; +} + +.demo-button--medium { + font-size: 14px; + padding: 11px 20px; +} + +.demo-button--large { + font-size: 16px; + padding: 12px 24px; +} diff --git a/packages/create-rstack/template-lib-react-js/src/index.jsx b/packages/create-rstack/template-lib-react-js/src/index.jsx new file mode 100644 index 00000000..fe9c53c5 --- /dev/null +++ b/packages/create-rstack/template-lib-react-js/src/index.jsx @@ -0,0 +1 @@ +export { Button } from './Button'; diff --git a/packages/create-rstack/template-lib-react-js/tests/index.test.jsx b/packages/create-rstack/template-lib-react-js/tests/index.test.jsx new file mode 100644 index 00000000..652fcc82 --- /dev/null +++ b/packages/create-rstack/template-lib-react-js/tests/index.test.jsx @@ -0,0 +1,11 @@ +import { expect, test } from 'rstack/test'; +import { render, screen } from '@testing-library/react'; +import { Button } from '../src/Button'; + +test('The button should have correct background color', async () => { + render( + ); +}; diff --git a/packages/create-rstack/template-lib-react-ts/src/button.css b/packages/create-rstack/template-lib-react-ts/src/button.css new file mode 100644 index 00000000..257ef46f --- /dev/null +++ b/packages/create-rstack/template-lib-react-ts/src/button.css @@ -0,0 +1,34 @@ +.demo-button { + font-weight: 700; + border: 0; + border-radius: 3em; + cursor: pointer; + display: inline-block; + line-height: 1; +} + +.demo-button--primary { + color: white; + background-color: #1ea7fd; +} + +.demo-button--secondary { + color: #333; + background-color: transparent; + box-shadow: rgba(0, 0, 0, 0.15) 0 0 0 1px inset; +} + +.demo-button--small { + font-size: 12px; + padding: 10px 16px; +} + +.demo-button--medium { + font-size: 14px; + padding: 11px 20px; +} + +.demo-button--large { + font-size: 16px; + padding: 12px 24px; +} diff --git a/packages/create-rstack/template-lib-react-ts/src/index.tsx b/packages/create-rstack/template-lib-react-ts/src/index.tsx new file mode 100644 index 00000000..fe9c53c5 --- /dev/null +++ b/packages/create-rstack/template-lib-react-ts/src/index.tsx @@ -0,0 +1 @@ +export { Button } from './Button'; diff --git a/packages/create-rstack/template-lib-react-ts/tests/index.test.tsx b/packages/create-rstack/template-lib-react-ts/tests/index.test.tsx new file mode 100644 index 00000000..652fcc82 --- /dev/null +++ b/packages/create-rstack/template-lib-react-ts/tests/index.test.tsx @@ -0,0 +1,11 @@ +import { expect, test } from 'rstack/test'; +import { render, screen } from '@testing-library/react'; +import { Button } from '../src/Button'; + +test('The button should have correct background color', async () => { + render(