Skip to content

Commit 6a4aa5f

Browse files
committed
feat(adapter): add build adapter for react and tanstack router
1 parent 19a9300 commit 6a4aa5f

10 files changed

Lines changed: 328 additions & 152 deletions

File tree

package.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"types": "./dist/index.d.ts",
2828
"exports": {
2929
".": "./dist/index.js",
30+
"./adapters": "./dist/adapters/index.js",
3031
"./package.json": "./package.json"
3132
},
3233
"scripts": {
@@ -39,10 +40,6 @@
3940
"format": "oxfmt",
4041
"format:check": "oxfmt --check"
4142
},
42-
"dependencies": {
43-
"@types/react": "^19.2.10",
44-
"react": "^19.2.4"
45-
},
4643
"devDependencies": {
4744
"oxfmt": "^0.27.0",
4845
"oxlint": "^1.42.0",
@@ -51,5 +48,9 @@
5148
"typescript": "^5.9.3",
5249
"vitest": "^4.0.18"
5350
},
51+
"peerDependencies": {
52+
"@types/react": ">=18.0.0 || >=19.0.0",
53+
"react": ">=18.0.0 || >=19.0.0"
54+
},
5455
"packageManager": "pnpm@10.28.2"
5556
}

pnpm-lock.yaml

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/adapters/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './react-adapter';
2+
export * from './tanstack-router-adapter';

