feat: impl

This commit is contained in:
hypercross 2026-04-14 15:24:54 +08:00
parent 9942bd9a7f
commit dd43bb1a1d
26 changed files with 3665 additions and 0 deletions

23
.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
# Dependencies
node_modules/
# Build output
dist/
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# Environment
.env
.env.local

164
README.md Normal file
View File

@ -0,0 +1,164 @@
# Yarn Spinner Loader
Load and compile Yarn Spinner project files (`.yarnproject`) for various build tools (esbuild, rollup, webpack, vite).
## Features
- ✅ Parse and validate `.yarnproject` files against the official JSON schema
- ✅ Load and compile `.yarn` dialogue files using glob patterns
- ✅ Support for multiple build tools (esbuild, rollup, webpack, vite)
- ✅ TypeScript support with full type definitions
- ✅ Comprehensive test coverage with real fixtures
## Installation
```bash
npm install yarn-spinner-loader
```
## Usage
### Core API
```typescript
import { loadYarnProject } from 'yarn-spinner-loader';
const result = await loadYarnProject('path/to/project.yarnproject');
console.log(result.project.projectName);
console.log(result.yarnFiles); // Array of parsed Yarn documents
```
### With esbuild
```typescript
import { build } from 'esbuild';
import { yarnSpinnerPlugin } from 'yarn-spinner-loader/esbuild';
build({
entryPoints: ['src/index.ts'],
plugins: [yarnSpinnerPlugin()],
bundle: true,
outfile: 'dist/bundle.js',
});
```
### With Vite
```typescript
// vite.config.ts
import { defineConfig } from 'vite';
import { yarnSpinnerVite } from 'yarn-spinner-loader/vite';
export default defineConfig({
plugins: [yarnSpinnerVite()],
});
```
### With Rollup
```typescript
// rollup.config.js
import { yarnSpinnerRollup } from 'yarn-spinner-loader/rollup';
export default {
input: 'src/main.js',
plugins: [yarnSpinnerRollup()],
output: {
file: 'dist/bundle.js',
format: 'es',
},
};
```
### With Webpack
```javascript
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.yarnproject$/,
use: 'yarn-spinner-loader/webpack',
},
],
},
};
```
## API Reference
### `loadYarnProject(path, options?)`
Load and compile a `.yarnproject` file and all its referenced `.yarn` files.
**Parameters:**
- `path`: Path to the `.yarnproject` file
- `options.baseDir`: Base directory for resolving glob patterns (default: directory of `.yarnproject` file)
**Returns:** `Promise<LoadResult>`
```typescript
interface LoadResult {
project: YarnProject;
baseDir: string;
yarnFiles: Array<{
relativePath: string;
absolutePath: string;
document: YarnDocument;
}>;
}
```
### `loadYarnProjectSync(path, options?)`
Synchronous version of `loadYarnProject`.
### `validateYarnProject(config)`
Validate a parsed `.yarnproject` object against the JSON schema.
**Returns:**
```typescript
{ valid: true; data: YarnProject } | { valid: false; errors: ValidationError[] }
```
## Project Structure
```
yarn-spinner-loader/
├── src/
│ ├── loader/ # Core loader implementation
│ │ ├── index.ts # YarnProjectLoader
│ │ ├── validator.ts # JSON Schema validation
│ │ └── types.ts # TypeScript types
│ ├── plugins/ # Build tool plugins
│ │ ├── esbuild.ts
│ │ ├── rollup.ts
│ │ ├── vite.ts
│ │ └── webpack.ts
│ ├── yarn-spinner/ # Yarn Spinner parser (existing)
│ └── index.ts # Main entry point
├── tests/
│ ├── fixtures/ # Test fixtures
│ │ ├── simple/
│ │ ├── localised/
│ │ └── complex/
│ ├── validator.test.ts
│ └── loader.test.ts
└── package.json
```
## Testing
Run tests with vitest:
```bash
npm test # Watch mode
npm run test:run # Run once
```
## License
MIT

2373
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

87
package.json Normal file
View File

