diff --git a/README.md b/README.md index 6872592..c5071d2 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ In Obsidian, run **Open API docs** to view the available permissions and `api.*` Current API groups include vault, metadata, workspace, editor, file manager, UI, output, storage, network, path, link, search, and YAML helpers. Every host operation goes through a permission-gated RPC method or host-side permission. -The complete hardened `Temporal` API and permissionless utilities run entirely inside the worker. Normal scripts use `utils.today`, `utils.yesterday`, `utils.tomorrow`, `utils.now`, `utils.duration`, `utils.link`, `utils.file`, `utils.tag`, and `utils.formatBytes`; expression mode exposes the same functions directly. The date helpers return standard Temporal objects, and `duration` accepts ISO syntax, milliseconds, component objects, or shorthand such as `2w` and `1mo 3d`. +The complete hardened `Temporal` API and permissionless utilities run entirely inside the worker. Normal scripts use `utils.today`, `utils.yesterday`, `utils.tomorrow`, `utils.now`, `utils.duration`, `utils.link`, `utils.file`, `utils.tag`, `utils.formatBytes`, and `utils.slugify`; expression mode exposes the same functions directly. The date helpers return standard Temporal objects, and `duration` accepts ISO syntax, milliseconds, component objects, or shorthand such as `2w` and `1mo 3d`. ## Plugin Integration diff --git a/packages/obsidian/src/ui/docs-view.ts b/packages/obsidian/src/ui/docs-view.ts index 8106b9c..94f7963 100644 --- a/packages/obsidian/src/ui/docs-view.ts +++ b/packages/obsidian/src/ui/docs-view.ts @@ -66,6 +66,7 @@ export class SafeJsDocsView extends ItemView { ['utils.file(value) / file(value)', 'Wrap a path or file descriptor with path fields and link creation.'], ['utils.tag(value) / tag(value)', 'Normalize an Obsidian tag and expose its nested levels.'], ['utils.formatBytes(value, options?) / formatBytes(...)', 'Format a byte count with decimal or binary units.'], + ['utils.slugify(value, options?) / slugify(...)', 'Create a Unicode-aware slug. Options can set separator or disable lowercasing.'], ]; const list = section.createEl('ul'); for (const [usage, description] of utilities) { diff --git a/packages/obsidian/src/worker/worker-utilities.ts b/packages/obsidian/src/worker/worker-utilities.ts index a5a99de..a3452ad 100644 --- a/packages/obsidian/src/worker/worker-utilities.ts +++ b/packages/obsidian/src/worker/worker-utilities.ts @@ -1,5 +1,5 @@ import { createDuration, createNow, createToday, createTomorrow, createYesterday, getTemporalApi } from 'packages/obsidian/src/worker/worker-date-utilities'; -import { createFileUtility, createLinkUtility, createTagUtility, formatByteCount } from 'packages/obsidian/src/worker/worker-value-utilities'; +import { createFileUtility, createLinkUtility, createTagUtility, formatByteCount, slugifyText } from 'packages/obsidian/src/worker/worker-value-utilities'; import type { Harden } from 'ses'; import type { Temporal } from 'temporal-polyfill/implementation'; @@ -13,6 +13,7 @@ interface UtilityFactories { file(value: unknown): unknown; tag(value: unknown): unknown; formatBytes(value: unknown, options?: unknown): string; + slugify(value: unknown, options?: unknown): string; } interface WorkerUtilities { @@ -50,6 +51,9 @@ export function createWorkerUtilities(hardenValue: Harden): WorkerUtilities { formatBytes(value: unknown, options?: unknown): string { return formatByteCount(value, options); }, + slugify(value: unknown, options?: unknown): string { + return slugifyText(value, options); + }, }; const utils = hardenValue(factories); @@ -81,10 +85,13 @@ export function createWorkerUtilities(hardenValue: Harden): WorkerUtilities { function formatBytes(value: unknown, options?: unknown): string { return factories.formatBytes(value, options); } + function slugify(value: unknown, options?: unknown): string { + return factories.slugify(value, options); + } return { temporal: hardenValue(getTemporalApi()), utils, - expressionGlobals: hardenValue({ today, yesterday, tomorrow, now, duration, link, file, tag, formatBytes }), + expressionGlobals: hardenValue({ today, yesterday, tomorrow, now, duration, link, file, tag, formatBytes, slugify }), }; } diff --git a/packages/obsidian/src/worker/worker-value-utilities.ts b/packages/obsidian/src/worker/worker-value-utilities.ts index 24d2b89..4ee5117 100644 --- a/packages/obsidian/src/worker/worker-value-utilities.ts +++ b/packages/obsidian/src/worker/worker-value-utilities.ts @@ -7,6 +7,10 @@ const DECIMAL_BYTE_UNITS = ['B', 'kB', 'MB', 'GB', 'TB'] as const; const BINARY_BYTE_UNITS = ['B', 'KiB', 'MiB', 'GiB', 'TiB'] as const; const DEFAULT_BYTE_DECIMALS = 1; const MAX_BYTE_DECIMALS = 10; +const DEFAULT_SLUG_SEPARATOR = '-'; +const LATIN_COMBINING_MARKS_PATTERN = /(\p{Script=Latin})\p{M}+/gu; +const NON_SLUG_CHARACTERS_PATTERN = /[^\p{L}\p{N}\p{M}]+/gu; +const SLUG_SEPARATOR_CONTENT_PATTERN = /[\p{L}\p{N}\p{M}\s]/u; export function createLinkUtility(target: unknown, display: unknown, options: unknown, hardenValue: Harden): unknown { let raw = typeof target === 'string' ? target.trim() : ''; @@ -83,6 +87,23 @@ export function formatByteCount(value: unknown, options: unknown): string { return `${(bytes / base ** unitIndex).toFixed(unitIndex === 0 ? 0 : decimals)} ${units[unitIndex]}`; } +export function slugifyText(value: unknown, options: unknown): string { + if (typeof value !== 'string') throw new Error('Slug value must be a string.'); + + const config = readRecord(options); + const separator = config?.separator ?? DEFAULT_SLUG_SEPARATOR; + if (typeof separator !== 'string' || separator === '' || SLUG_SEPARATOR_CONTENT_PATTERN.test(separator)) { + throw new Error('Slug separator must be a non-empty string containing only non-whitespace punctuation or symbols.'); + } + + const normalized = value.normalize('NFKD').replace(LATIN_COMBINING_MARKS_PATTERN, '$1'); + const cased = config?.lowercase === false ? normalized : normalized.toLowerCase(); + let slug = cased.replace(NON_SLUG_CHARACTERS_PATTERN, separator); + if (slug.startsWith(separator)) slug = slug.slice(separator.length); + if (slug.endsWith(separator)) slug = slug.slice(0, -separator.length); + return slug.normalize('NFC'); +} + function splitOnce(value: string, separator: string): [string, string | undefined] { const index = value.indexOf(separator); return index < 0 ? [value, undefined] : [value.slice(0, index), value.slice(index + separator.length)]; diff --git a/test/worker-utilities.test.ts b/test/worker-utilities.test.ts index a6e4358..88c4102 100644 --- a/test/worker-utilities.test.ts +++ b/test/worker-utilities.test.ts @@ -70,6 +70,19 @@ test('worker Obsidian value helpers normalize without host access', () => { expect(utils.formatBytes(0.5)).toBe('1 B'); }); +test('worker slugify helper creates Unicode-aware slugs', () => { + const { utils } = createWorkerUtilities(sesHarden); + + expect(utils.slugify(' Crème brûlée & 東京 ')).toBe('creme-brulee-東京'); + expect(utils.slugify('Project Status', { separator: '_', lowercase: false })).toBe('Project_Status'); + expect(utils.slugify('Καλημέρα κόσμε')).toBe('καλημέρα-κόσμε'); + expect(utils.slugify('が ぎ')).toBe('が-ぎ'); + expect(utils.slugify('---')).toBe(''); + expect(() => utils.slugify(42)).toThrow('Slug value must be a string.'); + expect(() => utils.slugify('value', { separator: '' })).toThrow('Slug separator must be a non-empty string'); + expect(() => utils.slugify('value', { separator: 'x' })).toThrow('Slug separator must be a non-empty string'); +}); + test('expression utility aliases are direct globals and caller inputs can override them', async () => { const { expressionGlobals, temporal } = createWorkerUtilities(sesHarden); const compartment = new SesCompartment({ @@ -77,12 +90,16 @@ test('expression utility aliases are direct globals and caller inputs can overri __options__: true, }); - const result = (await compartment.evaluate('(async () => ({ total: price * quantity, duration, formatted: formatBytes(1000) }))()')) as { + const result = (await compartment.evaluate( + '(async () => ({ total: price * quantity, duration, formatted: formatBytes(1000), slug: slugify("Hello world") }))()', + )) as { total: number; duration: string; formatted: string; + slug: string; }; expect(result.total).toBe(36); expect(result.duration).toBe('caller override'); expect(result.formatted).toBe('1.0 kB'); + expect(result.slug).toBe('hello-world'); });