Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@
"build:tailwind": "yarn --cwd packages/ink-tailwind build",
"build:ui": "yarn --cwd packages/ink-ui-src build",
"build:web": "yarn --cwd packages/ink-web build",
"test": "yarn --cwd packages/ink test",
"test:web": "yarn --cwd packages/ink-web test",
"test": "yarn test:ink && yarn test:css",
"test:ink":"yarn --cwd packages/ink test",
"test:css": "yarn --cwd packages/ink-css test",
"dev:http": "yarn --cwd examples/with-http dev",
"dev:express": "yarn --cwd examples/with-express dev",
"dev:fastify": "yarn --cwd examples/with-fastify dev",
Expand Down
12 changes: 9 additions & 3 deletions packages/ink-css/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,20 @@
"tsconfig.json"
],
"scripts": {
"build": "tsc"
"build": "tsc",
"test": "nyc ts-mocha tests/*.test.ts"
},
"dependencies": {
"@stackpress/ink": "0.3.10"
},
"devDependencies": {
"@types/chai": "4.3.20",
"@types/mocha": "10.0.10",
"@types/node": "22.9.3",
"ts-node": "10.9.2",
"typescript": "5.7.2"
"chai": "4.5.0",
"mocha": "10.8.2",
"nyc": "17.1.0",
"ts-mocha": "10.0.0",
"ts-node": "10.9.2"
}
}
161 changes: 161 additions & 0 deletions packages/ink-css/tests/StyleParser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { expect } from 'chai';
import { default as StyleParser, pattern } from '../src/StyleParser';
import NodeFS from '@stackpress/types/dist/system/NodeFS';
import path from 'path';
import fs from 'fs';