@ -0,0 +1,87 @@
{
"name": "yarn-spinner-loader",
"version": "0.1.0",
"description": "Load and compile Yarn Spinner project files for various build tools",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./esbuild": {
"types": "./dist/plugins/esbuild.d.ts",
"import": "./dist/plugins/esbuild.js",
"require": "./dist/plugins/esbuild.cjs"
},
"./rollup": {
"types": "./dist/plugins/rollup.d.ts",
"import": "./dist/plugins/rollup.js",
"require": "./dist/plugins/rollup.cjs"
},
"./vite": {
"types": "./dist/plugins/vite.d.ts",
"import": "./dist/plugins/vite.js",
"require": "./dist/plugins/vite.cjs"
},
"./webpack": {
"types": "./dist/plugins/webpack.d.ts",
"import": "./dist/plugins/webpack.js",
"require": "./dist/plugins/webpack.cjs"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"test": "vitest",
"test:run": "vitest run",
"typecheck": "tsc --noEmit"
},
"keywords": [
"yarn-spinner",
"yarnproject",
"loader",
"esbuild",
"rollup",
"webpack",
"vite"
],
"author": "",
"license": "MIT",
"dependencies": {
"ajv": "^8.17.1",
"fast-glob": "^3.3.2"
},
"devDependencies": {
"@types/node": "^22.10.5",
"tsup": "^8.3.5",
"typescript": "^5.7.2",
"vitest": "^3.0.4"
},
"peerDependencies": {
"esbuild": ">=0.20.0",
"rollup": ">=4.0.0",
"vite": ">=5.0.0",
"webpack": ">=5.0.0"
},
"peerDependenciesMeta": {
"esbuild": {
"optional": true
},
"rollup": {
"optional": true
},
"vite": {
"optional": true
},
"webpack": {
"optional": true
}
}
}

29
src/index.ts Normal file
View File

@ -0,0 +1,29 @@
/**
* Yarn Spinner Loader - Main Entry Point
*
* Load and compile Yarn Spinner project files (.yarnproject)
* and their associated .yarn dialogue files.
*/
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';
// 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';

199
src/loader/index.ts Normal file
View File

@ -0,0 +1,199 @@
import { readFileSync, existsSync } from 'node:fs';
import { dirname, resolve, relative } from 'node:path';
import { glob } from 'fast-glob';
import type { YarnProject } from './types';
import { validateYarnProject, type SchemaValidationError } from './validator';
import { parseYarn } from '../yarn-spinner/parse/parser';
import type { YarnDocument } from '../yarn-spinner/model/ast';
/**
* Options for loading a Yarn project
*/
export interface LoadOptions {
/** Base directory for resolving glob patterns (default: directory of .yarnproject file) */
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
*/
export interface LoadResult {
/** Parsed .yarnproject configuration */
project: YarnProject;
/** Directory containing the .yarnproject file */
baseDir: string;
/** All loaded and parsed .yarn files */
yarnFiles: LoadedYarnFile[];
}
/**
* Error thrown when loading fails
*/
export class LoadError extends Error {
constructor(
message: string,
public readonly cause?: unknown,
) {
super(message);
this.name = 'LoadError';
}
}
/**
* Error thrown when validation fails
*/
export class ValidationError extends Error {
constructor(
message: string,
public readonly errors: SchemaValidationError[],
) {
super(message);
this.name = 'ValidationError';
}
}
/**
* Load and compile a .yarnproject file and all its referenced .yarn files
*/
export async function loadYarnProject(
projectFilePath: string,
options: LoadOptions = {},
): Promise<LoadResult> {
// Resolve and read .yarnproject file
const absoluteProjectPath = resolve(projectFilePath);
if (!existsSync(absoluteProjectPath)) {
throw new LoadError(`Project file not found: ${absoluteProjectPath}`);
}
const projectContent = readFileSync(absoluteProjectPath, 'utf-8');
let projectConfig: unknown;
try {
projectConfig = JSON.parse(projectContent);
} catch (error) {
throw new LoadError(`Failed to parse .yarnproject file as JSON: ${absoluteProjectPath}`, error);
}
// Validate against schema
const validation = validateYarnProject(projectConfig);
if (!validation.valid) {
throw new ValidationError(
`Invalid .yarnproject file: ${validation.errors.map(e => `${e.path}: ${e.message}`).join(', ')}`,
validation.errors,
);
}
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 || [];
const yarnFilePaths = await glob(sourcePatterns, {
cwd: baseDir,
ignore: ignorePatterns,
absolute: true,
onlyFiles: true,
});
// 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);
return {
relativePath,
absolutePath,
document,
};
});
return {
project,
baseDir,
yarnFiles,
};
}
/**
* Synchronous version of loadYarnProject
*/
export function loadYarnProjectSync(
projectFilePath: string,
options: LoadOptions = {},
): LoadResult {
// Resolve and read .yarnproject file
const absoluteProjectPath = resolve(projectFilePath);
if (!existsSync(absoluteProjectPath)) {
throw new LoadError(`Project file not found: ${absoluteProjectPath}`);
}
const projectContent = readFileSync(absoluteProjectPath, 'utf-8');
let projectConfig: unknown;
try {
projectConfig = JSON.parse(projectContent);
} catch (error) {
throw new LoadError(`Failed to parse .yarnproject file as JSON: ${absoluteProjectPath}`, error);
}
// Validate against schema
const validation = validateYarnProject(projectConfig);
if (!validation.valid) {
throw new ValidationError(
`Invalid .yarnproject file: ${validation.errors.map(e => `${e.path}: ${e.message}`).join(', ')}`,
validation.errors,
);
}
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 || [];
const yarnFilePaths = glob.sync(sourcePatterns, {
cwd: baseDir,
ignore: ignorePatterns,
absolute: true,
onlyFiles: true,
});
// 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);
return {
relativePath,
absolutePath,
document,
};
});
return {
project,
baseDir,
yarnFiles,
};
}

