diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1ed55d5..89734f0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -19,4 +19,5 @@ jobs: with: node-version: ${{ matrix.node-version }} - run: npm install + - run: npm run build - run: npm test diff --git a/index.d.ts b/index.d.ts deleted file mode 100644 index 662706e..0000000 --- a/index.d.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type {ChalkInstance} from 'chalk'; - -/** -Terminal string styling with [tagged template literals](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#tagged_templates) - -@example -``` -import chalkTemplate from 'chalk-template'; - -console.log(chalkTemplate` -CPU: {red ${cpu.totalPercent}%} -RAM: {green ${ram.used / ram.total * 100}%} -DISK: {rgb(255,131,0) ${disk.used / disk.total * 100}%} -`); -``` - -@example -``` -import chalkTemplate from 'chalk-template'; -import chalk from 'chalk'; - -console.log(chalk.red.bgBlack(chalkTemplate`2 + 3 = {bold ${2 + 3}}`)); -``` -*/ -export default function chalkTemplate(text: TemplateStringsArray, ...placeholders: unknown[]): string; - -/** -Terminal string styling with [tagged template literals](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#tagged_templates), -configured for standard error instead of standard output - -@example -``` -import {chalkTemplateStderr as chalkTemplate} from 'chalk-template'; - -console.log(chalkTemplate` -CPU: {red ${cpu.totalPercent}%} -RAM: {green ${ram.used / ram.total * 100}%} -DISK: {rgb(255,131,0) ${disk.used / disk.total * 100}%} -`); -``` - -@example -``` -import {chalkTemplateStderr as chalkTemplate} from 'chalk-template'; -import {chalkStderr as chalk} from 'chalk'; - -console.log(chalk.red.bgBlack(chalkTemplate`2 + 3 = {bold ${2 + 3}}`)); -``` -*/ -export function chalkTemplateStderr(text: TemplateStringsArray, ...placeholders: unknown[]): string; - -/** -Terminal string styling. - -This function can be useful if you need to wrap the template function. However, prefer the default export whenever possible. - -__Note:__ It's up to you to properly escape the input. - -@example -``` -import {template} from 'chalk-template'; - -console.log(template('Today is {red hot}')); -``` -*/ -export function template(text: string): string; - -/** -Terminal string styling, configured for stderr. - -This function can be useful if you need to wrap the template function. However, prefer the `chalkTemplateStderr` export whenever possible. - -__Note:__ It's up to you to properly escape the input. - -@example -``` -import {templateStderr as template} from 'chalk-template'; - -console.log(template('Today is {red hot}')); -``` -*/ -export function templateStderr(text: string): string; - -/** -Terminal string styling, using a custom Chalk instance. - -This function can be useful if you need to create a template function using your own Chalk instance. - -__Note:__ It's up to you to properly escape the input. - -@example -``` -import {Chalk} from 'chalk' -import {makeTemplate} from 'chalk-template'; - -const template = makeTemplate(new Chalk()); - -console.log(template('Today is {red hot}'')); -``` -*/ -export function makeTemplate(chalk: ChalkInstance): (text: string) => string; - -/** -Terminal string styling with [tagged template literals](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#tagged_templates), -configured using a custom Chalk instance. - -@example -``` -import {Chalk} from 'chalk' -import {makeTaggedTemplate} from 'chalk-template'; - -const chalkTemplate = makeTaggedTemplate(new Chalk()); - -console.log(chalkTemplate`Today is {red hot}`); -``` -*/ -export function makeTaggedTemplate(chalk: ChalkInstance): (text: TemplateStringsArray, ...placeholders: unknown[]) => string; diff --git a/index.js b/index.js deleted file mode 100644 index 30be571..0000000 --- a/index.js +++ /dev/null @@ -1,188 +0,0 @@ -// eslint-disable-next-line unicorn/import-style -import chalk, {chalkStderr} from 'chalk'; - -const TEMPLATE_REGEX = /(?:\\(u(?:[a-f\d]{4}|{[a-f\d]{1,6}})|x[a-f\d]{2}|.))|(?:{(~)?(#?[\w:]+(?:\([^)]*\))?(?:\.#?[\w:]+(?:\([^)]*\))?)*)(?:[ \t]|(?=\r?\n)))|(})|((?:.|[\r\n\f])+?)/gi; -const STYLE_REGEX = /(?:^|\.)(?:(?:(\w+)(?:\(([^)]*)\))?)|(?:#(?=[:a-fA-F\d]{2,})([a-fA-F\d]{6})?(?::([a-fA-F\d]{6}))?))/g; -const STRING_REGEX = /^(['"])((?:\\.|(?!\1)[^\\])*)\1$/; -const ESCAPE_REGEX = /\\(u(?:[a-f\d]{4}|{[a-f\d]{1,6}})|x[a-f\d]{2}|.)|([^\\])/gi; - -const ESCAPES = new Map([ - ['n', '\n'], - ['r', '\r'], - ['t', '\t'], - ['b', '\b'], - ['f', '\f'], - ['v', '\v'], - ['0', '\0'], - ['\\', '\\'], - ['e', '\u001B'], - ['a', '\u0007'], -]); - -function unescape(c) { - const u = c[0] === 'u'; - const bracket = c[1] === '{'; - - if ((u && !bracket && c.length === 5) || (c[0] === 'x' && c.length === 3)) { - return String.fromCodePoint(Number.parseInt(c.slice(1), 16)); - } - - if (u && bracket) { - return String.fromCodePoint(Number.parseInt(c.slice(2, -1), 16)); - } - - return ESCAPES.get(c) || c; -} - -function parseArguments(name, arguments_) { - const results = []; - const chunks = arguments_.trim().split(/\s*,\s*/g); - let matches; - - for (const chunk of chunks) { - const number = Number(chunk); - if (!Number.isNaN(number)) { - results.push(number); - } else if ((matches = chunk.match(STRING_REGEX))) { - results.push(matches[2].replace(ESCAPE_REGEX, (_, escape, character) => escape ? unescape(escape) : character)); - } else { - throw new Error(`Invalid Chalk template style argument: ${chunk} (in style '${name}')`); - } - } - - return results; -} - -function parseHex(hex) { - const n = Number.parseInt(hex, 16); - return [ - // eslint-disable-next-line no-bitwise - (n >> 16) & 0xFF, - // eslint-disable-next-line no-bitwise - (n >> 8) & 0xFF, - // eslint-disable-next-line no-bitwise - n & 0xFF, - ]; -} - -function parseStyle(style) { - STYLE_REGEX.lastIndex = 0; - - const results = []; - let matches; - - while ((matches = STYLE_REGEX.exec(style)) !== null) { - const name = matches[1]; - - if (matches[2]) { - results.push([name, ...parseArguments(name, matches[2])]); - } else if (matches[3] || matches[4]) { - if (matches[3]) { - results.push(['rgb', ...parseHex(matches[3])]); - } - - if (matches[4]) { - results.push(['bgRgb', ...parseHex(matches[4])]); - } - } else { - results.push([name]); - } - } - - return results; -} - -export function makeTemplate(chalk) { - function buildStyle(styles) { - const enabled = {}; - - for (const layer of styles) { - for (const style of layer.styles) { - enabled[style[0]] = layer.inverse ? null : style.slice(1); - } - } - - let current = chalk; - for (const [styleName, styles] of Object.entries(enabled)) { - if (!Array.isArray(styles)) { - continue; - } - - if (!(styleName in current)) { - throw new Error(`Unknown Chalk style: ${styleName}`); - } - - current = styles.length > 0 ? current[styleName](...styles) : current[styleName]; - } - - return current; - } - - function template(string) { - const styles = []; - const chunks = []; - let chunk = []; - - // eslint-disable-next-line max-params - string.replace(TEMPLATE_REGEX, (_, escapeCharacter, inverse, style, close, character) => { - if (escapeCharacter) { - chunk.push(unescape(escapeCharacter)); - } else if (style) { - const string = chunk.join(''); - chunk = []; - chunks.push(styles.length === 0 ? string : buildStyle(styles)(string)); - styles.push({inverse, styles: parseStyle(style)}); - } else if (close) { - if (styles.length === 0) { - throw new Error('Found extraneous } in Chalk template literal'); - } - - chunks.push(buildStyle(styles)(chunk.join(''))); - chunk = []; - styles.pop(); - } else { - chunk.push(character); - } - }); - - chunks.push(chunk.join('')); - - if (styles.length > 0) { - throw new Error(`Chalk template literal is missing ${styles.length} closing bracket${styles.length === 1 ? '' : 's'} (\`}\`)`); - } - - return chunks.join(''); - } - - return template; -} - -function makeChalkTemplate(template) { - function chalkTemplate(firstString, ...arguments_) { - if (!Array.isArray(firstString) || !Array.isArray(firstString.raw)) { - // If chalkTemplate() was called by itself or with a string - throw new TypeError('A tagged template literal must be provided'); - } - - const parts = [firstString.raw[0]]; - - for (let index = 1; index < firstString.raw.length; index++) { - parts.push( - String(arguments_[index - 1]).replace(/[{}\\]/g, '\\$&'), - String(firstString.raw[index]), - ); - } - - return template(parts.join('')); - } - - return chalkTemplate; -} - -export const makeTaggedTemplate = chalkInstance => makeChalkTemplate(makeTemplate(chalkInstance)); - -export const template = makeTemplate(chalk); -export default makeChalkTemplate(template); - -export const templateStderr = makeTemplate(chalkStderr); -export const chalkTemplateStderr = makeChalkTemplate(templateStderr); diff --git a/index.test-d.ts b/index.test-d.ts deleted file mode 100644 index 9be2e31..0000000 --- a/index.test-d.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {expectType} from 'tsd'; -import chalk, {Chalk} from 'chalk'; -import chalkTemplate, {template, chalkTemplateStderr, templateStderr, makeTemplate, makeTaggedTemplate} from './index.js'; - -// -- Template literal -- -expectType(chalkTemplate``); -const name = 'John'; -expectType(chalkTemplate`Hello {bold.red ${name}}`); -expectType(chalkTemplate`Works with numbers {bold.red ${1}}`); - -expectType(template('Today is {bold.red hot}')); - -// -- Complex template literal -- -expectType(chalk.red.bgGreen.bold(chalkTemplate`Hello {italic.blue ${name}}`)); -expectType(chalk.strikethrough.cyanBright.bgBlack(chalkTemplate`Works with {reset {bold numbers}} {bold.red ${1}}`)); - -// -- Stderr Types -- -expectType(chalkTemplateStderr); -expectType(templateStderr); - -// -- Make template functions -- -expectType(makeTemplate(new Chalk())); -expectType(makeTaggedTemplate(new Chalk())); diff --git a/package.json b/package.json index 695b004..beb445d 100644 --- a/package.json +++ b/package.json @@ -7,14 +7,16 @@ "funding": "https://github.com/chalk/chalk-template?sponsor=1", "type": "module", "exports": { - "types": "./index.d.ts", - "default": "./index.js" + "types": "./dist/index.d.ts", + "default": "./dist/index.js" }, "engines": { "node": ">=14.16" }, "scripts": { - "test": "xo && ava test/index.js && cross-env FORCE_COLOR=0 ava test/no-color.js && cross-env FORCE_COLOR=3 TERM=dumb ava test/full-color.js && cross-env FORCE_COLOR=3 TERM=dumb ava test/template.js && tsd" + "build": "tsc --project tsconfig.json", + "clean": "rimraf dist", + "test": "ava test/index.js && cross-env FORCE_COLOR=0 ava test/no-color.js && cross-env FORCE_COLOR=3 TERM=dumb ava test/full-color.js && cross-env FORCE_COLOR=3 TERM=dumb ava test/template.js" }, "files": [ "index.js", @@ -48,9 +50,16 @@ "dependencies": { "chalk": "^5.2.0" }, + "prettier": { + "singleQuote": true + }, "devDependencies": { + "@tsconfig/esm": "^1.0.3", + "@tsconfig/node14": "^1.0.3", + "@tsconfig/recommended": "^1.0.2", "ava": "^5.2.0", "cross-env": "^7.0.3", + "prettier": "^2.8.8", "tsd": "^0.28.1", "xo": "^0.54.2" } diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..5f8f8cd --- /dev/null +++ b/src/index.ts @@ -0,0 +1,54 @@ +import { Chalk, chalkStderr, type ChalkInstance } from "chalk"; +import { parse, type AstNode } from "./templateParser.js"; +import { renderChalk } from "./templateRenderer.js"; + +interface ChalkResult { + rendered: String; + ast: AstNode; +} +function renderer( + chalk: ChalkInstance, + options?: { ["returnAstNode"]: boolean } +) { + function renderTaggedTemplate(...args: any[]): String; + function renderTaggedTemplate( + pieces: TemplateStringsArray, + ...args: any[] + ): String | ChalkResult { + let msg: string; + const lastIdx = pieces.length - 1; + if ( + Array.isArray(pieces) && + pieces.every(isString) && + lastIdx === args.length + ) { + msg = + args.map((a, i) => pieces[i] + stringify(a)).join("") + pieces[lastIdx]; + } else { + msg = [pieces, ...args.map(stringify)].join(" "); + } + const ast = parse(chalk, msg); + const rendered = renderChalk(chalk, ast); + if (options && options.returnAstNode) { + return { rendered, ast }; + } + return rendered; + } + return renderTaggedTemplate; +} + +function stringify(arg: any) { + return `${arg}`; +} + +function isString(obj: any) { + return typeof obj === "string"; +} + +export const chalkTemplateWithChalk = (chalk: ChalkInstance) => renderer(chalk); +export const chalkTemplate = renderer(new Chalk()); +export const chalkTemplateStderr = renderer(chalkStderr); +/** + * The AST is EXPERIMENTAL and subject to chance. + */ +export const chalkTemplateRenderer = renderer; diff --git a/src/templateParser.ts b/src/templateParser.ts new file mode 100644 index 0000000..5b61f0c --- /dev/null +++ b/src/templateParser.ts @@ -0,0 +1,295 @@ +import type { ChalkInstance } from 'chalk'; + +export interface TemplateNode { + type: 'template'; + nodes: AstNode[]; + templateString: string; +} + +export interface ChalkTemplate { + type: 'chalktemplate'; + style: Style[]; + body: ChalkTemplateBodyNode[]; +} + +export interface EscapeMeNode { + type: 'escapeme'; + value: string; +} +export interface TextNode { + type: 'text'; + value: string; +} + +export type AstNode = TemplateNode | ChalkTemplate | EscapeMeNode | TextNode; + +export type ChalkTemplateBodyNode = ChalkTemplate | EscapeMeNode | TextNode; + +export interface TextStyle { + type: 'textstyle'; + invert: boolean; + value: string; + key: string; +} +export interface RgbStyle { + type: 'rgbstyle'; + key: string; + rgb?: RGB; + bgRgb?: RGB; +} +export interface RGB { + red: number; + green: number; + blue: number; +} +export interface HexStyle { + type: 'hexstyle'; + key: string; + fghex?: string; + bghex?: string; +} +export type Style = TextStyle | RgbStyle | HexStyle; + +const prefix = '{'; + +export function parse( + chalk: ChalkInstance, + templateString: string +): TemplateNode { + let position = 0; + + return parseTemplate(); + + function parseTemplate(): TemplateNode { + const nodes: AstNode[] = []; + for (;;) { + const node = parseNode(); + if (!node) break; + nodes.push(node); + } + return { + type: 'template', + nodes, + templateString, + }; + } + + function parseNode(): ChalkTemplate | EscapeMeNode | TextNode | undefined { + return parseChalkTemplate() ?? parseEscapeme() ?? parseText(); + } + + function parseChalkTemplate(): ChalkTemplate | undefined { + const original = position; + let body: ChalkTemplateBodyNode[] = []; + let style: Style[] | undefined; + if (consume(prefix)) { + style = parseStyles(); + if (!style) return reset(original); + let ended = false; + for (;;) { + const node = parseNode(); + if (node && node.type === 'escapeme' && node.value === '}') { + ended = true; + break; + } + if (!node) break; + body.push(node); + } + if (!ended && !consume('}')) return reset(original); + return { + type: 'chalktemplate', + style, + body, + }; + } + return undefined; + } + + function parseEscapeme(): EscapeMeNode | undefined { + const escapeNode = (value: string): EscapeMeNode => { + return { type: 'escapeme', value }; + }; + if (consume('{')) return escapeNode('{'); + else if (consume('}')) return escapeNode('}'); + else if (consume('\\')) return escapeNode('\\'); + return undefined; + } + + function parseText(): TextNode { + const textmatcher = () => { + let match = ''; + return (char: string) => { + if ((match + char).endsWith(prefix)) + return { + kind: 'reject', + amount: prefix.length - 1, + }; + if (/[^{}\\]/.test(char)) { + match += char; + return true; + } + return false; + }; + }; + const value = consumeWhile(textmatcher()); + if (value === undefined) return undefined; + + return { + type: 'text', + value, + }; + } + + function parseStyles(): Style[] | undefined { + const original = position; + const styles: Style[] = []; + for (;;) { + const style = + parseHexStyle() ?? + parseRgbStyle('rgb') ?? + parseRgbStyle('bgRgb') ?? + parseTextStyle(); + if (!style) break; + styles.push(style); + if (!consume('.')) break; + } + // There must be whitespace following the style, to delineate end of style + // If the whitespace is ' ', then it is swallowed, otherwise it is preserved + const nextSpace = consumeNextWhitespace(); + if (!nextSpace) return reset(original); + if (nextSpace !== ' ') { + position--; + } + if (styles.length === 0) return undefined; + return styles; + } + + function parseHexStyle(): HexStyle | undefined { + const original = position; + const hash = consume('#'); + if (hash) { + const fghex = consumeWhile((char) => /[0-9a-fA-F]/.test(char)); + let bghex: string | undefined; + if (consume(':')) { + bghex = consumeWhile((char) => /[0-9a-fA-F]/.test(char)); + if (!bghex) return reset(original); + } else { + // no seperator, that means there must be a foreground value + if (!fghex) return reset(original); + } + return { + type: 'hexstyle', + fghex, + bghex, + key: `fghex:${fghex}:bghex:${bghex}` + }; + } + return undefined; + } + function parseRgbStyle(kind: 'rgb' | 'bgRgb'): RgbStyle | undefined { + const original = position; + const rgb = consume(kind); + if (rgb) { + consumeWhitespace(); + const lparen = consume('('); + if (!lparen) reset(original); + consumeWhitespace(); + const red = consumeNumber(); + if (!red) reset(original); + consumeWhitespace(); + consume(','); + consumeWhitespace(); + const green = consumeNumber(); + if (!green) reset(original); + consumeWhitespace(); + consume(','); + consumeWhitespace(); + const blue = consumeNumber(); + if (!blue) reset(original); + consumeWhitespace(); + const rparen = consume(')'); + if (!rparen) reset(original); + return { + type: 'rgbstyle', + [kind]: { + red, + green, + blue, + }, + key: `rgb:${kind}:${red}:${green}:${blue}` + }; + } + return undefined; + } + + function parseTextStyle(): TextStyle | undefined { + const original = position; + const invert = consume('~'); + const style = consumeWhile((char) => /[^\s#\\.]/.test(char)); + if (!style) return reset(original); + if (!chalk[style]) return reset(original); + return { + type: 'textstyle', + invert: !!invert, + value: style, + key: `text:${style}` + }; + } + + function consumeNextWhitespace() { + const nextWhiteSpace = () => { + let limit = 1; + return (char: string) => { + if (limit === 0) return false; + limit--; + return /\s/.test(char); + }; + }; + return consumeWhile(nextWhiteSpace()); + } + + function consume(segment: string): boolean | undefined { + for (let j = 0; j < segment.length; j++) { + if (templateString[position + j] !== segment[j]) { + return undefined; + } + } + position += segment.length; + return true; + } + + function consumeWhile( + fn: (char: string) => boolean | { kind: string; amount: number } + ): string | undefined { + let newIndex = position; + let adjust = 0; + while (templateString[newIndex] != null) { + const action = fn(templateString[newIndex]); + if (action === true) newIndex++; + else if (action === false) break; + else if (action.kind === 'reject') { + adjust = -action.amount; + break; + } + } + if (newIndex > position) { + const result = templateString.substring(position, newIndex + adjust); + position = newIndex + adjust; + return result; + } + return undefined; + } + + function reset(index: number): undefined { + position = index; + return undefined; + } + + function consumeNumber() { + return consumeWhile((char) => /\d/.test(char)); + } + + function consumeWhitespace() { + consumeWhile((char) => /\s/.test(char)); + } +} diff --git a/src/templateRenderer.ts b/src/templateRenderer.ts new file mode 100644 index 0000000..802d455 --- /dev/null +++ b/src/templateRenderer.ts @@ -0,0 +1,64 @@ +import type { ChalkInstance } from 'chalk'; +import type { AstNode, Style } from './templateParser.js'; + +function configChalk(chalk: ChalkInstance, styles: Map) { + let currentChalk = chalk; + for (const style of styles.values()) { + if (style.type === 'hexstyle') { + if (style.fghex) { + currentChalk = currentChalk.hex('#' + style.fghex); + } + if (style.bghex) { + currentChalk = currentChalk.bgHex('#' + style.bghex); + } + } else if (style.type === 'rgbstyle') { + if (style.bgRgb) { + const { red, green, blue } = style.bgRgb; + currentChalk = currentChalk.bgRgb(red, green, blue); + } + if (style.rgb) { + const { red, green, blue } = style.rgb; + currentChalk = currentChalk.rgb(red, green, blue); + } + } else if (style.type === 'textstyle') { + currentChalk = currentChalk[style.value]; + } + } + return currentChalk; +} + +export function renderChalk(chalk: ChalkInstance, node: AstNode): string { + let styles = new Map(); + function visitor(current: AstNode) { + if (current.type === 'template') return current.nodes.map(visitor).join(''); + else if (current.type === 'escapeme') return current.value; + else if (current.type === 'text') return current.value; + else if (current.type === 'chalktemplate') { + const prevStyles = new Map(styles); + + for (const style of current.style) { + const { type, key } = style; + if (type === 'textstyle') { + const { invert } = style; + if (invert && styles.has(key)) { + styles.delete(key); + break; + } + } + styles.set(key, style); + } + let result = ''; + for (const node of current.body) { + if (node.type === 'chalktemplate') { + result += visitor(node); + } else { + result += configChalk(chalk, styles)(node.value); + } + } + styles = prevStyles; + return result; + } + return ''; + } + return visitor(node); +} diff --git a/test/full-color.js b/test/full-color.js index 042c4c9..4f9f64b 100644 --- a/test/full-color.js +++ b/test/full-color.js @@ -1,5 +1,5 @@ import test from 'ava'; -import chalkTemplate from '../index.js'; +import {chalkTemplate} from '../dist/index.js'; test('correctly parse and evaluate color-convert functions', t => { t.is(chalkTemplate`{bold.rgb(144,10,178).inverse Hello, {~inverse there!}}`, @@ -13,34 +13,20 @@ test('correctly parse and evaluate color-convert functions', t => { + '\u001B[48;2;144;10;178mthere!\u001B[49m\u001B[22m'); }); -test('properly handle escapes', t => { +test('no need to escapes', t => { t.is(chalkTemplate`{bold hello \{in brackets\}}`, - '\u001B[1mhello {in brackets}\u001B[22m'); + '\x1B[1mhello \x1B[22m\x1B[1m{\x1B[22m\x1B[1min brackets\x1B[22m}'); }); -test('throw if there is an unclosed block', t => { - t.throws(() => { - // eslint-disable-next-line no-unused-expressions - chalkTemplate`{bold this shouldn't work ever\}`; - }, { - message: 'Chalk template literal is missing 1 closing bracket (`}`)', - }); - - t.throws(() => { - // eslint-disable-next-line no-unused-expressions - chalkTemplate`{bold this shouldn't {inverse appear {underline ever\} :) \}`; - }, { - message: 'Chalk template literal is missing 3 closing brackets (`}`)', - }); +test('do not throw if there is an unclosed block', t => { + t.is(chalkTemplate`{bold this should work\}`,'\x1B[1mthis should work\x1B[22m') + t.is(chalkTemplate`{bold bold does not work {inverse inverse works {underline underline works\} :) \}`,'{bold bold does not work \x1B[7minverse works \x1B[27m\x1B[7m\x1B[4munderline works\x1B[24m\x1B[27m\x1B[7m :) \x1B[27m'); }); -test('throw if there is an invalid style', t => { - t.throws(() => { - // eslint-disable-next-line no-unused-expressions - chalkTemplate`{abadstylethatdoesntexist this shouldn't work ever}`; - }, { - message: 'Unknown Chalk style: abadstylethatdoesntexist', - }); +test('do not throw if there is an invalid style', t => { + t.is( + chalkTemplate`{abadstylethatdoesntexist this should work as unprocessed}`, + `{abadstylethatdoesntexist this should work as unprocessed}`); }); test('properly style multiline color blocks', t => { @@ -62,9 +48,9 @@ test('properly style multiline color blocks', t => { ); }); -test('escape interpolated values', t => { +test('handle escape interpolated values', t => { t.is(chalkTemplate`Hello {bold hi}`, 'Hello \u001B[1mhi\u001B[22m'); - t.is(chalkTemplate`Hello ${'{bold hi}'}`, 'Hello {bold hi}'); + t.is(chalkTemplate`Hello ${'{bold hi}'}`, 'Hello \u001B[1mhi\u001B[22m'); }); test('should allow bracketed Unicode escapes', t => { diff --git a/test/index.js b/test/index.js index 215d930..772310e 100644 --- a/test/index.js +++ b/test/index.js @@ -1,6 +1,5 @@ import test from 'ava'; -import chalk from 'chalk'; -import chalkTemplate, {chalkTemplateStderr, makeTaggedTemplate} from '../index.js'; +import {chalkTemplate, chalkTemplateStderr} from '../dist/index.js'; test('return an empty string for an empty literal', t => { t.is(chalkTemplate``, ''); @@ -10,6 +9,4 @@ test('return an empty string for an empty literal (stderr)', t => { t.is(chalkTemplateStderr``, ''); }); -test('return an empty string for an empty literal (chalk)', t => { - t.is(makeTaggedTemplate(chalk)``, ''); -}); + diff --git a/test/no-color.js b/test/no-color.js index 418efe3..6319722 100644 --- a/test/no-color.js +++ b/test/no-color.js @@ -1,8 +1,10 @@ import test from 'ava'; -import chalk from 'chalk'; -import chalkTemplateStdout, {chalkTemplateStderr, makeTaggedTemplate} from '../index.js'; -for (const [chalkTemplate, stdio] of [[chalkTemplateStdout, 'stdout'], [chalkTemplateStderr, 'stderr'], [makeTaggedTemplate(chalk), 'chalk']]) { +import chalk from 'chalk' +import {chalkTemplate as chalkTemplateStdout, chalkTemplateStderr} from '../dist/index.js'; + + +for (const [chalkTemplate, stdio] of [[chalkTemplateStdout, 'stdout'], [chalkTemplateStderr, 'stderr']]) { test(`[${stdio}] return a regular string for a literal with no templates`, t => { t.is(chalkTemplate`hello`, 'hello'); }); @@ -51,13 +53,8 @@ for (const [chalkTemplate, stdio] of [[chalkTemplateStdout, 'stdout'], [chalkTem 'xylophones are foxy! xylophones are foxy!'); }); - test(`[${stdio}] throws if an extra unescaped } is found`, t => { - t.throws(() => { - // eslint-disable-next-line no-unused-expressions - chalkTemplate`{red hi!}}`; - }, { - message: 'Found extraneous } in Chalk template literal', - }); + test(`[${stdio}] no error if extra } is found`, t => { + t.is(chalkTemplate`{red hi!}}`, "hi!}"); }); test(`[${stdio}] should not parse upper-case escapes`, t => { diff --git a/test/template.js b/test/template.js index 0ac240f..f549ce3 100644 --- a/test/template.js +++ b/test/template.js @@ -1,8 +1,8 @@ import test from 'ava'; import chalk from 'chalk'; -import {template as templateStdout, templateStderr, makeTemplate} from '../index.js'; +import {chalkTemplate, chalkTemplateStderr} from '../dist/index.js'; -for (const [template, stdio] of [[templateStdout, 'stdout'], [templateStderr, 'stderr'], [makeTemplate(chalk), 'chalk']]) { +for (const [template, stdio] of [[chalkTemplate, 'stdout'], [chalkTemplateStderr, 'stderr']]) { test(`[${stdio}] correctly parse and evaluate color-convert functions`, t => { t.is(template('{bold.rgb(144,10,178).inverse Hello, {~inverse there!}}'), '\u001B[1m\u001B[38;2;144;10;178m\u001B[7mHello, ' @@ -15,31 +15,20 @@ for (const [template, stdio] of [[templateStdout, 'stdout'], [templateStderr, 's + '\u001B[48;2;144;10;178mthere!\u001B[49m\u001B[22m'); }); - test(`[${stdio}] properly handle escapes`, t => { - t.is(template('{bold hello \\{in brackets\\}}'), - '\u001B[1mhello {in brackets}\u001B[22m'); + test(`[${stdio}] no need to escapes`, t => { + t.is(template('{bold hello \{in brackets\}}'), + '\x1B[1mhello \x1B[22m\x1B[1m{\x1B[22m\x1B[1min brackets\x1B[22m}'); }); - test(`[${stdio}] throw if there is an unclosed block`, t => { - t.throws(() => { - template('{bold this shouldn\'t work ever\\}'); - }, { - message: 'Chalk template literal is missing 1 closing bracket (`}`)', - }); - - t.throws(() => { - template('{bold this shouldn\'t {inverse appear {underline ever\\} :) \\}'); - }, { - message: 'Chalk template literal is missing 3 closing brackets (`}`)', - }); + test(`[${stdio}] do not throw if there is an unclosed block`, t => { + t.is(template('{bold this should work\}'),'\x1B[1mthis should work\x1B[22m') + t.is(template('{bold bold does not work {inverse inverse works {underline underline works\} :) \}'),'{bold bold does not work \x1B[7minverse works \x1B[27m\x1B[7m\x1B[4munderline works\x1B[24m\x1B[27m\x1B[7m :) \x1B[27m'); }); test(`[${stdio}] throw if there is an invalid style`, t => { - t.throws(() => { - template('{abadstylethatdoesntexist this shouldn\'t work ever}'); - }, { - message: 'Unknown Chalk style: abadstylethatdoesntexist', - }); + t.is( + template('{abadstylethatdoesntexist this should work as unprocessed}'), + `{abadstylethatdoesntexist this should work as unprocessed}`); }); test(`[${stdio}] properly style multiline color blocks`, t => { diff --git a/test/templateParser-debug.js b/test/templateParser-debug.js new file mode 100644 index 0000000..3fb3acb --- /dev/null +++ b/test/templateParser-debug.js @@ -0,0 +1,97 @@ +import { chalkTemplateRenderer } from "../dist/index.js"; +import { Chalk } from "chalk"; +import util from "node:util"; + +const prefix = "{"; +function toString(node) { + function visitor(current) { + if (current.type === "template") return current.nodes.map(visitor).join(""); + else if (current.type === "escapeme") return current.value; + else if (current.type === "text") return current.value; + else if (current.type === "chalktemplate") + return `${prefix}${current.style.map(visitor).join(".")} ${current.body + .map(visitor) + .join("")}}`; + else if (current.type === "textstyle") { + return `${current.invert ? "~" : ""}${current.value}`; + } else if (current.type === "hexstyle") { + if (current.fghex && current.bghex) + return "#" + current.fghex + ":" + current.bghex; + else if (current.fghex && !current.bghex) return "#" + current.fghex; + else if (!current.fghex && current.bghex) return "#:" + current.bghex; + } else if (current.type === "rgbstyle") { + if (current.rgb) { + const { red, green, blue } = current.rgb; + return `rgb(${red},${green},${blue})`; + } else { + const { red, green, blue } = current.bgRgb; + return `bgRgb(${red},${green},${blue})`; + } + } + + return ""; + } + return visitor(node); +} + +const debugRender = chalkTemplateRenderer(new Chalk(), { returnAstNode: true }); + +function test(result) { + debugger + const ast = result.ast; + const rendered = result.rendered; + const templateString = ast.templateString; + + console.log(util.inspect(ast, {depth: null, colors: true})) + console.log("template", templateString); + console.log("toString", toString(ast)); + console.log(rendered); +} + +function testRandom() { + test(debugRender`{strikethrough.cyanBright.bgBlue ok {~strikethrough two}}`); + test(debugRender`{bold.rgb (144 ,10,178).inverse Hello, {~inverse there!}}`); + test( + debugRender`{strikethrough.cyanBright.bgBlack Works with {reset {bold numbers}} {bold.red ${1}}}` + ); + test(debugRender`{bold.bgRgb (144 ,10,178) Hello, there!}`); + test(debugRender`{bold hello \\{in brackets\\}}`); + test(debugRender`{abadstylethatdoesntexist this shouldn\'t work ever}`); + test(debugRender`\u{AB}`); + test(debugRender`This is a {bold \u{AB681}} test`); + test(debugRender`{#FF0000 hello}`); + test(debugRender`{#CCAAFF:AABBCC hello}`); + test(debugRender`{#X:Y hello}`); + test(debugRender`{bold hello`); +} + +function testHex() { + test(debugRender`{#:FF0000 hello}`); + test(debugRender`{#00FF00:FF0000 hello}`); + test(debugRender`{bold.#FF0000 hello}`); + test(debugRender`{bold.#:FF0000 hello}`); + test(debugRender`{bold.#00FF00:FF0000 hello}`); + test(debugRender`{#FF0000.bold hello}`); + test(debugRender`{#:FF0000.bold hello}`); + test(debugRender`{#00FF00:FF0000.bold hello}`); +} +function testFn() {xo + test(debugRender`debugRender`); + test(debugRender`b${"c"}`); + test(debugRender`${"d"}`); + test(debugRender`${"e"}${"f"}`); + test(debugRender`\b`); + test(debugRender`${8}`); + test(debugRender("hi")); + test(debugRender("hi", "a")); +} + +function debug() { + testRandom(); + testHex(); + testFn() +} + +// debug() +test(debugRender`{bold.rgb(144,10,178).inverse Hello, {~inverse there!}}`) +test(debugRender`{bold.bgRgb(144,10,178).inverse Hello, {~inverse there!}}`) \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ea2781a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@tsconfig/esm/tsconfig.json", + "compilerOptions": { + "moduleResolution": "nodenext", + "declaration": true, + "target": "ES6", + "outDir": "dist", + "rootDir": "src" + } +}