@devsantara/head 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -60,7 +60,12 @@ import { HeadBuilder } from '@devsantara/head';
60
60
  const head = new HeadBuilder()
61
61
  .addTitle('My Awesome Website')
62
62
  .addDescription('A comprehensive guide to web development')
63
+ .addStyle('body { margin: 0; padding: 0; }')
63
64
  .addViewport({ width: 'device-width', initialScale: 1 })
65
+ .addScript({ code: 'console.log("Hello, world!");' })
66
+ .addScript(new URL('https://devsantara.com/assets/scripts/utils.js'), {
67
+ async: true,
68
+ })
64
69
  .build();
65
70
  ```
66
71
 
@@ -85,6 +90,28 @@ const head = new HeadBuilder()
85
90
  content: 'width=device-width, initial-scale=1',
86
91
  },
87
92
  },
93
+ {
94
+ type: 'style',
95
+ attributes: {
96
+ type: 'text/css',
97
+ children: 'body { margin: 0; padding: 0; }',
98
+ },
99
+ },
100
+ {
101
+ type: 'script',
102
+ attributes: {
103
+ type: 'text/javascript',
104
+ children: 'console.log("Hello, world!");',
105
+ },
106
+ },
107
+ {
108
+ type: 'script',
109
+ attributes: {
110
+ type: 'text/javascript',
111
+ src: 'https://devsantara.com/assets/scripts/utils.js',
112
+ async: true,
113
+ },
114
+ },
88
115
  ];
89
116
  ```
90
117
 