23
src/loader/types.ts Normal file
View File

@ -0,0 +1,23 @@
export interface YarnProject {
projectFileVersion: number;
projectName?: string;
authorName?: string[];
sourceFiles: string[];
excludeFiles?: string[];
baseLanguage: string;
localisation?: {
[languageCode: string]: {
strings?: string;
assets?: string;
};
};
definitions?: string | string[];
compilerOptions?: {
requireVariableDeclarations?: boolean;
allowPreviewFeatures?: boolean;
};
editorOptions?: {
yarnScriptEditor?: Record<string, unknown>;
[key: string]: unknown;
};
}

124
src/loader/validator.ts Normal file
View File

@ -0,0 +1,124 @@
import Ajv from 'ajv';
import type { YarnProject } from './types.js';
export interface SchemaValidationError {
path: string;
message: string;
}
/**
* JSON Schema for .yarnproject files
* Based on https://schemas.yarnspinner.dev/yarnproject.schema.json
*/
const YARNPROJECT_SCHEMA = {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
properties: {
projectFileVersion: {
type: 'integer',
minimum: 2,
default: 4,
},
projectName: {
type: 'string',
},
authorName: {
type: 'array',
items: {
type: 'string',
},
},
sourceFiles: {
type: 'array',
items: {
type: 'string',
},
default: ['**/*.yarn'],
},
excludeFiles: {
type: 'array',
items: {
type: 'string',
},
},
baseLanguage: {
type: 'string',
},
localisation: {
type: 'object',
additionalProperties: {
type: 'object',
properties: {
strings: {
type: 'string',
},
assets: {
type: 'string',
},
},
},
},
definitions: {
oneOf: [
{ type: 'string' },
{
type: 'array',
items: { type: 'string' },
},
],
},
compilerOptions: {
type: 'object',
properties: {
requireVariableDeclarations: {
type: 'boolean',
default: false,
},
allowPreviewFeatures: {
type: 'boolean',
default: false,
},
},
additionalProperties: true,
},
editorOptions: {
type: 'object',
additionalProperties: true,
},
},
required: ['projectFileVersion', 'sourceFiles', 'baseLanguage'],
additionalProperties: false,
};
const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(YARNPROJECT_SCHEMA);
/**
* Validate a parsed .yarnproject object against the schema
*/
export function validateYarnProject(config: unknown): { valid: true; data: YarnProject } | { valid: false; errors: SchemaValidationError[] } {
const valid = validate(config);
if (valid) {
// Check required fields before casting
const obj = config as Record<string, unknown>;
if (obj.projectFileVersion && obj.sourceFiles && obj.baseLanguage) {
return { valid: true, data: obj as unknown as YarnProject };
}
return { valid: true, data: obj as unknown as YarnProject };
}
const errors: SchemaValidationError[] = (validate.errors || []).map((err: any) => ({
path: err.instancePath || '/',
message: err.message || 'Unknown error',
}));
return { valid: false, errors };
}
/**
* Type guard to check if config is a valid YarnProject
*/
export function isYarnProject(config: unknown): config is YarnProject {
return validate(config) === true;
}

