Loading
Create a TypeScript utility library, add tests with Vitest, write documentation, configure package.json, and publish to npm.
You're going to build a TypeScript utility library, test it thoroughly with Vitest, configure it for both CommonJS and ESM consumption, write documentation, and publish it to npm. The library is a collection of string manipulation utilities — focused enough to be useful, simple enough to fully test, and realistic enough to demonstrate every step of the publishing process.
Publishing a package teaches you things that consuming packages never will: how module resolution actually works, why exports maps exist, what types fields do, how SemVer affects real users, and why testing before publishing isn't optional. By the end, you'll have a published package that any developer can install with npm install your-package-name.
tsup is a zero-config TypeScript bundler powered by esbuild. It produces both ESM and CJS outputs from a single source, handles declaration files, and tree-shakes unused code.
Configure TypeScript:
Run tests:
The package.json fields determine how npm registries and bundlers find and resolve your package.
Key fields explained:
exports — The modern module resolution map. Bundlers and Node.js 12+ use this to determine which file to load based on import vs require.files — Only these paths are included in the published tarball. Source code, tests, and config stay out.sideEffects: false — Tells bundlers this package is safe to tree-shake. If a consumer imports only slugify, the other functions are excluded from their bundle.prepublishOnly — Runs tests and build automatically before npm publish. If tests fail, the publish is aborted.Before publishing, verify everything works as a consumer would experience it:
Both ESM and CJS must work. If either fails, your exports map is misconfigured.
After publishing, verify it works:
SemVer matters because real people depend on your package.
A breaking change is anything that makes existing code stop working: renaming an exported function, changing a parameter type, removing a function, changing default behavior. If a consumer's code would break after upgrading without changing their code, it's a major version bump.
Add a GitHub Actions workflow for CI:
This ensures every PR is tested and the package builds successfully before merge. Add npm run lint to the pipeline once your codebase grows. Add npm run test:coverage and enforce a minimum coverage threshold to prevent regressions from untested code paths. Once CI is green and your tests pass, you can publish with confidence that your package works for every consumer, on every module system, with every supported Node.js version.
mkdir string-kit && cd string-kit
npm init -y
npm install -D typescript vitest @vitest/coverage-v8 tsup// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"outDir": "dist",
"rootDir": "src",
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}// src/index.ts
export { slugify } from "./slugify.js";
export { truncate } from "./truncate.js";
export { capitalize } from "./capitalize.js";
export { camelCase, kebabCase, snakeCase, pascalCase } from "./case.js";
export { escapeHtml, unescapeHtml } from "./html.js";// src/slugify.ts
/** Convert a string to a URL-friendly slug. */
export function slugify(input: string): string {
return input
.normalize("NFD") // Decompose accented characters
.replace(/[\u0300-\u036f]/g, "") // Remove diacritics
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, "") // Remove non-alphanumeric
.replace(/[\s_]+/g, "-") // Spaces/underscores to hyphens
.replace(/-+/g, "-") // Collapse multiple hyphens
.replace(/^-|-$/g, ""); // Trim leading/trailing hyphens
}// src/truncate.ts
interface TruncateOptions {
length: number;
suffix?: string;
wordBoundary?: boolean;
}
/** Truncate a string to a maximum length with configurable suffix and word boundary awareness. */
export function truncate(input: string, options: TruncateOptions): string {
const { length, suffix = "...", wordBoundary = false } = options;
if (input.length <= length) return input;
const maxLength = length - suffix.length;
if (maxLength <= 0) return suffix.slice(0, length);
let truncated = input.slice(0, maxLength);
if (wordBoundary) {
const lastSpace = truncated.lastIndexOf(" ");
if (lastSpace > 0) {
truncated = truncated.slice(0, lastSpace);
}
}
return truncated.trimEnd() + suffix;
}// src/capitalize.ts
/** Capitalize the first letter of a string. */
export function capitalize(input: string): string {
if (input.length === 0) return input;
return input.charAt(0).toUpperCase() + input.slice(1);
}// src/case.ts
function splitWords(input: string): string[] {
return input
.replace(/([a-z])([A-Z])/g, "$1 $2") // camelCase boundaries
.replace(/[_\-]+/g, " ") // underscores/hyphens to spaces
.trim()
.split(/\s+/)
.filter(Boolean);
}
/** Convert a string to camelCase. */
export function camelCase(input: string): string {
const words = splitWords(input);
return words
.map((w, i) =>
i === 0 ? w.toLowerCase() : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()
)
.join("");
}
/** Convert a string to kebab-case. */
export function kebabCase(input: string): string {
return splitWords(input)
.map((w) => w.toLowerCase())
.join("-");
}
/** Convert a string to snake_case. */
export function snakeCase(input: string): string {
return splitWords(input)
.map((w) => w.toLowerCase())
.join("_");
}
/** Convert a string to PascalCase. */
export function pascalCase(input: string): string {
return splitWords(input)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join("");
}// src/html.ts
const HTML_ENTITIES: Record<string, string> = {
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
};
const REVERSE_ENTITIES: Record<string, string> = Object.fromEntries(
Object.entries(HTML_ENTITIES).map(([k, v]) => [v, k])
);
/** Escape HTML special characters to prevent XSS. */
export function escapeHtml(input: string): string {
return input.replace(/[&<>"']/g, (char) => HTML_ENTITIES[char] || char);
}
/** Unescape HTML entities back to their original characters. */
export function unescapeHtml(input: string): string {
return input.replace(/&(?:amp|lt|gt|quot|#39);/g, (entity) => REVERSE_ENTITIES[entity] || entity);
}// src/slugify.test.ts
import { describe, it, expect } from "vitest";
import { slugify } from "./slugify";
describe("slugify", () => {
it("converts basic strings", () => {
expect(slugify("Hello World")).toBe("hello-world");
});
it("handles accented characters", () => {
expect(slugify("Crème Brûlée")).toBe("creme-brulee");
});
it("removes special characters", () => {
expect(slugify("What's up? (Nothing!)")).toBe("whats-up-nothing");
});
it("collapses multiple spaces and hyphens", () => {
expect(slugify("too many---dashes")).toBe("too-many-dashes");
});
it("trims leading and trailing hyphens", () => {
expect(slugify("--hello--")).toBe("hello");
});
it("handles empty string", () => {
expect(slugify("")).toBe("");
});
it("handles string with only special characters", () => {
expect(slugify("!@#$%")).toBe("");
});
});// src/truncate.test.ts
import { describe, it, expect } from "vitest";
import { truncate } from "./truncate";
describe("truncate", () => {
const longText = "The quick brown fox jumps over the lazy dog";
it("returns original string if under length", () => {
expect(truncate("short", { length: 10 })).toBe("short");
});
it("truncates with default suffix", () => {
expect(truncate(longText, { length: 20 })).toBe("The quick brown f...");
});
it("uses custom suffix", () => {
expect(truncate(longText, { length: 20, suffix: "…" })).toBe("The quick brown fox…");
});
it("respects word boundaries", () => {
expect(truncate(longText, { length: 20, wordBoundary: true })).toBe("The quick brown...");
});
it("handles length shorter than suffix", () => {
expect(truncate(longText, { length: 2 })).toBe("..");
});
it("handles empty string", () => {
expect(truncate("", { length: 10 })).toBe("");
});
});// src/case.test.ts
import { describe, it, expect } from "vitest";
import { camelCase, kebabCase, snakeCase, pascalCase } from "./case";
describe("case conversions", () => {
const inputs = ["hello world", "hello-world", "hello_world", "helloWorld", "HelloWorld"];
it("converts to camelCase", () => {
inputs.forEach((input) => {
expect(camelCase(input)).toBe("helloWorld");
});
});
it("converts to kebab-case", () => {
inputs.forEach((input) => {
expect(kebabCase(input)).toBe("hello-world");
});
});
it("converts to snake_case", () => {
inputs.forEach((input) => {
expect(snakeCase(input)).toBe("hello_world");
});
});
it("converts to PascalCase", () => {
inputs.forEach((input) => {
expect(pascalCase(input)).toBe("HelloWorld");
});
});
it("handles empty string", () => {
expect(camelCase("")).toBe("");
expect(kebabCase("")).toBe("");
});
});npx vitest run
npx vitest run --coverage// tsup.config.ts
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["cjs", "esm"],
dts: true,
splitting: true,
sourcemap: true,
clean: true,
treeshake: true,
minify: false, // Keep readable for debugging
});{
"name": "@yourscope/string-kit",
"version": "1.0.0",
"description": "Lightweight string utilities for TypeScript",
"author": "Your Name <you@example.com>",
"license": "MIT",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
},
"files": ["dist", "README.md", "LICENSE"],
"scripts": {
"build": "tsup",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "tsc --noEmit",
"prepublishOnly": "npm run test && npm run build"
},
"keywords": ["string", "utilities", "slugify", "truncate", "typescript"],
"repository": {
"type": "git",
"url": "https://github.com/you/string-kit"
},
"sideEffects": false
}# Build
npm run build
# Verify the output
ls dist/
# Should show: index.js, index.cjs, index.d.ts, index.d.cts
# Test the package locally
npm pack --dry-run
# Shows exactly what files would be published
# Create a tarball and test it
npm pack
# Creates yourscope-string-kit-1.0.0.tgz
# In a separate test project:
mkdir /tmp/test-pkg && cd /tmp/test-pkg
npm init -y
npm install /path/to/yourscope-string-kit-1.0.0.tgz// /tmp/test-pkg/test.mjs
import { slugify, camelCase, escapeHtml } from "@yourscope/string-kit";
console.log(slugify("Hello World")); // "hello-world"
console.log(camelCase("foo-bar-baz")); // "fooBarBaz"
console.log(escapeHtml("<script>")); // "<script>"// /tmp/test-pkg/test.cjs
const { slugify } = require("@yourscope/string-kit");
console.log(slugify("CJS Works Too")); // "cjs-works-too"# Login (one-time)
npm login
# Publish (scoped packages are private by default)
npm publish --access public
# Or for unscoped packages:
npm publishmkdir /tmp/verify && cd /tmp/verify
npm init -y
npm install @yourscope/string-kit
node -e "const { slugify } = require('@yourscope/string-kit'); console.log(slugify('it works'))"# Bug fix (1.0.0 → 1.0.1)
npm version patch
# New feature, backwards compatible (1.0.1 → 1.1.0)
npm version minor
# Breaking change (1.1.0 → 2.0.0)
npm version major# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm test
- run: npm run build