diff --git a/.changeset/new-string-nodes.md b/.changeset/new-string-nodes.md new file mode 100644 index 000000000..b60222cef --- /dev/null +++ b/.changeset/new-string-nodes.md @@ -0,0 +1,8 @@ +--- +"@tokens-studio/graph-engine": minor +--- + +Added new string manipulation nodes: +- Case Convert: Transform strings between camelCase, snake_case, kebab-case, and PascalCase +- Replace: Simple string replacement without regex +- Normalize: String normalization with accent removal options \ No newline at end of file diff --git a/packages/documentation/docs/nodes/string/lowercase.mdx b/packages/documentation/docs/nodes/string/lowercase.mdx deleted file mode 100644 index 4de647693..000000000 --- a/packages/documentation/docs/nodes/string/lowercase.mdx +++ /dev/null @@ -1,62 +0,0 @@ ---- -title: Lowercase ---- -import { Editor, PanelGroup, DropPanelStore, PanelItem } from '@tokens-studio/graph-editor'; -import {InitialLayout} from '@site/src/components/editor/layout'; -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - - -# lowercase - -Node Type ID: `"studio.tokens.string.lowercase"` - -## Package - -This is a core package available at [Graph Engine](https://www.npmjs.com/package/@tokens-studio/graph-engine) - -To install - - - -```bash -npm install @tokens-studio/graph-engine -``` - - -```bash -yarn add @tokens-studio/graph-engine -``` - - - - - -## Description -Converts a string to lowercase - -## Inputs - - -#### value - -*No description* - - - - -## Outputs - - -#### value - -*No description* - - - - - -## Example - -*No examples* - diff --git a/packages/graph-editor/src/data/version.ts b/packages/graph-editor/src/data/version.ts index 0dea284ee..23911495f 100644 --- a/packages/graph-editor/src/data/version.ts +++ b/packages/graph-editor/src/data/version.ts @@ -1 +1 @@ -export const version = '4.3.4'; +export const version = '4.3.6'; diff --git a/packages/graph-engine/src/nodes/string/case.ts b/packages/graph-engine/src/nodes/string/case.ts new file mode 100644 index 000000000..bfa6b5b68 --- /dev/null +++ b/packages/graph-engine/src/nodes/string/case.ts @@ -0,0 +1,86 @@ +import { INodeDefinition, ToInput, ToOutput } from '../../index.js'; +import { Node } from '../../programmatic/node.js'; +import { StringSchema } from '../../schemas/index.js'; + +export enum CaseType { + CAMEL = 'camel', + SNAKE = 'snake', + KEBAB = 'kebab', + PASCAL = 'pascal' +} + +/** + * This node converts strings between different case formats + */ +export default class NodeDefinition extends Node { + static title = 'Case Convert'; + static type = 'studio.tokens.string.case'; + static description = 'Converts strings between different case formats'; + + declare inputs: ToInput<{ + string: string; + type: CaseType; + }>; + declare outputs: ToOutput<{ + string: string; + }>; + + constructor(props: INodeDefinition) { + super(props); + this.addInput('string', { + type: StringSchema + }); + this.addInput('type', { + type: { + ...StringSchema, + enum: Object.values(CaseType), + default: CaseType.CAMEL + } + }); + this.addOutput('string', { + type: StringSchema + }); + } + + execute(): void | Promise { + const { string, type } = this.getAllInputs(); + + // First normalize the string by splitting on word boundaries + const words = string + // Add space before capitals in camelCase/PascalCase, but handle consecutive capitals + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .replace(/([a-z\d])([A-Z])/g, '$1 $2') + // Replace common delimiters with spaces + .replace(/[-_.]/g, ' ') + // Remove extra spaces and convert to lowercase + .trim() + .toLowerCase() + // Split into words and remove empty strings + .split(/\s+/) + .filter(word => word.length > 0); + + let result: string; + switch (type) { + case CaseType.CAMEL: + result = words[0] + words.slice(1).map(capitalize).join(''); + break; + case CaseType.SNAKE: + result = words.join('_'); + break; + case CaseType.KEBAB: + result = words.join('-'); + break; + case CaseType.PASCAL: + result = words.map(capitalize).join(''); + break; + default: + result = string; + } + + this.outputs.string.set(result); + } +} + +function capitalize(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); +} diff --git a/packages/graph-engine/src/nodes/string/index.ts b/packages/graph-engine/src/nodes/string/index.ts index 86910ed47..da8396e3f 100644 --- a/packages/graph-engine/src/nodes/string/index.ts +++ b/packages/graph-engine/src/nodes/string/index.ts @@ -1,8 +1,11 @@ +import caseConvert from './case.js'; import interpolate from './interpolate.js'; import join from './join.js'; import lowercase from './lowercase.js'; +import normalize from './normalize.js'; import pad from './pad.js'; import regex from './regex.js'; +import replace from './replace.js'; import split from './split.js'; import stringify from './stringify.js'; import uppercase from './uppercase.js'; @@ -10,9 +13,12 @@ import uppercase from './uppercase.js'; export const nodes = [ interpolate, join, + caseConvert, lowercase, + normalize, pad, regex, + replace, split, stringify, uppercase diff --git a/packages/graph-engine/src/nodes/string/normalize.ts b/packages/graph-engine/src/nodes/string/normalize.ts new file mode 100644 index 000000000..8c0b2fbef --- /dev/null +++ b/packages/graph-engine/src/nodes/string/normalize.ts @@ -0,0 +1,70 @@ +import { INodeDefinition, ToInput, ToOutput } from '../../index.js'; +import { Node } from '../../programmatic/node.js'; +import { StringSchema } from '../../schemas/index.js'; + +export enum NormalizationForm { + NFD = 'NFD', + NFC = 'NFC', + NFKD = 'NFKD', + NFKC = 'NFKC' +} + +/** + * This node normalizes strings and can remove diacritical marks (accents) + */ +export default class NodeDefinition extends Node { + static title = 'Normalize'; + static type = 'studio.tokens.string.normalize'; + static description = + 'Normalizes strings and optionally removes diacritical marks'; + + declare inputs: ToInput<{ + string: string; + form: NormalizationForm; + removeAccents: boolean; + }>; + declare outputs: ToOutput<{ + string: string; + }>; + + constructor(props: INodeDefinition) { + super(props); + this.addInput('string', { + type: StringSchema + }); + this.addInput('form', { + type: { + ...StringSchema, + enum: Object.values(NormalizationForm), + default: NormalizationForm.NFC + } + }); + this.addInput('removeAccents', { + type: { + type: 'boolean', + title: 'Remove Accents', + description: 'Whether to remove diacritical marks', + default: true + } + }); + this.addOutput('string', { + type: StringSchema + }); + } + + execute(): void | Promise { + const { string, form, removeAccents } = this.getAllInputs(); + + let result = string.normalize(form); + + if (removeAccents) { + // Remove combining diacritical marks + result = result + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .normalize(form); + } + + this.outputs.string.set(result); + } +} diff --git a/packages/graph-engine/src/nodes/string/replace.ts b/packages/graph-engine/src/nodes/string/replace.ts new file mode 100644 index 000000000..84becbe23 --- /dev/null +++ b/packages/graph-engine/src/nodes/string/replace.ts @@ -0,0 +1,47 @@ +import { INodeDefinition, ToInput, ToOutput } from '../../index.js'; +import { Node } from '../../programmatic/node.js'; +import { StringSchema } from '../../schemas/index.js'; + +/** + * This node replaces all occurrences of a search string with a replacement string + */ +export default class NodeDefinition extends Node { + static title = 'Replace'; + static type = 'studio.tokens.string.replace'; + static description = + 'Replaces all occurrences of a search string with a replacement string'; + + declare inputs: ToInput<{ + string: string; + search: string; + replace: string; + }>; + declare outputs: ToOutput<{ + string: string; + }>; + + constructor(props: INodeDefinition) { + super(props); + this.addInput('string', { + type: StringSchema + }); + this.addInput('search', { + type: StringSchema + }); + this.addInput('replace', { + type: { + ...StringSchema, + default: '' + } + }); + this.addOutput('string', { + type: StringSchema + }); + } + + execute(): void | Promise { + const { string, search, replace } = this.getAllInputs(); + const result = string.split(search).join(replace); + this.outputs.string.set(result); + } +} diff --git a/packages/graph-engine/tests/suites/nodes/string/case.test.ts b/packages/graph-engine/tests/suites/nodes/string/case.test.ts new file mode 100644 index 000000000..c2ad32980 --- /dev/null +++ b/packages/graph-engine/tests/suites/nodes/string/case.test.ts @@ -0,0 +1,65 @@ +import { Graph } from '../../../../src/graph/graph.js'; +import { describe, expect, test } from 'vitest'; +import Node, { CaseType } from '../../../../src/nodes/string/case.js'; + +describe('string/case', () => { + test('should convert to camelCase', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + node.inputs.string.setValue('hello world test'); + node.inputs.type.setValue(CaseType.CAMEL); + + await node.execute(); + + expect(node.outputs.string.value).toBe('helloWorldTest'); + }); + + test('should convert to snake_case', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + node.inputs.string.setValue('Hello World Test'); + node.inputs.type.setValue(CaseType.SNAKE); + + await node.execute(); + + expect(node.outputs.string.value).toBe('hello_world_test'); + }); + + test('should convert to kebab-case', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + node.inputs.string.setValue('HelloWorld test'); + node.inputs.type.setValue(CaseType.KEBAB); + + await node.execute(); + + expect(node.outputs.string.value).toBe('hello-world-test'); + }); + + test('should convert to PascalCase', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + node.inputs.string.setValue('hello_world_test'); + node.inputs.type.setValue(CaseType.PASCAL); + + await node.execute(); + + expect(node.outputs.string.value).toBe('HelloWorldTest'); + }); + + test('should handle mixed input formats', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + node.inputs.string.setValue('some-mixed_FORMAT test'); + node.inputs.type.setValue(CaseType.CAMEL); + + await node.execute(); + + expect(node.outputs.string.value).toBe('someMixedFormatTest'); + }); +}); diff --git a/packages/graph-engine/tests/suites/nodes/string/normalize.test.ts b/packages/graph-engine/tests/suites/nodes/string/normalize.test.ts new file mode 100644 index 000000000..2f80f9ed3 --- /dev/null +++ b/packages/graph-engine/tests/suites/nodes/string/normalize.test.ts @@ -0,0 +1,68 @@ +import { Graph } from '../../../../src/graph/graph.js'; +import { describe, expect, test } from 'vitest'; +import Node, { + NormalizationForm +} from '../../../../src/nodes/string/normalize.js'; + +describe('string/normalize', () => { + test('should remove accents when removeAccents is true', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + node.inputs.string.setValue('café résumé'); + node.inputs.removeAccents.setValue(true); + node.inputs.form.setValue(NormalizationForm.NFC); + + await node.execute(); + + expect(node.outputs.string.value).toBe('cafe resume'); + }); + + test('should preserve accents when removeAccents is false', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + node.inputs.string.setValue('café résumé'); + node.inputs.removeAccents.setValue(false); + node.inputs.form.setValue(NormalizationForm.NFC); + + await node.execute(); + + expect(node.outputs.string.value).toBe('café résumé'); + }); + + test('should handle different normalization forms', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + // Using a string with a precomposed character (é) vs decomposed (e + ´) + const precomposed = 'café'; // é is a single character + const decomposed = 'cafe\u0301'; // e and ´ are separate characters + + node.inputs.string.setValue(decomposed); + node.inputs.removeAccents.setValue(false); + + // Test NFC (precomposed) + node.inputs.form.setValue(NormalizationForm.NFC); + await node.execute(); + expect(node.outputs.string.value).toBe(precomposed); + + // Test NFD (decomposed) + node.inputs.form.setValue(NormalizationForm.NFD); + await node.execute(); + expect(node.outputs.string.value).toBe(decomposed); + }); + + test('should handle empty string', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + node.inputs.string.setValue(''); + node.inputs.removeAccents.setValue(true); + node.inputs.form.setValue(NormalizationForm.NFC); + + await node.execute(); + + expect(node.outputs.string.value).toBe(''); + }); +}); diff --git a/packages/graph-engine/tests/suites/nodes/string/replace.test.ts b/packages/graph-engine/tests/suites/nodes/string/replace.test.ts new file mode 100644 index 000000000..1916e055d --- /dev/null +++ b/packages/graph-engine/tests/suites/nodes/string/replace.test.ts @@ -0,0 +1,57 @@ +import { Graph } from '../../../../src/graph/graph.js'; +import { describe, expect, test } from 'vitest'; +import Node from '../../../../src/nodes/string/replace.js'; + +describe('string/replace', () => { + test('should replace all occurrences of a string', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + node.inputs.string.setValue('hello hello world'); + node.inputs.search.setValue('hello'); + node.inputs.replace.setValue('hi'); + + await node.execute(); + + expect(node.outputs.string.value).toBe('hi hi world'); + }); + + test('should handle empty replacement string', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + node.inputs.string.setValue('hello world'); + node.inputs.search.setValue('hello '); + node.inputs.replace.setValue(''); + + await node.execute(); + + expect(node.outputs.string.value).toBe('world'); + }); + + test('should handle search string not found', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + node.inputs.string.setValue('hello world'); + node.inputs.search.setValue('xyz'); + node.inputs.replace.setValue('abc'); + + await node.execute(); + + expect(node.outputs.string.value).toBe('hello world'); + }); + + test('should handle empty input string', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + node.inputs.string.setValue(''); + node.inputs.search.setValue('test'); + node.inputs.replace.setValue('replace'); + + await node.execute(); + + expect(node.outputs.string.value).toBe(''); + }); +});