42
src/plugins/esbuild.ts Normal file
View File

@ -0,0 +1,42 @@
import type { Plugin } from 'esbuild';
import { loadYarnProject, type LoadResult } from '../loader/index.js';
export interface YarnSpinnerEsbuildOptions {
/** Additional options passed to loadYarnProject */
loadOptions?: Parameters<typeof loadYarnProject>[1];
}
/**
* Esbuild plugin for loading Yarn Spinner project files
*
* @example
* ```ts
* import { yarnSpinnerPlugin } from 'yarn-spinner-loader/esbuild';
*
* esbuild.build({
* plugins: [yarnSpinnerPlugin()],
* });
* ```
*/
export function yarnSpinnerPlugin(options: YarnSpinnerEsbuildOptions = {}): Plugin {
return {
name: 'yarn-spinner-loader',
setup(build) {
// Intercept .yarnproject files
build.onLoad({ filter: /\.yarnproject$/ }, async (args) => {
try {
const result: LoadResult = await loadYarnProject(args.path, options.loadOptions);
return {
contents: `export default ${JSON.stringify(result, null, 2)};`,
loader: 'js',
};
} catch (error) {
return {
errors: [{ text: error instanceof Error ? error.message : String(error) }],
};
}
});
},
};
}

36
src/plugins/rollup.ts Normal file
View File

@ -0,0 +1,36 @@
import type { Plugin } from 'rollup';
import { loadYarnProject, type LoadResult } from '../loader/index.js';
export interface YarnSpinnerRollupOptions {
/** Additional options passed to loadYarnProject */
loadOptions?: Parameters<typeof loadYarnProject>[1];
}
/**
* Rollup plugin for loading Yarn Spinner project files
*
* @example
* ```ts
* import { yarnSpinnerRollup } from 'yarn-spinner-loader/rollup';
*
* rollup({
* plugins: [yarnSpinnerRollup()],
* });
* ```
*/
export function yarnSpinnerRollup(options: YarnSpinnerRollupOptions = {}): Plugin {
return {
name: 'yarn-spinner-loader',
async load(id) {
if (id.endsWith('.yarnproject')) {
try {
const result: LoadResult = await loadYarnProject(id, options.loadOptions);
return `export default ${JSON.stringify(result, null, 2)};`;
} catch (error) {
this.error(error instanceof Error ? error.message : String(error));
}
}
return null;
},
};
}

45
src/plugins/vite.ts Normal file
View File

@ -0,0 +1,45 @@
import type { Plugin } from 'vite';
import { loadYarnProject, type LoadResult } from '../loader/index.js';
export interface YarnSpinnerViteOptions {
/** Additional options passed to loadYarnProject */
loadOptions?: Parameters<typeof loadYarnProject>[1];
}
/**
* Vite plugin for loading Yarn Spinner project files
*
* @example
* ```ts
* import { yarnSpinnerVite } from 'yarn-spinner-loader/vite';
*
* export default defineConfig({
* plugins: [yarnSpinnerVite()],
* });
* ```
*/
export function yarnSpinnerVite(options: YarnSpinnerViteOptions = {}): Plugin {
return {
name: 'yarn-spinner-loader',
async load(id) {
if (id.endsWith('.yarnproject')) {
try {
const result: LoadResult = await loadYarnProject(id, options.loadOptions);
return `export default ${JSON.stringify(result, null, 2)};`;
} catch (error) {
this.error(error instanceof Error ? error.message : String(error));
}
}
return null;
},
transform(_src, id) {
if (id.endsWith('.yarnproject')) {
return {
code: `export default ${JSON.stringify({}, null, 2)};`,
map: null,
};
}
return null;
},
};
}

