Compare commits

..

No commits in common. "master" and "v0.1.1" have entirely different histories.

16 changed files with 165 additions and 229 deletions

View File

@ -1,40 +0,0 @@
# AGENTS.md - Yarn Spinner Loader
## Commands
- `npm run build` — Build all entry points (tsup, ESM + CJS)
- `npm run dev` — Watch mode
- `npm run test` — Vitest watch mode
- `npm run test:run` — Run tests once
- `npm run typecheck``tsc --noEmit`
## Architecture
- **`src/index.ts`** — Main entry point. Full browser package: parse + compile + run + all types. No Node.js deps.
- **`src/core.ts`** — Mirror of index.ts. Not in package.json exports.
- **`src/runner.ts`** — Minimal runtime export: `YarnRunner` + runtime/IR types only. No parser, no compiler.
- **`src/loader/`** — Filesystem loader: parses `.yarnproject` files, validates against JSON schema (ajv), resolves globs (fast-glob), compiles `.yarn` files.
- **`src/plugins/`** — Build tool plugins: esbuild, rollup, vite, webpack. Each is a separate tsup entry point outputting to `dist/plugins/`.
- **`src/yarn-spinner/`** — Embedded Yarn Spinner implementation: `parse/`, `compile/`, `runtime/`, `markup/`, `model/`.
- **`src/runner/`** — Empty directory. Ignore.
## Package exports
- `yarn-spinner-loader``dist/index.js` (full browser package)
- `yarn-spinner-loader/runner``dist/runner.js` (runtime only)
- `yarn-spinner-loader/esbuild``dist/plugins/esbuild.js`
- `yarn-spinner-loader/rollup``dist/plugins/rollup.js`
- `yarn-spinner-loader/vite``dist/plugins/vite.js`
- `yarn-spinner-loader/webpack``dist/plugins/webpack.js`
## Key conventions
- `src/index.ts` and `src/core.ts` must NOT import from `src/loader/` or any Node.js-specific modules.
- `src/loader/` is the only code that uses `fs`, `path`, `fast-glob`.
- TypeScript: strict mode, ES2022, `moduleResolution: "bundler"`.
- Tests live in `tests/`, not `src/`. Pattern: `tests/**/*.test.ts`.
## Build notes
- tsup config has 6 entry points. Main + runner output to `dist/`, plugins output to `dist/plugins/`.
- `npm run build` cleans `dist/` automatically.
- `core.ts` is NOT exported in package.json — it exists for internal consistency only.
- This package is intended as a `devDependency` alongside bundlers. Dependencies (`ajv`, `fast-glob`) are needed at build time.
## Security
- `.npmrc` contains an npm auth token. Never commit or expose this.

View File

@ -26,7 +26,7 @@ import { loadYarnProject } from 'yarn-spinner-loader';
const result = await loadYarnProject('path/to/project.yarnproject');
console.log(result.project.projectName);
console.log(result.program); // Merged IRProgram from all .yarn files
console.log(result.yarnFiles); // Array of parsed Yarn documents
```
### With esbuild
@ -103,7 +103,11 @@ Load and compile a `.yarnproject` file and all its referenced `.yarn` files.
interface LoadResult {
project: YarnProject;
baseDir: string;
program: IRProgram;
yarnFiles: Array<{
relativePath: string;
absolutePath: string;
document: YarnDocument;
}>;
}
```

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "yarn-spinner-loader",
"version": "0.2.1",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "yarn-spinner-loader",
"version": "0.2.1",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"ajv": "^8.17.1",

View File