describe('StyleParser', () => {
describe('pattern', () => {
it('should match valid class names', () => {
const validClasses = [
'abc',
'abc-def',
'abc-1def',
'abc-1def-2ghi'
];

validClasses.forEach(className => {
const matches = className.match(pattern);
expect(matches).to.not.be.null;
expect(matches![0]).to.equal(className);
});
});

it('should not match invalid class names', () => {
const invalidClasses = [
'ABC', // uppercase not allowed
'@abc', // special characters not allowed
'abc_def' // underscore not allowed
];

invalidClasses.forEach(className => {
// Test each invalid class name individually against the pattern
const regex = new RegExp('^' + pattern.source + '$');
const matches = className.match(regex);
expect(matches, `${className} should not match`).to.be.null;
});
});
});

describe('StyleParser class', () => {
let parser: StyleParser;
let tempDir: string;
let testFile: string;

beforeEach(() => {
parser = new StyleParser({
cwd: process.cwd()
});

// Create a temporary test directory and file
tempDir = path.join(process.cwd(), 'temp_test_dir');
testFile = path.join(tempDir, 'test.css');
fs.mkdirSync(tempDir, { recursive: true });
});

afterEach(() => {
// Clean up temporary files
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});

describe('static match()', () => {
it('should return unique class names from content', () => {
const content = 'some-class other-class some-class third-class';
const matches = StyleParser.match(content);
expect(matches).to.deep.equal(['some-class', 'other-class', 'third-class']);
});

it('should return empty array for content with no matches', () => {
const content = 'NO_VALID_CLASSES_HERE';
const matches = StyleParser.match(content);
expect(matches).to.deep.equal([]);
});
});

describe('instance methods', () => {
it('should initialize with default options', () => {
const defaultParser = new StyleParser();
expect(defaultParser.cwd).to.equal(process.cwd());
expect(defaultParser.fs).to.be.instanceOf(NodeFS);
});

it('should initialize with custom options', () => {
const customFs = new NodeFS();
const customCwd = process.cwd() + '/custom';
const customParser = new StyleParser({ fs: customFs, cwd: customCwd });

expect(customParser.cwd).to.equal(customCwd);
expect(customParser.fs).to.equal(customFs);
});

it('should throw exception for non-existent file', () => {
const nonExistentFile = 'non-existent-file.txt';
const absolutePath = parser.loader.absolute(nonExistentFile);
// First add the file to the cache using the absolute path
parser.vfs.set(absolutePath, 'some content');
// Now try to add the non-existent file, which should throw
expect(() => parser.add(nonExistentFile)).to.throw('File not found');
});

it('should successfully read and cache file content', () => {
// Create a test file
const fileContent = 'test-class another-class';
fs.writeFileSync(testFile, fileContent);

// Add the file to the parser
const absolutePath = parser.loader.absolute(testFile);
parser.vfs.clear(); // Clear the cache first

// First set it in the cache (required by implementation)
parser.vfs.set(absolutePath, '');
parser.add(testFile);

// Verify the file was cached with updated content
expect(parser.vfs.has(absolutePath)).to.be.true;
expect(parser.vfs.get(absolutePath)).to.equal(fileContent);
});

it('should skip adding file if not in cache', () => {
// Create a test file
const fileContent = 'test-class';
fs.writeFileSync(testFile, fileContent);

// Add the file without setting it in cache first
const absolutePath = parser.loader.absolute(testFile);
parser.vfs.clear(); // Clear the cache first
parser.add(testFile);

// Verify the file was not added to cache
expect(parser.vfs.has(absolutePath)).to.be.false;
});

it('should parse all cached files and return unique class names', () => {
// Add multiple files to the cache
parser.vfs.clear(); // Clear the cache first
parser.set('file1.css', 'class1 shared-class');
parser.set('file2.css', 'class2 shared-class');

const classNames = parser.parse();
expect(classNames).to.deep.equal(['class1', 'shared-class', 'class2', 'shared-class']);
});

it('should walk through cached files and yield class names', () => {
// Add multiple files to the cache
parser.vfs.clear(); // Clear the cache first
parser.set('file1.css', 'class1 shared-class');
parser.set('file2.css', 'class2 shared-class');

const classNames = Array.from(parser.walk());
expect(classNames).to.deep.equal(['class1', 'shared-class', 'class2', 'shared-class']);
});

it('should handle empty cache in parse and walk', () => {
parser.vfs.clear(); // Clear the cache first
expect(parser.parse()).to.deep.equal([]);
expect(Array.from(parser.walk())).to.deep.equal([]);
});
});
});
});
2 changes: 1 addition & 1 deletion packages/ink/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
],
"scripts": {
"build": "tsc",
"test": "ts-mocha tests/*.test.ts"
"test": "nyc ts-mocha tests/*.test.ts"
},
"dependencies": {
"@stackpress/types": "0.3.10",
Expand Down
40 changes: 37 additions & 3 deletions packages/ink/src/directives/IteratorDirective.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,30 @@ import Parser from '../compiler/Parser';
//local
import AbstractDirective from './AbstractDirective';

/**
* IteratorDirective implements the 'each' directive for iterating over arrays and objects
* in templates. It supports the following syntax:
*
* Arrays: <each value="item" key="index" from="['a', 'b', 'c']">...</each>
* Objects: <each value="val" key="k" from="{a: 1, b: 2}">...</each>
* Expressions: <each value="item" from="items.filter(x => x > 0)">...</each>
*/
export default class IteratorDirective extends AbstractDirective {
/**
* Returns the directive name
* Returns the directive name used in templates
*/
public get name() {
return 'each';
}

/**
* Saves the compiler instance
* Processes the markup token and generates the iteration code
* @param parent - The parent markup token (if any)
* @param token - The current markup token being processed
* @param components - List of available components
* @param next - Function to process child elements
* @returns Generated JavaScript code for iteration
* @throws Exception if the iteration syntax is invalid
*/
public markup(
parent: MarkupToken|null,
Expand All @@ -30,12 +44,15 @@ export default class IteratorDirective extends AbstractDirective {
next: NextDirective
) {
let expression = '';
//syntax <each value=item key=i from=list>...</each>

// Validate that attributes are present
if (!token.attributes
|| token.attributes.properties.length === 0
) {
throw Exception.for('Invalid each statement');
}

// Extract key, value, and from attributes
const key = token.attributes.properties.find(
property => property.key.name === 'key'
);
Expand All @@ -45,38 +62,55 @@ export default class IteratorDirective extends AbstractDirective {
const from = token.attributes.properties.find(
property => property.key.name === 'from'
);

// Validate required attributes
if (!from || (!key && !value)) {
throw Exception.for('Invalid each statement');
} else if (key && key.value.type !== 'Identifier') {
throw Exception.for('Invalid key value in each');
} else if (value && value.value.type !== 'Identifier') {
throw Exception.for('Invalid value in each');
}

// Use default '_' for unused key/value names
const keyName = (key?.value as IdentifierToken)?.name || '_';
const valueName = (value?.value as IdentifierToken)?.name || '_';

expression += `...`;

// Handle different types of 'from' values
if (from.value.type === 'ProgramExpression') {
// For JavaScript expressions (e.g., array.filter())
const script = from.value as ScriptToken;
expression += `Object.entries(${script.source})`;
} else if (from.value.type === 'ArrayExpression') {
// For literal arrays (e.g., ['a', 'b', 'c'])
expression += `Object.entries(${
JSON.stringify(Parser.array(from.value))
})`;
} else if (from.value.type === 'ObjectExpression') {
// For literal objects (e.g., {a: 1, b: 2})
expression += `Object.entries(${
JSON.stringify(Parser.object(from.value))
})`;
} else if (from.value.type === 'Identifier') {
// For variables (e.g., items)
expression += `Object.entries(${from.value.name})`;
} else {
throw Exception.for('Invalid from value in each');
}

// Generate map function with destructuring for key/value pairs
expression += `.map(([${keyName}, ${valueName}]) => `;

// Process child elements if they exist
if (token.children) {
expression += next(token, token.children, components);
} else {
expression += '[]';
}

// Flatten the results to handle nested arrays
expression += ').flat()';
return expression;
}
Expand Down
Loading
Loading