src/adapters/react-adapter.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { createElement, type ReactNode } from 'react';
2+
import type { HeadAdapter, HeadElement } from '../types';
3+
4+
export type HeadReactAdapterResult = ReactNode[];
5+
6+
/**
7+
* Adapter for converting HeadElement[] to React elements
8+
*
9+
* This adapter transforms the head elements into React.ReactNode[] that can be
10+
* rendered inside a React component or used with React-based head management libraries.
11+
*
12+
* @example
13+
* const elements = new HeadBuilder()
14+
* .addMeta({ name: 'description', content: 'My site' })
15+
* .addLink({ rel: 'canonical', href: 'https://example.com' })
16+
* .build();
17+
*
18+
* const reactElements = HeadReactAdapter.adapter(elements);
19+
* // Returns: [<meta name="description" content="My site" />, <link rel="canonical" href="https://example.com" />]
20+
*/
21+
export class HeadReactAdapter implements HeadAdapter<HeadReactAdapterResult> {
22+
/**
23+
* Transforms HeadElement[] to React.ReactNode[]
24+
* @param elements - Array of head elements from HeadBuilder.build()
25+
* @returns An array of React elements
26+
*/
27+
static adapter(elements: HeadElement[]): HeadReactAdapterResult {
28+
return elements.map((element, index) => {
29+
const { type, attributes } = element;
30+
31+
return createElement(type, {
32+
key: `head-${type}-${index}`,
33+
...attributes,
34+
});
35+
});
36+
}
37+
38+
adapter(elements: HeadElement[]): HeadReactAdapterResult {
39+
return HeadReactAdapter.adapter(elements);
40+
}
41+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type {
2+
HeadAdapter,
3+
HeadElement,
4+
HeadLinkAttributes,
5+
HeadMetaAttributes,
6+
HeadScriptAttributes,
7+
HeadStyleAttributes,
8+
} from '../types';
9+
import { isElementOfType } from '../utils';
10+
11+
export interface HeadTanStackRouterAdapterResult {
12+
links?: HeadLinkAttributes[];
13+
scripts?: HeadScriptAttributes[];
14+
meta?: HeadMetaAttributes[];
15+
styles?: HeadStyleAttributes[];
16+
}
17+
18+
/**
19+
* Adapter for converting HeadElement[] to TanStack Router head configuration
20+
*
21+
* This adapter transforms the head elements into the format expected by
22+
* TanStack Router's head management system, organizing elements by type.
23+
*
24+
* @example
25+
* const elements = new HeadBuilder()
26+
* .addMeta({ name: 'description', content: 'My site' })
27+
* .addLink({ rel: 'canonical', href: 'https://example.com' })
28+
* .build();
29+
*
30+
* const config = HeadTanstackRouterAdapter.adapter(elements);
31+
* // Returns: {
32+
* // meta: [{ name: 'description', content: 'My site' }],
33+
* // links: [{ rel: 'canonical', href: 'https://example.com' }],
34+
* // scripts: [],
35+
* // styles: []
36+
* // }
37+
*/
38+
export class HeadTanstackRouterAdapter implements HeadAdapter<HeadTanStackRouterAdapterResult> {
39+
/**
40+
* Transforms HeadElement[] to TanStack Router head config
41+
* @param elements - Array of head elements from HeadBuilder.build()
42+
* @returns A TanStackHeadConfig object with elements organized by type
43+
*/
44+
static adapter(elements: HeadElement[]): HeadTanStackRouterAdapterResult {
45+
const config: HeadTanStackRouterAdapterResult = {
46+
meta: [],
47+
links: [],
48+
scripts: [],
49+
styles: [],
50+
};
51+
52+
for (const element of elements) {
53+
if (isElementOfType(element, 'meta')) {
54+
config.meta?.push(element.attributes);
55+
} else if (isElementOfType(element, 'link')) {
56+
config.links?.push(element.attributes);
57+
} else if (isElementOfType(element, 'script')) {
58+
config.scripts?.push(element.attributes);
59+
} else if (isElementOfType(element, 'style')) {
60+
config.styles?.push(element.attributes);
61+
}
62+
}
63+
64+
return config;
65+
}
66+
67+
adapter(elements: HeadElement[]): HeadTanStackRouterAdapterResult {
68+
return HeadTanstackRouterAdapter.adapter(elements);
69+
}
70+
}

src/builder.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import type { HeadAttributeTypeMap, HeadAdapter, HeadElement } from './types';
2+
3+
export class HeadBuilder<TOutput = HeadElement[]> {
4+
private metadataBase?: URL;
5+
private elements: HeadElement[] = [];
6+
private adapter?: HeadAdapter<TOutput>;
7+
8+
/**
9+
* Creates a new HeadBuilder instance with optional metadataBase and adapter configuration
10+
*
11+
* The metadataBase serves as the base path and origin for absolute URLs in various
12+
* metadata fields. When relative URLs (for Open Graph images, alternates, etc.) are used,
13+
* they are composed with this base. If not provided, relative URLs will be used as-is.
14+
*
15+
* The adapter can be injected to automatically transform the output when calling build().
16+
* If provided, build() will return the adapted output; otherwise, it returns HeadElement[].
17+
*
18+
* @param options - Configuration options
19+
* @param options.metadataBase - The base URL to use for resolving relative URLs in metadata
20+
* @param options.adapter - Optional adapter instance to transform the build output
21+
*
22+
* @example
23+
* // Without adapter - returns HeadElement[]
24+
* const elements = new HeadBuilder()
25+
* .addMeta({ name: 'description', content: 'My site' })
26+
* .build();
27+
*
28+
* @example
29+
* // With HTMLAdapter - returns string
30+
* const html = new HeadBuilder({ adapter: new HTMLAdapter() })
31+
* .addMeta({ name: 'description', content: 'My site' })
32+
* .build();
33+
*
34+
* @example
35+
* // With metadataBase and ReactAdapter - returns ReactNode[]
36+
* const reactNodes = new HeadBuilder({
37+
* metadataBase: new URL('https://devsantara.com'),
38+
* adapter: new ReactAdapter()
39+
* })
40+
* .addMeta({ name: 'description', content: 'My site' })
41+
* .build();
42+
*/
43+
constructor(options?: {
44+
metadataBase?: URL;
45+
adapter?: HeadAdapter<TOutput>;
46+
}) {
47+
this.metadataBase = options?.metadataBase;
48+
this.adapter = options?.adapter;
49+
}
50+
51+
/**
52+
* Adds a head element to the builder's collection
53+
*
54+
* This private method is used internally to add metadata elements (meta, link, script, or style)
55+
* to the collection that will be used when building the final head configuration.
56+
*
57+
* @example
58+
* this.addElement('meta', { name: 'description', content: 'A description' })
59+
* this.addElement('link', { rel: 'canonical', href: 'https://devsantara.com' })
60+
*/
61+
private addElement<T extends keyof HeadAttributeTypeMap>(
62+
type: T,
63+
attributes: HeadAttributeTypeMap[T],
64+
) {
65+
this.elements.push({ type, attributes });
66+
return this;
67+
}
68+
69+
/**
70+
* Gets the configured metadataBase URL
71+
*/
72+
getMetadataBase(): URL | undefined {
73+
return this.metadataBase;
74+
}
75+
76+
/**
77+
* Adds a meta element directly to the head configuration
78+
*
79+
* This is a general utility method for adding meta elements when a specific
80+
* helper method doesn't exist. It directly adds the element to the internal collection.
81+
*
82+
* @example
83+
* const head = new HeadBuilder()
84+
* .addMeta({ name: 'description', content: 'My site description' })
85+
* .addMeta({ charSet: 'utf-8' })
86+
* .build();
87+
*/
88+
addMeta(attributes: HeadAttributeTypeMap['meta']) {
89+
return this.addElement('meta', attributes);
90+
}
91+
92+
/**
93+
* Adds a link element directly to the head configuration
94+
*
95+
* This is a general utility method for adding link elements when a specific
96+
* helper method doesn't exist. It directly adds the element to the internal collection.
97+
*
98+
* @example
99+
* const head = new HeadBuilder()
100+
* .addLink({ rel: 'canonical', href: 'https://devsantara.com' })
101+
* .addLink({ rel: 'stylesheet', href: '/styles.css' })
102+
* .build();
103+
*/
104+
addLink(attributes: HeadAttributeTypeMap['link']) {
105+
return this.addElement('link', attributes);
106+
}
107+
108+
/**
109+
* Adds a script element directly to the head configuration
110+
*
111+
* This is a general utility method for adding script elements when a specific
112+
* helper method doesn't exist. It directly adds the element to the internal collection.
113+
*
114+
* @example
115+
* const head = new HeadBuilder()
116+
* .addScript({ src: '/analytics.js', async: true })
117+
* .addScript({children: 'console.log("Hello World");'});
118+
* .build();
119+
*/
120+
addScript(attributes: HeadAttributeTypeMap['script']) {
121+
return this.addElement('script', attributes);
122+
}
123+
124+
/**
125+
* Adds a style element directly to the head configuration
126+
*
127+
* This is a general utility method for adding style elements when a specific
128+
* helper method doesn't exist. It directly adds the element to the internal collection.
129+
*
130+
* @example
131+
* const head = new HeadBuilder()
132+
* .addStyle({
133+
* children: `
134+
* .header { background: #333; color: white; padding: 20px; }
135+
* .hero { min-height: 100vh; display: flex; align-items: center; }
136+
* `
137+
* })
138+
* .build();
139+
*/
140+
addStyle(attributes: HeadAttributeTypeMap['style']) {
141+
return this.addElement('style', attributes);
142+
}
143+
144+
/**
145+
* Builds and returns the head configuration
146+
*
147+
* If an adapter was provided in the constructor, returns the adapted output.
148+
* Otherwise, returns the raw HeadElement[] array.
149+
*/
150+
build(): TOutput {
151+
if (this.adapter) {
152+
return this.adapter.adapter(this.elements);
153+
}
154+
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
155+
return this.elements as unknown as TOutput;
156+
}
157+
}

0 commit comments

Comments
 (0)