40
src/plugins/webpack.ts Normal file
View File

@ -0,0 +1,40 @@
import { loadYarnProjectSync, type LoadResult } from '../loader/index.js';
export interface YarnSpinnerWebpackOptions {
/** Additional options passed to loadYarnProject */
loadOptions?: Parameters<typeof loadYarnProjectSync>[1];
}
/**
* Webpack loader for loading Yarn Spinner project files
*
* @example
* ```ts
* // webpack.config.js
* module.exports = {
* module: {
* rules: [
* {
* test: /\.yarnproject$/,
* use: 'yarn-spinner-loader/webpack',
* },
* ],
* },
* };
* ```
*/
export function YarnSpinnerWebpackLoader(this: any, source: string) {
const options: YarnSpinnerWebpackOptions = this.getOptions() || {};
const resourcePath = this.resourcePath;
try {
const result: LoadResult = loadYarnProjectSync(resourcePath, options.loadOptions);
const code = `export default ${JSON.stringify(result, null, 2)};`;
return code;
} catch (error) {
this.emitError(error instanceof Error ? error : new Error(String(error)));
return source;
}
}
export default YarnSpinnerWebpackLoader;

29
tests/fixtures/complex/.yarnproject vendored Normal file
View File

@ -0,0 +1,29 @@
{
"projectFileVersion": 4,
"projectName": "Complex Test Project",
"authorName": ["Author One", "Author Two"],
"sourceFiles": [
"dialogue/**/*.yarn",
"scripts/**/*.yarn"
],
"excludeFiles": [
"**/*.backup.yarn",
"dialogue/deprecated/**/*.yarn"
],
"baseLanguage": "en",
"localisation": {
"zh-Hans": {
"strings": "translations/zh-Hans.csv"
}
},
"definitions": ["custom-commands.ysls.json"],
"compilerOptions": {
"requireVariableDeclarations": true,
"allowPreviewFeatures": false
},
"editorOptions": {
"yarnScriptEditor": {
"theme": "dark"
}
}
}

View File

@ -0,0 +1,24 @@
title: ComplexNode1
tags: test complex
when: $has_sword
---
Character: This is a complex node with tags and conditions.
<<once>>
This text only shows once.
<<endonce>>
===
title: ComplexNode2
---
<<if $has_key>>
Character: You have the key!
<<else>>
Character: You need a key to proceed.
<<endif>>
Here are your options:
-> [Option 1] Go left
You went left.
-> [Option 2] Go right
You went right.
===

View File

@ -0,0 +1,4 @@
title: BackupNode
---
This file should be excluded.
===

View File

@ -0,0 +1,5 @@
title: ScriptNode
---
<<set $variable = 1>>
<<set $another = true>>
===

15
tests/fixtures/localised/.yarnproject vendored Normal file
View File

@ -0,0 +1,15 @@
{
"projectFileVersion": 4,
"projectName": "Localised Project",
"sourceFiles": ["**/*.yarn"],
"baseLanguage": "en",
"localisation": {
"zh-Hans": {
"strings": "translations/zh-Hans.csv",
"assets": "translations/zh-Hans/"
},
"ja": {
"strings": "translations/ja.csv"
}
}
}

5
tests/fixtures/localised/main.yarn vendored Normal file
View File

@ -0,0 +1,5 @@
title: Welcome
---
Welcome to the localised project!
This text should be translated.
===

6
tests/fixtures/simple/.yarnproject vendored Normal file
View File

@ -0,0 +1,6 @@
{
"projectFileVersion": 4,
"projectName": "Simple Test Project",
"sourceFiles": ["**/*.yarn"],
"baseLanguage": "en"
}

12
tests/fixtures/simple/dialogue.yarn vendored Normal file
View File

@ -0,0 +1,12 @@
title: Start
---
Hello, world!
This is a simple test dialogue.
===
title: Greeting
tags: greeting
---
Player: Hi there!
NPC: Welcome to our game!
===

