From 59a43db9c2e21a9dc3c19c5029624d87695dcc20 Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Mon, 9 Feb 2026 13:12:51 +0700 Subject: [PATCH 1/6] feat(builder): re-implement style, link and script api (#38) * feat(script): split script url or source and other attributes * feat(style): split style css and other attributes * docs: add basic usage for script and style * feat(link): extract href as dedicated parameter in addLink * fix(script): we can't using relative url * docs: adjust addScript args name --- .changeset/goofy-groups-post.md | 5 +++ .changeset/pink-pumas-judge.md | 5 +++ .changeset/whole-badgers-slide.md | 5 +++ README.md | 41 ++++++++++++++++---- src/builder.ts | 62 +++++++++++++++++++++++-------- 5 files changed, 96 insertions(+), 22 deletions(-) create mode 100644 .changeset/goofy-groups-post.md create mode 100644 .changeset/pink-pumas-judge.md create mode 100644 .changeset/whole-badgers-slide.md diff --git a/.changeset/goofy-groups-post.md b/.changeset/goofy-groups-post.md new file mode 100644 index 0000000..1839a5f --- /dev/null +++ b/.changeset/goofy-groups-post.md @@ -0,0 +1,5 @@ +--- +'@devsantara/head': minor +--- + +feat(script): extract src or inline source as dedicated parameter in addScript diff --git a/.changeset/pink-pumas-judge.md b/.changeset/pink-pumas-judge.md new file mode 100644 index 0000000..d7b5c42 --- /dev/null +++ b/.changeset/pink-pumas-judge.md @@ -0,0 +1,5 @@ +--- +'@devsantara/head': minor +--- + +feat(link): extract href as dedicated parameter in addLink diff --git a/.changeset/whole-badgers-slide.md b/.changeset/whole-badgers-slide.md new file mode 100644 index 0000000..9d5080a --- /dev/null +++ b/.changeset/whole-badgers-slide.md @@ -0,0 +1,5 @@ +--- +'@devsantara/head': minor +--- + +feat(style): extract inline css as dedicated parameter in addStyle diff --git a/README.md b/README.md index 4cdb358..f9780ce 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,12 @@ import { HeadBuilder } from '@devsantara/head'; const head = new HeadBuilder() .addTitle('My Awesome Website') .addDescription('A comprehensive guide to web development') + .addStyle('body { margin: 0; padding: 0; }') .addViewport({ width: 'device-width', initialScale: 1 }) + .addScript({ code: 'console.log("Hello, world!");' }) + .addScript(new URL('https://devsantara.com/assets/scripts/utils.js'), { + async: true, + }) .build(); ``` @@ -85,6 +90,28 @@ const head = new HeadBuilder() content: 'width=device-width, initial-scale=1', }, }, + { + type: 'style', + attributes: { + type: 'text/css', + children: 'body { margin: 0; padding: 0; }', + }, + }, + { + type: 'script', + attributes: { + type: 'text/javascript', + children: 'console.log("Hello, world!");', + }, + }, + { + type: 'script', + attributes: { + type: 'text/javascript', + src: 'https://devsantara.com/assets/scripts/utils.js', + async: true, + }, + }, ]; ``` @@ -222,13 +249,13 @@ export const Route = createRootRoute({ For advanced use cases not covered by the essential methods below, use these basic methods to add any custom element directly. -| Method | Description | -| ------------------------------------------------------- | ------------------------------------------------ | -| `addTitle(title: string)` | Adds a `` element | -| `addMeta(attributes: HeadAttributeTypeMap['meta'])` | Adds a `<meta>` element with custom attributes | -| `addLink(attributes: HeadAttributeTypeMap['link'])` | Adds a `<link>` element with custom attributes | -| `addScript(attributes: HeadAttributeTypeMap['script'])` | Adds a `<script>` element with custom attributes | -| `addStyle(attributes: HeadAttributeTypeMap['style'])` | Adds a `<style>` element with custom attributes | +| Method | Description | +| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| `addTitle(title: string)` | Adds a `<title>` element | +| `addMeta(attributes: HeadAttributeTypeMap['meta'])` | Adds a `<meta>` element with custom attributes | +| `addLink(href: string \| URL, attributes?)` | Adds a `<link>` element with a URL and custom attributes | +| `addScript(srcOrCode: string \| URL \| { code: string }, attributes?)` | Adds a `<script>` element (external file with string/URL or inline with `{ code: string }`) | +| `addStyle(css: string, attributes?)` | Adds a `<style>` element with inline CSS | ### Essential Methods diff --git a/src/builder.ts b/src/builder.ts index c8cf6a0..0825c99 100644 --- a/src/builder.ts +++ b/src/builder.ts @@ -134,48 +134,80 @@ export class HeadBuilder<TOutput = HeadElement[]> { } /** - * Adds a custom link element with any valid attributes. Use this for link tags without dedicated helper methods. + * Adds a custom link element with any valid attributes. * - * @param attributes - The link element attributes + * @param href - The URL to link to + * @param attributes - Additional link element attributes * @returns The builder instance for method chaining * * @example * new HeadBuilder() - * .addLink({ rel: 'preconnect', href: 'https://fonts.googleapis.com' }) + * .addLink('https://fonts.googleapis.com', { rel: 'preconnect' }) * .build(); */ - addLink(attributes: HeadAttributeTypeMap['link']) { - return this.addElement('link', attributes); + addLink( + href: string | URL, + attributes?: Omit<HeadAttributeTypeMap['link'], 'href'>, + ) { + return this.addElement('link', { href: href.toString(), ...attributes }); } /** - * Adds a custom script element with any valid attributes for external scripts or inline code. + * Adds a script element, either inline code or an external file. * - * @param attributes - The script element attributes + * @param srcOrCode - Script source: a URL string/object for external files, or `{ code: string }` for inline scripts + * @param attributes - Additional script attributes (async, defer, integrity, etc.) * @returns The builder instance for method chaining * * @example * new HeadBuilder() - * .addScript({ src: '/analytics.js', async: true }) + * .addScript('/script.js') + * .addScript(new URL('https://example.com/script.js'), { async: true }) + * .addScript({ code: 'console.log("Hello, World!")' }) * .build(); */ - addScript(attributes: HeadAttributeTypeMap['script']) { - return this.addElement('script', attributes); + addScript( + srcOrCode: string | URL | { code: string }, + attributes?: Omit<HeadAttributeTypeMap['script'], 'children' | 'src'>, + ) { + // Inline script with { code: string } + if (typeof srcOrCode === 'object' && 'code' in srcOrCode) { + return this.addElement('script', { + children: srcOrCode.code, + type: 'text/javascript', + ...attributes, + }); + } + + // External script (string or URL) + return this.addElement('script', { + src: srcOrCode.toString(), + type: 'text/javascript', + ...attributes, + }); } /** - * Adds a custom style element with inline CSS. + * Adds an inline style element with CSS code. * - * @param attributes - The style element attributes + * @param css - The inline CSS code + * @param attributes - Additional style attributes * @returns The builder instance for method chaining * * @example * new HeadBuilder() - * .addStyle({ children: 'body { margin: 0; padding: 0; }' }) + * .addStyle('body { margin: 0; padding: 0; }') * .build(); */ - addStyle(attributes: HeadAttributeTypeMap['style']) { - return this.addElement('style', attributes); + addStyle( + css: string, + attributes?: Omit<HeadAttributeTypeMap['style'], 'children'>, + ) { + return this.addElement('style', { + children: css, + type: 'text/css', + ...attributes, + }); } /** From 3a925eb4a2cc0d6b07950f242649c889d33c4f62 Mon Sep 17 00:00:00 2001 From: Edwin Tantawi <edwintantawi.dev@gmail.com> Date: Mon, 9 Feb 2026 13:14:04 +0700 Subject: [PATCH 2/6] feat(builder): implement element deduplication with map (#36) * feat(builder): implement element deduplication with map - Replace array-based element storage with Map for O(1) deduplication - Add getElementKey() method to generate unique keys based on element type and attributes - Elements with same key now replace previous ones instead of duplicating - Update build() method to convert Map to array format * chore(changeset): add changeset * chore(release): update changeset from patch into minor * refactor(builder): rename elements collection name --- .changeset/loose-banks-nail.md | 10 +++++++ src/builder.ts | 50 ++++++++++++++++++++++++++++++---- 2 files changed, 55 insertions(+), 5 deletions(-) create mode 100644 .changeset/loose-banks-nail.md diff --git a/.changeset/loose-banks-nail.md b/.changeset/loose-banks-nail.md new file mode 100644 index 0000000..eb7e5bf --- /dev/null +++ b/.changeset/loose-banks-nail.md @@ -0,0 +1,10 @@ +--- +'@devsantara/head': minor +--- + +feat(builder): implement element deduplication with map + +- Replace array-based element storage with Map for O(1) deduplication +- Add getElementKey() method to generate unique keys based on element type and attributes +- Elements with same key now replace previous ones instead of duplicating +- Update build() method to convert Map to array format diff --git a/src/builder.ts b/src/builder.ts index 0825c99..98d7fee 100644 --- a/src/builder.ts +++ b/src/builder.ts @@ -34,8 +34,12 @@ type BuilderOption<T> = T | ((helper: BuilderHelper) => T); export class HeadBuilder<TOutput = HeadElement[]> { private metadataBase?: URL; - private elements: HeadElement[] = []; private adapter?: HeadAdapter<TOutput>; + /** + * Internal collection of head elements being built, + * stored in a Map for deduplication based on element type and key attributes. + */ + private elementsMap = new Map<string, HeadElement>(); /** * Resolves a value that can be either static or a callback function receiving helper utilities. @@ -104,7 +108,41 @@ export class HeadBuilder<TOutput = HeadElement[]> { } /** - * Adds a head element to the internal collection for later transformation. + * Generates a unique key for a head element based on its type and attributes. + * Used for deduplication - elements with the same key replace previous ones. + * + * @param element - The head element to generate a key for + * @returns A unique string key for the element + */ + private getElementKey({ type, attributes }: HeadElement) { + if (type === 'title') { + return 'title'; + } + if (type === 'meta') { + if ('charSet' in attributes) { + return 'meta:charSet'; + } + if ('name' in attributes && 'content' in attributes) { + return `meta:name:${attributes.name}`; + } + if ('property' in attributes && 'content' in attributes) { + return `meta:property:${attributes.property}`; + } + } + if (type === 'link') { + if (attributes.rel === 'canonical') { + return 'link:canonical'; + } + if (attributes.rel === 'alternate' && 'hrefLang' in attributes) { + return `link:alternate:${attributes.hrefLang}`; + } + } + return JSON.stringify(`${type}:${JSON.stringify(attributes)}`); + } + + /** + * Adds a head element to the internal collection with deduplication. + * Elements with the same key will replace previous ones. * * @param type - The HTML element type * @param attributes - The element's attributes @@ -114,7 +152,8 @@ export class HeadBuilder<TOutput = HeadElement[]> { type: T, attributes: HeadAttributeTypeMap[T], ) { - this.elements.push({ type, attributes }); + const key = this.getElementKey({ type, attributes }); + this.elementsMap.set(key, { type, attributes }); return this; } @@ -669,10 +708,11 @@ export class HeadBuilder<TOutput = HeadElement[]> { * @returns The head configuration in the target format */ build(): TOutput { + const elements = Array.from(this.elementsMap.values()); if (this.adapter) { - return this.adapter.transform(this.elements); + return this.adapter.transform(elements); } // oxlint-disable-next-line typescript/no-unsafe-type-assertion - return this.elements as unknown as TOutput; + return elements as unknown as TOutput; } } From 134dd7820aee06d780691ed17252556facc9db13 Mon Sep 17 00:00:00 2001 From: Edwin Tantawi <edwintantawi.dev@gmail.com> Date: Mon, 9 Feb 2026 13:24:27 +0700 Subject: [PATCH 3/6] feat(title): add templated title support (#37) * feat(title): add templated title support * chore(release): remove extra quote --- .changeset/fast-forks-cough.md | 5 ++++ README.md | 28 ++++++++++++++++++- src/builder.ts | 50 ++++++++++++++++++++++++++++++++-- src/types.ts | 5 ++++ 4 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 .changeset/fast-forks-cough.md diff --git a/.changeset/fast-forks-cough.md b/.changeset/fast-forks-cough.md new file mode 100644 index 0000000..c883898 --- /dev/null +++ b/.changeset/fast-forks-cough.md @@ -0,0 +1,5 @@ +--- +'@devsantara/head': minor +--- + +feat(title): add templated title support diff --git a/README.md b/README.md index f9780ce..f9cd2ef 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,32 @@ const head = new HeadBuilder({ ]; ``` +### With Templated Title + +Set a title template once and dynamically update titles on different pages: + +```typescript +import { HeadBuilder } from '@devsantara/head'; + +// Shared head +const sharedHead = new HeadBuilder().addTitle({ + template: '%s | My Awesome site', // <- Set title template + default: 'Home', +}); + +// Home page +const homeHead = sharedHead; +// Output: <title>Home | My Awesome site + +// Posts page +const postHead = sharedHead.addTitle('Posts').build(); +// Output: Posts | My Awesome site + +// About page +const aboutHead = sharedHead.addTitle('About Us').build(); +// Output: About Us | My Awesome site +``` + ### With React Adapter ```tsx @@ -251,7 +277,7 @@ For advanced use cases not covered by the essential methods below, use these bas | Method | Description | | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | -| `addTitle(title: string)` | Adds a `` element | +| `addTitle(title: string \| TitleOptions)` | Adds a `<title>` element with optional templating | | `addMeta(attributes: HeadAttributeTypeMap['meta'])` | Adds a `<meta>` element with custom attributes | | `addLink(href: string \| URL, attributes?)` | Adds a `<link>` element with a URL and custom attributes | | `addScript(srcOrCode: string \| URL \| { code: string }, attributes?)` | Adds a `<script>` element (external file with string/URL or inline with `{ code: string }`) | diff --git a/src/builder.ts b/src/builder.ts index 98d7fee..7a68fd8 100644 --- a/src/builder.ts +++ b/src/builder.ts @@ -12,6 +12,7 @@ import type { IconOptions, IconPreset, StylesheetOptions, + TitleOptions, } from './types'; /** @@ -33,8 +34,20 @@ interface BuilderHelper { type BuilderOption<T> = T | ((helper: BuilderHelper) => T); export class HeadBuilder<TOutput = HeadElement[]> { + /** + * Optional base URL for resolving relative URLs in metadata (Open Graph, canonical, etc.) + */ private metadataBase?: URL; + /** + * Optional adapter to transform the built head elements into a framework-specific format. + * If not provided, `build()` returns `HeadElement[]` + */ private adapter?: HeadAdapter<TOutput>; + /** + * Internal storage for title options to support templated titles. + * This allows the builder to generate the title dynamically based on previously set options. + */ + private titleOptions?: TitleOptions; /** * Internal collection of head elements being built, * stored in a Map for deduplication based on element type and key attributes. @@ -284,17 +297,48 @@ export class HeadBuilder<TOutput = HeadElement[]> { /** * Adds a title element that appears in browser tabs, search results, and bookmarks. + * Supports both simple string titles and templated titles with dynamic substitution. * - * @param title - The document title text + * @param title - The document title as a string, or TitleOptions object with template and default * @returns The builder instance for method chaining * * @example + * // Simple title * new HeadBuilder() * .addTitle('My Awesome Website') * .build(); + * + * @example + * // Templated title with page-specific suffix + * const baseHead = new HeadBuilder() + * .addTitle({ template: '%s | My Site', default: 'Home' }) + * + * const head = baseHead.addTitle('About Us').build(); // Results in title "About Us | My Site" */ - addTitle(title: string) { - return this.addElement('title', { children: title }); + addTitle(title: string | TitleOptions) { + if (typeof title === 'string') { + /** + * If title is provided as a string and titleOptions with a template exists, + * we generate the title using the template. This allows dynamic title generation based on previously set options. + * If no template is set, we use the raw title string as is. + */ + const titleText = this.titleOptions + ? this.titleOptions.template.replace('%s', title) + : title; + + this.addElement('title', { children: titleText }); + return this; + } + + /** + * If title is provided as an object with template and default, + * we store the options and generate the title using the template with default. + */ + this.titleOptions = title; + this.addElement('title', { + children: this.titleOptions.template.replace('%s', title.default), + }); + return this; } /** diff --git a/src/types.ts b/src/types.ts index c1a38aa..181c209 100644 --- a/src/types.ts +++ b/src/types.ts @@ -88,6 +88,11 @@ export type ColorScheme = | 'normal' | (string & {}); +/** + * Title configuration with support for templated titles using a template string and default title value. + */ +export type TitleOptions = { template: string; default: string }; + /** * Viewport configuration for responsive web design and mobile optimization. */ From dfd1960f775a163c6ce3436591427e90488833e4 Mon Sep 17 00:00:00 2001 From: Edwin Tantawi <edwintantawi.dev@gmail.com> Date: Mon, 9 Feb 2026 13:50:29 +0700 Subject: [PATCH 4/6] refactor(builder): explicitly return this and unify variable names (#39) * refactor(builder): explicitly return this for chaining method * refactor(builder): rename parseValueOrFn return variable --- .changeset/itchy-eagles-give.md | 5 + src/builder.ts | 191 +++++++++++++++++--------------- 2 files changed, 105 insertions(+), 91 deletions(-) create mode 100644 .changeset/itchy-eagles-give.md diff --git a/.changeset/itchy-eagles-give.md b/.changeset/itchy-eagles-give.md new file mode 100644 index 0000000..c16beb1 --- /dev/null +++ b/.changeset/itchy-eagles-give.md @@ -0,0 +1,5 @@ +--- +'@devsantara/head': patch +--- + +refactor(builder): explicitly return this and unify variable names diff --git a/src/builder.ts b/src/builder.ts index 7a68fd8..a4d6490 100644 --- a/src/builder.ts +++ b/src/builder.ts @@ -127,7 +127,7 @@ export class HeadBuilder<TOutput = HeadElement[]> { * @param element - The head element to generate a key for * @returns A unique string key for the element */ - private getElementKey({ type, attributes }: HeadElement) { + private getElementKey({ type, attributes }: HeadElement): string { if (type === 'title') { return 'title'; } @@ -159,15 +159,15 @@ export class HeadBuilder<TOutput = HeadElement[]> { * * @param type - The HTML element type * @param attributes - The element's attributes - * @returns The builder instance for method chaining + * @returns The unique key for the added element */ private addElement<T extends keyof HeadAttributeTypeMap>( type: T, attributes: HeadAttributeTypeMap[T], - ) { + ): string { const key = this.getElementKey({ type, attributes }); this.elementsMap.set(key, { type, attributes }); - return this; + return key; } /** @@ -181,8 +181,9 @@ export class HeadBuilder<TOutput = HeadElement[]> { * .addMeta({ name: 'theme-color', content: '#ffffff' }) * .build(); */ - addMeta(attributes: HeadAttributeTypeMap['meta']) { - return this.addElement('meta', attributes); + addMeta(attributes: HeadAttributeTypeMap['meta']): this { + this.addElement('meta', attributes); + return this; } /** @@ -200,8 +201,9 @@ export class HeadBuilder<TOutput = HeadElement[]> { addLink( href: string | URL, attributes?: Omit<HeadAttributeTypeMap['link'], 'href'>, - ) { - return this.addElement('link', { href: href.toString(), ...attributes }); + ): this { + this.addElement('link', { href: href.toString(), ...attributes }); + return this; } /** @@ -221,22 +223,24 @@ export class HeadBuilder<TOutput = HeadElement[]> { addScript( srcOrCode: string | URL | { code: string }, attributes?: Omit<HeadAttributeTypeMap['script'], 'children' | 'src'>, - ) { + ): this { // Inline script with { code: string } if (typeof srcOrCode === 'object' && 'code' in srcOrCode) { - return this.addElement('script', { + this.addElement('script', { children: srcOrCode.code, type: 'text/javascript', ...attributes, }); + return this; } // External script (string or URL) - return this.addElement('script', { + this.addElement('script', { src: srcOrCode.toString(), type: 'text/javascript', ...attributes, }); + return this; } /** @@ -254,12 +258,13 @@ export class HeadBuilder<TOutput = HeadElement[]> { addStyle( css: string, attributes?: Omit<HeadAttributeTypeMap['style'], 'children'>, - ) { - return this.addElement('style', { + ): this { + this.addElement('style', { children: css, type: 'text/css', ...attributes, }); + return this; } /** @@ -273,8 +278,9 @@ export class HeadBuilder<TOutput = HeadElement[]> { * .addCharSet('utf-8') * .build(); */ - addCharSet(charSet: CharSet) { - return this.addElement('meta', { charSet }); + addCharSet(charSet: CharSet): this { + this.addElement('meta', { charSet }); + return this; } /** @@ -288,11 +294,12 @@ export class HeadBuilder<TOutput = HeadElement[]> { * .addColorScheme('light dark') * .build(); */ - addColorScheme(colorScheme: ColorScheme) { - return this.addElement('meta', { + addColorScheme(colorScheme: ColorScheme): this { + this.addElement('meta', { name: 'color-scheme', content: colorScheme, }); + return this; } /** @@ -315,7 +322,7 @@ export class HeadBuilder<TOutput = HeadElement[]> { * * const head = baseHead.addTitle('About Us').build(); // Results in title "About Us | My Site" */ - addTitle(title: string | TitleOptions) { + addTitle(title: string | TitleOptions): this { if (typeof title === 'string') { /** * If title is provided as a string and titleOptions with a template exists, @@ -352,7 +359,7 @@ export class HeadBuilder<TOutput = HeadElement[]> { * .addViewport({ width: 'device-width', initialScale: 1 }) * .build(); */ - addViewport(options: ViewportOptions) { + addViewport(options: ViewportOptions): this { const contentParts: string[] = []; if (options.width !== undefined) { @@ -380,10 +387,11 @@ export class HeadBuilder<TOutput = HeadElement[]> { contentParts.push(`interactive-widget=${options.interactiveWidget}`); } - return this.addElement('meta', { + this.addElement('meta', { name: 'viewport', content: contentParts.join(', '), }); + return this; } /** @@ -397,11 +405,12 @@ export class HeadBuilder<TOutput = HeadElement[]> { * .addDescription('A comprehensive guide to web development') * .build(); */ - addDescription(description: string) { - return this.addElement('meta', { + addDescription(description: string): this { + this.addElement('meta', { name: 'description', content: description, }); + return this; } /** @@ -415,12 +424,13 @@ export class HeadBuilder<TOutput = HeadElement[]> { * .addCanonical((helper) => helper.resolveUrl('/page')) * .build(); */ - addCanonical(valueOrFn: BuilderOption<string | URL>) { + addCanonical(valueOrFn: BuilderOption<string | URL>): this { const value = this.parseValueOrFn(valueOrFn); - return this.addElement('link', { + this.addElement('link', { rel: 'canonical', href: value.toString(), }); + return this; } /** @@ -434,7 +444,7 @@ export class HeadBuilder<TOutput = HeadElement[]> { * .addRobots({ index: true, follow: true, 'max-snippet': 160 }) * .build(); */ - addRobots(options: RobotsOptions) { + addRobots(options: RobotsOptions): this { const directiveParts: string[] = []; for (const [key, value] of Object.entries(options)) { @@ -451,10 +461,11 @@ export class HeadBuilder<TOutput = HeadElement[]> { } } - return this.addElement('meta', { + this.addElement('meta', { name: 'robots', content: directiveParts.join(', '), }); + return this; } /** @@ -472,74 +483,74 @@ export class HeadBuilder<TOutput = HeadElement[]> { * })) * .build(); */ - addOpenGraph(valueOrFn: BuilderOption<OpenGraphOptions>) { - const options = this.parseValueOrFn(valueOrFn); + addOpenGraph(valueOrFn: BuilderOption<OpenGraphOptions>): this { + const value = this.parseValueOrFn(valueOrFn); // Add basic properties - if (options.title) { - this.addElement('meta', { property: 'og:title', content: options.title }); + if (value.title) { + this.addElement('meta', { property: 'og:title', content: value.title }); } - if (options.description) { + if (value.description) { this.addElement('meta', { property: 'og:description', - content: options.description, + content: value.description, }); } - if (options.url) { + if (value.url) { this.addElement('meta', { property: 'og:url', - content: options.url.toString(), + content: value.url.toString(), }); } - if (options.locale) { + if (value.locale) { this.addElement('meta', { property: 'og:locale', - content: options.locale, + content: value.locale, }); } // Add image properties - if (options.image) { + if (value.image) { this.addElement('meta', { property: 'og:image', - content: options.image.url.toString(), + content: value.image.url.toString(), }); - if (options.image.alt) { + if (value.image.alt) { this.addElement('meta', { property: 'og:image:alt', - content: options.image.alt, + content: value.image.alt, }); } - if (options.image.type) { + if (value.image.type) { this.addElement('meta', { property: 'og:image:type', - content: options.image.type, + content: value.image.type, }); } - if (options.image.width) { + if (value.image.width) { this.addElement('meta', { property: 'og:image:width', - content: options.image.width.toString(), + content: value.image.width.toString(), }); } - if (options.image.height) { + if (value.image.height) { this.addElement('meta', { property: 'og:image:height', - content: options.image.height.toString(), + content: value.image.height.toString(), }); } } // Add type and type-specific properties - if (options.type) { + if (value.type) { this.addElement('meta', { property: 'og:type', - content: options.type.name, + content: value.type.name, }); - if ('properties' in options.type) { - for (const typeProperty of options.type.properties) { + if ('properties' in value.type) { + for (const typeProperty of value.type.properties) { this.addElement('meta', { property: typeProperty.name, content: typeProperty.content, @@ -565,68 +576,68 @@ export class HeadBuilder<TOutput = HeadElement[]> { * })) * .build(); */ - addTwitter(valueOrFn: BuilderOption<TwitterOptions>) { - const options = this.parseValueOrFn(valueOrFn); + addTwitter(valueOrFn: BuilderOption<TwitterOptions>): this { + const value = this.parseValueOrFn(valueOrFn); // Add basic properties - if (options.title) { + if (value.title) { this.addElement('meta', { name: 'twitter:title', - content: options.title, + content: value.title, }); } - if (options.description) { + if (value.description) { this.addElement('meta', { name: 'twitter:description', - content: options.description, + content: value.description, }); } - if (options.site) { - this.addElement('meta', { name: 'twitter:site', content: options.site }); + if (value.site) { + this.addElement('meta', { name: 'twitter:site', content: value.site }); } - if (options.siteId) { + if (value.siteId) { this.addElement('meta', { name: 'twitter:site:id', - content: options.siteId, + content: value.siteId, }); } - if (options.creator) { + if (value.creator) { this.addElement('meta', { name: 'twitter:creator', - content: options.creator, + content: value.creator, }); } - if (options.creatorId) { + if (value.creatorId) { this.addElement('meta', { name: 'twitter:creator:id', - content: options.creatorId, + content: value.creatorId, }); } // Add image properties - if (options.image) { + if (value.image) { this.addElement('meta', { name: 'twitter:image', - content: options.image.url.toString(), + content: value.image.url.toString(), }); - if (options.image.alt) { + if (value.image.alt) { this.addElement('meta', { name: 'twitter:image:alt', - content: options.image.alt, + content: value.image.alt, }); } } // Add card and card-specific properties - if (options.card) { + if (value.card) { this.addElement('meta', { name: 'twitter:card', - content: options.card.name, + content: value.card.name, }); - if ('properties' in options.card) { - for (const cardProperty of options.card.properties) { + if ('properties' in value.card) { + for (const cardProperty of value.card.properties) { this.addElement('meta', { name: cardProperty.name, content: cardProperty.content.toString(), @@ -655,10 +666,10 @@ export class HeadBuilder<TOutput = HeadElement[]> { */ addAlternateLocale<TLocale extends string = string>( valueOrFn: BuilderOption<AlternateLocaleOptions<TLocale>>, - ) { - const options = this.parseValueOrFn(valueOrFn); + ): this { + const value = this.parseValueOrFn(valueOrFn); - for (const [lang, href] of Object.entries(options)) { + for (const [lang, href] of Object.entries(value)) { this.addElement('link', { rel: 'alternate', hrefLang: lang, @@ -680,13 +691,13 @@ export class HeadBuilder<TOutput = HeadElement[]> { * .addManifest((helper) => helper.resolveUrl('/manifest.json')) * .build(); */ - addManifest(valueOrFn: BuilderOption<string | URL>) { - const href = this.parseValueOrFn(valueOrFn); - - return this.addElement('link', { + addManifest(valueOrFn: BuilderOption<string | URL>): this { + const value = this.parseValueOrFn(valueOrFn); + this.addElement('link', { rel: 'manifest', - href: href.toString(), + href: value.toString(), }); + return this; } /** @@ -701,12 +712,13 @@ export class HeadBuilder<TOutput = HeadElement[]> { * .addStylesheet('/styles.css', { media: 'print' }) * .build(); */ - addStylesheet(href: string | URL, options?: StylesheetOptions) { - return this.addElement('link', { + addStylesheet(href: string | URL, options?: StylesheetOptions): this { + this.addElement('link', { rel: 'stylesheet', href: href.toString(), ...options, }); + return this; } /** @@ -724,8 +736,8 @@ export class HeadBuilder<TOutput = HeadElement[]> { * })) * .build(); */ - addIcon(preset: IconPreset, valueOrFn: BuilderOption<IconOptions>) { - const options = this.parseValueOrFn(valueOrFn); + addIcon(preset: IconPreset, valueOrFn: BuilderOption<IconOptions>): this { + const { href, ...value } = this.parseValueOrFn(valueOrFn); // Map preset to rel attribute const relMap: Record<IconPreset, string> = { @@ -733,17 +745,14 @@ export class HeadBuilder<TOutput = HeadElement[]> { icon: 'icon', shortcut: 'shortcut icon', }; - const rel = relMap[preset] || preset; - return this.addElement('link', { + this.addElement('link', { rel, - href: options.href.toString(), - type: options.type, - sizes: options.sizes, - media: options.media, - fetchPriority: options.fetchPriority, + href: href.toString(), + ...value, }); + return this; } /** From 72df9bcdaa75f8703cda73d5b0300a48f669190c Mon Sep 17 00:00:00 2001 From: Edwin Tantawi <edwintantawi.dev@gmail.com> Date: Tue, 10 Feb 2026 01:03:07 +0700 Subject: [PATCH 5/6] test: add test case to cover all codebase (#41) * test: setup vitest with coverage * test(utils): add unit testing for isElementOfType and getChildren * test(vitest): add ui script * test(adapter): properly test adapters * test: add test case to cover all codebase * fix(builder): missing manifest key and remove unused try-catch --- .changeset/frank-books-grow.md | 5 + .changeset/public-turtles-hammer.md | 5 + .gitignore | 3 + package.json | 4 + pnpm-lock.yaml | 421 +++++++++++++----- src/adapters/tests/react-adapter.test.ts | 40 ++ .../tests/tanstack-router-adapter.test.ts | 208 +++++++++ src/builder.ts | 17 +- .../builder-add-alternate-locale.test.ts | 88 ++++ src/tests/builder-add-canonical.test.ts | 56 +++ src/tests/builder-add-charset.test.ts | 19 + src/tests/builder-add-color-scheme.test.ts | 28 ++ src/tests/builder-add-description.test.ts | 21 + src/tests/builder-add-icon.test.ts | 81 ++++ src/tests/builder-add-link.test.ts | 50 +++ src/tests/builder-add-manifest.test.ts | 52 +++ src/tests/builder-add-meta.test.ts | 32 ++ src/tests/builder-add-open-graph.test.ts | 139 ++++++ src/tests/builder-add-robots.test.ts | 70 +++ src/tests/builder-add-script.test.ts | 61 +++ src/tests/builder-add-style.test.ts | 27 ++ src/tests/builder-add-stylesheet.test.ts | 74 +++ src/tests/builder-add-title.test.ts | 55 +++ src/tests/builder-add-twitter.test.ts | 142 ++++++ src/tests/builder-add-viewport.test.ts | 86 ++++ src/tests/builder-element-key.test.ts | 176 ++++++++ src/tests/builder-instance.test.ts | 29 ++ src/tests/builder-resolve-url.test.ts | 148 ++++++ src/tests/utils.test.ts | 56 +++ src/types.ts | 14 +- vitest.config.ts | 10 + 31 files changed, 2090 insertions(+), 127 deletions(-) create mode 100644 .changeset/frank-books-grow.md create mode 100644 .changeset/public-turtles-hammer.md create mode 100644 src/adapters/tests/react-adapter.test.ts create mode 100644 src/adapters/tests/tanstack-router-adapter.test.ts create mode 100644 src/tests/builder-add-alternate-locale.test.ts create mode 100644 src/tests/builder-add-canonical.test.ts create mode 100644 src/tests/builder-add-charset.test.ts create mode 100644 src/tests/builder-add-color-scheme.test.ts create mode 100644 src/tests/builder-add-description.test.ts create mode 100644 src/tests/builder-add-icon.test.ts create mode 100644 src/tests/builder-add-link.test.ts create mode 100644 src/tests/builder-add-manifest.test.ts create mode 100644 src/tests/builder-add-meta.test.ts create mode 100644 src/tests/builder-add-open-graph.test.ts create mode 100644 src/tests/builder-add-robots.test.ts create mode 100644 src/tests/builder-add-script.test.ts create mode 100644 src/tests/builder-add-style.test.ts create mode 100644 src/tests/builder-add-stylesheet.test.ts create mode 100644 src/tests/builder-add-title.test.ts create mode 100644 src/tests/builder-add-twitter.test.ts create mode 100644 src/tests/builder-add-viewport.test.ts create mode 100644 src/tests/builder-element-key.test.ts create mode 100644 src/tests/builder-instance.test.ts create mode 100644 src/tests/builder-resolve-url.test.ts create mode 100644 src/tests/utils.test.ts create mode 100644 vitest.config.ts diff --git a/.changeset/frank-books-grow.md b/.changeset/frank-books-grow.md new file mode 100644 index 0000000..1dec88a --- /dev/null +++ b/.changeset/frank-books-grow.md @@ -0,0 +1,5 @@ +--- +'@devsantara/head': patch +--- + +test: add test case to cover all codebase diff --git a/.changeset/public-turtles-hammer.md b/.changeset/public-turtles-hammer.md new file mode 100644 index 0000000..a8d40e0 --- /dev/null +++ b/.changeset/public-turtles-hammer.md @@ -0,0 +1,5 @@ +--- +'@devsantara/head': minor +--- + +fix(builder): missing manifest key and remove unused try-catch diff --git a/.gitignore b/.gitignore index cf2a798..a774930 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,8 @@ node_modules/ # Build dist/ +# Testing +coverage/ + # Misc .DS_Store \ No newline at end of file diff --git a/package.json b/package.json index 03f2bfd..cf377fe 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,8 @@ "build": "tsdown", "dev": "tsdown --watch", "test": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest run --coverage", "lint": "oxlint --type-aware", "lint:fix": "oxlint --type-aware --fix", "lint:ts": "tsc --noEmit", @@ -49,6 +51,8 @@ }, "devDependencies": { "@changesets/cli": "^2.29.8", + "@vitest/coverage-v8": "4.0.18", + "@vitest/ui": "4.0.18", "oxfmt": "^0.27.0", "oxlint": "^1.42.0", "oxlint-tsgolint": "^0.11.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc5a257..8fb6df8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,12 @@ importers: '@changesets/cli': specifier: ^2.29.8 version: 2.29.8(@types/node@25.1.0) + '@vitest/coverage-v8': + specifier: 4.0.18 + version: 4.0.18(vitest@4.0.18) + '@vitest/ui': + specifier: 4.0.18 + version: 4.0.18(vitest@4.0.18) oxfmt: specifier: ^0.27.0 version: 0.27.0 @@ -35,7 +41,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@types/node@25.1.0) + version: 4.0.18(@types/node@25.1.0)(@vitest/ui@4.0.18) packages: @@ -43,14 +49,27 @@ packages: resolution: {integrity: sha512-5xRfRZk6wx1BRu2XnTE8cTh2mx1ixrZ3/vpn7p/RCJpgctL6pexVVHE3eqtwlYvHhPAuOYCAlnsAyXpBdmfh5Q==} engines: {node: ^20.19.0 || >=22.12.0} + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@8.0.0-beta.4': resolution: {integrity: sha512-FGwbdQ/I2nJXXfyxa7dT0Fr/zPWwgX7m+hNVj0HrIHYJtyLxSQeQY1Kd8QkAYviQJV3OWFlRLuGd5epF03bdQg==} engines: {node: ^20.19.0 || >=22.12.0} + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@8.0.0-beta.4': resolution: {integrity: sha512-6t0IaUEzlinbLmsGIvBZIHEJGjuchx+cMj+FbS78zL17tucYervgbwO07V5/CgBenVraontpmyMCTVyqCfxhFQ==} engines: {node: ^20.19.0 || >=22.12.0} + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/parser@8.0.0-beta.4': resolution: {integrity: sha512-fBcUqUN3eenLyg25QFkOwY1lmV6L0RdG92g6gxyS2CVCY8kHdibkQz1+zV3bLzxcvNnfHoi3i9n5Dci+g93acg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -60,10 +79,18 @@ packages: resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + '@babel/types@8.0.0-beta.4': resolution: {integrity: sha512-xjk2xqYp25ePzAs0I08hN2lrbUDDQFfCjwq6MIEa8HwHa0WK8NfNtdvtXod8Ku2CbE1iui7qwWojGvjQiyrQeA==} engines: {node: ^20.19.0 || >=22.12.0} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@changesets/apply-release-plan@7.0.14': resolution: {integrity: sha512-ddBvf9PHdy2YY0OUiEl3TV78mH9sckndJR14QAt87KLEbIov81XO0q0QAmvooBxXlqRRP8I9B7XOzZwQG7JkWA==} @@ -128,158 +155,158 @@ packages: '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} - '@esbuild/aix-ppc64@0.27.2': - resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.27.2': - resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.27.2': - resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.27.2': - resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.27.2': - resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.27.2': - resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.27.2': - resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.2': - resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.27.2': - resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.27.2': - resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.27.2': - resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.27.2': - resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.27.2': - resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.27.2': - resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.27.2': - resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.27.2': - resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.27.2': - resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.27.2': - resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.2': - resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.27.2': - resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.2': - resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.27.2': - resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.27.2': - resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.27.2': - resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.27.2': - resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.27.2': - resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -448,6 +475,9 @@ packages: cpu: [x64] os: [win32] + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} @@ -700,6 +730,15 @@ packages: '@types/react@19.2.10': resolution: {integrity: sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==} + '@vitest/coverage-v8@4.0.18': + resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} + peerDependencies: + '@vitest/browser': 4.0.18 + vitest: 4.0.18 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.0.18': resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} @@ -726,6 +765,11 @@ packages: '@vitest/spy@4.0.18': resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + '@vitest/ui@4.0.18': + resolution: {integrity: sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==} + peerDependencies: + vitest: 4.0.18 + '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} @@ -759,6 +803,9 @@ packages: resolution: {integrity: sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw==} engines: {node: '>=20.19.0'} + ast-v8-to-istanbul@0.3.11: + resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==} + better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} @@ -823,8 +870,8 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - esbuild@0.27.2: - resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} hasBin: true @@ -859,6 +906,9 @@ packages: picomatch: optional: true + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -867,6 +917,9 @@ packages: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -894,9 +947,16 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + hookable@6.0.1: resolution: {integrity: sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw==} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + human-id@4.1.3: resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} hasBin: true @@ -936,6 +996,21 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-yaml@3.14.2: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true @@ -962,6 +1037,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -974,6 +1056,10 @@ packages: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -1134,6 +1220,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1149,6 +1240,10 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -1177,6 +1272,10 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} @@ -1204,6 +1303,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -1356,21 +1459,36 @@ snapshots: '@types/jsesc': 2.5.1 jsesc: 3.1.0 + '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@8.0.0-beta.4': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@8.0.0-beta.4': {} + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + '@babel/parser@8.0.0-beta.4': dependencies: '@babel/types': 8.0.0-beta.4 '@babel/runtime@7.28.6': {} + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@8.0.0-beta.4': dependencies: '@babel/helper-string-parser': 8.0.0-beta.4 '@babel/helper-validator-identifier': 8.0.0-beta.4 + '@bcoe/v8-coverage@1.0.2': {} + '@changesets/apply-release-plan@7.0.14': dependencies: '@changesets/config': 3.1.2 @@ -1531,82 +1649,82 @@ snapshots: tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.27.2': + '@esbuild/aix-ppc64@0.27.3': optional: true - '@esbuild/android-arm64@0.27.2': + '@esbuild/android-arm64@0.27.3': optional: true - '@esbuild/android-arm@0.27.2': + '@esbuild/android-arm@0.27.3': optional: true - '@esbuild/android-x64@0.27.2': + '@esbuild/android-x64@0.27.3': optional: true - '@esbuild/darwin-arm64@0.27.2': + '@esbuild/darwin-arm64@0.27.3': optional: true - '@esbuild/darwin-x64@0.27.2': + '@esbuild/darwin-x64@0.27.3': optional: true - '@esbuild/freebsd-arm64@0.27.2': + '@esbuild/freebsd-arm64@0.27.3': optional: true - '@esbuild/freebsd-x64@0.27.2': + '@esbuild/freebsd-x64@0.27.3': optional: true - '@esbuild/linux-arm64@0.27.2': + '@esbuild/linux-arm64@0.27.3': optional: true - '@esbuild/linux-arm@0.27.2': + '@esbuild/linux-arm@0.27.3': optional: true - '@esbuild/linux-ia32@0.27.2': + '@esbuild/linux-ia32@0.27.3': optional: true - '@esbuild/linux-loong64@0.27.2': + '@esbuild/linux-loong64@0.27.3': optional: true - '@esbuild/linux-mips64el@0.27.2': + '@esbuild/linux-mips64el@0.27.3': optional: true - '@esbuild/linux-ppc64@0.27.2': + '@esbuild/linux-ppc64@0.27.3': optional: true - '@esbuild/linux-riscv64@0.27.2': + '@esbuild/linux-riscv64@0.27.3': optional: true - '@esbuild/linux-s390x@0.27.2': + '@esbuild/linux-s390x@0.27.3': optional: true - '@esbuild/linux-x64@0.27.2': + '@esbuild/linux-x64@0.27.3': optional: true - '@esbuild/netbsd-arm64@0.27.2': + '@esbuild/netbsd-arm64@0.27.3': optional: true - '@esbuild/netbsd-x64@0.27.2': + '@esbuild/netbsd-x64@0.27.3': optional: true - '@esbuild/openbsd-arm64@0.27.2': + '@esbuild/openbsd-arm64@0.27.3': optional: true - '@esbuild/openbsd-x64@0.27.2': + '@esbuild/openbsd-x64@0.27.3': optional: true - '@esbuild/openharmony-arm64@0.27.2': + '@esbuild/openharmony-arm64@0.27.3': optional: true - '@esbuild/sunos-x64@0.27.2': + '@esbuild/sunos-x64@0.27.3': optional: true - '@esbuild/win32-arm64@0.27.2': + '@esbuild/win32-arm64@0.27.3': optional: true - '@esbuild/win32-ia32@0.27.2': + '@esbuild/win32-ia32@0.27.3': optional: true - '@esbuild/win32-x64@0.27.2': + '@esbuild/win32-x64@0.27.3': optional: true '@inquirer/external-editor@1.0.3(@types/node@25.1.0)': @@ -1733,6 +1851,8 @@ snapshots: '@oxlint/win32-x64@1.42.0': optional: true + '@polka/url@1.0.0-next.29': {} + '@quansync/fs@1.0.0': dependencies: quansync: 1.0.0 @@ -1884,6 +2004,20 @@ snapshots: dependencies: csstype: 3.2.3 + '@vitest/coverage-v8@4.0.18(vitest@4.0.18)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.0.18 + ast-v8-to-istanbul: 0.3.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.2 + obug: 2.1.1 + std-env: 3.10.0 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@types/node@25.1.0)(@vitest/ui@4.0.18) + '@vitest/expect@4.0.18': dependencies: '@standard-schema/spec': 1.1.0 @@ -1918,6 +2052,17 @@ snapshots: '@vitest/spy@4.0.18': {} + '@vitest/ui@4.0.18(vitest@4.0.18)': + dependencies: + '@vitest/utils': 4.0.18 + fflate: 0.8.2 + flatted: 3.3.3 + pathe: 2.0.3 + sirv: 3.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@types/node@25.1.0)(@vitest/ui@4.0.18) + '@vitest/utils@4.0.18': dependencies: '@vitest/pretty-format': 4.0.18 @@ -1945,6 +2090,12 @@ snapshots: estree-walker: 3.0.3 pathe: 2.0.3 + ast-v8-to-istanbul@0.3.11: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 @@ -1990,34 +2141,34 @@ snapshots: es-module-lexer@1.7.0: {} - esbuild@0.27.2: + esbuild@0.27.3: optionalDependencies: - '@esbuild/aix-ppc64': 0.27.2 - '@esbuild/android-arm': 0.27.2 - '@esbuild/android-arm64': 0.27.2 - '@esbuild/android-x64': 0.27.2 - '@esbuild/darwin-arm64': 0.27.2 - '@esbuild/darwin-x64': 0.27.2 - '@esbuild/freebsd-arm64': 0.27.2 - '@esbuild/freebsd-x64': 0.27.2 - '@esbuild/linux-arm': 0.27.2 - '@esbuild/linux-arm64': 0.27.2 - '@esbuild/linux-ia32': 0.27.2 - '@esbuild/linux-loong64': 0.27.2 - '@esbuild/linux-mips64el': 0.27.2 - '@esbuild/linux-ppc64': 0.27.2 - '@esbuild/linux-riscv64': 0.27.2 - '@esbuild/linux-s390x': 0.27.2 - '@esbuild/linux-x64': 0.27.2 - '@esbuild/netbsd-arm64': 0.27.2 - '@esbuild/netbsd-x64': 0.27.2 - '@esbuild/openbsd-arm64': 0.27.2 - '@esbuild/openbsd-x64': 0.27.2 - '@esbuild/openharmony-arm64': 0.27.2 - '@esbuild/sunos-x64': 0.27.2 - '@esbuild/win32-arm64': 0.27.2 - '@esbuild/win32-ia32': 0.27.2 - '@esbuild/win32-x64': 0.27.2 + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 esprima@4.0.1: {} @@ -2045,6 +2196,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fflate@0.8.2: {} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -2054,6 +2207,8 @@ snapshots: locate-path: 5.0.0 path-exists: 4.0.0 + flatted@3.3.3: {} + fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -2088,8 +2243,12 @@ snapshots: graceful-fs@4.2.11: {} + has-flag@4.0.0: {} + hookable@6.0.1: {} + html-escaper@2.0.2: {} + human-id@4.1.3: {} iconv-lite@0.7.2: @@ -2116,6 +2275,21 @@ snapshots: isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + js-tokens@10.0.0: {} + js-yaml@3.14.2: dependencies: argparse: 1.0.10 @@ -2141,6 +2315,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.2: + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + merge2@1.4.1: {} micromatch@4.0.8: @@ -2150,6 +2334,8 @@ snapshots: mri@1.2.0: {} + mrmime@2.0.1: {} + nanoid@3.3.11: {} obug@2.1.1: {} @@ -2329,6 +2515,8 @@ snapshots: semver@7.7.3: {} + semver@7.7.4: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -2339,6 +2527,12 @@ snapshots: signal-exit@4.1.0: {} + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + slash@3.0.0: {} source-map-js@1.2.1: {} @@ -2360,6 +2554,10 @@ snapshots: strip-bom@3.0.0: {} + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + term-size@2.2.1: {} tinybench@2.9.0: {} @@ -2379,6 +2577,8 @@ snapshots: dependencies: is-number: 7.0.0 + totalist@3.0.1: {} + tree-kill@1.2.2: {} tsdown@0.20.1(typescript@5.9.3): @@ -2429,7 +2629,7 @@ snapshots: vite@7.3.1(@types/node@25.1.0): dependencies: - esbuild: 0.27.2 + esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 @@ -2439,7 +2639,7 @@ snapshots: '@types/node': 25.1.0 fsevents: 2.3.3 - vitest@4.0.18(@types/node@25.1.0): + vitest@4.0.18(@types/node@25.1.0)(@vitest/ui@4.0.18): dependencies: '@vitest/expect': 4.0.18 '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.1.0)) @@ -2463,6 +2663,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.1.0 + '@vitest/ui': 4.0.18(vitest@4.0.18) transitivePeerDependencies: - jiti - less diff --git a/src/adapters/tests/react-adapter.test.ts b/src/adapters/tests/react-adapter.test.ts new file mode 100644 index 0000000..c13ff47 --- /dev/null +++ b/src/adapters/tests/react-adapter.test.ts @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { describe, it, expect } from 'vitest'; +import { HeadReactAdapter } from '../react-adapter'; +import type { HeadElement } from '../../types'; + +describe('HeadReactAdapter', () => { + const adapter = new HeadReactAdapter(); + + describe('transform', () => { + it('should returns empty array for empty input', () => { + expect(adapter.transform([])).toEqual([]); + }); + + it('should converts elements to React components with key pattern "head-{type}-{index}"', () => { + const elements: HeadElement[] = [ + { type: 'title', attributes: { children: 'My Page' } }, + { + type: 'meta', + attributes: { name: 'description', content: 'A description' }, + }, + { type: 'link', attributes: { rel: 'icon', href: '/favicon.ico' } }, + { type: 'script', attributes: { src: '/script.js', async: true } }, + { type: 'style', attributes: { children: 'body { margin: 0; }' } }, + ]; + + const result = adapter.transform(elements); + + expect(result).toHaveLength(elements.length); + result.forEach((node, index) => { + expect(React.isValidElement(node)).toBe(true); + expect(node).toEqual( + React.createElement(elements[index].type, { + key: `head-${elements[index].type}-${index}`, + ...elements[index].attributes, + }), + ); + }); + }); + }); +}); diff --git a/src/adapters/tests/tanstack-router-adapter.test.ts b/src/adapters/tests/tanstack-router-adapter.test.ts new file mode 100644 index 0000000..036b519 --- /dev/null +++ b/src/adapters/tests/tanstack-router-adapter.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect } from 'vitest'; +import { HeadTanstackRouterAdapter } from '../tanstack-router-adapter'; +import type { HeadElement } from '../../types'; + +describe('HeadTanstackRouterAdapter', () => { + const adapter = new HeadTanstackRouterAdapter(); + + describe('transform', () => { + it('should return empty arrays when given empty elements', () => { + const result = adapter.transform([]); + expect(result).toEqual({ + meta: [], + links: [], + scripts: [], + styles: [], + }); + }); + + it('should transform meta element', () => { + const elements: HeadElement[] = [ + { + type: 'meta', + attributes: { name: 'viewport', content: 'width=device-width' }, + }, + ]; + + const result = adapter.transform(elements); + + expect(result.meta).toHaveLength(1); + expect(result.meta?.[0]).toEqual({ + name: 'viewport', + content: 'width=device-width', + }); + expect(result.links).toHaveLength(0); + expect(result.scripts).toHaveLength(0); + expect(result.styles).toHaveLength(0); + }); + + it('should transform link element', () => { + const elements: HeadElement[] = [ + { + type: 'link', + attributes: { rel: 'icon', href: '/favicon.ico' }, + }, + ]; + + const result = adapter.transform(elements); + + expect(result.links).toHaveLength(1); + expect(result.links?.[0]).toEqual({ rel: 'icon', href: '/favicon.ico' }); + expect(result.meta).toHaveLength(0); + expect(result.scripts).toHaveLength(0); + expect(result.styles).toHaveLength(0); + }); + + it('should transform script element', () => { + const elements: HeadElement[] = [ + { + type: 'script', + attributes: { src: '/script.js', async: true }, + }, + ]; + + const result = adapter.transform(elements); + + expect(result.scripts).toHaveLength(1); + expect(result.scripts?.[0]).toEqual({ src: '/script.js', async: true }); + expect(result.meta).toHaveLength(0); + expect(result.links).toHaveLength(0); + expect(result.styles).toHaveLength(0); + }); + + it('should transform style element', () => { + const elements: HeadElement[] = [ + { + type: 'style', + attributes: { children: 'body { margin: 0; }' }, + }, + ]; + + const result = adapter.transform(elements); + + expect(result.styles).toHaveLength(1); + expect(result.styles?.[0]).toEqual({ children: 'body { margin: 0; }' }); + expect(result.meta).toHaveLength(0); + expect(result.links).toHaveLength(0); + expect(result.scripts).toHaveLength(0); + }); + + it('should transform title element into meta with title property', () => { + const elements: HeadElement[] = [ + { + type: 'title', + attributes: { children: 'My Page' }, + }, + ]; + + const result = adapter.transform(elements); + + expect(result.meta).toHaveLength(1); + expect(result.meta?.[0]).toEqual({ title: 'My Page' }); + expect(result.links).toHaveLength(0); + expect(result.scripts).toHaveLength(0); + expect(result.styles).toHaveLength(0); + }); + + it('should transform multiple elements into categorized configuration', () => { + const elements: HeadElement[] = [ + { + type: 'title', + attributes: { children: 'My Page' }, + }, + { + type: 'meta', + attributes: { name: 'description', content: 'A description' }, + }, + { + type: 'meta', + attributes: { name: 'viewport', content: 'width=device-width' }, + }, + { + type: 'link', + attributes: { rel: 'icon', href: '/favicon.ico' }, + }, + { + type: 'link', + attributes: { rel: 'stylesheet', href: '/styles.css' }, + }, + { + type: 'script', + attributes: { src: '/script.js', async: true }, + }, + { + type: 'script', + attributes: { children: 'console.log("Hello World!");', async: true }, + }, + { + type: 'style', + attributes: { children: 'body { margin: 0; }' }, + }, + ]; + + const result = adapter.transform(elements); + + expect(result.meta).toHaveLength(3); + expect(result.meta?.[0]).toEqual({ title: 'My Page' }); + expect(result.meta?.[1]).toEqual({ + name: 'description', + content: 'A description', + }); + expect(result.meta?.[2]).toEqual({ + name: 'viewport', + content: 'width=device-width', + }); + + expect(result.links).toHaveLength(2); + expect(result.links?.[0]).toEqual({ rel: 'icon', href: '/favicon.ico' }); + expect(result.links?.[1]).toEqual({ + rel: 'stylesheet', + href: '/styles.css', + }); + + expect(result.scripts).toHaveLength(2); + expect(result.scripts?.[0]).toEqual({ src: '/script.js', async: true }); + expect(result.scripts?.[1]).toEqual({ + children: 'console.log("Hello World!");', + async: true, + }); + + expect(result.styles).toHaveLength(1); + expect(result.styles?.[0]).toEqual({ children: 'body { margin: 0; }' }); + }); + + it('should ignore unknown element types', () => { + const elements: HeadElement[] = [ + { + type: 'meta', + attributes: { name: 'description', content: 'A description' }, + }, + { + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + type: 'base' as any, + attributes: { href: 'https://devsantara.com' }, + }, + { + type: 'meta', + attributes: { name: 'viewport', content: 'width=device-width' }, + }, + ]; + + const result = adapter.transform(elements); + + // Only the meta element should be included + expect(result.meta).toHaveLength(2); + expect(result.meta?.[0]).toEqual({ + name: 'description', + content: 'A description', + }); + expect(result.meta?.[1]).toEqual({ + name: 'viewport', + content: 'width=device-width', + }); + expect(result.links).toHaveLength(0); + expect(result.scripts).toHaveLength(0); + expect(result.styles).toHaveLength(0); + }); + }); +}); diff --git a/src/builder.ts b/src/builder.ts index a4d6490..05b6a53 100644 --- a/src/builder.ts +++ b/src/builder.ts @@ -111,13 +111,8 @@ export class HeadBuilder<TOutput = HeadElement[]> { } // Resolve relative URL against metadataBase - try { - const resolved = new URL(url, this.metadataBase); - return resolved.href; - } catch { - // If URL construction fails, return raw url - return url; - } + const resolved = new URL(url, this.metadataBase); + return resolved.href; } /** @@ -146,6 +141,9 @@ export class HeadBuilder<TOutput = HeadElement[]> { if (attributes.rel === 'canonical') { return 'link:canonical'; } + if (attributes.rel === 'manifest') { + return 'link:manifest'; + } if (attributes.rel === 'alternate' && 'hrefLang' in attributes) { return `link:alternate:${attributes.hrefLang}`; } @@ -216,7 +214,7 @@ export class HeadBuilder<TOutput = HeadElement[]> { * @example * new HeadBuilder() * .addScript('/script.js') - * .addScript(new URL('https://example.com/script.js'), { async: true }) + * .addScript(new URL('https://devsantara.com/script.js'), { async: true }) * .addScript({ code: 'console.log("Hello, World!")' }) * .build(); */ @@ -656,7 +654,7 @@ export class HeadBuilder<TOutput = HeadElement[]> { * @returns The builder instance for method chaining * * @example - * new HeadBuilder({ metadataBase: new URL('https://example.com') }) + * new HeadBuilder({ metadataBase: new URL('https://devsantara.com') }) * .addAlternateLocale((helper) => ({ * 'en-US': helper.resolveUrl('/en'), * 'fr-FR': helper.resolveUrl('/fr'), @@ -715,6 +713,7 @@ export class HeadBuilder<TOutput = HeadElement[]> { addStylesheet(href: string | URL, options?: StylesheetOptions): this { this.addElement('link', { rel: 'stylesheet', + type: 'text/css', href: href.toString(), ...options, }); diff --git a/src/tests/builder-add-alternate-locale.test.ts b/src/tests/builder-add-alternate-locale.test.ts new file mode 100644 index 0000000..e048a77 --- /dev/null +++ b/src/tests/builder-add-alternate-locale.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; +import { HeadBuilder } from '../builder'; +import type { HeadElement } from '../types'; + +describe('HeadBuilder.addAlternateLocale', () => { + it('should add alternate locale links with single locale', () => { + const result = new HeadBuilder() + .addAlternateLocale({ 'en-US': 'https://devsantara.com/en' }) + .build(); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'link', + attributes: { + rel: 'alternate', + hrefLang: 'en-US', + href: 'https://devsantara.com/en', + }, + }); + }); + + it('should add alternate locale links with multiple locales', () => { + const result = new HeadBuilder<HeadElement<'link'>[]>() + .addAlternateLocale({ + 'en-US': 'https://devsantara.com/en', + 'fr-FR': 'https://devsantara.com/fr', + 'x-default': 'https://devsantara.com', + }) + .build(); + + expect(result).toHaveLength(3); + expect(result[0].attributes.hrefLang).toBe('en-US'); + expect(result[1].attributes.hrefLang).toBe('fr-FR'); + expect(result[2].attributes.hrefLang).toBe('x-default'); + }); + + it('should add alternate locale links with URL objects', () => { + const result = new HeadBuilder<HeadElement<'link'>[]>() + .addAlternateLocale({ + 'en-US': new URL('https://devsantara.com/en'), + }) + .build(); + + expect(result[0].attributes.href).toBe('https://devsantara.com/en'); + }); + + it('should add alternate locale links with callback using resolveUrl', () => { + const result = new HeadBuilder({ + metadataBase: new URL('https://devsantara.com'), + }) + .addAlternateLocale((h) => ({ + 'en-US': h.resolveUrl('/en'), + 'fr-FR': h.resolveUrl('/fr'), + })) + .build(); + + expect(result[0].attributes).toEqual({ + rel: 'alternate', + hrefLang: 'en-US', + href: 'https://devsantara.com/en', + }); + expect(result[1].attributes).toEqual({ + rel: 'alternate', + hrefLang: 'fr-FR', + href: 'https://devsantara.com/fr', + }); + }); + + it('should support type-constrained locales', () => { + type SupportedLocales = 'en-US' | 'fr-FR'; + + const result = new HeadBuilder<HeadElement<'link'>[]>() + .addAlternateLocale<SupportedLocales>({ + 'en-US': '/en', + 'fr-FR': '/fr', + }) + .build(); + + expect(result).toHaveLength(2); + expect(result[0].attributes.hrefLang).toBe('en-US'); + expect(result[1].attributes.hrefLang).toBe('fr-FR'); + }); + + it('should return builder for chaining', () => { + const builder = new HeadBuilder(); + expect(builder.addAlternateLocale({ 'en-US': '/en' })).toBe(builder); + }); +}); diff --git a/src/tests/builder-add-canonical.test.ts b/src/tests/builder-add-canonical.test.ts new file mode 100644 index 0000000..b24dc1b --- /dev/null +++ b/src/tests/builder-add-canonical.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { HeadBuilder } from '../builder'; + +describe('HeadBuilder.addCanonical', () => { + it('should add canonical link with string URL', () => { + const result = new HeadBuilder() + .addCanonical('https://devsantara.com/page') + .build(); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'link', + attributes: { rel: 'canonical', href: 'https://devsantara.com/page' }, + }); + }); + + it('should add canonical link with URL object', () => { + const result = new HeadBuilder() + .addCanonical(new URL('https://devsantara.com/about')) + .build(); + + expect(result[0].attributes).toEqual({ + rel: 'canonical', + href: 'https://devsantara.com/about', + }); + }); + + it('should add canonical link with callback function using resolveUrl', () => { + const result = new HeadBuilder({ + metadataBase: new URL('https://devsantara.com'), + }) + .addCanonical((h) => h.resolveUrl('/page')) + .build(); + + expect(result[0].attributes).toEqual({ + rel: 'canonical', + href: 'https://devsantara.com/page', + }); + }); + + it('should add canonical link with callback returning URL object', () => { + const result = new HeadBuilder() + .addCanonical(() => new URL('https://other.com/page')) + .build(); + + expect(result[0].attributes).toEqual({ + rel: 'canonical', + href: 'https://other.com/page', + }); + }); + + it('should return builder for chaining', () => { + const builder = new HeadBuilder(); + expect(builder.addCanonical('https://devsantara.com')).toBe(builder); + }); +}); diff --git a/src/tests/builder-add-charset.test.ts b/src/tests/builder-add-charset.test.ts new file mode 100644 index 0000000..f6c39fd --- /dev/null +++ b/src/tests/builder-add-charset.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import { HeadBuilder } from '../builder'; + +describe('HeadBuilder.addCharSet', () => { + it('should add charset meta element', () => { + const result = new HeadBuilder().addCharSet('utf-8').build(); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'meta', + attributes: { charSet: 'utf-8' }, + }); + }); + + it('should return builder for chaining', () => { + const builder = new HeadBuilder(); + expect(builder.addCharSet('utf-8')).toBe(builder); + }); +}); diff --git a/src/tests/builder-add-color-scheme.test.ts b/src/tests/builder-add-color-scheme.test.ts new file mode 100644 index 0000000..d738706 --- /dev/null +++ b/src/tests/builder-add-color-scheme.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { HeadBuilder } from '../builder'; + +describe('HeadBuilder.addColorScheme', () => { + it('should add color-scheme meta for light, dark, and combined values', () => { + const tests: Array<{ + value: 'light' | 'dark' | 'light dark'; + expected: string; + }> = [ + { value: 'light', expected: 'light' }, + { value: 'dark', expected: 'dark' }, + { value: 'light dark', expected: 'light dark' }, + ]; + + for (const { value, expected } of tests) { + const result = new HeadBuilder().addColorScheme(value).build(); + expect(result[0]).toEqual({ + type: 'meta', + attributes: { name: 'color-scheme', content: expected }, + }); + } + }); + + it('should return builder for chaining', () => { + const builder = new HeadBuilder(); + expect(builder.addColorScheme('light')).toBe(builder); + }); +}); diff --git a/src/tests/builder-add-description.test.ts b/src/tests/builder-add-description.test.ts new file mode 100644 index 0000000..089d903 --- /dev/null +++ b/src/tests/builder-add-description.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; +import { HeadBuilder } from '../builder'; + +describe('HeadBuilder.addDescription', () => { + it('should add description meta element', () => { + const result = new HeadBuilder() + .addDescription('A comprehensive guide') + .build(); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'meta', + attributes: { name: 'description', content: 'A comprehensive guide' }, + }); + }); + + it('should return builder for chaining', () => { + const builder = new HeadBuilder(); + expect(builder.addDescription('desc')).toBe(builder); + }); +}); diff --git a/src/tests/builder-add-icon.test.ts b/src/tests/builder-add-icon.test.ts new file mode 100644 index 0000000..199b5c5 --- /dev/null +++ b/src/tests/builder-add-icon.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { HeadBuilder } from '../builder'; +import type { HeadElement } from '../types'; + +describe('HeadBuilder.addIcon', () => { + it('should add icon with different presets (icon, apple, shortcut, custom)', () => { + const presets: Array<{ preset: string; expectedRel: string }> = [ + { preset: 'icon', expectedRel: 'icon' }, + { preset: 'apple', expectedRel: 'apple-touch-icon' }, + { preset: 'shortcut', expectedRel: 'shortcut icon' }, + { preset: 'custom-icon', expectedRel: 'custom-icon' }, + ]; + + for (const { preset, expectedRel } of presets) { + const result = new HeadBuilder() + .addIcon(preset, { href: '/favicon.ico' }) + .build(); + + expect(result[0].attributes).toEqual({ + rel: expectedRel, + href: '/favicon.ico', + }); + } + }); + + it('should add icon with additional attributes', () => { + const result = new HeadBuilder() + .addIcon('icon', { href: '/icon.png', sizes: '32x32', type: 'image/png' }) + .build(); + + expect(result[0].attributes).toEqual({ + rel: 'icon', + href: '/icon.png', + sizes: '32x32', + type: 'image/png', + }); + }); + + it('should add icon with URL object', () => { + const result = new HeadBuilder<HeadElement<'link'>[]>() + .addIcon('icon', { href: new URL('https://devsantara.com/favicon.ico') }) + .build(); + + expect(result[0].attributes.href).toBe( + 'https://devsantara.com/favicon.ico', + ); + }); + + it('should add icon with callback using resolveUrl', () => { + const result = new HeadBuilder({ + metadataBase: new URL('https://devsantara.com'), + }) + .addIcon('apple', (h) => ({ + href: new URL(h.resolveUrl('/apple-icon.png')), + sizes: '180x180', + })) + .build(); + + expect(result[0].attributes).toEqual({ + rel: 'apple-touch-icon', + href: 'https://devsantara.com/apple-icon.png', + sizes: '180x180', + }); + }); + + it('should add multiple icons with different sizes', () => { + const result = new HeadBuilder<HeadElement<'link'>[]>() + .addIcon('icon', { href: '/icon-16.png', sizes: '16x16' }) + .addIcon('icon', { href: '/icon-32.png', sizes: '32x32' }) + .build(); + + expect(result).toHaveLength(2); + expect(result[0].attributes.sizes).toBe('16x16'); + expect(result[1].attributes.sizes).toBe('32x32'); + }); + + it('should return builder for chaining', () => { + const builder = new HeadBuilder(); + expect(builder.addIcon('icon', { href: '/favicon.ico' })).toBe(builder); + }); +}); diff --git a/src/tests/builder-add-link.test.ts b/src/tests/builder-add-link.test.ts new file mode 100644 index 0000000..48e5967 --- /dev/null +++ b/src/tests/builder-add-link.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { HeadBuilder } from '../builder'; + +describe('HeadBuilder.addLink', () => { + it('should add link element with href only', () => { + const result = new HeadBuilder().addLink('https://devsantara.com').build(); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'link', + attributes: { href: 'https://devsantara.com' }, + }); + }); + + it('should add link element with additional attributes', () => { + const result = new HeadBuilder() + .addLink('https://fonts.googleapis.com', { + rel: 'preconnect', + fetchPriority: 'high', + }) + .build(); + + expect(result[0]).toEqual({ + type: 'link', + attributes: { + href: 'https://fonts.googleapis.com', + rel: 'preconnect', + fetchPriority: 'high', + }, + }); + }); + + it('should add link element with URL object', () => { + const result = new HeadBuilder() + .addLink(new URL('https://devsantara.com/styles.css'), { + rel: 'stylesheet', + }) + .build(); + + expect(result[0].attributes).toEqual({ + href: 'https://devsantara.com/styles.css', + rel: 'stylesheet', + }); + }); + + it('should return builder for chaining', () => { + const builder = new HeadBuilder(); + expect(builder.addLink('https://devsantara.com')).toBe(builder); + }); +}); diff --git a/src/tests/builder-add-manifest.test.ts b/src/tests/builder-add-manifest.test.ts new file mode 100644 index 0000000..2e26842 --- /dev/null +++ b/src/tests/builder-add-manifest.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { HeadBuilder } from '../builder'; +import type { HeadElement } from '../types'; + +describe('HeadBuilder.addManifest', () => { + it('should add manifest link with string URL', () => { + const result = new HeadBuilder().addManifest('/manifest.json').build(); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'link', + attributes: { rel: 'manifest', href: '/manifest.json' }, + }); + }); + + it('should add manifest link with URL object', () => { + const result = new HeadBuilder() + .addManifest(new URL('https://devsantara.com/manifest.webmanifest')) + .build(); + + expect(result[0].attributes).toEqual({ + rel: 'manifest', + href: 'https://devsantara.com/manifest.webmanifest', + }); + }); + + it('should add manifest link with callback using resolveUrl', () => { + const result = new HeadBuilder({ + metadataBase: new URL('https://devsantara.com'), + }) + .addManifest((h) => h.resolveUrl('/manifest.json')) + .build(); + + expect(result[0].attributes).toEqual({ + rel: 'manifest', + href: 'https://devsantara.com/manifest.json', + }); + }); + + it('should add manifest link with callback returning URL object', () => { + const result = new HeadBuilder<HeadElement<'link'>[]>() + .addManifest(() => new URL('https://other.com/manifest.json')) + .build(); + + expect(result[0].attributes.href).toBe('https://other.com/manifest.json'); + }); + + it('should return builder for chaining', () => { + const builder = new HeadBuilder(); + expect(builder.addManifest('/manifest.json')).toBe(builder); + }); +}); diff --git a/src/tests/builder-add-meta.test.ts b/src/tests/builder-add-meta.test.ts new file mode 100644 index 0000000..75cb624 --- /dev/null +++ b/src/tests/builder-add-meta.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { HeadBuilder } from '../builder'; + +describe('HeadBuilder.addMeta', () => { + it('should add meta element with name and content', () => { + const result = new HeadBuilder() + .addMeta({ name: 'theme-color', content: '#ffffff' }) + .build(); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'meta', + attributes: { name: 'theme-color', content: '#ffffff' }, + }); + }); + + it('should add meta element with property and content', () => { + const result = new HeadBuilder() + .addMeta({ property: 'og:title', content: 'My Page' }) + .build(); + + expect(result[0]).toEqual({ + type: 'meta', + attributes: { property: 'og:title', content: 'My Page' }, + }); + }); + + it('should return builder for chaining', () => { + const builder = new HeadBuilder(); + expect(builder.addMeta({ name: 'test', content: 'val' })).toBe(builder); + }); +}); diff --git a/src/tests/builder-add-open-graph.test.ts b/src/tests/builder-add-open-graph.test.ts new file mode 100644 index 0000000..ccc51f3 --- /dev/null +++ b/src/tests/builder-add-open-graph.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect } from 'vitest'; +import { HeadBuilder } from '../builder'; + +describe('HeadBuilder.addOpenGraph', () => { + it('should add basic og properties (title, description, url, locale)', () => { + const result = new HeadBuilder() + .addOpenGraph({ + title: 'My Page', + description: 'Page description', + url: new URL('https://devsantara.com/page'), + locale: 'en_US', + }) + .build(); + + expect(result).toHaveLength(4); + expect(result[0].attributes).toEqual({ + property: 'og:title', + content: 'My Page', + }); + expect(result[1].attributes).toEqual({ + property: 'og:description', + content: 'Page description', + }); + expect(result[2].attributes).toEqual({ + property: 'og:url', + content: 'https://devsantara.com/page', + }); + expect(result[3].attributes).toEqual({ + property: 'og:locale', + content: 'en_US', + }); + }); + + it('should add og:image with full properties', () => { + const result = new HeadBuilder() + .addOpenGraph({ + image: { + url: new URL('https://devsantara.com/image.jpg'), + alt: 'Image alt', + type: 'image/jpeg', + width: 1200, + height: 630, + }, + }) + .build(); + + expect(result).toHaveLength(5); + expect(result[0].attributes).toEqual({ + property: 'og:image', + content: 'https://devsantara.com/image.jpg', + }); + expect(result[1].attributes).toEqual({ + property: 'og:image:alt', + content: 'Image alt', + }); + expect(result[2].attributes).toEqual({ + property: 'og:image:type', + content: 'image/jpeg', + }); + expect(result[3].attributes).toEqual({ + property: 'og:image:width', + content: '1200', + }); + expect(result[4].attributes).toEqual({ + property: 'og:image:height', + content: '630', + }); + }); + + it('should add og:type with properties', () => { + const result = new HeadBuilder() + .addOpenGraph({ + type: { + name: 'article', + properties: [ + { name: 'article:published_time', content: '2023-01-01' }, + { name: 'article:author', content: 'John Doe' }, + ], + }, + }) + .build(); + + expect(result).toHaveLength(3); + expect(result[0].attributes).toEqual({ + property: 'og:type', + content: 'article', + }); + expect(result[1].attributes).toEqual({ + property: 'article:published_time', + content: '2023-01-01', + }); + expect(result[2].attributes).toEqual({ + property: 'article:author', + content: 'John Doe', + }); + }); + + it('should add og:type without properties', () => { + const result = new HeadBuilder() + .addOpenGraph({ type: { name: 'website' } }) + .build(); + + expect(result).toHaveLength(1); + expect(result[0].attributes).toEqual({ + property: 'og:type', + content: 'website', + }); + }); + + it('should add og with callback using resolveUrl', () => { + const result = new HeadBuilder({ + metadataBase: new URL('https://devsantara.com'), + }) + .addOpenGraph((h) => ({ + title: 'My Page', + url: new URL(h.resolveUrl('/page')), + image: { url: new URL(h.resolveUrl('/image.jpg')) }, + })) + .build(); + + expect(result[0].attributes).toEqual({ + property: 'og:title', + content: 'My Page', + }); + expect(result[1].attributes).toEqual({ + property: 'og:url', + content: 'https://devsantara.com/page', + }); + expect(result[2].attributes).toEqual({ + property: 'og:image', + content: 'https://devsantara.com/image.jpg', + }); + }); + + it('should return builder for chaining', () => { + const builder = new HeadBuilder(); + expect(builder.addOpenGraph({ title: 'Title' })).toBe(builder); + }); +}); diff --git a/src/tests/builder-add-robots.test.ts b/src/tests/builder-add-robots.test.ts new file mode 100644 index 0000000..5cb651f --- /dev/null +++ b/src/tests/builder-add-robots.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest'; +import { HeadBuilder } from '../builder'; + +describe('HeadBuilder.addRobots', () => { + it('should add robots meta with index/follow booleans', () => { + const result = new HeadBuilder() + .addRobots({ index: true, follow: true }) + .build(); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'meta', + attributes: { name: 'robots', content: 'index, follow' }, + }); + }); + + it('should add robots meta with noindex/nofollow', () => { + const result = new HeadBuilder() + .addRobots({ index: false, follow: false }) + .build(); + + expect(result[0].attributes.content).toBe('noindex, nofollow'); + }); + + it('should add robots meta with string and numeric directives', () => { + const result = new HeadBuilder() + .addRobots({ 'max-snippet': 160, 'max-image-preview': 'large' }) + .build(); + + expect(result[0].attributes.content).toBe( + 'max-snippet:160, max-image-preview:large', + ); + }); + + it('should add robots meta with boolean flag directives', () => { + const result = new HeadBuilder() + .addRobots({ noarchive: true, noimageindex: true }) + .build(); + + expect(result[0].attributes.content).toBe('noarchive, noimageindex'); + }); + + it('should add robots meta with mixed directives', () => { + const result = new HeadBuilder() + .addRobots({ + index: true, + follow: true, + 'max-snippet': 160, + noarchive: true, + }) + .build(); + + expect(result[0].attributes.content).toBe( + 'index, follow, max-snippet:160, noarchive', + ); + }); + + it('should skip undefined values and false boolean flags', () => { + const result = new HeadBuilder() + .addRobots({ index: true, follow: undefined, noarchive: false }) + .build(); + + expect(result[0].attributes.content).toBe('index'); + }); + + it('should return builder for chaining', () => { + const builder = new HeadBuilder(); + expect(builder.addRobots({ index: true })).toBe(builder); + }); +}); diff --git a/src/tests/builder-add-script.test.ts b/src/tests/builder-add-script.test.ts new file mode 100644 index 0000000..4862ee4 --- /dev/null +++ b/src/tests/builder-add-script.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { HeadBuilder } from '../builder'; + +describe('HeadBuilder.addScript', () => { + it('should add external script with string URL', () => { + const result = new HeadBuilder().addScript('/script.js').build(); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'script', + attributes: { src: '/script.js', type: 'text/javascript' }, + }); + }); + + it('should add external script with URL object', () => { + const result = new HeadBuilder() + .addScript(new URL('https://devsantara.com/script.js')) + .build(); + + expect(result[0].attributes).toEqual({ + src: 'https://devsantara.com/script.js', + type: 'text/javascript', + }); + }); + + it('should add inline script with code object', () => { + const result = new HeadBuilder() + .addScript({ code: 'console.log("Hello");' }) + .build(); + + expect(result[0]).toEqual({ + type: 'script', + attributes: { + children: 'console.log("Hello");', + type: 'text/javascript', + }, + }); + }); + + it('should add script with additional attributes', () => { + const result = new HeadBuilder() + .addScript('/script.js', { async: true, defer: false }) + .build(); + + expect(result[0].attributes).toHaveProperty('async', true); + expect(result[0].attributes).toHaveProperty('defer', false); + }); + + it('should add inline script with additional attributes', () => { + const result = new HeadBuilder() + .addScript({ code: 'alert("test");' }, { nonce: 'abc123' }) + .build(); + + expect(result[0].attributes).toHaveProperty('nonce', 'abc123'); + }); + + it('should return builder for chaining', () => { + const builder = new HeadBuilder(); + expect(builder.addScript('/script.js')).toBe(builder); + }); +}); diff --git a/src/tests/builder-add-style.test.ts b/src/tests/builder-add-style.test.ts new file mode 100644 index 0000000..32099f5 --- /dev/null +++ b/src/tests/builder-add-style.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { HeadBuilder } from '../builder'; + +describe('HeadBuilder.addStyle', () => { + it('should add style element with CSS code', () => { + const result = new HeadBuilder().addStyle('body { margin: 0; }').build(); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'style', + attributes: { children: 'body { margin: 0; }', type: 'text/css' }, + }); + }); + + it('should add style element with additional attributes', () => { + const result = new HeadBuilder() + .addStyle('body { margin: 0; }', { media: 'print' }) + .build(); + + expect(result[0].attributes).toHaveProperty('media', 'print'); + }); + + it('should return builder for chaining', () => { + const builder = new HeadBuilder(); + expect(builder.addStyle('css')).toBe(builder); + }); +}); diff --git a/src/tests/builder-add-stylesheet.test.ts b/src/tests/builder-add-stylesheet.test.ts new file mode 100644 index 0000000..3660864 --- /dev/null +++ b/src/tests/builder-add-stylesheet.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { HeadBuilder } from '../builder'; +import type { HeadElement } from '../types'; + +describe('HeadBuilder.addStylesheet', () => { + it('should add stylesheet link with string URL', () => { + const result = new HeadBuilder().addStylesheet('/styles.css').build(); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'link', + attributes: { rel: 'stylesheet', type: 'text/css', href: '/styles.css' }, + }); + }); + + it('should add stylesheet link with URL object', () => { + const result = new HeadBuilder<HeadElement<'link'>[]>() + .addStylesheet(new URL('https://cdn.example.com/theme.css')) + .build(); + + expect(result[0].attributes.href).toBe('https://cdn.example.com/theme.css'); + }); + + it('should add stylesheet link with media attribute', () => { + const result = new HeadBuilder<HeadElement<'link'>[]>() + .addStylesheet('/print.css', { media: 'print' }) + .build(); + + expect(result[0].attributes.media).toBe('print'); + }); + + it('should add stylesheet link with integrity and crossOrigin', () => { + const result = new HeadBuilder() + .addStylesheet('https://cdn.example.com/styles.css', { + integrity: 'sha384-abc123', + crossOrigin: 'anonymous', + }) + .build(); + + expect(result[0].attributes).toEqual({ + rel: 'stylesheet', + type: 'text/css', + href: 'https://cdn.example.com/styles.css', + integrity: 'sha384-abc123', + crossOrigin: 'anonymous', + }); + }); + + it('should add stylesheet link with multiple attributes', () => { + const result = new HeadBuilder<HeadElement<'link'>[]>() + .addStylesheet('/styles.css', { + media: 'screen and (min-width: 768px)', + fetchPriority: 'high', + }) + .build(); + + expect(result[0].attributes.media).toBe('screen and (min-width: 768px)'); + expect(result[0].attributes.fetchPriority).toBe('high'); + }); + + it('should add multiple stylesheets', () => { + const result = new HeadBuilder() + .addStylesheet('/base.css') + .addStylesheet('/theme.css') + .build(); + + expect(result).toHaveLength(2); + }); + + it('should return builder for chaining', () => { + const builder = new HeadBuilder(); + expect(builder.addStylesheet('/styles.css')).toBe(builder); + }); +}); diff --git a/src/tests/builder-add-title.test.ts b/src/tests/builder-add-title.test.ts new file mode 100644 index 0000000..9ead64f --- /dev/null +++ b/src/tests/builder-add-title.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { HeadBuilder } from '../builder'; + +describe('HeadBuilder.addTitle', () => { + it('should add title element with string', () => { + const result = new HeadBuilder().addTitle('My Page').build(); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'title', + attributes: { children: 'My Page' }, + }); + }); + + it('should add title with template and default', () => { + const result = new HeadBuilder() + .addTitle({ template: '%s | My Site', default: 'Home' }) + .build(); + + expect(result[0]).toEqual({ + type: 'title', + attributes: { children: 'Home | My Site' }, + }); + }); + + it('should apply template to subsequent string titles', () => { + const result = new HeadBuilder() + .addTitle({ template: '%s | My Site', default: 'Home' }) + .addTitle('About Us') + .build(); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'title', + attributes: { children: 'About Us | My Site' }, + }); + }); + + it('should update template when new template is set', () => { + const result = new HeadBuilder() + .addTitle({ template: '%s | Site A', default: 'Home' }) + .addTitle({ template: '%s | Site B', default: 'Index' }) + .build(); + + expect(result[0]).toEqual({ + type: 'title', + attributes: { children: 'Index | Site B' }, + }); + }); + + it('should return builder for chaining', () => { + const builder = new HeadBuilder(); + expect(builder.addTitle('Title')).toBe(builder); + }); +}); diff --git a/src/tests/builder-add-twitter.test.ts b/src/tests/builder-add-twitter.test.ts new file mode 100644 index 0000000..67dd738 --- /dev/null +++ b/src/tests/builder-add-twitter.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from 'vitest'; +import { HeadBuilder } from '../builder'; + +describe('HeadBuilder.addTwitter', () => { + it('should add basic twitter properties (title, description, site, creator)', () => { + const result = new HeadBuilder() + .addTwitter({ + title: 'My Page', + description: 'Page description', + site: '@mysite', + creator: '@creator', + }) + .build(); + + expect(result).toHaveLength(4); + expect(result[0].attributes).toEqual({ + name: 'twitter:title', + content: 'My Page', + }); + expect(result[1].attributes).toEqual({ + name: 'twitter:description', + content: 'Page description', + }); + expect(result[2].attributes).toEqual({ + name: 'twitter:site', + content: '@mysite', + }); + expect(result[3].attributes).toEqual({ + name: 'twitter:creator', + content: '@creator', + }); + }); + + it('should add twitter siteId and creatorId', () => { + const result = new HeadBuilder() + .addTwitter({ siteId: '123456', creatorId: '789012' }) + .build(); + + expect(result[0].attributes).toEqual({ + name: 'twitter:site:id', + content: '123456', + }); + expect(result[1].attributes).toEqual({ + name: 'twitter:creator:id', + content: '789012', + }); + }); + + it('should add twitter:image with alt', () => { + const result = new HeadBuilder() + .addTwitter({ + image: { + url: new URL('https://devsantara.com/image.jpg'), + alt: 'Image alt', + }, + }) + .build(); + + expect(result).toHaveLength(2); + expect(result[0].attributes).toEqual({ + name: 'twitter:image', + content: 'https://devsantara.com/image.jpg', + }); + expect(result[1].attributes).toEqual({ + name: 'twitter:image:alt', + content: 'Image alt', + }); + }); + + it('should add twitter:card with player properties', () => { + const result = new HeadBuilder() + .addTwitter({ + card: { + name: 'player', + properties: [ + { + name: 'twitter:player', + content: 'https://devsantara.com/player', + }, + { name: 'twitter:player:width', content: 1280 }, + { name: 'twitter:player:height', content: 720 }, + ], + }, + }) + .build(); + + expect(result).toHaveLength(4); + expect(result[0].attributes).toEqual({ + name: 'twitter:card', + content: 'player', + }); + expect(result[1].attributes).toEqual({ + name: 'twitter:player', + content: 'https://devsantara.com/player', + }); + expect(result[2].attributes).toEqual({ + name: 'twitter:player:width', + content: '1280', + }); + expect(result[3].attributes).toEqual({ + name: 'twitter:player:height', + content: '720', + }); + }); + + it('should add twitter:card without properties', () => { + const result = new HeadBuilder() + .addTwitter({ card: { name: 'summary_large_image' } }) + .build(); + + expect(result).toHaveLength(1); + expect(result[0].attributes).toEqual({ + name: 'twitter:card', + content: 'summary_large_image', + }); + }); + + it('should add twitter with callback using resolveUrl', () => { + const result = new HeadBuilder({ + metadataBase: new URL('https://devsantara.com'), + }) + .addTwitter((h) => ({ + title: 'My Page', + image: { url: h.resolveUrl('/image.jpg') }, + })) + .build(); + + expect(result[0].attributes).toEqual({ + name: 'twitter:title', + content: 'My Page', + }); + expect(result[1].attributes).toEqual({ + name: 'twitter:image', + content: 'https://devsantara.com/image.jpg', + }); + }); + + it('should return builder for chaining', () => { + const builder = new HeadBuilder(); + expect(builder.addTwitter({ title: 'Title' })).toBe(builder); + }); +}); diff --git a/src/tests/builder-add-viewport.test.ts b/src/tests/builder-add-viewport.test.ts new file mode 100644 index 0000000..e0c31bc --- /dev/null +++ b/src/tests/builder-add-viewport.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest'; +import { HeadBuilder } from '../builder'; + +describe('HeadBuilder.addViewport', () => { + it('should add viewport with width and initialScale', () => { + const result = new HeadBuilder() + .addViewport({ width: 'device-width', initialScale: 1 }) + .build(); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'meta', + attributes: { + name: 'viewport', + content: 'width=device-width, initial-scale=1', + }, + }); + }); + + it('should add viewport with numeric width and height', () => { + const result = new HeadBuilder() + .addViewport({ width: 320, height: 568 }) + .build(); + + expect(result[0].attributes.content).toBe('width=320, height=568'); + }); + + it('should add viewport with scale options', () => { + const result = new HeadBuilder() + .addViewport({ initialScale: 1, minimumScale: 0.5, maximumScale: 2 }) + .build(); + + expect(result[0].attributes.content).toBe( + 'initial-scale=1, minimum-scale=0.5, maximum-scale=2', + ); + }); + + it('should add viewport with userScalable yes/no', () => { + expect( + new HeadBuilder().addViewport({ userScalable: true }).build()[0] + .attributes.content, + ).toBe('user-scalable=yes'); + + expect( + new HeadBuilder().addViewport({ userScalable: false }).build()[0] + .attributes.content, + ).toBe('user-scalable=no'); + }); + + it('should add viewport with viewportFit and interactiveWidget', () => { + const result = new HeadBuilder() + .addViewport({ + viewportFit: 'cover', + interactiveWidget: 'resizes-content', + }) + .build(); + + expect(result[0].attributes.content).toBe( + 'viewport-fit=cover, interactive-widget=resizes-content', + ); + }); + + it('should add viewport with all options combined', () => { + const result = new HeadBuilder() + .addViewport({ + width: 'device-width', + height: 'device-height', + initialScale: 1, + minimumScale: 0.5, + maximumScale: 2, + userScalable: false, + viewportFit: 'cover', + interactiveWidget: 'resizes-visual', + }) + .build(); + + expect(result[0].attributes.content).toBe( + 'width=device-width, height=device-height, initial-scale=1, minimum-scale=0.5, maximum-scale=2, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-visual', + ); + }); + + it('should return builder for chaining', () => { + const builder = new HeadBuilder(); + expect(builder.addViewport({ width: 'device-width' })).toBe(builder); + }); +}); diff --git a/src/tests/builder-element-key.test.ts b/src/tests/builder-element-key.test.ts new file mode 100644 index 0000000..681a3ff --- /dev/null +++ b/src/tests/builder-element-key.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect } from 'vitest'; +import { HeadBuilder } from '../builder'; + +describe('HeadBuilder Element Key and Deduplication', () => { + describe('unique element keys', () => { + it('should use "title" key - only one title element exists', () => { + const result = new HeadBuilder() + .addTitle('First') + .addTitle('Second') + .build(); + + expect(result).toHaveLength(1); + expect(result[0].attributes).toEqual({ children: 'Second' }); + }); + + it('should use "meta:charSet" key - only one charset exists', () => { + const result = new HeadBuilder() + .addCharSet('utf-8') + .addCharSet('iso-8859-1') + .build(); + + expect(result).toHaveLength(1); + expect(result[0].attributes).toEqual({ charSet: 'iso-8859-1' }); + }); + + it('should use "meta:name:{name}" key - same name deduplicates', () => { + const result = new HeadBuilder() + .addMeta({ name: 'description', content: 'First' }) + .addMeta({ name: 'description', content: 'Second' }) + .addMeta({ name: 'keywords', content: 'Unique' }) + .build(); + + expect(result).toHaveLength(2); + expect(result[0].attributes).toEqual({ + name: 'description', + content: 'Second', + }); + expect(result[1].attributes).toEqual({ + name: 'keywords', + content: 'Unique', + }); + }); + + it('should use "meta:property:{property}" key - same property deduplicates', () => { + const result = new HeadBuilder() + .addMeta({ property: 'og:title', content: 'First' }) + .addMeta({ property: 'og:title', content: 'Second' }) + .addMeta({ property: 'og:description', content: 'Unique' }) + .build(); + + expect(result).toHaveLength(2); + expect(result[0].attributes).toEqual({ + property: 'og:title', + content: 'Second', + }); + }); + + it('should use JSON key for meta with property but no content', () => { + const result = new HeadBuilder() + .addMeta({ property: 'og:test' }) + .addMeta({ property: 'og:test' }) + .build(); + + expect(result).toHaveLength(1); + expect(result[0].attributes).toEqual({ property: 'og:test' }); + }); + + it('should use "link:canonical" key - only one canonical exists', () => { + const result = new HeadBuilder() + .addCanonical('https://devsantara.com/first') + .addCanonical('https://devsantara.com/second') + .build(); + + expect(result).toHaveLength(1); + expect(result[0].attributes).toEqual({ + rel: 'canonical', + href: 'https://devsantara.com/second', + }); + }); + + it('should use "link:manifest" key - only one manifest exists', () => { + const result = new HeadBuilder() + .addManifest('/first.json') + .addManifest('/second.json') + .build(); + + expect(result).toHaveLength(1); + expect(result[0].attributes).toEqual({ + rel: 'manifest', + href: '/second.json', + }); + }); + + it('should use "link:alternate:{hrefLang}" key - same hrefLang deduplicates', () => { + const result = new HeadBuilder() + .addAlternateLocale({ 'en-US': '/en-first', 'fr-FR': '/fr' }) + .addAlternateLocale({ 'en-US': '/en-second' }) + .build(); + + expect(result).toHaveLength(2); + expect(result[0].attributes).toEqual({ + rel: 'alternate', + hrefLang: 'en-US', + href: '/en-second', + }); + expect(result[1].attributes).toEqual({ + rel: 'alternate', + hrefLang: 'fr-FR', + href: '/fr', + }); + }); + + it('should use JSON key for non-special elements - identical attributes deduplicate', () => { + const result = new HeadBuilder() + .addLink('https://fonts.com', { rel: 'preconnect' }) + .addLink('https://fonts.com', { rel: 'preconnect' }) + .addLink('https://fonts.com', { rel: 'dns-prefetch' }) + .build(); + + expect(result).toHaveLength(2); + }); + }); + + describe('cross-method deduplication', () => { + it('should deduplicate same meta property across methods', () => { + const result = new HeadBuilder() + .addOpenGraph({ title: 'OG Title First' }) + .addMeta({ property: 'og:title', content: 'Manual Second' }) + .build(); + + expect(result).toHaveLength(1); + expect(result[0].attributes).toEqual({ + property: 'og:title', + content: 'Manual Second', + }); + }); + + it('should keep separate keys for name vs property attributes', () => { + const result = new HeadBuilder() + .addTwitter({ title: 'Twitter' }) + .addOpenGraph({ title: 'OG' }) + .build(); + + expect(result).toHaveLength(2); + expect(result[0].attributes).toHaveProperty('name', 'twitter:title'); + expect(result[1].attributes).toHaveProperty('property', 'og:title'); + }); + }); + + describe('element order preservation', () => { + it('should preserve insertion order for non-deduplicated elements', () => { + const result = new HeadBuilder() + .addMeta({ name: 'author', content: 'John' }) + .addMeta({ name: 'keywords', content: 'test' }) + .addMeta({ name: 'description', content: 'desc' }) + .build(); + + expect(result).toHaveLength(3); + expect(result[0].attributes).toHaveProperty('name', 'author'); + expect(result[1].attributes).toHaveProperty('name', 'keywords'); + expect(result[2].attributes).toHaveProperty('name', 'description'); + }); + + it('should move deduplicated element to original position', () => { + const result = new HeadBuilder() + .addTitle('First') + .addMeta({ name: 'description', content: 'Desc' }) + .addTitle('Second') + .build(); + + expect(result).toHaveLength(2); + expect(result[0].type).toBe('title'); + expect(result[1].type).toBe('meta'); + }); + }); +}); diff --git a/src/tests/builder-instance.test.ts b/src/tests/builder-instance.test.ts new file mode 100644 index 0000000..123e0e3 --- /dev/null +++ b/src/tests/builder-instance.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { HeadBuilder } from '../builder'; +import type { HeadAdapter } from '../types'; + +describe('HeadBuilder', () => { + it('should create instance with metadataBase and adapter options', () => { + const mockAdapter: HeadAdapter<string> = { + transform: (elements) => JSON.stringify(elements), + }; + const builder = new HeadBuilder({ + metadataBase: new URL('https://devsantara.com'), + adapter: mockAdapter, + }); + expect(builder).toBeInstanceOf(HeadBuilder); + }); + + it('should return HeadElement[] from build() without adapter', () => { + const result = new HeadBuilder().build(); + expect(Array.isArray(result)).toBe(true); + }); + + it('should transform output from build() when adapter provided', () => { + const mockAdapter: HeadAdapter<{ custom: string }> = { + transform: () => ({ custom: 'adapted' }), + }; + const result = new HeadBuilder({ adapter: mockAdapter }).build(); + expect(result).toEqual({ custom: 'adapted' }); + }); +}); diff --git a/src/tests/builder-resolve-url.test.ts b/src/tests/builder-resolve-url.test.ts new file mode 100644 index 0000000..37dd051 --- /dev/null +++ b/src/tests/builder-resolve-url.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect } from 'vitest'; +import { HeadBuilder } from '../builder'; +import type { HeadElement } from '../types'; + +describe('HeadBuilder URL Resolution', () => { + const metadataBase = new URL('https://devsantara.com'); + + describe('resolveUrl with metadataBase', () => { + it('should resolve relative paths (/, ./, ., nested)', () => { + const builder = new HeadBuilder({ metadataBase }); + + const tests = [ + { input: '/page', expected: 'https://devsantara.com/page' }, + { input: './', expected: 'https://devsantara.com/' }, + { input: '.', expected: 'https://devsantara.com/' }, + { + input: '/blog/posts/article', + expected: 'https://devsantara.com/blog/posts/article', + }, + ]; + + for (const { input, expected } of tests) { + const result = builder.addCanonical((h) => h.resolveUrl(input)).build(); + expect(result[0].attributes).toEqual({ + rel: 'canonical', + href: expected, + }); + } + }); + + it('should preserve query params and hash fragments', () => { + const result = new HeadBuilder({ metadataBase }) + .addCanonical((h) => h.resolveUrl('/page?param=value#section')) + .build(); + + expect(result[0].attributes).toEqual({ + rel: 'canonical', + href: 'https://devsantara.com/page?param=value#section', + }); + }); + + it('should respect metadataBase with subdomain and port', () => { + const customBase = new URL('https://blog.example.com:3000'); + const result = new HeadBuilder({ metadataBase: customBase }) + .addCanonical((h) => h.resolveUrl('/page')) + .build(); + + expect(result[0].attributes).toEqual({ + rel: 'canonical', + href: 'https://blog.example.com:3000/page', + }); + }); + }); + + describe('resolveUrl with URL object', () => { + it('should return URL href ignoring metadataBase', () => { + const result = new HeadBuilder({ metadataBase }) + .addCanonical((h) => + h.resolveUrl(new URL('https://other.com:8080/path?q=v#h')), + ) + .build(); + + expect(result[0].attributes).toEqual({ + rel: 'canonical', + href: 'https://other.com:8080/path?q=v#h', + }); + }); + }); + + describe('resolveUrl without metadataBase', () => { + it('should return raw string URL as-is', () => { + const tests = ['/page', './page', 'https://devsantara.com/page']; + + for (const input of tests) { + const result = new HeadBuilder() + .addCanonical((h) => h.resolveUrl(input)) + .build(); + expect(result[0].attributes).toEqual({ rel: 'canonical', href: input }); + } + }); + }); + + describe('resolveUrl in different methods', () => { + it('should work across addOpenGraph, addTwitter, addAlternateLocale, addManifest, addIcon', () => { + const result = new HeadBuilder({ metadataBase }) + .addOpenGraph((h) => ({ + url: new URL(h.resolveUrl('/page')), + image: { url: new URL(h.resolveUrl('/og.jpg')) }, + })) + .addTwitter((h) => ({ + image: { url: new URL(h.resolveUrl('/tw.jpg')) }, + })) + .addAlternateLocale((h) => ({ 'en-US': h.resolveUrl('/en') })) + .addManifest((h) => h.resolveUrl('/manifest.json')) + .addIcon('apple', (h) => ({ href: new URL(h.resolveUrl('/icon.png')) })) + .build(); + + expect(result[0].attributes).toEqual({ + property: 'og:url', + content: 'https://devsantara.com/page', + }); + expect(result[1].attributes).toEqual({ + property: 'og:image', + content: 'https://devsantara.com/og.jpg', + }); + expect(result[2].attributes).toEqual({ + name: 'twitter:image', + content: 'https://devsantara.com/tw.jpg', + }); + expect(result[3].attributes).toEqual({ + rel: 'alternate', + hrefLang: 'en-US', + href: 'https://devsantara.com/en', + }); + expect(result[4].attributes).toEqual({ + rel: 'manifest', + href: 'https://devsantara.com/manifest.json', + }); + expect(result[5].attributes).toEqual({ + rel: 'apple-touch-icon', + href: 'https://devsantara.com/icon.png', + }); + }); + }); + + describe('edge cases', () => { + it('should handle empty string, protocol-relative URLs, special characters', () => { + const result = new HeadBuilder<HeadElement<'link'>[]>({ + metadataBase: new URL('https://devsantara.com/path'), + }) + .addCanonical((h) => h.resolveUrl('')) + .build(); + expect(result[0].attributes.href).toBe('https://devsantara.com/path'); + + const result2 = new HeadBuilder<HeadElement<'link'>[]>() + .addCanonical((h) => h.resolveUrl('//example.com/page')) + .build(); + expect(result2[0].attributes.href).toBe('//example.com/page'); + + const result3 = new HeadBuilder<HeadElement<'link'>[]>({ metadataBase }) + .addCanonical((h) => h.resolveUrl('/page?name=John Doe')) + .build(); + expect(result3[0].attributes.href).toBe( + 'https://devsantara.com/page?name=John%20Doe', + ); + }); + }); +}); diff --git a/src/tests/utils.test.ts b/src/tests/utils.test.ts new file mode 100644 index 0000000..f641d7c --- /dev/null +++ b/src/tests/utils.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { getChildren, isElementOfType } from '../utils'; +import type { HeadElement } from '../types'; + +describe('Utils', () => { + describe('isElementOfType', () => { + it('should return false when element type does not match', () => { + const element: HeadElement<'title'> = { + type: 'title', + attributes: { children: 'My Title' }, + }; + const result = isElementOfType(element, 'meta'); + expect(result).toBe(false); + }); + + it('should return true when element type matches', () => { + const element: HeadElement<'title'> = { + type: 'title', + attributes: { children: 'My Title' }, + }; + const result = isElementOfType(element, 'title'); + expect(result).toBe(true); + }); + }); + + describe('getChildren', () => { + it('should return empty string when children is not present', () => { + const attributes: HeadElement<'meta'>['attributes'] = { + name: 'description', + content: 'A description', + }; + const result = getChildren(attributes); + expect(result).toBe(''); + }); + + it('should return children as string when present', () => { + const attributes: HeadElement<'title'>['attributes'] = { + children: 'My Title', + }; + const result = getChildren(attributes); + expect(result).toBe('My Title'); + }); + + it('should convert children to string if it is a number', () => { + const attributes: HeadElement<'title'>['attributes'] = { children: 123 }; + const result = getChildren(attributes); + expect(result).toBe('123'); + }); + + it('should handle boolean children', () => { + const attributes: HeadElement<'title'>['attributes'] = { children: true }; + const result = getChildren(attributes); + expect(result).toBe('true'); + }); + }); +}); diff --git a/src/types.ts b/src/types.ts index 181c209..c4784a5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -326,19 +326,17 @@ export interface TwitterOptions { } /** - * Locale key type supporting 'x-default', specific locale strings, or custom values. + * Locale key type supporting specific locale strings or custom values. */ -type AlternateLocaleKey<TLocale extends string> = - | ('x-default' | TLocale) - | (string & {}); +type AlternateLocaleKey<TLocale extends string> = TLocale | (string & {}); /** * Alternate locale/language mapping for internationalization, linking language codes to their corresponding URLs. + * The 'x-default' key is optional. */ -export type AlternateLocaleOptions<TLocale extends string> = Record< - AlternateLocaleKey<TLocale>, - string | URL ->; +export type AlternateLocaleOptions<TLocale extends string> = { + 'x-default'?: string | URL; +} & Record<AlternateLocaleKey<TLocale>, string | URL>; /** * Icon preset type with autocomplete for common icon types while allowing custom values. diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..b1b6af6 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + coverage: { + reportsDirectory: 'coverage', + reporter: ['text', 'html'], + }, + }, +}); From 58a7bb1014d789c3b41d6eb002cb59e88b4e61dc Mon Sep 17 00:00:00 2001 From: Edwin Tantawi <edwintantawi.dev@gmail.com> Date: Tue, 10 Feb 2026 01:15:53 +0700 Subject: [PATCH 6/6] docs: add detailed templated title and deduplication --- README.md | 81 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 72 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index f9cd2ef..244be0b 100644 --- a/README.md +++ b/README.md @@ -169,30 +169,93 @@ const head = new HeadBuilder({ ### With Templated Title -Set a title template once and dynamically update titles on different pages: +Set a title template with a default value, then pass page-specific titles as strings. The builder automatically applies the saved template to subsequent title updates: ```typescript import { HeadBuilder } from '@devsantara/head'; -// Shared head +// Create a builder and set title template with default +// The template stays active for all future addTitle() calls const sharedHead = new HeadBuilder().addTitle({ - template: '%s | My Awesome site', // <- Set title template - default: 'Home', + template: '%s | My Awesome site', // Store template (%s is the placeholder) + default: 'Home', // Initial title using template }); - -// Home page -const homeHead = sharedHead; // Output: <title>Home | My Awesome site -// Posts page +// Update title for Posts page +// Pass a string, builder applies the saved template automatically const postHead = sharedHead.addTitle('Posts').build(); // Output: Posts | My Awesome site -// About page +// Update title for About page +// Template is still active from the first addTitle() call const aboutHead = sharedHead.addTitle('About Us').build(); // Output: About Us | My Awesome site ``` +**How it works:** + +1. First `addTitle()` with template object stores the template internally +2. Subsequent `addTitle()` calls with strings automatically use the stored template +3. The `%s` placeholder gets replaced with your page title +4. Each title replaces the previous one (deduplication) + +### With Element Deduplication + +HeadBuilder automatically deduplicates elements—when you add an element matching an existing one, the new one replaces the old: + +```typescript +import { HeadBuilder } from '@devsantara/head'; + +const head = new HeadBuilder() + .addTitle('My Site') + .addTitle('Updated Title') // Replaces previous title + + .addDescription('First description') + .addDescription('Updated description') // Replaces previous + + .addMeta({ name: 'keywords', content: 'web, development' }) + .addMeta({ name: 'author', content: 'John Doe' }) // Separate meta tags coexist + + .addCanonical('https://devsantara.com/page1') + .addCanonical('https://devsantara.com/page2') // Replaces previous canonical + + .build(); +``` + +```typescript +// Output (HeadElement[]): +[ + { type: 'title', attributes: { children: 'Updated Title' } }, + { + type: 'meta', + attributes: { name: 'description', content: 'Updated description' }, + }, + { + type: 'meta', + attributes: { name: 'keywords', content: 'web, development' }, + }, + { type: 'meta', attributes: { name: 'author', content: 'John Doe' } }, + { + type: 'link', + attributes: { rel: 'canonical', href: 'https://devsantara.com/page2' }, + }, +]; +``` + +**How it works:** + +- **Title**: Only one per document +- **Meta by name**: One per unique `name` attribute (e.g., description, keywords) +- **Meta by property**: One per unique `property` attribute (e.g., `og:title`, `og:description`) +- **Charset**: Only one per document +- **Canonical**: Only one per document +- **Manifest**: Only one per document +- **Alternate locales**: One per unique language code +- **Other tags**: Deduplicated by exact attribute match + +This ensures clean metadata without accidental duplicates. + ### With React Adapter ```tsx