Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/frank-books-grow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@devsantara/head': patch
---

test: add test case to cover all codebase
5 changes: 5 additions & 0 deletions .changeset/public-turtles-hammer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@devsantara/head': minor
---

fix(builder): missing manifest key and remove unused try-catch
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,8 @@ node_modules/
# Build
dist/

# Testing
coverage/

# Misc
.DS_Store
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
421 changes: 311 additions & 110 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

40 changes: 40 additions & 0 deletions src/adapters/tests/react-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -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,
}),
);
});
});
});
});
208 changes: 208 additions & 0 deletions src/adapters/tests/tanstack-router-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
17 changes: 8 additions & 9 deletions src/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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}`;
}
Expand Down Expand Up @@ -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();
*/
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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,
});
Expand Down
Loading