View File

@ -0,0 +1,7 @@
title: AnotherNode
---
This is another test node.
<<if $variable>>
Conditional text here.
<<endif>>
===

128
tests/loader.test.ts Normal file
View File

@ -0,0 +1,128 @@
import { describe, it, expect } from 'vitest';
import { loadYarnProject, loadYarnProjectSync, LoadError } from '../src/loader/index';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
describe('loadYarnProject', () => {
it('should load a simple project', async () => {
const projectPath = resolve(__dirname, 'fixtures/simple/.yarnproject');
const result = await loadYarnProject(projectPath);
expect(result.project.projectFileVersion).toBe(4);
expect(result.project.projectName).toBe('Simple Test Project');
expect(result.project.sourceFiles).toEqual(['**/*.yarn']);
expect(result.project.baseLanguage).toBe('en');
expect(result.yarnFiles.length).toBeGreaterThan(0);
});
it('should parse all yarn files in simple project', async () => {
const projectPath = resolve(__dirname, 'fixtures/simple/.yarnproject');
const result = await loadYarnProject(projectPath);
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');
});
it('should load a localised project', async () => {
const projectPath = resolve(__dirname, 'fixtures/localised/.yarnproject');
const result = await loadYarnProject(projectPath);
expect(result.project.projectName).toBe('Localised Project');
expect(result.project.baseLanguage).toBe('en');
expect(result.project.localisation).toBeDefined();
expect(result.project.localisation!['zh-Hans']).toBeDefined();
expect(result.yarnFiles.length).toBeGreaterThan(0);
});
it('should load a complex project with multiple patterns', async () => {
const projectPath = resolve(__dirname, 'fixtures/complex/.yarnproject');
const result = await loadYarnProject(projectPath);
expect(result.project.projectFileVersion).toBe(4);
expect(result.project.authorName).toEqual(['Author One', 'Author Two']);
expect(result.project.sourceFiles).toContain('dialogue/**/*.yarn');
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.yarnFiles
.flatMap(f => f.document.nodes)
.find(n => n.title === 'Start');
expect(startNode).toBeDefined();
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.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 () => {
const projectPath = resolve(__dirname, 'fixtures/nonexistent/.yarnproject');
await expect(loadYarnProject(projectPath)).rejects.toThrow(LoadError);
});
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();
});
});
describe('loadYarnProjectSync', () => {
it('should load a simple project synchronously', () => {
const projectPath = resolve(__dirname, 'fixtures/simple/.yarnproject');
const result = loadYarnProjectSync(projectPath);
expect(result.project.projectFileVersion).toBe(4);
expect(result.yarnFiles.length).toBeGreaterThan(0);
});
it('should parse all yarn files synchronously', () => {
const projectPath = resolve(__dirname, 'fixtures/simple/.yarnproject');
const result = loadYarnProjectSync(projectPath);
const titles = result.yarnFiles.flatMap(f => f.document.nodes.map(n => n.title));
expect(titles).toContain('Start');
});
});

167
tests/validator.test.ts Normal file
View File

