Custom Tasks
Extend Skier with your own build steps. This guide covers everything from basic tasks to advanced patterns.
Task Structure
A task is an object with three properties:
interface Task {
name: string; // Unique identifier
title?: string; // Human-readable description
config?: Record<string, any>; // Your task's configuration
run: (config, context) => Promise<Record<string, any> | void>;
}The run function receives:
config— Your task's configuration objectcontext— The Skier context with globals, logger, and debug flag
Basic Example
const logBuildTimeTask = {
name: 'log-build-time',
run: async (config, ctx) => {
const timestamp = new Date().toISOString();
ctx.logger.info(`Build started at ${timestamp}`);
// Return data to merge into globals
return { buildTime: timestamp };
},
};
// Use it
export default [
logBuildTimeTask,
// ...other tasks
];The Context Object
interface TaskContext {
globals: Record<string, any>; // Shared data between tasks
logger: {
info(msg: string): void;
warn(msg: string): void;
error(msg: string): void;
debug(msg: string): void;
};
debug: boolean; // True if --debug flag passed
}Important: Access globals via ctx.globals, but return new data to add to globals:
run: async (config, ctx) => {
// Read existing globals
const posts = ctx.globals.posts || [];
// Process data
const featured = posts.filter(p => p.featured);
// Return to merge into globals
return { featuredPosts: featured };
}Task Factory Pattern
For configurable tasks, use a factory function:
const greetTask = (options = {}) => ({
name: 'greet',
config: options,
run: async (config, ctx) => {
const name = config.name || 'World';
ctx.logger.info(`Hello, ${name}!`);
},
});
// Use it
export default [
greetTask({ name: 'Developer' }),
];Practical Patterns
Data Aggregation
Compute statistics from collected items:
const computeStatsTask = {
name: 'compute-stats',
run: async (config, ctx) => {
const posts = ctx.globals.posts || [];
// Group by year
const postsByYear = posts.reduce((acc, post) => {
const year = new Date(post.date).getFullYear();
(acc[year] = acc[year] || []).push(post);
return acc;
}, {});
// Count by category
const categoryCounts = posts.reduce((acc, post) => {
const cat = post.category || 'Uncategorized';
acc[cat] = (acc[cat] || 0) + 1;
return acc;
}, {});
return { postsByYear, categoryCounts };
},
};External API Fetch
Fetch data from APIs at build time:
const fetchPlaylistTask = (options) => ({
name: 'fetch-playlist',
config: options,
run: async (config, ctx) => {
try {
const response = await fetch(
`https://api.spotify.com/v1/playlists/${config.playlistId}`,
{ headers: { Authorization: `Bearer ${config.token}` } }
);
if (!response.ok) {
ctx.logger.warn('Spotify API failed, using cached data');
return {}; // Graceful degradation
}
const data = await response.json();
return { playlist: data };
} catch (err) {
ctx.logger.error(`Fetch failed: ${err.message}`);
return {}; // Don't break the build
}
},
});Data Transformation
Enrich item data before rendering:
const enrichPostsTask = {
name: 'enrich-posts',
run: async (config, ctx) => {
const posts = ctx.globals.posts || [];
const enrichedPosts = posts.map(post => ({
...post,
// Add computed fields
readingTime: Math.ceil(post.content.split(' ').length / 200),
formattedDate: new Date(post.date).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'long',
year: 'numeric',
}),
// Add related posts
relatedPosts: posts
.filter(p => p.slug !== post.slug && p.category === post.category)
.slice(0, 3),
}));
return { posts: enrichedPosts };
},
};Build Validation
Fail the build on data errors:
const validateDataTask = {
name: 'validate-data',
run: async (config, ctx) => {
const posts = ctx.globals.posts || [];
const errors = [];
posts.forEach((post, i) => {
if (!post.title) {
errors.push(`Post ${i}: missing title`);
}
if (!post.date) {
errors.push(`Post ${i}: missing date`);
}
if (post.date && isNaN(Date.parse(post.date))) {
errors.push(`Post ${i}: invalid date "${post.date}"`);
}
});
if (errors.length > 0) {
errors.forEach(e => ctx.logger.error(e));
throw new Error(`Validation failed with ${errors.length} errors`);
}
ctx.logger.info(`Validated ${posts.length} posts`);
},
};File Generation
Create custom output files:
import { writeFile, mkdir } from 'fs/promises';
import { join, dirname } from 'path';
const generateJsonApiTask = (options) => ({
name: 'generate-json-api',
config: options,
run: async (config, ctx) => {
const posts = ctx.globals.posts || [];
const outPath = join(config.outDir, 'api', 'posts.json');
// Ensure directory exists
await mkdir(dirname(outPath), { recursive: true });
// Write JSON API endpoint
const apiData = {
posts: posts.map(({ title, slug, date, excerpt }) => ({
title, slug, date, excerpt,
})),
total: posts.length,
generated: new Date().toISOString(),
};
await writeFile(outPath, JSON.stringify(apiData, null, 2));
ctx.logger.info(`Generated ${outPath}`);
},
});TypeScript Support
Full type definitions for custom tasks:
// tasks/myTask.ts
import type { Task, TaskContext, TaskConfig } from 'skier';
interface MyTaskConfig extends TaskConfig {
prefix: string;
maxItems?: number;
}
export const myTask = (options: MyTaskConfig): Task => ({
name: 'my-task',
config: options,
run: async (config: MyTaskConfig, ctx: TaskContext) => {
const items = (ctx.globals.items || []).slice(0, config.maxItems ?? 10);
return {
prefixedItems: items.map(i => ({
...i,
title: `${config.prefix}: ${i.title}`,
})),
};
},
});Testing Custom Tasks
Use Jest to unit test your tasks:
// tasks/myTask.test.js
import { myTask } from './myTask.js';
describe('myTask', () => {
const mockContext = {
globals: {
items: [
{ title: 'One' },
{ title: 'Two' },
],
},
logger: {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
debug: false,
};
it('prefixes item titles', async () => {
const task = myTask({ prefix: 'Test' });
const result = await task.run(task.config, mockContext);
expect(result.prefixedItems[0].title).toBe('Test: One');
});
it('respects maxItems', async () => {
const task = myTask({ prefix: 'Test', maxItems: 1 });
const result = await task.run(task.config, mockContext);
expect(result.prefixedItems).toHaveLength(1);
});
});Best Practices
- Give tasks unique names — Helps with debugging
- Return data, don't mutate — Return new objects instead of modifying
ctx.globals - Use the logger —
ctx.logger.info()keeps output consistent - Fail gracefully — Catch errors and log warnings rather than breaking builds
- Keep tasks focused — One task, one responsibility
- Document your config — Future you will thank you
Next: See Architecture to understand how tasks fit into the pipeline.