@ -1,6 +1,6 @@
{
"name": "yarn-spinner-loader",
"version": "0.3.0",
"version": "0.1.1",
"description": "Load and compile Yarn Spinner project files for various build tools",
"type": "module",
"main": "./dist/index.js",
@ -12,10 +12,10 @@
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./runner": {
"types": "./dist/runner.d.ts",
"import": "./dist/runner.js",
"require": "./dist/runner.cjs"
"./node": {
"types": "./dist/node.d.ts",
"import": "./dist/node.js",
"require": "./dist/node.cjs"
},
"./esbuild": {
"types": "./dist/plugins/esbuild.d.ts",

View File

@ -5,64 +5,21 @@
* This module does NOT depend on Node.js modules.
*/
// Parser
export { parseYarn, ParseError } from './yarn-spinner/parse/parser';
// Re-export yarn-spinner parser
export { parseYarn } from './yarn-spinner/parse/parser';
export { compile } from './yarn-spinner/compile/compiler';
export { YarnRunner } from './yarn-spinner/runtime/runner';
// Compiler
export { compile, type CompileOptions } from './yarn-spinner/compile/compiler';
// Runner
export { YarnRunner, type RunnerOptions } from './yarn-spinner/runtime/runner';
// AST types
export type {
Position,
NodeHeaderMap,
YarnDocument,
EnumDefinition,
YarnNode,
Statement,
Line,
Command,
Jump,
Detour,
Return,
OptionGroup,
Option,
IfBlock,
OnceBlock,
EnumBlock,
} from './yarn-spinner/model/ast';
// IR types
export type {
IRProgram,
IRNode,
IRNodeGroup,
IRInstruction,
} from './yarn-spinner/compile/ir';
// Runtime types
// Re-export types
export type {
LoadOptions,
LoadResult,
LoadedYarnFile,
YarnProject,
SchemaValidationError,
RunnerOptions,
TextResult,
OptionsResult,
CommandResult,
RuntimeResult,
} from './yarn-spinner/runtime/results';
// Markup types
export type {
MarkupValue,
MarkupWrapperType,
MarkupWrapper,
MarkupSegment,
MarkupParseResult,
} from './yarn-spinner/markup/types';
// Loader types
export type {
LoadOptions,
LoadResult,
YarnProject,
SchemaValidationError,
} from './types';

View File

@ -1,69 +1,26 @@
/**
* Yarn Spinner Loader - Main Entry Point
*
* Full browser-compatible package: parse, compile, and run Yarn Spinner documents.
* Pure API for working with Yarn Spinner documents.
* This module does NOT depend on Node.js modules.
* For filesystem loading, use the 'yarn-spinner-loader/node' entry point.
* For filesystem operations, use the 'yarn-spinner-loader/node' entry point.
*/
// Parser
export { parseYarn, ParseError } from './yarn-spinner/parse/parser';
// Re-export yarn-spinner parser and runtime
export { parseYarn } from './yarn-spinner/parse/parser';
export { compile } from './yarn-spinner/compile/compiler';
export { YarnRunner } from './yarn-spinner/runtime/runner';
// Compiler
export { compile, type CompileOptions } from './yarn-spinner/compile/compiler';
// Runner
export { YarnRunner, type RunnerOptions } from './yarn-spinner/runtime/runner';
// AST types
export type {
Position,
NodeHeaderMap,
YarnDocument,
EnumDefinition,
YarnNode,
Statement,
Line,
Command,
Jump,
Detour,
Return,
OptionGroup,
Option,
IfBlock,
OnceBlock,
EnumBlock,
} from './yarn-spinner/model/ast';
// IR types
export type {
IRProgram,
IRNode,
IRNodeGroup,
IRInstruction,
} from './yarn-spinner/compile/ir';
// Runtime types
// Re-export types
export type {
LoadOptions,
LoadResult,
LoadedYarnFile,
YarnProject,
SchemaValidationError,
RunnerOptions,
TextResult,
OptionsResult,
CommandResult,
RuntimeResult,
} from './yarn-spinner/runtime/results';
// Markup types
export type {
MarkupValue,
MarkupWrapperType,
MarkupWrapper,
MarkupSegment,
MarkupParseResult,
} from './yarn-spinner/markup/types';
// Loader types
export type {
LoadOptions,
LoadResult,
YarnProject,
SchemaValidationError,
} from './types';

View File

@ -1,11 +1,10 @@
import { readFileSync, existsSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { dirname, resolve, relative } from 'node:path';
import fg from 'fast-glob';
import type { YarnProject } from './types';
import { validateYarnProject, type SchemaValidationError } from './validator';
import { parseYarn } from '../yarn-spinner/parse/parser';
import { compile } from '../yarn-spinner/compile/compiler';
import type { IRProgram } from '../yarn-spinner/compile/ir';
import type { YarnDocument } from '../yarn-spinner/model/ast';
/**
* Options for loading a Yarn project
@ -15,6 +14,18 @@ export interface LoadOptions {
baseDir?: string;
}
/**
* Loaded Yarn document with metadata
*/
export interface LoadedYarnFile {
/** File path relative to base directory */
relativePath: string;
/** Absolute file path */
absolutePath: string;
/** Parsed Yarn document */
document: YarnDocument;
}
/**
* Result of loading a Yarn project
*/
@ -23,8 +34,8 @@ export interface LoadResult {
project: YarnProject;
/** Directory containing the .yarnproject file */
baseDir: string;
/** Merged IR program from all .yarn files */
program: IRProgram;
/** All loaded and parsed .yarn files */
yarnFiles: LoadedYarnFile[];
}
/**
@ -60,6 +71,7 @@ export async function loadYarnProject(
projectFilePath: string,
options: LoadOptions = {},
): Promise<LoadResult> {
// Resolve and read .yarnproject file
const absoluteProjectPath = resolve(projectFilePath);
if (!existsSync(absoluteProjectPath)) {
@ -75,6 +87,7 @@ export async function loadYarnProject(
throw new LoadError(`Failed to parse .yarnproject file as JSON: ${absoluteProjectPath}`, error);
}
// Validate against schema
const validation = validateYarnProject(projectConfig);
if (!validation.valid) {
@ -87,6 +100,7 @@ export async function loadYarnProject(
const project = validation.data as YarnProject;
const baseDir = options.baseDir || dirname(absoluteProjectPath);
// Find all .yarn files using glob patterns
const sourcePatterns = project.sourceFiles;
const ignorePatterns = project.excludeFiles || [];
@ -97,21 +111,23 @@ export async function loadYarnProject(
onlyFiles: true,
});
const program: IRProgram = { enums: {}, nodes: {} };
for (const absolutePath of yarnFilePaths) {
// Load and parse each .yarn file
const yarnFiles: LoadedYarnFile[] = yarnFilePaths.map(absolutePath => {
const relativePath = relative(baseDir, absolutePath);
const content = readFileSync(absolutePath, 'utf-8');
const document = parseYarn(content);
const compiled = compile(document);
Object.assign(program.enums, compiled.enums);
Object.assign(program.nodes, compiled.nodes);
}
return {
relativePath,
absolutePath,
document,
};
});
return {
project,
baseDir,
program,
yarnFiles,
};
}
@ -122,6 +138,7 @@ export function loadYarnProjectSync(
projectFilePath: string,
options: LoadOptions = {},
): LoadResult {
// Resolve and read .yarnproject file
const absoluteProjectPath = resolve(projectFilePath);
if (!existsSync(absoluteProjectPath)) {
@ -137,6 +154,7 @@ export function loadYarnProjectSync(
throw new LoadError(`Failed to parse .yarnproject file as JSON: ${absoluteProjectPath}`, error);
}
// Validate against schema
const validation = validateYarnProject(projectConfig);
if (!validation.valid) {
@ -149,6 +167,7 @@ export function loadYarnProjectSync(
const project = validation.data as YarnProject;
const baseDir = options.baseDir || dirname(absoluteProjectPath);
// Find all .yarn files using glob patterns
const sourcePatterns = project.sourceFiles;
const ignorePatterns = project.excludeFiles || [];
@ -159,20 +178,22 @@ export function loadYarnProjectSync(
onlyFiles: true,
});
const program: IRProgram = { enums: {}, nodes: {} };
for (const absolutePath of yarnFilePaths) {
// Load and parse each .yarn file
const yarnFiles: LoadedYarnFile[] = yarnFilePaths.map(absolutePath => {
const relativePath = relative(baseDir, absolutePath);
const content = readFileSync(absolutePath, 'utf-8');
const document = parseYarn(content);
const compiled = compile(document);
Object.assign(program.enums, compiled.enums);
Object.assign(program.nodes, compiled.nodes);
}
return {
relativePath,
absolutePath,
document,
};
});
return {
project,
baseDir,
program,
yarnFiles,
};
}

24
src/node.ts Normal file
View File

@ -0,0 +1,24 @@
/**
* Yarn Spinner Loader - Node.js API
*
* Functions that depend on Node.js modules (fs, path, fast-glob).
* Use this entry point when you need to load .yarnproject files from the filesystem.
*/
export {
loadYarnProject,
loadYarnProjectSync,
type LoadOptions,
type LoadResult,
type LoadedYarnFile,
LoadError,
ValidationError as LoaderValidationError,
} from './loader/index';
export {
validateYarnProject,
isYarnProject,
type SchemaValidationError,
} from './loader/validator';
export type { YarnProject } from './loader/types';

View File

@ -1,30 +0,0 @@
/**
* Yarn Spinner Loader - Runner API
*
* Minimal runtime-only export for executing pre-compiled IRPrograms.
* No parser, no compiler just the runner and its types.
*/
export { YarnRunner, type RunnerOptions } from './yarn-spinner/runtime/runner';
export type {
TextResult,
OptionsResult,
CommandResult,
RuntimeResult,
} from './yarn-spinner/runtime/results';
export type {
IRProgram,
IRNode,
IRNodeGroup,
IRInstruction,
} from './yarn-spinner/compile/ir';
export type {
MarkupParseResult,
MarkupSegment,
MarkupWrapper,
MarkupWrapperType,
MarkupValue,
} from './yarn-spinner/markup/types';

View File

@ -6,7 +6,7 @@
import type { YarnProject } from './loader/types';
import type { SchemaValidationError } from './loader/validator';
import type { IRProgram } from './yarn-spinner/compile/ir';
import type { YarnDocument } from './yarn-spinner/model/ast';
import type { RunnerOptions } from './yarn-spinner/runtime/runner';
import type {
TextResult,
@ -18,7 +18,7 @@ import type {
export type {
YarnProject,
SchemaValidationError,
IRProgram,
YarnDocument,
RunnerOptions,
TextResult,
OptionsResult,
@ -34,6 +34,18 @@ export interface LoadOptions {
baseDir?: string;
}
/**
* Loaded Yarn document with metadata
*/
export interface LoadedYarnFile {
/** File path relative to base directory */
relativePath: string;
/** Absolute file path */
absolutePath: string;
/** Parsed Yarn document */
document: YarnDocument;
}
/**
* Result of loading a Yarn project
*/
@ -42,6 +54,6 @@ export interface LoadResult {
project: YarnProject;
/** Directory containing the .yarnproject file */
baseDir: string;
/** Merged IR program from all .yarn files */
program: IRProgram;
/** All loaded and parsed .yarn files */
yarnFiles: LoadedYarnFile[];
}

View File

@ -14,14 +14,14 @@ describe('loadYarnProject', () => {
expect(result.project.projectName).toBe('Simple Test Project');
expect(result.project.sourceFiles).toEqual(['**/*.yarn']);
expect(result.project.baseLanguage).toBe('en');
expect(Object.keys(result.program.nodes).length).toBeGreaterThan(0);
expect(result.yarnFiles.length).toBeGreaterThan(0);
});
it('should compile all yarn files in simple project', async () => {
it('should parse all yarn files in simple project', async () => {
const projectPath = resolve(__dirname, 'fixtures/simple/.yarnproject');
const result = await loadYarnProject(projectPath);
const titles = Object.keys(result.program.nodes);
const titles = result.yarnFiles.flatMap(f => f.document.nodes.map(n => n.title));
expect(titles).toContain('Start');
expect(titles).toContain('Greeting');
expect(titles).toContain('AnotherNode');
@ -35,7 +35,7 @@ describe('loadYarnProject', () => {
expect(result.project.baseLanguage).toBe('en');
expect(result.project.localisation).toBeDefined();
expect(result.project.localisation!['zh-Hans']).toBeDefined();
expect(Object.keys(result.program.nodes).length).toBeGreaterThan(0);
expect(result.yarnFiles.length).toBeGreaterThan(0);
});
it('should load a complex project with multiple patterns', async () => {
@ -48,21 +48,51 @@ describe('loadYarnProject', () => {
expect(result.project.sourceFiles).toContain('scripts/**/*.yarn');
});
it('should exclude files matching excludeFiles patterns', async () => {
const projectPath = resolve(__dirname, 'fixtures/complex/.yarnproject');
const result = await loadYarnProject(projectPath);
// Check that backup file is excluded
const backupFiles = result.yarnFiles.filter(f =>
f.relativePath.includes('backup'),
);
expect(backupFiles).toHaveLength(0);
});
it('should include files from multiple source patterns', async () => {
const projectPath = resolve(__dirname, 'fixtures/complex/.yarnproject');
const result = await loadYarnProject(projectPath);
const relativePaths = result.yarnFiles.map(f => f.relativePath);
// Should include dialogue files
expect(relativePaths.some(p => p.includes('dialogue'))).toBe(true);
// Should include script files
expect(relativePaths.some(p => p.includes('scripts'))).toBe(true);
});
it('should parse yarn documents correctly', async () => {
const projectPath = resolve(__dirname, 'fixtures/simple/.yarnproject');
const result = await loadYarnProject(projectPath);
const startNode = result.program.nodes['Start'];
const startNode = result.yarnFiles
.flatMap(f => f.document.nodes)
.find(n => n.title === 'Start');
expect(startNode).toBeDefined();
expect(startNode && 'instructions' in startNode ? startNode.instructions.length : 0).toBeGreaterThan(0);
expect(startNode!.body.length).toBeGreaterThan(0);
});
it('should handle nodes with tags', async () => {
const projectPath = resolve(__dirname, 'fixtures/simple/.yarnproject');
const result = await loadYarnProject(projectPath);
const greetingNode = result.program.nodes['Greeting'];
const greetingNode = result.yarnFiles
.flatMap(f => f.document.nodes)
.find(n => n.title === 'Greeting');
expect(greetingNode).toBeDefined();
expect(greetingNode!.nodeTags).toContain('greeting');
});
it('should throw LoadError for non-existent file', async () => {
@ -74,6 +104,7 @@ describe('loadYarnProject', () => {
it('should throw error for invalid JSON', async () => {
const projectPath = resolve(__dirname, 'fixtures/invalid/.yarnproject');
// This will fail because the fixture doesn't exist
await expect(loadYarnProject(projectPath)).rejects.toThrow();
});
});
@ -84,14 +115,14 @@ describe('loadYarnProjectSync', () => {
const result = loadYarnProjectSync(projectPath);
expect(result.project.projectFileVersion).toBe(4);
expect(Object.keys(result.program.nodes).length).toBeGreaterThan(0);
expect(result.yarnFiles.length).toBeGreaterThan(0);
});
it('should compile all yarn files synchronously', () => {
it('should parse all yarn files synchronously', () => {
const projectPath = resolve(__dirname, 'fixtures/simple/.yarnproject');
const result = loadYarnProjectSync(projectPath);
const titles = Object.keys(result.program.nodes);
const titles = result.yarnFiles.flatMap(f => f.document.nodes.map(n => n.title));
expect(titles).toContain('Start');
});
});

View File

@ -60,7 +60,7 @@ describe('yarnSpinnerPlugin (esbuild)', () => {
expect(result).toBeDefined();
expect(result.contents).toContain('export default');
expect(result.contents).toContain('project');
expect(result.contents).toContain('program');
expect(result.contents).toContain('yarnFiles');
expect(result.loader).toBe('js');
});

View File

@ -39,7 +39,7 @@ describe('yarnSpinnerRollup', () => {
expect(typeof result).toBe('string');
expect(result).toContain('export default');
expect(result).toContain('project');
expect(result).toContain('program');
expect(result).toContain('yarnFiles');
});
it('should return null for non-.yarnproject files', async () => {

View File

@ -39,7 +39,7 @@ describe('yarnSpinnerVite', () => {
expect(typeof result).toBe('string');
expect(result).toContain('export default');
expect(result).toContain('project');
expect(result).toContain('program');
expect(result).toContain('yarnFiles');
});
it('should return null for non-.yarnproject files', async () => {

View File

@ -26,7 +26,7 @@ describe('YarnSpinnerWebpackLoader', () => {
expect(typeof result).toBe('string');
expect(result).toContain('export default');
expect(result).toContain('project');
expect(result).toContain('program');
expect(result).toContain('yarnFiles');
// Ensure no errors were emitted
expect(mockContext.emitError).not.toHaveBeenCalled();
});

View File

@ -10,7 +10,7 @@ export default defineConfig([
outDir: 'dist',
},
{
entry: ['src/runner.ts'],
entry: ['src/node.ts'],
format: ['esm', 'cjs'],
dts: true,
sourcemap: true,