@ -0,0 +1,167 @@
import { describe, it, expect } from 'vitest';
import { validateYarnProject, isYarnProject } from '../src/loader/validator';
describe('validateYarnProject', () => {
it('should validate a minimal valid config', () => {
const config = {
projectFileVersion: 4,
sourceFiles: ['**/*.yarn'],
baseLanguage: 'en',
};
const result = validateYarnProject(config);
expect(result.valid).toBe(true);
});
it('should validate a full config', () => {
const config = {
projectFileVersion: 4,
projectName: 'Test Project',
authorName: ['Author 1', 'Author 2'],
sourceFiles: ['**/*.yarn'],
excludeFiles: ['**/*.backup.yarn'],
baseLanguage: 'en',
localisation: {
'zh-Hans': {
strings: 'translations/zh-Hans.csv',
assets: 'translations/zh-Hans/',
},
},
definitions: ['custom.ysls.json'],
compilerOptions: {
requireVariableDeclarations: true,
allowPreviewFeatures: false,
},
editorOptions: {
yarnScriptEditor: { theme: 'dark' },
},
};
const result = validateYarnProject(config);
expect(result.valid).toBe(true);
});
it('should reject missing projectFileVersion', () => {
const config = {
sourceFiles: ['**/*.yarn'],
baseLanguage: 'en',
};
const result = validateYarnProject(config);
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.errors.length).toBeGreaterThan(0);
expect(result.errors[0].message).toContain('projectFileVersion');
}
});
it('should reject missing sourceFiles', () => {
const config = {
projectFileVersion: 4,
baseLanguage: 'en',
};
const result = validateYarnProject(config);
expect(result.valid).toBe(false);
});
it('should reject missing baseLanguage', () => {
const config = {
projectFileVersion: 4,
sourceFiles: ['**/*.yarn'],
};
const result = validateYarnProject(config);
expect(result.valid).toBe(false);
});
it('should reject invalid projectFileVersion type', () => {
const config = {
projectFileVersion: '4',
sourceFiles: ['**/*.yarn'],
baseLanguage: 'en',
};
const result = validateYarnProject(config);
expect(result.valid).toBe(false);
});
it('should reject projectFileVersion less than 2', () => {
const config = {
projectFileVersion: 1,
sourceFiles: ['**/*.yarn'],
baseLanguage: 'en',
};
const result = validateYarnProject(config);
expect(result.valid).toBe(false);
});
it('should reject non-array sourceFiles', () => {
const config = {
projectFileVersion: 4,
sourceFiles: '**/*.yarn',
baseLanguage: 'en',
};
const result = validateYarnProject(config);
expect(result.valid).toBe(false);
});
it('should reject additional properties', () => {
const config = {
projectFileVersion: 4,
sourceFiles: ['**/*.yarn'],
baseLanguage: 'en',
unknownField: 'value',
};
const result = validateYarnProject(config);
expect(result.valid).toBe(false);
});
it('should accept definitions as string', () => {
const config = {
projectFileVersion: 4,
sourceFiles: ['**/*.yarn'],
baseLanguage: 'en',
definitions: 'custom.ysls.json',
};
const result = validateYarnProject(config);
expect(result.valid).toBe(true);
});
it('should accept definitions as array of strings', () => {
const config = {
projectFileVersion: 4,
sourceFiles: ['**/*.yarn'],
baseLanguage: 'en',
definitions: ['custom1.ysls.json', 'custom2.ysls.json'],
};
const result = validateYarnProject(config);
expect(result.valid).toBe(true);
});
});
describe('isYarnProject', () => {
it('should return true for valid config', () => {
const config = {
projectFileVersion: 4,
sourceFiles: ['**/*.yarn'],
baseLanguage: 'en',
};
expect(isYarnProject(config)).toBe(true);
});
it('should return false for invalid config', () => {
const config = {
sourceFiles: ['**/*.yarn'],
baseLanguage: 'en',
};
expect(isYarnProject(config)).toBe(false);
});
});

20
tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

40
tsup.config.ts Normal file
View File

@ -0,0 +1,40 @@
import { defineConfig } from 'tsup';
export default defineConfig([
{
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true,
sourcemap: true,
clean: true,
outDir: 'dist',
},
{
entry: ['src/plugins/esbuild.ts'],
format: ['esm', 'cjs'],
dts: true,
sourcemap: true,
outDir: 'dist/plugins',
},
{
entry: ['src/plugins/rollup.ts'],
format: ['esm', 'cjs'],
dts: true,
sourcemap: true,
outDir: 'dist/plugins',
},
{
entry: ['src/plugins/vite.ts'],
format: ['esm', 'cjs'],
dts: true,
sourcemap: true,
outDir: 'dist/plugins',
},
{
entry: ['src/plugins/webpack.ts'],
format: ['esm', 'cjs'],
dts: true,
sourcemap: true,
outDir: 'dist/plugins',
},
]);

18
vitest.config.ts Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: false,
environment: 'node',
include: ['tests/**/*.test.ts'],
exclude: ['node_modules', 'dist'],
},
resolve: {
alias: {
// Allow .js imports to resolve to .ts files
},
},
esbuild: {
target: 'es2022',
},
});