slug helper

This commit is contained in:
Moritz Jung 2026-07-05 20:51:38 +02:00
parent 15b459521f
commit 86b5e5b548
5 changed files with 50 additions and 4 deletions

View file

@ -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

View file

@ -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) {

View file

@ -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 }),
};
}

View file

@ -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)];

View file

@ -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');
});