Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@scalar/helpers

Package Overview
Dependencies
Maintainers
7
Versions
47
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@scalar/helpers - npm Package Compare versions

Comparing version
0.6.0
to
0.8.0
+22
dist/general/compare-versions.d.ts
/**
* Compare two semver-style version strings without pulling in a `semver`
* dependency. We only need the small subset that supports `MAJOR.MINOR.PATCH`
* with an optional pre-release tag, which is more than enough for the
* "What's new" feature where versions look like `3.5.1` or `3.6.0-beta.1`.
*
* Returns a negative number when `a < b`, `0` when they are equal, and a
* positive number when `a > b` - matching the contract of `Array.sort`.
*
* Pre-release versions (e.g. `1.0.0-rc.1`) are treated as **lower** than
* the same version without a pre-release tag, per the semver spec. This is
* intentional so users on a stable release do not see beta-only entries.
*
* Build metadata (anything after `+`) is stripped before comparison, also
* per the semver spec.
*/
export declare const compareVersions: (a: string, b: string) => number;
/** Convenience wrapper: `true` when `a` is strictly less than `b`. */
export declare const isVersionLessThan: (a: string, b: string) => boolean;
/** Convenience wrapper: `true` when `a` is less than or equal to `b`. */
export declare const isVersionLessThanOrEqual: (a: string, b: string) => boolean;
//# sourceMappingURL=compare-versions.d.ts.map
{"version":3,"file":"compare-versions.d.ts","sourceRoot":"","sources":["../../src/general/compare-versions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,eAAe,GAAI,GAAG,MAAM,EAAE,GAAG,MAAM,KAAG,MAyEtD,CAAA;AAED,sEAAsE;AACtE,eAAO,MAAM,iBAAiB,GAAI,GAAG,MAAM,EAAE,GAAG,MAAM,KAAG,OAExD,CAAA;AAED,yEAAyE;AACzE,eAAO,MAAM,wBAAwB,GAAI,GAAG,MAAM,EAAE,GAAG,MAAM,KAAG,OAE/D,CAAA"}
/**
* Compare two semver-style version strings without pulling in a `semver`
* dependency. We only need the small subset that supports `MAJOR.MINOR.PATCH`
* with an optional pre-release tag, which is more than enough for the
* "What's new" feature where versions look like `3.5.1` or `3.6.0-beta.1`.
*
* Returns a negative number when `a < b`, `0` when they are equal, and a
* positive number when `a > b` - matching the contract of `Array.sort`.
*
* Pre-release versions (e.g. `1.0.0-rc.1`) are treated as **lower** than
* the same version without a pre-release tag, per the semver spec. This is
* intentional so users on a stable release do not see beta-only entries.
*
* Build metadata (anything after `+`) is stripped before comparison, also
* per the semver spec.
*/
export const compareVersions = (a, b) => {
const parsed = (input) => {
const [withoutBuild = ''] = input.split('+');
const hyphenIndex = withoutBuild.indexOf('-');
const core = hyphenIndex === -1 ? withoutBuild : withoutBuild.slice(0, hyphenIndex);
const preRelease = hyphenIndex === -1 ? undefined : withoutBuild.slice(hyphenIndex + 1);
const numeric = core.split('.').map((part) => Number.parseInt(part, 10) || 0);
const pre = preRelease ? preRelease.split('.') : [];
return { numeric, pre };
};
const left = parsed(a);
const right = parsed(b);
const length = Math.max(left.numeric.length, right.numeric.length);
for (let index = 0; index < length; index += 1) {
const diff = (left.numeric[index] ?? 0) - (right.numeric[index] ?? 0);
if (diff !== 0) {
return diff;
}
}
// No pre-release on either side, versions are equal.
if (left.pre.length === 0 && right.pre.length === 0) {
return 0;
}
// A version without a pre-release outranks one that has it.
if (left.pre.length === 0) {
return 1;
}
if (right.pre.length === 0) {
return -1;
}
// Both have pre-release identifiers - compare segment by segment.
const preLength = Math.max(left.pre.length, right.pre.length);
for (let index = 0; index < preLength; index += 1) {
const leftSegment = left.pre[index];
const rightSegment = right.pre[index];
if (leftSegment === undefined) {
return -1;
}
if (rightSegment === undefined) {
return 1;
}
const leftNumeric = Number.parseInt(leftSegment, 10);
const rightNumeric = Number.parseInt(rightSegment, 10);
const leftIsNumeric = !Number.isNaN(leftNumeric) && String(leftNumeric) === leftSegment;
const rightIsNumeric = !Number.isNaN(rightNumeric) && String(rightNumeric) === rightSegment;
if (leftIsNumeric && rightIsNumeric) {
const diff = leftNumeric - rightNumeric;
if (diff !== 0) {
return diff;
}
continue;
}
// Numeric identifiers always have lower precedence than alphanumeric.
if (leftIsNumeric) {
return -1;
}
if (rightIsNumeric) {
return 1;
}
if (leftSegment < rightSegment) {
return -1;
}
if (leftSegment > rightSegment) {
return 1;
}
}
return 0;
};
/** Convenience wrapper: `true` when `a` is strictly less than `b`. */
export const isVersionLessThan = (a, b) => {
return compareVersions(a, b) < 0;
};
/** Convenience wrapper: `true` when `a` is less than or equal to `b`. */
export const isVersionLessThanOrEqual = (a, b) => {
return compareVersions(a, b) <= 0;
};
/**
* Serialize curated release notes into the human-readable `RELEASE_NOTES.md`
* shape used as a derived view next to `RELEASE_NOTES.json`.
*
* The Scalar app's "What's new" modal reads structured data from JSON at
* build time; this module exists so the release-notes generator can still
* emit a markdown file for humans browsing the repo without maintaining a
* second hand-written format.
*
* Format (one entry per release, caller supplies order — newest first):
*
* ```markdown
* ## 3.5.1 (2026-04-25)
*
* ### Smoother request runs and a friendlier slugger
*
* A small follow-up to the 3.5.0 release ...
*
* - Pending edits are now flushed before a request runs.
* - Switched to our own slug generator.
*
* Rich blocks (images, extra headings) follow the same pattern inside the
* entry body after the title line.
*
* [Read full release notes](https://github.com/scalar/scalar/releases/tag/%40scalar%2Fapi-client%403.5.1)
* ```
*
* Anything before the first `## ` heading is the preamble (passed in via
* `serializeReleaseNotes` options or defaulted).
*/
/** Free-form paragraph of plain text. */
export type ParagraphBlock = {
type: 'paragraph';
text: string;
};
/** Subsection heading inside a release entry. Defaults to level 3. */
export type HeadingBlock = {
type: 'heading';
text: string;
level?: 3 | 4;
};
/** Bullet (or numbered) list. */
export type ListBlock = {
type: 'list';
items: string[];
ordered?: boolean;
};
/** Inline image with optional caption. */
export type ImageBlock = {
type: 'image';
src: string;
alt: string;
caption?: string;
width?: number;
height?: number;
};
/** Inline video clip with optional caption and playback hints. */
export type VideoBlock = {
type: 'video';
src: string;
poster?: string;
caption?: string;
autoplay?: boolean;
loop?: boolean;
muted?: boolean;
controls?: boolean;
};
export type HrefBlock = {
type: 'href';
href: string;
label: string;
};
/** Rich content block rendered between other blocks inside a release entry. */
export type ContentBlock = ParagraphBlock | HeadingBlock | ListBlock | ImageBlock | VideoBlock | HrefBlock;
/** One release note row. Mirrors the Scalar app's `ReleaseNote` shape. */
export type ReleaseNoteEntry = {
/** Semver-style version string (for example `3.5.1`). */
version: string;
/** Release date in `YYYY-MM-DD` format. */
date: string;
/** Short, sentence-case headline. */
title: string;
/** Optional body: paragraphs, lists, headings, images, videos, and links. */
content?: ContentBlock[];
};
/**
* Default file preamble. Used by `serializeReleaseNotes` when no preamble
* is supplied so the generator never accidentally writes a headerless file.
*
* The accompanying `RELEASE_NOTES.json` is the source of truth - this
* markdown file is a human-friendly view that gets regenerated on every
* release. Edits made directly to the markdown will be overwritten.
*/
export declare const DEFAULT_RELEASE_NOTES_PREAMBLE = "# Release notes\n\n<!--\n Auto-generated by the `release-notes-generator` command in\n `tooling/scripts` on every release. Newest entries on top.\n\n Source of truth: `RELEASE_NOTES.json` next to this file. The Scalar\n app's \"What's new\" modal imports the JSON directly; this markdown file\n is a derived, human-friendly view that gets regenerated on every\n release - edits made directly here will be overwritten.\n-->\n";
/**
* Serialize a list of entries into a `RELEASE_NOTES.md` document.
* Entries are emitted in the order provided - callers should sort newest
* first before calling this function.
*
* The output is deterministic for a given preamble and entry list.
*/
export declare const serializeReleaseNotes: (entries: ReleaseNoteEntry[], options?: {
preamble?: string;
}) => string;
//# sourceMappingURL=release-notes.d.ts.map
{"version":3,"file":"release-notes.d.ts","sourceRoot":"","sources":["../../src/markdown/release-notes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,yCAAyC;AACzC,MAAM,MAAM,cAAc,GAAG;IAC3B,IAAI,EAAE,WAAW,CAAA;IACjB,IAAI,EAAE,MAAM,CAAA;CACb,CAAA;AAED,sEAAsE;AACtE,MAAM,MAAM,YAAY,GAAG;IACzB,IAAI,EAAE,SAAS,CAAA;IACf,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,CAAC,EAAE,CAAC,GAAG,CAAC,CAAA;CACd,CAAA;AAED,iCAAiC;AACjC,MAAM,MAAM,SAAS,GAAG;IACtB,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,EAAE,CAAA;IACf,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB,CAAA;AAED,0CAA0C;AAC1C,MAAM,MAAM,UAAU,GAAG;IACvB,IAAI,EAAE,OAAO,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;IACX,GAAG,EAAE,MAAM,CAAA;IACX,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB,CAAA;AAED,kEAAkE;AAClE,MAAM,MAAM,UAAU,GAAG;IACvB,IAAI,EAAE,OAAO,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;IACX,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB,CAAA;AAED,MAAM,MAAM,SAAS,GAAG;IACtB,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;CACd,CAAA;AAED,+EAA+E;AAC/E,MAAM,MAAM,YAAY,GAAG,cAAc,GAAG,YAAY,GAAG,SAAS,GAAG,UAAU,GAAG,UAAU,GAAG,SAAS,CAAA;AAE1G,0EAA0E;AAC1E,MAAM,MAAM,gBAAgB,GAAG;IAC7B,yDAAyD;IACzD,OAAO,EAAE,MAAM,CAAA;IACf,2CAA2C;IAC3C,IAAI,EAAE,MAAM,CAAA;IACZ,qCAAqC;IACrC,KAAK,EAAE,MAAM,CAAA;IACb,6EAA6E;IAC7E,OAAO,CAAC,EAAE,YAAY,EAAE,CAAA;CACzB,CAAA;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,8BAA8B,mbAW1C,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,GAAI,SAAS,gBAAgB,EAAE,EAAE,UAAS;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAO,KAAG,MASxG,CAAA"}
/**
* Serialize curated release notes into the human-readable `RELEASE_NOTES.md`
* shape used as a derived view next to `RELEASE_NOTES.json`.
*
* The Scalar app's "What's new" modal reads structured data from JSON at
* build time; this module exists so the release-notes generator can still
* emit a markdown file for humans browsing the repo without maintaining a
* second hand-written format.
*
* Format (one entry per release, caller supplies order — newest first):
*
* ```markdown
* ## 3.5.1 (2026-04-25)
*
* ### Smoother request runs and a friendlier slugger
*
* A small follow-up to the 3.5.0 release ...
*
* - Pending edits are now flushed before a request runs.
* - Switched to our own slug generator.
*
* Rich blocks (images, extra headings) follow the same pattern inside the
* entry body after the title line.
*
* [Read full release notes](https://github.com/scalar/scalar/releases/tag/%40scalar%2Fapi-client%403.5.1)
* ```
*
* Anything before the first `## ` heading is the preamble (passed in via
* `serializeReleaseNotes` options or defaulted).
*/
/**
* Default file preamble. Used by `serializeReleaseNotes` when no preamble
* is supplied so the generator never accidentally writes a headerless file.
*
* The accompanying `RELEASE_NOTES.json` is the source of truth - this
* markdown file is a human-friendly view that gets regenerated on every
* release. Edits made directly to the markdown will be overwritten.
*/
export const DEFAULT_RELEASE_NOTES_PREAMBLE = `# Release notes
<!--
Auto-generated by the \`release-notes-generator\` command in
\`tooling/scripts\` on every release. Newest entries on top.
Source of truth: \`RELEASE_NOTES.json\` next to this file. The Scalar
app's "What's new" modal imports the JSON directly; this markdown file
is a derived, human-friendly view that gets regenerated on every
release - edits made directly here will be overwritten.
-->
`;
/**
* Serialize a list of entries into a `RELEASE_NOTES.md` document.
* Entries are emitted in the order provided - callers should sort newest
* first before calling this function.
*
* The output is deterministic for a given preamble and entry list.
*/
export const serializeReleaseNotes = (entries, options = {}) => {
const preamble = options.preamble ?? DEFAULT_RELEASE_NOTES_PREAMBLE;
const sections = entries.map(serializeEntry);
const body = sections.join('\n');
// Always end with a single trailing newline so the file plays nicely
// with text-editing tools and POSIX expectations.
const preambleBlock = preamble.replace(/\s+$/, '');
return `${preambleBlock}\n\n${body.trimEnd()}\n`;
};
const serializeEntry = (entry) => {
const blocks = [];
blocks.push(`## ${entry.version} (${entry.date})`);
blocks.push(`### ${entry.title}`);
if (entry.content && entry.content.length > 0) {
for (const block of entry.content) {
const rendered = serializeContentBlock(block);
if (rendered) {
blocks.push(rendered);
}
}
}
return `${blocks.join('\n\n')}\n`;
};
/**
* Render a single content block as a markdown fragment. Each fragment
* is later joined by blank lines, so callers must not append leading or
* trailing blank lines themselves.
*
* Image blocks use markdown images; video blocks use `<video>` with
* responsive inline styles so they fill the article width like images in
* prose layouts. Captions render as italic text on the line below so they
* stay readable even in editors that do not parse `<figure>` tags.
*/
const serializeContentBlock = (block) => {
if (block.type === 'paragraph') {
return block.text.trim();
}
if (block.type === 'href') {
return `[${block.label.trim()}](${block.href})`;
}
if (block.type === 'heading') {
const prefix = block.level === 4 ? '####' : '###';
return `${prefix} ${block.text.trim()}`;
}
if (block.type === 'list') {
const marker = block.ordered ? (index) => `${index + 1}.` : () => '-';
return block.items.map((item, index) => `${marker(index)} ${item.trim()}`).join('\n');
}
if (block.type === 'image') {
const image = `![${block.alt}](${block.src})`;
return block.caption ? `${image}\n\n_${block.caption.trim()}_` : image;
}
if (block.type === 'video') {
// Video block. Use the `<video>` HTML element so GitHub and most
// documentation viewers render it inline. Width/height styles match
// typical responsive `<img>` behaviour (full width of the column).
// Poster, autoplay, loop, and muted mirror the JSON for parity with
// the in-app modal.
const attrs = [`src="${block.src}"`, 'style="max-width: 100%; width: 100%; height: auto;"'];
if (block.poster) {
attrs.push(`poster="${block.poster}"`);
}
if (block.autoplay) {
attrs.push('autoplay');
}
if (block.loop) {
attrs.push('loop');
}
if (block.muted) {
attrs.push('muted');
}
if (block.controls !== false) {
attrs.push('controls');
}
attrs.push('playsinline');
const video = `<video ${attrs.join(' ')}></video>`;
return block.caption ? `${video}\n\n_${block.caption.trim()}_` : video;
}
const _exhaustive = block;
void _exhaustive;
return '';
};
/**
* Sets a nested value on the target object using a path array, creating
* intermediate plain objects as needed.
*
* Unlike json-magic's `setValueAtPath` (which accepts a JSON-pointer string and
* creates arrays for numeric segments), this helper uses an array of string
* segments and only ever creates plain objects. It mirrors the shape of
* `getValueAtPath` so the two compose cleanly.
*
* @example
* ```ts
* const target = {}
* setValueAtPath(target, ['filter', 'status'], 'active')
*
* { filter: { status: 'active' } }
* ```
*/
export declare const setValueAtPath: (target: Record<string, unknown>, path: readonly string[], value: unknown) => void;
//# sourceMappingURL=set-value-at-path.d.ts.map
{"version":3,"file":"set-value-at-path.d.ts","sourceRoot":"","sources":["../../src/object/set-value-at-path.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,cAAc,GAAI,QAAQ,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,SAAS,MAAM,EAAE,EAAE,OAAO,OAAO,KAAG,IAiBzG,CAAA"}
import { isObject } from './is-object.js';
import { preventPollution } from './prevent-pollution.js';
/**
* Sets a nested value on the target object using a path array, creating
* intermediate plain objects as needed.
*
* Unlike json-magic's `setValueAtPath` (which accepts a JSON-pointer string and
* creates arrays for numeric segments), this helper uses an array of string
* segments and only ever creates plain objects. It mirrors the shape of
* `getValueAtPath` so the two compose cleanly.
*
* @example
* ```ts
* const target = {}
* setValueAtPath(target, ['filter', 'status'], 'active')
*
* { filter: { status: 'active' } }
* ```
*/
export const setValueAtPath = (target, path, value) => {
const [key, ...rest] = path;
if (!key) {
return;
}
preventPollution(key);
if (!rest.length) {
target[key] = value;
return;
}
const next = isObject(target[key]) ? target[key] : {};
target[key] = next;
setValueAtPath(next, rest, value);
};
/**
* Maps a comma-separated selector list to the theme modes it applies to.
* Only **exact** `.light-mode` or `.dark-mode` selectors match (no compound selectors like `.light-mode .foo`).
*/
export declare const getColorModesFromSelectors: (text: string) => ("light" | "dark")[];
/**
* Parses a single custom property value from the stylesheet into a normalized form:
* - `#RRGGBB` / `#RRGGBBAA` (uppercase)
* - `#RGB` short hex expanded to six digits
* - `rgb()` / `rgba()` with comma-separated channels (lower input only)
* - `var(--x)` / `var(--x, fallback)` returned as-is for a later resolve pass
*/
export declare const parseVariableValue: (value: string) => string | undefined;
/**
* Recursively resolves a value if it is (or becomes) `var(--name)` against `variables`.
* Missing names or non-var values are returned unchanged.
*/
export declare const resolveVariableValue: (value: string, variables: Record<string, string>) => string;
/**
* Resolves `var(--*)` values in a flat map of custom properties in one pass.
* Values that are not var references are copied through.
*/
export declare const resolveVariables: (variables: Record<string, string>) => Record<string, string>;
/**
* Extracts CSS custom properties (variables) from a given CSS string
* for .light-mode and .dark-mode selectors and returns an object
* with 'light' and 'dark' keys containing the filtered variables.
*
* @param css - The CSS string to parse.
* @returns An object with `light` and `dark` properties containing the extracted CSS variables.
*/
export declare const loadCssVariables: (css: string) => Promise<{
light: Record<string, string>;
dark: Record<string, string>;
}>;
//# sourceMappingURL=load-css-variables.d.ts.map
{"version":3,"file":"load-css-variables.d.ts","sourceRoot":"","sources":["../../src/theme/load-css-variables.ts"],"names":[],"mappings":"AAkBA;;;GAGG;AACH,eAAO,MAAM,0BAA0B,GAAI,MAAM,MAAM,yBActD,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,kBAAkB,GAAI,OAAO,MAAM,uBA6B/C,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,oBAAoB,GAAI,OAAO,MAAM,EAAE,WAAW,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAG,MAcvF,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,gBAAgB,GAAI,WAAW,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAUzF,CAAA;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,gBAAgB,GAAU,KAAK,MAAM;;;EAsCjD,CAAA"}
/**
* Patterns for CSS color values we can normalize to hex (or pass through as var()).
* Space-separated rgb() and modern slash syntax are not supported here.
*/
const hexShortRegex = /^#([0-9a-fA-F]){3}$/;
const hexRegex = /^#([0-9a-fA-F]{6})$/;
const hexAlphaRegex = /^#([0-9a-fA-F]{8})$/;
const rgbRegex = /^rgb\(\s*(\d{1,3})\s*,?\s*(\d{1,3})\s*,?\s*(\d{1,3})\s*\)$/;
const rgbaRegex = /^rgba\(\s*(\d{1,3})\s*,?\s*(\d{1,3})\s*,?\s*(\d{1,3})\s*,?\s*(\d*\.?\d+)\s*\)$/;
/** Optional fallback: var(--name, fallback) */
const varRegex = /^var\(\s*(--[^)]+)\s*(?:,\s*([^)]*))?\s*\)$/i;
/**
* Maps a comma-separated selector list to the theme modes it applies to.
* Only **exact** `.light-mode` or `.dark-mode` selectors match (no compound selectors like `.light-mode .foo`).
*/
export const getColorModesFromSelectors = (text) => {
const selectors = text.split(',').map((selector) => selector.trim());
return selectors
.map((selector) => {
if (selector === '.light-mode') {
return 'light';
}
if (selector === '.dark-mode') {
return 'dark';
}
return null;
})
.filter((mode) => mode !== null);
};
/**
* Parses a single custom property value from the stylesheet into a normalized form:
* - `#RRGGBB` / `#RRGGBBAA` (uppercase)
* - `#RGB` short hex expanded to six digits
* - `rgb()` / `rgba()` with comma-separated channels (lower input only)
* - `var(--x)` / `var(--x, fallback)` returned as-is for a later resolve pass
*/
export const parseVariableValue = (value) => {
const normalized = value.trim().toLowerCase();
if (hexRegex.test(normalized) || hexAlphaRegex.test(normalized)) {
return normalized.toUpperCase();
}
if (hexShortRegex.test(normalized)) {
const [_, r, g, b] = normalized.toUpperCase();
return `#${r}${r}${g}${g}${b}${b}`;
}
const rgbaMatch = rgbaRegex.exec(normalized);
const rgbMatch = rgbaMatch ?? rgbRegex.exec(normalized);
if (rgbMatch) {
const [_, r = '0', g = '0', b = '0', a = '1'] = rgbMatch;
const toHex = (v) => Number.parseInt(v, 10).toString(16).padStart(2, '0').toUpperCase();
const alpha = Math.round(Number.parseFloat(a) * 255);
return `#${toHex(r)}${toHex(g)}${toHex(b)}${alpha === 255 ? '' : toHex(String(alpha))}`;
}
if (varRegex.test(normalized)) {
return normalized;
}
return undefined;
};
/**
* Recursively resolves a value if it is (or becomes) `var(--name)` against `variables`.
* Missing names or non-var values are returned unchanged.
*/
export const resolveVariableValue = (value, variables) => {
const varMatch = varRegex.exec(value);
if (varMatch) {
const [_, varName] = varMatch;
if (!varName) {
return value;
}
const resolved = variables[varName];
if (!resolved) {
return value;
}
return resolveVariableValue(resolved, variables);
}
return value;
};
/**
* Resolves `var(--*)` values in a flat map of custom properties in one pass.
* Values that are not var references are copied through.
*/
export const resolveVariables = (variables) => {
const entries = Object.entries(variables);
const resolved = entries.map(([name, value]) => {
if (varRegex.test(value)) {
return [name, resolveVariableValue(value, variables)];
}
return [name, value];
});
return Object.fromEntries(resolved);
};
/**
* Extracts CSS custom properties (variables) from a given CSS string
* for .light-mode and .dark-mode selectors and returns an object
* with 'light' and 'dark' keys containing the filtered variables.
*
* @param css - The CSS string to parse.
* @returns An object with `light` and `dark` properties containing the extracted CSS variables.
*/
export const loadCssVariables = async (css) => {
const sheet = new CSSStyleSheet();
await sheet.replace(css);
const cssRules = Array.from(sheet.cssRules).filter((cssRule) => cssRule instanceof CSSStyleRule);
const parsed = cssRules.reduce((variables, cssRule) => {
const colorModes = getColorModesFromSelectors(cssRule.selectorText);
if (!colorModes.length) {
return variables;
}
// Collect valid CSS variable declarations from the rule's style
const styles = Array.from(cssRule.style).reduce((style, name) => {
if (!name.startsWith('--')) {
return style;
}
const value = cssRule.style.getPropertyValue(name);
const parsedValue = parseVariableValue(value);
if (parsedValue) {
style[name] = parsedValue;
}
return style;
}, {});
colorModes.forEach((colorMode) => {
variables[colorMode] = { ...variables[colorMode], ...styles };
});
return variables;
}, { light: {}, dark: {} });
return {
light: resolveVariables(parsed.light),
dark: resolveVariables(parsed.dark),
};
};
import { type Result } from './result.js';
/**
* Run a function and capture any thrown error as a {@link Result} so callers
* never have to write `try/catch` themselves.
*
* **Overload:** when `fn` returns a `Promise`, the return type is
* `Promise<Result<T>>` and rejections become `err(message)` — the promise
* never rejects.
*
* **Overload:** when `fn` returns a plain value synchronously, the return type
* is `Result<T>` with no promise wrapper. You may still `await` that value;
* it behaves like an immediate result.
*
* Failures are logged with `console.error` so unexpected errors stay visible
* in devtools while the caller decides how to present them to the user.
*
* @example Async (always `await` the outer promise)
* const outcome = await safeRun(() => fetcher())
* isLoading.value = false
* if (!outcome.ok) {
* toast(outcome.error, 'error')
* return
* }
* useData(outcome.data)
*
* @example Sync (no `await` required)
* const outcome = safeRun(() => JSON.parse(raw))
* if (!outcome.ok) {
* toast(outcome.error, 'error')
* return
* }
* useData(outcome.data)
*/
export declare function safeRun<T>(fn: () => Promise<T>): Promise<Result<T>>;
export declare function safeRun<T>(fn: () => T): Result<T>;
//# sourceMappingURL=safe-run.d.ts.map
{"version":3,"file":"safe-run.d.ts","sourceRoot":"","sources":["../../src/types/safe-run.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,MAAM,EAAW,MAAM,UAAU,CAAA;AAI/C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,OAAO,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAA;AACpE,wBAAgB,OAAO,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,CAAA"}
import { err, ok } from './result.js';
const formatCaughtError = (error) => (error instanceof Error ? error.message : String(error));
export function safeRun(fn) {
try {
const out = fn();
if (out instanceof Promise) {
return out.then((data) => ok(data), (error) => {
console.error(error);
return err(formatCaughtError(error));
});
}
return ok(out);
}
catch (error) {
console.error(error);
return err(formatCaughtError(error));
}
}
+72
-0
# @scalar/helpers
## 0.8.0
### Minor Changes
- [#9211](https://github.com/scalar/scalar/pull/9211): feat: add compareVersions and serializeReleaseNotes helpers
- [#9211](https://github.com/scalar/scalar/pull/9211): feat: support media attachments for the changelog modal
- [#9211](https://github.com/scalar/scalar/pull/9211): feat(helpers): add `setValueAtPath` for path-array-based nested object writes
### Patch Changes
- [#9211](https://github.com/scalar/scalar/pull/9211): fix(api-client): block invalid request URLs before send and surface `buildRequest` failures as results
Request construction now treats a bad merged URL as a first-class failure instead of throwing deep inside helpers. After `mergeUrls`, `resolveRequestFactoryUrl` rejects incomplete targets when strict mode applies: relative URLs, an empty server base, or path strings that still contain unresolved `{{variable}}` placeholders. Callers may set `allowMissingRequestServerBase` where a full absolute URL is intentionally optional (for example the embedded modal layout in `OperationBlock`, or API Reference `onBeforeRequest` hooks that build against the document origin).
`buildRequest` returns a `Result` (`ok` / `err`) with stable error codes such as `MISSING_REQUEST_SERVER_BASE`, `INVALID_REQUEST_FACTORY_URL`, and `BUILD_REQUEST_FAILED` for unexpected synchronous failures. Those failures are wrapped with `safeRun` from `@scalar/helpers`, which logs to `console.error` and maps throws to a string message on the result. The API Reference plugin path logs and skips `onBeforeRequest` when a preview request cannot be built, so user hooks never run against a half-built fetch payload.
Downstream packages (`api-client`, `api-reference`, `scalar-app` where applicable) unwrap the result, show toasts or logs, and avoid calling `sendRequest` until the URL is valid.
- [#9211](https://github.com/scalar/scalar/pull/9211): fix(helpers): show friendly error message for relative / non-http URLs in the API client
- [#9211](https://github.com/scalar/scalar/pull/9211): feat: better server extraction from partial urls
- [#9211](https://github.com/scalar/scalar/pull/9211): fix(api-client): request body content types — OpenAPI extras, MIME labels, and "Other" without auto Content-Type
The request body dropdown lists built-in types first, then any additional media types from the OpenAPI operation. Labels use the MIME essence (no `charset` in the label). The **Other** option is available again for a raw body: it does **not** add an automatic `Content-Type` header (users can set one manually). Code snippets avoid injecting `Content-Type: other`.
`getDefaultHeaders` and `filterDisabledDefaultHeaders` are exported from `@scalar/workspace-store/request-example`; the API client uses them for code snippets instead of a duplicate helper.
- [#9211](https://github.com/scalar/scalar/pull/9211): feat(helpers): add `normalizationForm` and `stripAccents` options to `slugify` and `slugger`
- `normalizationForm` (`'NFC' | 'NFD' | 'NFKC' | 'NFKD'`, default `'NFC'`) passes the chosen form to `String.prototype.normalize()` before slugifying.
- `stripAccents` (`boolean`, default `false`) decomposes accented letters via NFD and removes all Unicode combining marks so e.g. `"Crème Brûlée"` becomes `"creme-brulee"`. Takes precedence over `normalizationForm`.
- [#9211](https://github.com/scalar/scalar/pull/9211): fix: forward selected forbidden headers via `X-Scalar-*` when proxying
Browsers strip selected forbidden headers from outgoing requests. When using the Scalar proxy (or running in Electron), we now rewrite a small allowlist (`Date`, `DNT`, and `Referer`) to `X-Scalar-*` headers so the proxy can forward the intended upstream headers without opening support for the full forbidden-header set.
- [#9211](https://github.com/scalar/scalar/pull/9211): Add `@scalar/helpers/theme/load-css-variables` for parsing Scalar theme CSS into light/dark custom property maps (moved from scalar-app).
## 0.7.0
### Minor Changes
- [#9063](https://github.com/scalar/scalar/pull/9063): feat: add compareVersions and serializeReleaseNotes helpers
- [#9167](https://github.com/scalar/scalar/pull/9167): feat: support media attachments for the changelog modal
- [#9055](https://github.com/scalar/scalar/pull/9055): feat(helpers): add `setValueAtPath` for path-array-based nested object writes
### Patch Changes
- [#9184](https://github.com/scalar/scalar/pull/9184): fix(api-client): block invalid request URLs before send and surface `buildRequest` failures as results
Request construction now treats a bad merged URL as a first-class failure instead of throwing deep inside helpers. After `mergeUrls`, `resolveRequestFactoryUrl` rejects incomplete targets when strict mode applies: relative URLs, an empty server base, or path strings that still contain unresolved `{{variable}}` placeholders. Callers may set `allowMissingRequestServerBase` where a full absolute URL is intentionally optional (for example the embedded modal layout in `OperationBlock`, or API Reference `onBeforeRequest` hooks that build against the document origin).
`buildRequest` returns a `Result` (`ok` / `err`) with stable error codes such as `MISSING_REQUEST_SERVER_BASE`, `INVALID_REQUEST_FACTORY_URL`, and `BUILD_REQUEST_FAILED` for unexpected synchronous failures. Those failures are wrapped with `safeRun` from `@scalar/helpers`, which logs to `console.error` and maps throws to a string message on the result. The API Reference plugin path logs and skips `onBeforeRequest` when a preview request cannot be built, so user hooks never run against a half-built fetch payload.
Downstream packages (`api-client`, `api-reference`, `scalar-app` where applicable) unwrap the result, show toasts or logs, and avoid calling `sendRequest` until the URL is valid.
- [#9157](https://github.com/scalar/scalar/pull/9157): fix(helpers): show friendly error message for relative / non-http URLs in the API client
- [#9200](https://github.com/scalar/scalar/pull/9200): feat: better server extraction from partial urls
- [#9134](https://github.com/scalar/scalar/pull/9134): fix(api-client): request body content types — OpenAPI extras, MIME labels, and "Other" without auto Content-Type
The request body dropdown lists built-in types first, then any additional media types from the OpenAPI operation. Labels use the MIME essence (no `charset` in the label). The **Other** option is available again for a raw body: it does **not** add an automatic `Content-Type` header (users can set one manually). Code snippets avoid injecting `Content-Type: other`.
`getDefaultHeaders` and `filterDisabledDefaultHeaders` are exported from `@scalar/workspace-store/request-example`; the API client uses them for code snippets instead of a duplicate helper.
- [#9151](https://github.com/scalar/scalar/pull/9151): feat(helpers): add `normalizationForm` and `stripAccents` options to `slugify` and `slugger`
- `normalizationForm` (`'NFC' | 'NFD' | 'NFKC' | 'NFKD'`, default `'NFC'`) passes the chosen form to `String.prototype.normalize()` before slugifying.
- `stripAccents` (`boolean`, default `false`) decomposes accented letters via NFD and removes all Unicode combining marks so e.g. `"Crème Brûlée"` becomes `"creme-brulee"`. Takes precedence over `normalizationForm`.
- [#9035](https://github.com/scalar/scalar/pull/9035): fix: forward selected forbidden headers via `X-Scalar-*` when proxying
Browsers strip selected forbidden headers from outgoing requests. When using the Scalar proxy (or running in Electron), we now rewrite a small allowlist (`Date`, `DNT`, and `Referer`) to `X-Scalar-*` headers so the proxy can forward the intended upstream headers without opening support for the full forbidden-header set.
- [#9133](https://github.com/scalar/scalar/pull/9133): Add `@scalar/helpers/theme/load-css-variables` for parsing Scalar theme CSS into light/dark custom property maps (moved from scalar-app).
## 0.6.0

@@ -4,0 +76,0 @@

+1
-0

@@ -12,2 +12,3 @@ /**

readonly INVALID_URL: "The URL seems to be invalid. Try adding a valid URL.";
readonly INVALID_URL_PROTOCOL: "The URL must start with http:// or https://.";
readonly INVALID_HEADER: "There is an invalid header present, please double check your params.";

@@ -14,0 +15,0 @@ readonly MISSING_FILE: "File uploads are not saved in history, you must re-upload the file.";

+1
-1

@@ -1,1 +0,1 @@

{"version":3,"file":"normalize-error.d.ts","sourceRoot":"","sources":["../../src/errors/normalize-error.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,MAAM,aAAa,CAAC,YAAY,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,YAAY,CAAC,CAAA;AAE9E,6CAA6C;AAC7C,eAAO,MAAM,MAAM;;;;;;;;;;CAUT,CAAA;AAEV,6DAA6D;AAC7D,eAAO,MAAM,kBAAkB,GAAI,SAAS,MAAM,WAiBjD,CAAA;AAED,qDAAqD;AACrD,eAAO,MAAM,cAAc,GAAI,GAAG,OAAO,EAAE,iBAAgB,MAAuB,KAAG,KAcpF,CAAA"}
{"version":3,"file":"normalize-error.d.ts","sourceRoot":"","sources":["../../src/errors/normalize-error.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,MAAM,aAAa,CAAC,YAAY,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,YAAY,CAAC,CAAA;AAE9E,6CAA6C;AAC7C,eAAO,MAAM,MAAM;;;;;;;;;;;CAWT,CAAA;AAEV,6DAA6D;AAC7D,eAAO,MAAM,kBAAkB,GAAI,SAAS,MAAM,WAyBjD,CAAA;AAED,qDAAqD;AACrD,eAAO,MAAM,cAAc,GAAI,GAAG,OAAO,EAAE,iBAAgB,MAAuB,KAAG,KAcpF,CAAA"}

@@ -6,2 +6,3 @@ /** Centralized list of all error messages */

INVALID_URL: 'The URL seems to be invalid. Try adding a valid URL.',
INVALID_URL_PROTOCOL: 'The URL must start with http:// or https://.',
INVALID_HEADER: 'There is an invalid header present, please double check your params.',

@@ -24,2 +25,9 @@ MISSING_FILE: 'File uploads are not saved in history, you must re-upload the file.',

}
// Invalid URL protocol (thrown by undici in the Electron main process when
// a non-http(s) URL is sent — e.g. a relative path). The message may arrive
// wrapped by Electron's IPC layer (`Error invoking remote method ...`), so
// match on substring rather than equality.
if (message.includes('Invalid URL protocol')) {
return ERRORS.INVALID_URL_PROTOCOL;
}
// Invalid Header

@@ -26,0 +34,0 @@ if (message === `Failed to execute 'fetch' on 'Window': Invalid name`) {

/**
* Content types that we automatically add in the client
* Built-in content types that we offer as primary choices in the request body content type dropdown.
*
* Additional content types defined on the OpenAPI request body (for example `text/csv` or `application/pdf`)
* are appended dynamically at the UI level and do not need to be listed here.
*/

@@ -12,2 +15,5 @@ export declare const CONTENT_TYPES: {

readonly 'application/edn': "EDN";
/**
* Raw body without an automatic `Content-Type` header (user may set one manually).
*/
readonly other: "Other";

@@ -14,0 +20,0 @@ readonly none: "None";

@@ -1,1 +0,1 @@

{"version":3,"file":"content-types.d.ts","sourceRoot":"","sources":["../../src/http/content-types.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,eAAO,MAAM,aAAa;;;;;;;;;;CAUhB,CAAA"}
{"version":3,"file":"content-types.d.ts","sourceRoot":"","sources":["../../src/http/content-types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,eAAO,MAAM,aAAa;;;;;;;;IAQxB;;OAEG;;;CAGK,CAAA"}
/**
* Content types that we automatically add in the client
* Built-in content types that we offer as primary choices in the request body content type dropdown.
*
* Additional content types defined on the OpenAPI request body (for example `text/csv` or `application/pdf`)
* are appended dynamically at the UI level and do not need to be listed here.
*/

@@ -12,4 +15,7 @@ export const CONTENT_TYPES = {

'application/edn': 'EDN',
/**
* Raw body without an automatic `Content-Type` header (user may set one manually).
*/
'other': 'Other',
'none': 'None',
};
export declare const X_SCALAR_COOKIE = "x-scalar-cookie";
export declare const X_SCALAR_SET_COOKIE = "x-scalar-set-cookie";
export declare const X_SCALAR_USER_AGENT = "x-scalar-user-agent";
export declare const X_SCALAR_DATE = "x-scalar-date";
export declare const X_SCALAR_DNT = "x-scalar-dnt";
export declare const X_SCALAR_REFERER = "x-scalar-referer";
//# sourceMappingURL=scalar-headers.d.ts.map

@@ -1,1 +0,1 @@

{"version":3,"file":"scalar-headers.d.ts","sourceRoot":"","sources":["../../src/http/scalar-headers.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,eAAe,oBAAoB,CAAA;AAChD,eAAO,MAAM,mBAAmB,wBAAwB,CAAA;AACxD,eAAO,MAAM,mBAAmB,wBAAwB,CAAA"}
{"version":3,"file":"scalar-headers.d.ts","sourceRoot":"","sources":["../../src/http/scalar-headers.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,eAAe,oBAAoB,CAAA;AAChD,eAAO,MAAM,mBAAmB,wBAAwB,CAAA;AACxD,eAAO,MAAM,mBAAmB,wBAAwB,CAAA;AACxD,eAAO,MAAM,aAAa,kBAAkB,CAAA;AAC5C,eAAO,MAAM,YAAY,iBAAiB,CAAA;AAC1C,eAAO,MAAM,gBAAgB,qBAAqB,CAAA"}
export const X_SCALAR_COOKIE = 'x-scalar-cookie';
export const X_SCALAR_SET_COOKIE = 'x-scalar-set-cookie';
export const X_SCALAR_USER_AGENT = 'x-scalar-user-agent';
export const X_SCALAR_DATE = 'x-scalar-date';
export const X_SCALAR_DNT = 'x-scalar-dnt';
export const X_SCALAR_REFERER = 'x-scalar-referer';

@@ -0,1 +1,9 @@

/**
* Unicode normalization forms used by `String.prototype.normalize()`:
* - `NFC`: canonical decomposition followed by recomposition (default for most text).
* - `NFD`: canonical decomposition (splits accents from base letters).
* - `NFKC`: compatibility decomposition followed by recomposition (folds ligatures and width variants).
* - `NFKD`: compatibility decomposition (like `NFKC` without recomposition).
*/
export type NormalizationForm = 'NFC' | 'NFD' | 'NFKC' | 'NFKD';
export type SlugifyOptions = {

@@ -15,2 +23,22 @@ /**

preserveCase?: boolean;
/**
* Unicode normalization form applied to the input before slugifying.
* Has no effect when `stripAccents` is `true`, which always uses NFD
* internally to decompose accented letters before stripping them.
* @default 'NFC'
* @example slugify('file', { normalizationForm: 'NFKC' }) // 'file' (ligature → two letters)
*/
normalizationForm?: NormalizationForm;
/**
* When `true`, strips combining diacritical marks (accents) from letters,
* producing ASCII-friendly slugs from accented text.
*
* Internally normalizes to NFD so that base letters and their accent marks
* become separate code points, then removes all Unicode combining marks
* (`\p{M}`). This takes precedence over `normalizationForm`.
*
* @default false
* @example slugify('Crème Brûlée', { stripAccents: true }) // 'creme-brulee'
*/
stripAccents?: boolean;
};

@@ -26,8 +54,10 @@ /**

*
* | Option | Type | Default | Description |
* |----------------------|------------|---------|----------------------------------------------------------------------------------------------|
* | `allowedSpecialChars`| `string` | `""` | Extra characters that should survive the non-word filter (e.g. `"."` keeps dots so `"v1.2"` → `"v1.2"` instead of `"v12"`). |
* | `preserveCase` | `boolean` | `false` | When `true`, the case is preserved. By default we lowercase the string |
* | Option | Type | Default | Description |
* |----------------------|---------------------|---------|------------------------------------------------------------------------------------------------------------------------ |
* | `allowedSpecialChars`| `string` | `""` | Extra characters that should survive the non-word filter (e.g. `"."` keeps dots so `"v1.2"` → `"v1.2"` instead of `"v12"`). |
* | `preserveCase` | `boolean` | `false` | When `true`, the case is preserved. By default we lowercase the string. |
* | `normalizationForm` | `NormalizationForm` | `'NFC'` | Unicode normalization form to apply. Ignored when `stripAccents` is `true`. |
* | `stripAccents` | `boolean` | `false` | When `true`, strips diacritical marks so e.g. `"Crème"` → `"creme"`. Takes precedence over `normalizationForm`. |
*/
export declare const slugify: (v: string, options?: SlugifyOptions) => string;
//# sourceMappingURL=slugify.d.ts.map

@@ -1,1 +0,1 @@

{"version":3,"file":"slugify.d.ts","sourceRoot":"","sources":["../../src/string/slugify.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,cAAc,GAAG;IAC3B;;;;;OAKG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAA;IAC5B;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;CACvB,CAAA;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,OAAO,GAAI,GAAG,MAAM,EAAE,UAAS,cAAmB,WA2B9D,CAAA"}
{"version":3,"file":"slugify.d.ts","sourceRoot":"","sources":["../../src/string/slugify.ts"],"names":[],"mappings":"AAcA;;;;;;GAMG;AACH,MAAM,MAAM,iBAAiB,GAAG,KAAK,GAAG,KAAK,GAAG,MAAM,GAAG,MAAM,CAAA;AAE/D,MAAM,MAAM,cAAc,GAAG;IAC3B;;;;;OAKG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAA;IAC5B;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB;;;;;;OAMG;IACH,iBAAiB,CAAC,EAAE,iBAAiB,CAAA;IACrC;;;;;;;;;;OAUG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;CACvB,CAAA;AAED;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,OAAO,GAAI,GAAG,MAAM,EAAE,UAAS,cAAmB,WAiC9D,CAAA"}
const RE_NON_WORD = /[^\p{L}\p{M}\p{N}\s_-]/gu;
const RE_SPACES = /[\s_-]+/g;
const RE_TRIM_HYPHENS = /^-+|-+$/g;
/**
* Matches all Unicode combining marks (accents, diacritics, etc.).
* Used with NFD-decomposed text so base letters and their marks are
* separate code points and the marks can be dropped cleanly.
*/
const RE_COMBINING_MARKS = /\p{M}/gu;
/** Cache of compiled non-word regexes keyed by their `allowedSpecialChars` string. */

@@ -15,9 +21,11 @@ const reNonWordCache = new Map();

*
* | Option | Type | Default | Description |
* |----------------------|------------|---------|----------------------------------------------------------------------------------------------|
* | `allowedSpecialChars`| `string` | `""` | Extra characters that should survive the non-word filter (e.g. `"."` keeps dots so `"v1.2"` → `"v1.2"` instead of `"v12"`). |
* | `preserveCase` | `boolean` | `false` | When `true`, the case is preserved. By default we lowercase the string |
* | Option | Type | Default | Description |
* |----------------------|---------------------|---------|------------------------------------------------------------------------------------------------------------------------ |
* | `allowedSpecialChars`| `string` | `""` | Extra characters that should survive the non-word filter (e.g. `"."` keeps dots so `"v1.2"` → `"v1.2"` instead of `"v12"`). |
* | `preserveCase` | `boolean` | `false` | When `true`, the case is preserved. By default we lowercase the string. |
* | `normalizationForm` | `NormalizationForm` | `'NFC'` | Unicode normalization form to apply. Ignored when `stripAccents` is `true`. |
* | `stripAccents` | `boolean` | `false` | When `true`, strips diacritical marks so e.g. `"Crème"` → `"creme"`. Takes precedence over `normalizationForm`. |
*/
export const slugify = (v, options = {}) => {
const { allowedSpecialChars = '', preserveCase = false } = options;
const { allowedSpecialChars = '', preserveCase = false, normalizationForm = 'NFC', stripAccents = false } = options;
// Compile the non-word regex once and cache it for future use.

@@ -38,6 +46,10 @@ const reNonWord = (() => {

})();
// Normalize before filtering so equivalent Unicode forms produce the same slug.
const normalized = v.slice(0, 255).trim().normalize('NFC');
const trimmed = v.slice(0, 255).trim();
// NFD decomposes accented letters into base letter + combining mark, so the
// marks can be stripped cleanly with a single regex pass.
const normalized = stripAccents
? trimmed.normalize('NFD').replace(RE_COMBINING_MARKS, '')
: trimmed.normalize(normalizationForm);
const result = preserveCase ? normalized : normalized.toLowerCase();
return result.replace(reNonWord, '').replace(RE_SPACES, '-').replace(RE_TRIM_HYPHENS, '');
};

@@ -22,4 +22,8 @@ /**

* // Returns: ['//api.example.com', '/v1/users']
*
* @example
* extractServer('google.com/v1/users')
* // Returns: ['google.com', '/v1/users']
*/
export declare const extractServerFromPath: (path?: string) => [string, string] | null;
//# sourceMappingURL=extract-server-from-path.d.ts.map

@@ -1,1 +0,1 @@

{"version":3,"file":"extract-server-from-path.d.ts","sourceRoot":"","sources":["../../src/url/extract-server-from-path.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,qBAAqB,GAAI,aAAS,KAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAmCpE,CAAA"}
{"version":3,"file":"extract-server-from-path.d.ts","sourceRoot":"","sources":["../../src/url/extract-server-from-path.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,eAAO,MAAM,qBAAqB,GAAI,aAAS,KAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IA2EpE,CAAA"}

@@ -22,2 +22,6 @@ /**

* // Returns: ['//api.example.com', '/v1/users']
*
* @example
* extractServer('google.com/v1/users')
* // Returns: ['google.com', '/v1/users']
*/

@@ -28,2 +32,41 @@ export const extractServerFromPath = (path = '') => {

}
/**
* Handle bare-hostname URLs without a protocol (e.g., "google.com/path").
* Only treat the input as a server when the candidate host contains a dot,
* so plain path-only inputs like "api/v1/users" stay paths.
*
* The protocol check is scoped to the start of the string so that inputs
* like "api.example.com/auth?redirect=https://app.com/cb" are still treated
* as bare hostnames rather than being skipped because of a "://" inside the
* query string, path, or fragment.
*/
if (!path.startsWith('/') && !/^[a-z][a-z0-9+\-.]*:\/\//i.test(path)) {
const hostEnd = path.search(/[/?#]/);
const host = hostEnd === -1 ? path : path.slice(0, hostEnd);
if (host.includes('.')) {
try {
const url = new URL(`https://${path}`);
if (url.origin === 'null') {
return null;
}
/**
* Preserve any explicit port from the input. The URL API strips the
* default port for the parsing scheme (443 for https), which would
* otherwise lose information the user explicitly provided. Since the
* actual protocol is unknown for bare hostnames, we must not apply
* protocol-dependent port normalization.
*/
let origin = url.host;
const explicitPortMatch = host.match(/:(\d+)$/);
if (explicitPortMatch && !url.port) {
origin = `${url.hostname}:${explicitPortMatch[1]}`;
}
const remainingPath = decodeURIComponent(url.pathname) + url.search + url.hash;
return [origin, remainingPath];
}
catch {
return null;
}
}
}
/** Handle protocol-relative URLs (e.g., "//api.example.com") */

@@ -30,0 +73,0 @@ if (path.startsWith('//')) {

@@ -17,3 +17,3 @@ {

],
"version": "0.6.0",
"version": "0.8.0",
"engines": {

@@ -106,2 +106,7 @@ "node": ">=22"

},
"./theme/*": {
"import": "./dist/theme/*.js",
"types": "./dist/theme/*.d.ts",
"default": "./dist/theme/*.js"
},
"./types/*": {

@@ -126,3 +131,4 @@ "import": "./dist/types/*.js",

"jsdom": "27.4.0",
"vitest": "4.1.0"
"vitest": "4.1.0",
"@scalar/themes": "0.15.5"
},

@@ -129,0 +135,0 @@ "scripts": {