@@ -140,6 +167,95 @@ const head = new HeadBuilder({
140
167
  ];
141
168
  ```
142
169
 
170
+ ### With Templated Title
171
+
172
+ 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:
173
+
174
+ ```typescript
175
+ import { HeadBuilder } from '@devsantara/head';
176
+
177
+ // Create a builder and set title template with default
178
+ // The template stays active for all future addTitle() calls
179
+ const sharedHead = new HeadBuilder().addTitle({
180
+ template: '%s | My Awesome site', // Store template (%s is the placeholder)
181
+ default: 'Home', // Initial title using template
182
+ });
183
+ // Output: <title>Home | My Awesome site</title>
184
+
185
+ // Update title for Posts page
186
+ // Pass a string, builder applies the saved template automatically
187
+ const postHead = sharedHead.addTitle('Posts').build();
188
+ // Output: <title>Posts | My Awesome site</title>
189
+
190
+ // Update title for About page
191
+ // Template is still active from the first addTitle() call
192
+ const aboutHead = sharedHead.addTitle('About Us').build();
193
+ // Output: <title>About Us | My Awesome site</title>
194
+ ```
195
+
196
+ **How it works:**
197
+
198
+ 1. First `addTitle()` with template object stores the template internally
199
+ 2. Subsequent `addTitle()` calls with strings automatically use the stored template
200
+ 3. The `%s` placeholder gets replaced with your page title
201
+ 4. Each title replaces the previous one (deduplication)
202
+
203
+ ### With Element Deduplication
204
+
205
+ HeadBuilder automatically deduplicates elements—when you add an element matching an existing one, the new one replaces the old:
206
+
207
+ ```typescript
208
+ import { HeadBuilder } from '@devsantara/head';
209
+
210
+ const head = new HeadBuilder()
211
+ .addTitle('My Site')
212
+ .addTitle('Updated Title') // Replaces previous title
213
+
214
+ .addDescription('First description')
215
+ .addDescription('Updated description') // Replaces previous
216
+
217
+ .addMeta({ name: 'keywords', content: 'web, development' })
218
+ .addMeta({ name: 'author', content: 'John Doe' }) // Separate meta tags coexist
219
+
220
+ .addCanonical('https://devsantara.com/page1')
221
+ .addCanonical('https://devsantara.com/page2') // Replaces previous canonical
222
+
223
+ .build();
224
+ ```
225
+
226
+ ```typescript
227
+ // Output (HeadElement[]):
228
+ [
229
+ { type: 'title', attributes: { children: 'Updated Title' } },
230
+ {
231
+ type: 'meta',
232
+ attributes: { name: 'description', content: 'Updated description' },
233
+ },
234
+ {
235
+ type: 'meta',
236
+ attributes: { name: 'keywords', content: 'web, development' },
237
+ },
238
+ { type: 'meta', attributes: { name: 'author', content: 'John Doe' } },
239
+ {
240
+ type: 'link',
241
+ attributes: { rel: 'canonical', href: 'https://devsantara.com/page2' },
242
+ },
243
+ ];
244
+ ```
245
+
246
+ **How it works:**
247
+
248
+ - **Title**: Only one per document
249
+ - **Meta by name**: One per unique `name` attribute (e.g., description, keywords)
250
+ - **Meta by property**: One per unique `property` attribute (e.g., `og:title`, `og:description`)
251
+ - **Charset**: Only one per document
252
+ - **Canonical**: Only one per document
253
+ - **Manifest**: Only one per document
254
+ - **Alternate locales**: One per unique language code
255
+ - **Other tags**: Deduplicated by exact attribute match
256
+
257
+ This ensures clean metadata without accidental duplicates.
258
+
143
259
  ### With React Adapter
144
260
 
145
261
  ```tsx
@@ -222,13 +338,13 @@ export const Route = createRootRoute({
222
338
 
223
339
  For advanced use cases not covered by the essential methods below, use these basic methods to add any custom element directly.
224
340
 
225
- | Method | Description |
226
- | ------------------------------------------------------- | ------------------------------------------------ |
227
- | `addTitle(title: string)` | Adds a `<title>` element |
228
- | `addMeta(attributes: HeadAttributeTypeMap['meta'])` | Adds a `<meta>` element with custom attributes |
229
- | `addLink(attributes: HeadAttributeTypeMap['link'])` | Adds a `<link>` element with custom attributes |
230
- | `addScript(attributes: HeadAttributeTypeMap['script'])` | Adds a `<script>` element with custom attributes |
231
- | `addStyle(attributes: HeadAttributeTypeMap['style'])` | Adds a `<style>` element with custom attributes |
341
+ | Method | Description |
342
+ | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
343
+ | `addTitle(title: string \| TitleOptions)` | Adds a `<title>` element with optional templating |
344
+ | `addMeta(attributes: HeadAttributeTypeMap['meta'])` | Adds a `<meta>` element with custom attributes |
345
+ | `addLink(href: string \| URL, attributes?)` | Adds a `<link>` element with a URL and custom attributes |
346
+ | `addScript(srcOrCode: string \| URL \| { code: string }, attributes?)` | Adds a `<script>` element (external file with string/URL or inline with `{ code: string }`) |
347
+ | `addStyle(css: string, attributes?)` | Adds a `<style>` element with inline CSS |
232
348
 
233
349
  ### Essential Methods
234
350
 
@@ -1,4 +1,4 @@
1
- import { c as HeadMetaAttributes, i as HeadAdapter, l as HeadScriptAttributes, o as HeadElement, s as HeadLinkAttributes, u as HeadStyleAttributes } from "../types-Cvpk_Zha.js";
1
+ import { c as HeadMetaAttributes, i as HeadAdapter, l as HeadScriptAttributes, o as HeadElement, s as HeadLinkAttributes, u as HeadStyleAttributes } from "../types-D-ta-0P-.js";
2
2
  import { ReactNode } from "react";
3
3
 
4
4
  //#region src/adapters/react-adapter.d.ts
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { _ as TwitterOptions, a as HeadAttributeTypeMap, c as HeadMetaAttributes, d as HeadTitleAttributes, f as IconOptions, g as StylesheetOptions, h as RobotsOptions, i as HeadAdapter, l as HeadScriptAttributes, m as OpenGraphOptions, n as CharSet, o as HeadElement, p as IconPreset, r as ColorScheme, s as HeadLinkAttributes, t as AlternateLocaleOptions, u as HeadStyleAttributes, v as ViewportOptions } from "./types-Cvpk_Zha.js";
1
+ import { _ as TitleOptions, a as HeadAttributeTypeMap, c as HeadMetaAttributes, d as HeadTitleAttributes, f as IconOptions, g as StylesheetOptions, h as RobotsOptions, i as HeadAdapter, l as HeadScriptAttributes, m as OpenGraphOptions, n as CharSet, o as HeadElement, p as IconPreset, r as ColorScheme, s as HeadLinkAttributes, t as AlternateLocaleOptions, u as HeadStyleAttributes, v as TwitterOptions, y as ViewportOptions } from "./types-D-ta-0P-.js";
2
2
 
3
3
  //#region src/builder.d.ts
4
4
  /**
@@ -18,9 +18,25 @@ interface BuilderHelper {
18
18
  */
19
19
  type BuilderOption<T> = T | ((helper: BuilderHelper) => T);
20
20
  declare class HeadBuilder<TOutput = HeadElement[]> {
21
+ /**
22
+ * Optional base URL for resolving relative URLs in metadata (Open Graph, canonical, etc.)
23
+ */
21
24
  private metadataBase?;
22
- private elements;
25
+ /**
26
+ * Optional adapter to transform the built head elements into a framework-specific format.
27
+ * If not provided, `build()` returns `HeadElement[]`
28
+ */
23
29
  private adapter?;
30
+ /**
31
+ * Internal storage for title options to support templated titles.
32
+ * This allows the builder to generate the title dynamically based on previously set options.
33
+ */
34
+ private titleOptions?;
35
+ /**
36
+ * Internal collection of head elements being built,
37
+ * stored in a Map for deduplication based on element type and key attributes.
38
+ */
39
+ private elementsMap;
24
40
  /**
25
41
  * Resolves a value that can be either static or a callback function receiving helper utilities.
26
42
  *
@@ -55,11 +71,20 @@ declare class HeadBuilder<TOutput = HeadElement[]> {
55
71
  */
56
72
  private resolveUrl;
57
73
  /**
58
- * Adds a head element to the internal collection for later transformation.
74
+ * Generates a unique key for a head element based on its type and attributes.
75
+ * Used for deduplication - elements with the same key replace previous ones.
76
+ *
77
+ * @param element - The head element to generate a key for
78
+ * @returns A unique string key for the element
79
+ */
80
+ private getElementKey;
81
+ /**
82
+ * Adds a head element to the internal collection with deduplication.
83
+ * Elements with the same key will replace previous ones.
59
84
  *
60
85
  * @param type - The HTML element type
61
86
  * @param attributes - The element's attributes
62
- * @returns The builder instance for method chaining
87
+ * @returns The unique key for the added element
63
88
  */
64
89
  private addElement;
65
90
  /**
@@ -75,41 +100,48 @@ declare class HeadBuilder<TOutput = HeadElement[]> {
75
100
  */
76
101
  addMeta(attributes: HeadAttributeTypeMap['meta']): this;
77
102
  /**
78
- * Adds a custom link element with any valid attributes. Use this for link tags without dedicated helper methods.
103
+ * Adds a custom link element with any valid attributes.
79
104
  *
80
- * @param attributes - The link element attributes
105
+ * @param href - The URL to link to
106
+ * @param attributes - Additional link element attributes
81
107
  * @returns The builder instance for method chaining
82
108
  *
83
109
  * @example
84
110
  * new HeadBuilder()
85
- * .addLink({ rel: 'preconnect', href: 'https://fonts.googleapis.com' })
111
+ * .addLink('https://fonts.googleapis.com', { rel: 'preconnect' })
86
112
  * .build();
87
113
  */
88
- addLink(attributes: HeadAttributeTypeMap['link']): this;
114
+ addLink(href: string | URL, attributes?: Omit<HeadAttributeTypeMap['link'], 'href'>): this;
89
115
  /**
90
- * Adds a custom script element with any valid attributes for external scripts or inline code.
116
+ * Adds a script element, either inline code or an external file.
91
117
  *
92
- * @param attributes - The script element attributes
118
+ * @param srcOrCode - Script source: a URL string/object for external files, or `{ code: string }` for inline scripts
119
+ * @param attributes - Additional script attributes (async, defer, integrity, etc.)
93
120
  * @returns The builder instance for method chaining
94
121
  *
95
122
  * @example
96
123
  * new HeadBuilder()
97
- * .addScript({ src: '/analytics.js', async: true })
124
+ * .addScript('/script.js')
125
+ * .addScript(new URL('https://devsantara.com/script.js'), { async: true })
126
+ * .addScript({ code: 'console.log("Hello, World!")' })
98
127
  * .build();
99
128
  */
100
- addScript(attributes: HeadAttributeTypeMap['script']): this;
129
+ addScript(srcOrCode: string | URL | {
130
+ code: string;
131
+ }, attributes?: Omit<HeadAttributeTypeMap['script'], 'children' | 'src'>): this;
101
132
  /**
102
- * Adds a custom style element with inline CSS.
133
+ * Adds an inline style element with CSS code.
103
134
  *
104
- * @param attributes - The style element attributes
135
+ * @param css - The inline CSS code
136
+ * @param attributes - Additional style attributes
105
137
  * @returns The builder instance for method chaining
106
138
  *
107
139
  * @example
108
140
  * new HeadBuilder()
109
- * .addStyle({ children: 'body { margin: 0; padding: 0; }' })
141
+ * .addStyle('body { margin: 0; padding: 0; }')
110
142
  * .build();
111
143
  */
112
- addStyle(attributes: HeadAttributeTypeMap['style']): this;
144
+ addStyle(css: string, attributes?: Omit<HeadAttributeTypeMap['style'], 'children'>): this;
113
145
  /**
114
146
  * Adds a character encoding declaration to specify how the document should be interpreted.
115
147
  *
@@ -136,16 +168,25 @@ declare class HeadBuilder<TOutput = HeadElement[]> {
136
168
  addColorScheme(colorScheme: ColorScheme): this;
137
169
  /**
138
170
  * Adds a title element that appears in browser tabs, search results, and bookmarks.
171
+ * Supports both simple string titles and templated titles with dynamic substitution.
139
172
  *
140
- * @param title - The document title text
173
+ * @param title - The document title as a string, or TitleOptions object with template and default
141
174
  * @returns The builder instance for method chaining
142
175
  *
143
176
  * @example
177
+ * // Simple title
144
178
  * new HeadBuilder()
145
179
  * .addTitle('My Awesome Website')
146
180
  * .build();
181
+ *
182
+ * @example
183
+ * // Templated title with page-specific suffix
184
+ * const baseHead = new HeadBuilder()
185
+ * .addTitle({ template: '%s | My Site', default: 'Home' })
186
+ *
187
+ * const head = baseHead.addTitle('About Us').build(); // Results in title "About Us | My Site"
147
188
  */
148
- addTitle(title: string): this;
189
+ addTitle(title: string | TitleOptions): this;
149
190
  /**
150
191
  * Adds viewport configuration for responsive web design and mobile optimization.
151
192
  *
@@ -232,7 +273,7 @@ declare class HeadBuilder<TOutput = HeadElement[]> {
232
273
  * @returns The builder instance for method chaining
233
274
  *
234
275
  * @example
235
- * new HeadBuilder({ metadataBase: new URL('https://example.com') })
276
+ * new HeadBuilder({ metadataBase: new URL('https://devsantara.com') })
236
277
  * .addAlternateLocale((helper) => ({
237
278
  * 'en-US': helper.resolveUrl('/en'),
238
279
  * 'fr-FR': helper.resolveUrl('/fr'),
@@ -290,4 +331,4 @@ declare class HeadBuilder<TOutput = HeadElement[]> {
290
331
  build(): TOutput;
291
332
  }
292
333
  //#endregion
293
- export { AlternateLocaleOptions, CharSet, ColorScheme, HeadAdapter, HeadAttributeTypeMap, HeadBuilder, HeadElement, HeadLinkAttributes, HeadMetaAttributes, HeadScriptAttributes, HeadStyleAttributes, HeadTitleAttributes, IconOptions, IconPreset, OpenGraphOptions, RobotsOptions, StylesheetOptions, TwitterOptions, ViewportOptions };
334
+ export { AlternateLocaleOptions, CharSet, ColorScheme, HeadAdapter, HeadAttributeTypeMap, HeadBuilder, HeadElement, HeadLinkAttributes, HeadMetaAttributes, HeadScriptAttributes, HeadStyleAttributes, HeadTitleAttributes, IconOptions, IconPreset, OpenGraphOptions, RobotsOptions, StylesheetOptions, TitleOptions, TwitterOptions, ViewportOptions };
package/dist/index.js CHANGED
@@ -1,9 +1,25 @@
1
1
  //#region src/builder.ts
2
2
  var HeadBuilder = class {
3
+ /**
4
+ * Optional base URL for resolving relative URLs in metadata (Open Graph, canonical, etc.)
5
+ */
3
6
  metadataBase;
4
- elements = [];
7
+ /**
8
+ * Optional adapter to transform the built head elements into a framework-specific format.
9
+ * If not provided, `build()` returns `HeadElement[]`
10
+ */
5
11
  adapter;
6
12
  /**
13
+ * Internal storage for title options to support templated titles.
14
+ * This allows the builder to generate the title dynamically based on previously set options.
15
+ */
16
+ titleOptions;
17
+ /**
18
+ * Internal collection of head elements being built,
19
+ * stored in a Map for deduplication based on element type and key attributes.
20
+ */
21
+ elementsMap = /* @__PURE__ */ new Map();
22
+ /**
7
23
  * Resolves a value that can be either static or a callback function receiving helper utilities.
8
24
  *
9
25
  * @param valueOrFn - Static value or function that returns the value
@@ -41,25 +57,47 @@ var HeadBuilder = class {
41
57
  resolveUrl(url) {
42
58
  if (url instanceof URL) return url.href;
43
59
  if (!this.metadataBase) return url;
44
- try {
45
- return new URL(url, this.metadataBase).href;
46
- } catch {
47
- return url;
60
+ return new URL(url, this.metadataBase).href;
61
+ }
62
+ /**
63
+ * Generates a unique key for a head element based on its type and attributes.
64
+ * Used for deduplication - elements with the same key replace previous ones.
65
+ *
66
+ * @param element - The head element to generate a key for
67
+ * @returns A unique string key for the element
68
+ */
69
+ getElementKey({ type, attributes }) {
70
+ if (type === "title") return "title";
71
+ if (type === "meta") {
72
+ if ("charSet" in attributes) return "meta:charSet";
73
+ if ("name" in attributes && "content" in attributes) return `meta:name:${attributes.name}`;
74
+ if ("property" in attributes && "content" in attributes) return `meta:property:${attributes.property}`;
75
+ }
76
+ if (type === "link") {
77
+ if (attributes.rel === "canonical") return "link:canonical";
78
+ if (attributes.rel === "manifest") return "link:manifest";
79
+ if (attributes.rel === "alternate" && "hrefLang" in attributes) return `link:alternate:${attributes.hrefLang}`;
48
80
  }
81
+ return JSON.stringify(`${type}:${JSON.stringify(attributes)}`);
49
82
  }
50
83
  /**
51
- * Adds a head element to the internal collection for later transformation.
84
+ * Adds a head element to the internal collection with deduplication.
85
+ * Elements with the same key will replace previous ones.
52
86
  *
53
87
  * @param type - The HTML element type
54
88
  * @param attributes - The element's attributes
55
- * @returns The builder instance for method chaining
89
+ * @returns The unique key for the added element
56
90
  */
57
91
  addElement(type, attributes) {
58
- this.elements.push({
92
+ const key = this.getElementKey({
59
93
  type,
60
94
  attributes
61
95
  });
62
- return this;
96
+ this.elementsMap.set(key, {
97
+ type,
98
+ attributes
99
+ });
100
+ return key;
63
101
  }
64
102
  /**
65
103
  * Adds a custom meta element with any valid attributes. Use this for meta tags without dedicated helper methods.
@@ -73,49 +111,77 @@ var HeadBuilder = class {
73
111
  * .build();
74
112
  */
75
113
  addMeta(attributes) {
76
- return this.addElement("meta", attributes);
114
+ this.addElement("meta", attributes);
115
+ return this;
77
116
  }
78
117
  /**
79
- * Adds a custom link element with any valid attributes. Use this for link tags without dedicated helper methods.
118
+ * Adds a custom link element with any valid attributes.
80
119
  *
81
- * @param attributes - The link element attributes
120
+ * @param href - The URL to link to
121
+ * @param attributes - Additional link element attributes
82
122
  * @returns The builder instance for method chaining
83
123
  *
84
124
  * @example
85
125
  * new HeadBuilder()
86
- * .addLink({ rel: 'preconnect', href: 'https://fonts.googleapis.com' })
126
+ * .addLink('https://fonts.googleapis.com', { rel: 'preconnect' })
87
127
  * .build();
88
128
  */
89
- addLink(attributes) {
90
- return this.addElement("link", attributes);
129
+ addLink(href, attributes) {
130
+ this.addElement("link", {
131
+ href: href.toString(),
132
+ ...attributes
133
+ });
134
+ return this;
91
135
  }
92
136
  /**
93
- * Adds a custom script element with any valid attributes for external scripts or inline code.
137
+ * Adds a script element, either inline code or an external file.
94
138
  *
95
- * @param attributes - The script element attributes
139
+ * @param srcOrCode - Script source: a URL string/object for external files, or `{ code: string }` for inline scripts
140
+ * @param attributes - Additional script attributes (async, defer, integrity, etc.)
96
141
  * @returns The builder instance for method chaining
97
142
  *
98
143
  * @example
99
144
  * new HeadBuilder()
100
- * .addScript({ src: '/analytics.js', async: true })
145
+ * .addScript('/script.js')
146
+ * .addScript(new URL('https://devsantara.com/script.js'), { async: true })
147
+ * .addScript({ code: 'console.log("Hello, World!")' })
101
148
  * .build();
102
149
  */
103
- addScript(attributes) {
104
- return this.addElement("script", attributes);
150
+ addScript(srcOrCode, attributes) {
151
+ if (typeof srcOrCode === "object" && "code" in srcOrCode) {
152
+ this.addElement("script", {
153
+ children: srcOrCode.code,
154
+ type: "text/javascript",
155
+ ...attributes
156
+ });
157
+ return this;
158
+ }
159
+ this.addElement("script", {
160
+ src: srcOrCode.toString(),
161
+ type: "text/javascript",
162
+ ...attributes
163
+ });
164
+ return this;
105
165
  }
106
166
  /**
107
- * Adds a custom style element with inline CSS.
167
+ * Adds an inline style element with CSS code.
108
168
  *
109
- * @param attributes - The style element attributes
169
+ * @param css - The inline CSS code
170
+ * @param attributes - Additional style attributes
110
171
  * @returns The builder instance for method chaining
111
172
  *
112
173
  * @example
113
174
  * new HeadBuilder()
114
- * .addStyle({ children: 'body { margin: 0; padding: 0; }' })
175
+ * .addStyle('body { margin: 0; padding: 0; }')
115
176
  * .build();
116
177
  */
117
- addStyle(attributes) {
118
- return this.addElement("style", attributes);
178
+ addStyle(css, attributes) {
179
+ this.addElement("style", {
180
+ children: css,
181
+ type: "text/css",
182
+ ...attributes
183
+ });
184
+ return this;
119
185
  }
120
186
  /**
121
187
  * Adds a character encoding declaration to specify how the document should be interpreted.
@@ -129,7 +195,8 @@ var HeadBuilder = class {
129
195
  * .build();
130
196
  */
131
197
  addCharSet(charSet) {
132
- return this.addElement("meta", { charSet });
198
+ this.addElement("meta", { charSet });
199
+ return this;
133
200
  }
134
201
  /**
135
202
  * Adds a color scheme preference indicating which color schemes the page supports for proper rendering.
@@ -143,24 +210,50 @@ var HeadBuilder = class {
143
210
  * .build();
144
211
  */
145
212
  addColorScheme(colorScheme) {
146
- return this.addElement("meta", {
213
+ this.addElement("meta", {
147
214
  name: "color-scheme",
148
215
  content: colorScheme
149
216
  });
217
+ return this;
150
218
  }
151
219
  /**
152
220
  * Adds a title element that appears in browser tabs, search results, and bookmarks.
221
+ * Supports both simple string titles and templated titles with dynamic substitution.
153
222
  *
154
- * @param title - The document title text
223
+ * @param title - The document title as a string, or TitleOptions object with template and default
155
224
  * @returns The builder instance for method chaining
156
225
  *
157
226
  * @example
227
+ * // Simple title
158
228
  * new HeadBuilder()
159
229
  * .addTitle('My Awesome Website')
160
230
  * .build();
231
+ *
232
+ * @example
233
+ * // Templated title with page-specific suffix
234
+ * const baseHead = new HeadBuilder()
235
+ * .addTitle({ template: '%s | My Site', default: 'Home' })
236
+ *
237
+ * const head = baseHead.addTitle('About Us').build(); // Results in title "About Us | My Site"
161
238
  */
162
239
  addTitle(title) {
163
- return this.addElement("title", { children: title });
240
+ if (typeof title === "string") {
241
+ /**
242
+ * If title is provided as a string and titleOptions with a template exists,
243
+ * we generate the title using the template. This allows dynamic title generation based on previously set options.
244
+ * If no template is set, we use the raw title string as is.
245
+ */
246
+ const titleText = this.titleOptions ? this.titleOptions.template.replace("%s", title) : title;
247
+ this.addElement("title", { children: titleText });
248
+ return this;
249
+ }
250
+ /**
251
+ * If title is provided as an object with template and default,
252
+ * we store the options and generate the title using the template with default.
253
+ */
254
+ this.titleOptions = title;
255
+ this.addElement("title", { children: this.titleOptions.template.replace("%s", title.default) });
256
+ return this;
164
257
  }
165
258
  /**
166
259
  * Adds viewport configuration for responsive web design and mobile optimization.
@@ -183,10 +276,11 @@ var HeadBuilder = class {
183
276
  if (options.userScalable !== void 0) contentParts.push(`user-scalable=${options.userScalable ? "yes" : "no"}`);
184
277
  if (options.viewportFit !== void 0) contentParts.push(`viewport-fit=${options.viewportFit}`);
185
278
  if (options.interactiveWidget !== void 0) contentParts.push(`interactive-widget=${options.interactiveWidget}`);
186
- return this.addElement("meta", {
279
+ this.addElement("meta", {
187
280
  name: "viewport",
188
281
  content: contentParts.join(", ")
189
282
  });
283
+ return this;
190
284
  }
191
285
  /**
192
286
  * Adds a description that appears in search engine results and social media previews.
@@ -200,10 +294,11 @@ var HeadBuilder = class {
200
294
  * .build();
201
295
  */
202
296
  addDescription(description) {
203
- return this.addElement("meta", {
297
+ this.addElement("meta", {
204
298
  name: "description",
205
299
  content: description
206
300
  });
301
+ return this;
207
302
  }
208
303
  /**
209
304
  * Adds a canonical URL to help search engines identify the preferred version of a page and prevent duplicate content issues.
@@ -218,10 +313,11 @@ var HeadBuilder = class {
218
313
  */
219
314
  addCanonical(valueOrFn) {
220
315
  const value = this.parseValueOrFn(valueOrFn);
221
- return this.addElement("link", {
316
+ this.addElement("link", {
222
317
  rel: "canonical",
223
318
  href: value.toString()
224
319
  });
320
+ return this;
225
321
  }
226
322
  /**
227
323
  * Adds robots directives to control search engine crawling and indexing behavior.
@@ -243,10 +339,11 @@ var HeadBuilder = class {
243
339
  else if (typeof value === "string" || typeof value === "number") directiveParts.push(`${key}:${value}`);
244
340
  else if (value) directiveParts.push(key);
245
341
  }
246
- return this.addElement("meta", {
342
+ this.addElement("meta", {
247
343
  name: "robots",
248
344
  content: directiveParts.join(", ")
249
345
  });
346
+ return this;
250
347
  }
251
348
  /**
252
349
  * Adds Open Graph metadata for rich social media previews on platforms like Facebook, LinkedIn, and Slack.
@@ -264,51 +361,51 @@ var HeadBuilder = class {
264
361
  * .build();
265
362
  */
266
363
  addOpenGraph(valueOrFn) {
267
- const options = this.parseValueOrFn(valueOrFn);
268
- if (options.title) this.addElement("meta", {
364
+ const value = this.parseValueOrFn(valueOrFn);
365
+ if (value.title) this.addElement("meta", {
269
366
  property: "og:title",
270
- content: options.title
367
+ content: value.title
271
368
  });
272
- if (options.description) this.addElement("meta", {
369
+ if (value.description) this.addElement("meta", {
273
370
  property: "og:description",
274
- content: options.description
371
+ content: value.description
275
372
  });
276
- if (options.url) this.addElement("meta", {
373
+ if (value.url) this.addElement("meta", {
277
374
  property: "og:url",
278
- content: options.url.toString()
375
+ content: value.url.toString()
279
376
  });
280
- if (options.locale) this.addElement("meta", {
377
+ if (value.locale) this.addElement("meta", {
281
378
  property: "og:locale",
282
- content: options.locale
379
+ content: value.locale
283
380
  });
284
- if (options.image) {
381
+ if (value.image) {
285
382
  this.addElement("meta", {
286
383
  property: "og:image",
287
- content: options.image.url.toString()
384
+ content: value.image.url.toString()
288
385
  });
289
- if (options.image.alt) this.addElement("meta", {
386
+ if (value.image.alt) this.addElement("meta", {
290
387
  property: "og:image:alt",
291
- content: options.image.alt
388
+ content: value.image.alt
292
389
  });
293
- if (options.image.type) this.addElement("meta", {
390
+ if (value.image.type) this.addElement("meta", {
294
391
  property: "og:image:type",
295
- content: options.image.type
392
+ content: value.image.type
296
393
  });
297
- if (options.image.width) this.addElement("meta", {
394
+ if (value.image.width) this.addElement("meta", {
298
395
  property: "og:image:width",
299
- content: options.image.width.toString()
396
+ content: value.image.width.toString()
300
397
  });
301
- if (options.image.height) this.addElement("meta", {
398
+ if (value.image.height) this.addElement("meta", {
302
399
  property: "og:image:height",
303
- content: options.image.height.toString()
400
+ content: value.image.height.toString()
304
401
  });
305
402
  }
306
- if (options.type) {
403
+ if (value.type) {
307
404
  this.addElement("meta", {
308
405
  property: "og:type",
309
- content: options.type.name
406
+ content: value.type.name
310
407
  });
311
- if ("properties" in options.type) for (const typeProperty of options.type.properties) this.addElement("meta", {
408
+ if ("properties" in value.type) for (const typeProperty of value.type.properties) this.addElement("meta", {
312
409
  property: typeProperty.name,
313
410
  content: typeProperty.content
314
411
  });
@@ -330,47 +427,47 @@ var HeadBuilder = class {
330
427
  * .build();
331
428
  */
332
429
  addTwitter(valueOrFn) {
333
- const options = this.parseValueOrFn(valueOrFn);
334
- if (options.title) this.addElement("meta", {
430
+ const value = this.parseValueOrFn(valueOrFn);
431
+ if (value.title) this.addElement("meta", {
335
432
  name: "twitter:title",
336
- content: options.title
433
+ content: value.title
337
434
  });
338
- if (options.description) this.addElement("meta", {
435
+ if (value.description) this.addElement("meta", {
339
436
  name: "twitter:description",
340
- content: options.description
437
+ content: value.description
341
438
  });
342
- if (options.site) this.addElement("meta", {
439
+ if (value.site) this.addElement("meta", {
343
440
  name: "twitter:site",
344
- content: options.site
441
+ content: value.site
345
442
  });
346
- if (options.siteId) this.addElement("meta", {
443
+ if (value.siteId) this.addElement("meta", {
347
444
  name: "twitter:site:id",
348
- content: options.siteId
445
+ content: value.siteId
349
446
  });
350
- if (options.creator) this.addElement("meta", {
447
+ if (value.creator) this.addElement("meta", {
351
448
  name: "twitter:creator",
352
- content: options.creator
449
+ content: value.creator
353
450
  });
354
- if (options.creatorId) this.addElement("meta", {
451
+ if (value.creatorId) this.addElement("meta", {
355
452
  name: "twitter:creator:id",
356
- content: options.creatorId
453
+ content: value.creatorId
357
454
  });
358
- if (options.image) {
455
+ if (value.image) {
359
456
  this.addElement("meta", {
360
457
  name: "twitter:image",
361
- content: options.image.url.toString()
458
+ content: value.image.url.toString()
362
459
  });
363
- if (options.image.alt) this.addElement("meta", {
460
+ if (value.image.alt) this.addElement("meta", {
364
461
  name: "twitter:image:alt",
365
- content: options.image.alt
462
+ content: value.image.alt
366
463
  });
367
464
  }
368
- if (options.card) {
465
+ if (value.card) {
369
466
  this.addElement("meta", {
370
467
  name: "twitter:card",
371
- content: options.card.name
468
+ content: value.card.name
372
469
  });
373
- if ("properties" in options.card) for (const cardProperty of options.card.properties) this.addElement("meta", {
470
+ if ("properties" in value.card) for (const cardProperty of value.card.properties) this.addElement("meta", {
374
471
  name: cardProperty.name,
375
472
  content: cardProperty.content.toString()
376
473
  });
@@ -384,7 +481,7 @@ var HeadBuilder = class {
384
481
  * @returns The builder instance for method chaining
385
482
  *
386
483
  * @example
387
- * new HeadBuilder({ metadataBase: new URL('https://example.com') })
484
+ * new HeadBuilder({ metadataBase: new URL('https://devsantara.com') })
388
485
  * .addAlternateLocale((helper) => ({
389
486
  * 'en-US': helper.resolveUrl('/en'),
390
487
  * 'fr-FR': helper.resolveUrl('/fr'),
@@ -393,8 +490,8 @@ var HeadBuilder = class {
393
490
  * .build();
394
491
  */
395
492
  addAlternateLocale(valueOrFn) {
396
- const options = this.parseValueOrFn(valueOrFn);
397
- for (const [lang, href] of Object.entries(options)) this.addElement("link", {
493
+ const value = this.parseValueOrFn(valueOrFn);
494
+ for (const [lang, href] of Object.entries(value)) this.addElement("link", {
398
495
  rel: "alternate",
399
496
  hrefLang: lang,
400
497
  href: String(href)
@@ -413,11 +510,12 @@ var HeadBuilder = class {
413
510
  * .build();
414
511
  */
415
512
  addManifest(valueOrFn) {
416
- const href = this.parseValueOrFn(valueOrFn);
417
- return this.addElement("link", {
513
+ const value = this.parseValueOrFn(valueOrFn);
514
+ this.addElement("link", {
418
515
  rel: "manifest",
419
- href: href.toString()
516
+ href: value.toString()
420
517
  });
518
+ return this;
421
519
  }
422
520
  /**
423
521
  * Adds an external CSS stylesheet link to the page.
@@ -432,11 +530,13 @@ var HeadBuilder = class {
432
530
  * .build();
433
531
  */
434
532
  addStylesheet(href, options) {
435
- return this.addElement("link", {
533
+ this.addElement("link", {
436
534
  rel: "stylesheet",
535
+ type: "text/css",
437
536
  href: href.toString(),
438
537
  ...options
439
538
  });
539
+ return this;
440
540
  }
441
541
  /**
442
542
  * Adds a favicon or app icon using preset types or custom rel values.
@@ -454,20 +554,18 @@ var HeadBuilder = class {
454
554
  * .build();
455
555
  */
456
556
  addIcon(preset, valueOrFn) {
457
- const options = this.parseValueOrFn(valueOrFn);
557
+ const { href, ...value } = this.parseValueOrFn(valueOrFn);
458
558
  const rel = {
459
559
  apple: "apple-touch-icon",
460
560
  icon: "icon",
461
561
  shortcut: "shortcut icon"
462
562
  }[preset] || preset;
463
- return this.addElement("link", {
563
+ this.addElement("link", {
464
564
  rel,
465
- href: options.href.toString(),
466
- type: options.type,
467
- sizes: options.sizes,
468
- media: options.media,
469
- fetchPriority: options.fetchPriority
565
+ href: href.toString(),
566
+ ...value
470
567
  });
568
+ return this;
471
569
  }
472
570
  /**
473
571
  * Builds and returns the final head configuration. Returns adapted output if an adapter was provided, otherwise returns `HeadElement[]`.
@@ -475,8 +573,9 @@ var HeadBuilder = class {
475
573
  * @returns The head configuration in the target format
476
574
  */
477
575
  build() {
478
- if (this.adapter) return this.adapter.transform(this.elements);
479
- return this.elements;
576
+ const elements = Array.from(this.elementsMap.values());
577
+ if (this.adapter) return this.adapter.transform(elements);
578
+ return elements;
480
579
  }
481
580
  };
482
581
 
@@ -54,6 +54,13 @@ type CharSet = 'utf-8' | (string & {});
54
54
  * Color scheme preference indicating which color schemes the document supports.
55
55
  */
56
56
  type ColorScheme = 'light' | 'dark' | 'light dark' | 'dark light' | 'only light' | 'only dark' | 'normal' | (string & {});
57
+ /**
58
+ * Title configuration with support for templated titles using a template string and default title value.
59
+ */
60
+ type TitleOptions = {
61
+ template: string;
62
+ default: string;
63
+ };
57
64
  /**
58
65
  * Viewport configuration for responsive web design and mobile optimization.
59
66
  */
@@ -430,13 +437,16 @@ interface TwitterOptions {
430
437
  card?: TwitterCard;
431
438
  }
432
439
  /**
433
- * Locale key type supporting 'x-default', specific locale strings, or custom values.
440
+ * Locale key type supporting specific locale strings or custom values.
434
441
  */
435
- type AlternateLocaleKey<TLocale extends string> = ('x-default' | TLocale) | (string & {});
442
+ type AlternateLocaleKey<TLocale extends string> = TLocale | (string & {});
436
443
  /**
437
444
  * Alternate locale/language mapping for internationalization, linking language codes to their corresponding URLs.
445
+ * The 'x-default' key is optional.
438
446
  */
439
- type AlternateLocaleOptions<TLocale extends string> = Record<AlternateLocaleKey<TLocale>, string | URL>;
447
+ type AlternateLocaleOptions<TLocale extends string> = {
448
+ 'x-default'?: string | URL;
449
+ } & Record<AlternateLocaleKey<TLocale>, string | URL>;
440
450
  /**
441
451
  * Icon preset type with autocomplete for common icon types while allowing custom values.
442
452
  */
@@ -452,4 +462,4 @@ type IconOptions = Omit<HeadAttributeTypeMap['link'], 'rel' | 'href'> & {
452
462
  */
453
463
  type StylesheetOptions = Omit<HeadLinkAttributes, 'rel' | 'href'>;
454
464
  //#endregion
455
- export { TwitterOptions as _, HeadAttributeTypeMap as a, HeadMetaAttributes as c, HeadTitleAttributes as d, IconOptions as f, StylesheetOptions as g, RobotsOptions as h, HeadAdapter as i, HeadScriptAttributes as l, OpenGraphOptions as m, CharSet as n, HeadElement as o, IconPreset as p, ColorScheme as r, HeadLinkAttributes as s, AlternateLocaleOptions as t, HeadStyleAttributes as u, ViewportOptions as v };
465
+ export { TitleOptions as _, HeadAttributeTypeMap as a, HeadMetaAttributes as c, HeadTitleAttributes as d, IconOptions as f, StylesheetOptions as g, RobotsOptions as h, HeadAdapter as i, HeadScriptAttributes as l, OpenGraphOptions as m, CharSet as n, HeadElement as o, IconPreset as p, ColorScheme as r, HeadLinkAttributes as s, AlternateLocaleOptions as t, HeadStyleAttributes as u, TwitterOptions as v, ViewportOptions as y };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devsantara/head",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A type-safe HTML head builder",
5
5
  "keywords": [
6
6
  "devsantara",
@@ -35,6 +35,8 @@
35
35
  },
36
36
  "devDependencies": {
37
37
  "@changesets/cli": "^2.29.8",
38
+ "@vitest/coverage-v8": "4.0.18",
39
+ "@vitest/ui": "4.0.18",
38
40
  "oxfmt": "^0.27.0",
39
41
  "oxlint": "^1.42.0",
40
42
  "oxlint-tsgolint": "^0.11.4",
@@ -50,6 +52,8 @@
50
52
  "build": "tsdown",
51
53
  "dev": "tsdown --watch",
52
54
  "test": "vitest",
55
+ "test:ui": "vitest --ui",
56
+ "test:coverage": "vitest run --coverage",
53
57
  "lint": "oxlint --type-aware",
54
58
  "lint:fix": "oxlint --type-aware --fix",
55
59
  "lint:ts": "tsc --noEmit",