astro-helmet 0.0.1

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.
@@ -0,0 +1,8 @@
1
+ /** @type {import("prettier").Config} */
2
+ export default {
3
+ useTabs: true,
4
+ singleQuote: true,
5
+ trailingComma: "none",
6
+ tabWidth: 2,
7
+ semi: false,
8
+ };
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # astro-helmet
2
+
3
+ `astro-helmet` is a utility for managing the document head of Astro projects. It allows you to define any head tags you need and render them to a string that can be included in your Astro layout. Head tags defined in layouts, pages and components can be easily merged and prioritised to ensure the correct order in the final document.
4
+
5
+ ## Installation
6
+
7
+ Install `astro-helmet` using npm:
8
+
9
+ ```bash
10
+ npm install astro-helmet
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ To use `astro-helmet`, you need to import the `Helmet` component and use it in your Astro project. Here's an example:
16
+
17
+ ```ts
18
+ import { renderHead, type HeadItems } from 'astro-helmet'
19
+
20
+ // Define your head items
21
+ const headItems: HeadItems = {
22
+ title: 'Your Page Title',
23
+ meta: [
24
+ { charset: 'UTF-8' },
25
+ { name: 'viewport', content: 'width=device-width, initial-scale=1' },
26
+ { property: 'og:title', content: 'Your Page Title' }
27
+ ],
28
+ link: [{ rel: 'stylesheet', href: '/styles/main.css' }],
29
+ script: [{ src: '/scripts/main.js', defer: true }]
30
+ }
31
+
32
+ // Call the render function to create the HTML string for the the head items
33
+ const head = renderHead([headItems])
34
+ ```
35
+
36
+ Then add the rendered `head` string to your Astro layout:
37
+
38
+ ```astro
39
+ <!doctype html>
40
+ <html lang="en">
41
+ <head>
42
+ <Fragment set:html={head} />
43
+ </head>
44
+ <body>
45
+ ...
46
+ ```
47
+
48
+ ## Features
49
+
50
+ - **Dynamic Head Management**: Easily manage and update your document's head tags.
51
+ - **Priority Handling**: Control the order of head elements with priority settings.
52
+ - **Extensible**: Add custom tags and attributes as needed.
53
+ - **Defaults**: Default charset and viewport meta tags are included by default.
54
+
55
+ ## Contributing
56
+
57
+ Contributions are welcome! Please open an issue or submit a pull request with your improvements.
58
+
59
+ ## License
60
+
61
+ This project is licensed under the ISC License.
@@ -0,0 +1,19 @@
1
+ type BaseItem = {
2
+ [key: string]: any;
3
+ priority?: number;
4
+ };
5
+ type ContentItem = BaseItem & {
6
+ innerHTML: string;
7
+ };
8
+ export type HeadItems = {
9
+ title?: string;
10
+ meta?: BaseItem[];
11
+ link?: BaseItem[];
12
+ style?: ContentItem[];
13
+ script?: ContentItem[];
14
+ noscript?: ContentItem[];
15
+ };
16
+ export declare function renderHead(headItems: HeadItems[]): string;
17
+ export declare function renderAttrs(item: BaseItem | ContentItem): string;
18
+ export {};
19
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,KAAK,QAAQ,GAAG;IACf,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAA;CACjB,CAAA;AAED,KAAK,WAAW,GAAG,QAAQ,GAAG;IAC7B,SAAS,EAAE,MAAM,CAAA;CACjB,CAAA;AAUD,MAAM,MAAM,SAAS,GAAG;IACvB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,IAAI,CAAC,EAAE,QAAQ,EAAE,CAAA;IACjB,IAAI,CAAC,EAAE,QAAQ,EAAE,CAAA;IACjB,KAAK,CAAC,EAAE,WAAW,EAAE,CAAA;IACrB,MAAM,CAAC,EAAE,WAAW,EAAE,CAAA;IACtB,QAAQ,CAAC,EAAE,WAAW,EAAE,CAAA;CACxB,CAAA;AAID,wBAAgB,UAAU,CAAC,SAAS,EAAE,SAAS,EAAE,GAAG,MAAM,CA2BzD;AAsGD,wBAAgB,WAAW,CAAC,IAAI,EAAE,QAAQ,GAAG,WAAW,GAAG,MAAM,CAShE"}
package/dist/index.js ADDED
@@ -0,0 +1,138 @@
1
+ const DEFAULT_CHARSET = { charset: 'UTF-8' };
2
+ const DEFAULT_VIEWPORT = { content: 'width=device-width, initial-scale=1' };
3
+ export function renderHead(headItems) {
4
+ const items = mergeHeadItems(headItems);
5
+ if (!items.title?.length)
6
+ throw new Error('Missing title tag.');
7
+ const tags = [];
8
+ const tagNames = ['meta', 'link', 'style', 'script', 'noscript'];
9
+ tagNames.forEach((tag) => {
10
+ tags.push(...items[tag].map((item) => ({ ...item, tagName: tag })));
11
+ });
12
+ let prioritisedTags = applyDefaultPriorities(tags);
13
+ prioritisedTags = applyDefaultTags(prioritisedTags);
14
+ const orderedTags = prioritisedTags.sort((a, b) => a.priority - b.priority);
15
+ const preTitleTags = orderedTags
16
+ .filter((i) => i.priority < 0)
17
+ .map((item) => renderHeadTag(item));
18
+ const postTitleTags = orderedTags
19
+ .filter((i) => i.priority > 0)
20
+ .map((item) => renderHeadTag(item));
21
+ return [
22
+ ...preTitleTags,
23
+ `<title>${items.title}</title>`,
24
+ ...postTitleTags
25
+ ].join('\n');
26
+ }
27
+ function mergeHeadItems(items) {
28
+ const mergedHeadItems = {
29
+ title: '',
30
+ meta: [],
31
+ link: [],
32
+ style: [],
33
+ script: [],
34
+ noscript: []
35
+ };
36
+ items.forEach((item) => {
37
+ if (item.title && item.title.length)
38
+ mergedHeadItems.title = item.title;
39
+ if (item.meta)
40
+ mergedHeadItems.meta.push(...item.meta);
41
+ if (item.link)
42
+ mergedHeadItems.link.push(...item.link);
43
+ if (item.style)
44
+ mergedHeadItems.style.push(...item.style);
45
+ if (item.script)
46
+ mergedHeadItems.script.push(...item.script);
47
+ if (item.noscript)
48
+ mergedHeadItems.noscript.push(...item.noscript);
49
+ });
50
+ mergedHeadItems.meta = deduplicateMetaItems(mergedHeadItems.meta);
51
+ return mergedHeadItems;
52
+ }
53
+ function applyDefaultPriorities(tags) {
54
+ const prioritisedTags = [];
55
+ const unprioritisedTags = [];
56
+ tags.forEach((tag) => {
57
+ if (tag.priority !== undefined)
58
+ prioritisedTags.push(tag);
59
+ else
60
+ unprioritisedTags.push(tag);
61
+ });
62
+ unprioritisedTags.forEach((tag) => {
63
+ let priority;
64
+ switch (tag.tagName) {
65
+ case 'meta':
66
+ if (tag.charset)
67
+ priority = -3;
68
+ else if (tag.name === 'viewport')
69
+ priority = -2;
70
+ else if (tag['http-equiv'])
71
+ priority = -1;
72
+ else
73
+ priority = 100;
74
+ break;
75
+ case 'link':
76
+ if (tag.rel === 'preconnect')
77
+ priority = 10;
78
+ else if (tag.rel === 'preload')
79
+ priority = 60;
80
+ else if (tag.rel === 'prefetch')
81
+ priority = 80;
82
+ else if (tag.rel === 'stylesheet')
83
+ priority = 50;
84
+ else
85
+ priority = 90;
86
+ break;
87
+ case 'style':
88
+ priority = tag.innerHTML.includes('@import') ? 30 : 51;
89
+ break;
90
+ case 'script':
91
+ if (tag.async)
92
+ priority = 20;
93
+ else if (tag.defer)
94
+ priority = 70;
95
+ else
96
+ priority = 40;
97
+ break;
98
+ default:
99
+ priority = 110;
100
+ }
101
+ prioritisedTags.push({ ...tag, priority });
102
+ });
103
+ return prioritisedTags;
104
+ }
105
+ function applyDefaultTags(tags) {
106
+ if (!tags.some((tag) => tag.tagName === 'meta' && tag.charset))
107
+ tags.push({ ...DEFAULT_CHARSET, tagName: 'meta', priority: -3 });
108
+ if (!tags.some((tag) => tag.tagName === 'meta' && tag.name === 'viewport'))
109
+ tags.push({ ...DEFAULT_VIEWPORT, tagName: 'meta', priority: -2 });
110
+ return tags;
111
+ }
112
+ function deduplicateMetaItems(metaItems) {
113
+ const metaMap = new Map();
114
+ metaItems.forEach((meta) => {
115
+ const key = meta.property || meta.name || meta['http-equiv'];
116
+ if (key)
117
+ metaMap.set(key, meta);
118
+ });
119
+ return Array.from(metaMap.values());
120
+ }
121
+ function renderHeadTag(item) {
122
+ const attrs = renderAttrs(item);
123
+ return ['meta', 'link'].includes(item.tagName)
124
+ ? `<${item.tagName} ${attrs} />`
125
+ : `<${item.tagName}${attrs && ' '}${attrs}>${item.innerHTML}</${item.tagName}>`;
126
+ }
127
+ export function renderAttrs(item) {
128
+ return Object.entries(item)
129
+ .filter(([key]) => !['innerHTML', 'priority', 'tagName'].includes(key))
130
+ .map(([key, value]) => {
131
+ if (typeof value === 'boolean')
132
+ return value ? key : '';
133
+ else
134
+ return `${key}="${value}"`;
135
+ })
136
+ .filter((attr) => attr !== '')
137
+ .join(' ');
138
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "astro-helmet",
3
+ "type": "module",
4
+ "version": "0.0.1",
5
+ "description": "A document head manager for astro.",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "scripts": {
9
+ "build": "tsc",
10
+ "test": "vitest"
11
+ },
12
+ "keywords": [
13
+ "astro",
14
+ "head",
15
+ "seo",
16
+ "meta-tags",
17
+ "html-head",
18
+ "helmet",
19
+ "document-head",
20
+ "web-development"
21
+ ],
22
+ "author": "Ryan Voitiskis",
23
+ "license": "ISC",
24
+ "devDependencies": {
25
+ "prettier": "^3.3.0",
26
+ "typescript": "^5.4.5"
27
+ }
28
+ }
package/src/index.ts ADDED
@@ -0,0 +1,172 @@
1
+ const DEFAULT_CHARSET = { charset: 'UTF-8' }
2
+ const DEFAULT_VIEWPORT = { content: 'width=device-width, initial-scale=1' }
3
+
4
+ type TagName = 'meta' | 'link' | 'style' | 'script' | 'noscript'
5
+
6
+ type BaseItem = {
7
+ [key: string]: any
8
+ priority?: number
9
+ }
10
+
11
+ type ContentItem = BaseItem & {
12
+ innerHTML: string
13
+ }
14
+
15
+ type Tag = (BaseItem | ContentItem) & {
16
+ tagName: TagName
17
+ }
18
+
19
+ type PrioritisedTag = Tag & {
20
+ priority: number
21
+ }
22
+
23
+ export type HeadItems = {
24
+ title?: string
25
+ meta?: BaseItem[]
26
+ link?: BaseItem[]
27
+ style?: ContentItem[]
28
+ script?: ContentItem[]
29
+ noscript?: ContentItem[]
30
+ }
31
+
32
+ type MergedHeadItems = Required<HeadItems>
33
+
34
+ export function renderHead(headItems: HeadItems[]): string {
35
+ const items = mergeHeadItems(headItems)
36
+ if (!items.title?.length) throw new Error('Missing title tag.')
37
+
38
+ const tags: Tag[] = []
39
+ const tagNames: TagName[] = ['meta', 'link', 'style', 'script', 'noscript']
40
+ tagNames.forEach((tag) => {
41
+ tags.push(...items[tag].map((item) => ({ ...item, tagName: tag })))
42
+ })
43
+
44
+ let prioritisedTags = applyDefaultPriorities(tags)
45
+ prioritisedTags = applyDefaultTags(prioritisedTags)
46
+
47
+ const orderedTags = prioritisedTags.sort((a, b) => a.priority - b.priority)
48
+
49
+ const preTitleTags = orderedTags
50
+ .filter((i) => i.priority < 0)
51
+ .map((item) => renderHeadTag(item))
52
+ const postTitleTags = orderedTags
53
+ .filter((i) => i.priority > 0)
54
+ .map((item) => renderHeadTag(item))
55
+
56
+ return [
57
+ ...preTitleTags,
58
+ `<title>${items.title}</title>`,
59
+ ...postTitleTags
60
+ ].join('\n')
61
+ }
62
+
63
+ function mergeHeadItems(items: HeadItems[]): MergedHeadItems {
64
+ const mergedHeadItems: MergedHeadItems = {
65
+ title: '',
66
+ meta: [],
67
+ link: [],
68
+ style: [],
69
+ script: [],
70
+ noscript: []
71
+ }
72
+
73
+ items.forEach((item) => {
74
+ if (item.title && item.title.length) mergedHeadItems.title = item.title
75
+ if (item.meta) mergedHeadItems.meta.push(...item.meta)
76
+ if (item.link) mergedHeadItems.link.push(...item.link)
77
+ if (item.style) mergedHeadItems.style.push(...item.style)
78
+ if (item.script) mergedHeadItems.script.push(...item.script)
79
+ if (item.noscript) mergedHeadItems.noscript.push(...item.noscript)
80
+ })
81
+
82
+ mergedHeadItems.meta = deduplicateMetaItems(mergedHeadItems.meta)
83
+
84
+ return mergedHeadItems
85
+ }
86
+
87
+ function applyDefaultPriorities(tags: Tag[]): PrioritisedTag[] {
88
+ const prioritisedTags: PrioritisedTag[] = []
89
+ const unprioritisedTags: Tag[] = []
90
+
91
+ tags.forEach((tag) => {
92
+ if (tag.priority !== undefined) prioritisedTags.push(tag as PrioritisedTag)
93
+ else unprioritisedTags.push(tag)
94
+ })
95
+
96
+ unprioritisedTags.forEach((tag) => {
97
+ let priority: number
98
+ switch (tag.tagName) {
99
+ case 'meta':
100
+ if (tag.charset) priority = -3
101
+ else if (tag.name === 'viewport') priority = -2
102
+ else if (tag['http-equiv']) priority = -1
103
+ else priority = 100
104
+ break
105
+
106
+ case 'link':
107
+ if (tag.rel === 'preconnect') priority = 10
108
+ else if (tag.rel === 'preload') priority = 60
109
+ else if (tag.rel === 'prefetch') priority = 80
110
+ else if (tag.rel === 'stylesheet') priority = 50
111
+ else priority = 90
112
+ break
113
+
114
+ case 'style':
115
+ priority = tag.innerHTML.includes('@import') ? 30 : 51
116
+ break
117
+
118
+ case 'script':
119
+ if (tag.async) priority = 20
120
+ else if (tag.defer) priority = 70
121
+ else priority = 40
122
+ break
123
+
124
+ default:
125
+ priority = 110
126
+ }
127
+ prioritisedTags.push({ ...tag, priority })
128
+ })
129
+
130
+ return prioritisedTags
131
+ }
132
+
133
+ function applyDefaultTags(tags: PrioritisedTag[]): PrioritisedTag[] {
134
+ if (!tags.some((tag) => tag.tagName === 'meta' && tag.charset))
135
+ tags.push({ ...DEFAULT_CHARSET, tagName: 'meta', priority: -3 })
136
+
137
+ if (!tags.some((tag) => tag.tagName === 'meta' && tag.name === 'viewport'))
138
+ tags.push({ ...DEFAULT_VIEWPORT, tagName: 'meta', priority: -2 })
139
+
140
+ return tags
141
+ }
142
+
143
+ function deduplicateMetaItems(metaItems: BaseItem[]): BaseItem[] {
144
+ const metaMap = new Map<string, BaseItem>()
145
+
146
+ metaItems.forEach((meta) => {
147
+ const key = meta.property || meta.name || meta['http-equiv']
148
+ if (key) metaMap.set(key, meta)
149
+ })
150
+
151
+ return Array.from(metaMap.values())
152
+ }
153
+
154
+ function renderHeadTag(item: BaseItem | ContentItem): string {
155
+ const attrs = renderAttrs(item)
156
+ return ['meta', 'link'].includes(item.tagName)
157
+ ? `<${item.tagName} ${attrs} />`
158
+ : `<${item.tagName}${attrs && ' '}${attrs}>${item.innerHTML}</${
159
+ item.tagName
160
+ }>`
161
+ }
162
+
163
+ export function renderAttrs(item: BaseItem | ContentItem): string {
164
+ return Object.entries(item)
165
+ .filter(([key]) => !['innerHTML', 'priority', 'tagName'].includes(key))
166
+ .map(([key, value]) => {
167
+ if (typeof value === 'boolean') return value ? key : ''
168
+ else return `${key}="${value}"`
169
+ })
170
+ .filter((attr) => attr !== '')
171
+ .join(' ')
172
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "outDir": "./dist",
6
+ "esModuleInterop": true,
7
+ "forceConsistentCasingInFileNames": true,
8
+ "strict": true,
9
+ "skipLibCheck": true,
10
+ "declaration": true,
11
+ "emitDeclarationOnly": false,
12
+ "declarationMap": true
13
+ },
14
+ "include": ["src/**/*"]
15
+ }