@scalar/helpers
Advanced tools
| /** | ||
| * 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 = ``; | ||
| 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 @@ |
@@ -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 +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('//')) { |
+8
-2
@@ -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": { |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
266756
19.14%231
6.94%5685
14.96%4
33.33%