kmcom-nuxt-layers 2.2.2 → 2.2.4

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.
Files changed (35) hide show
  1. package/layers/canvas/app/composables/useRendererCapabilities.ts +0 -1
  2. package/layers/content/app/components/Gallery/Lightbox.vue +1 -1
  3. package/layers/feeds/server/routes/feed/[collection]/atom/all.get.ts +8 -0
  4. package/layers/feeds/server/routes/feed/[collection]/atom.get.ts +8 -0
  5. package/layers/feeds/server/routes/feed/[collection]/json/all.get.ts +8 -0
  6. package/layers/feeds/server/routes/feed/[collection]/json.get.ts +9 -0
  7. package/layers/feeds/server/routes/feed/[collection]/rss/all.get.ts +8 -0
  8. package/layers/feeds/server/routes/feed/[collection]/rss.get.ts +8 -0
  9. package/layers/feeds/server/routes/feed/atom/all.get.ts +7 -0
  10. package/layers/feeds/server/routes/feed/atom.get.ts +7 -0
  11. package/layers/feeds/server/routes/feed/demo.get.ts +104 -0
  12. package/layers/feeds/server/routes/feed/discovery.get.ts +30 -0
  13. package/layers/feeds/server/routes/feed/index.get.ts +3 -0
  14. package/layers/feeds/server/routes/feed/json/all.get.ts +7 -0
  15. package/layers/feeds/server/routes/feed/json.get.ts +8 -0
  16. package/layers/feeds/server/routes/feed/rss/all.get.ts +7 -0
  17. package/layers/feeds/server/routes/feed/rss.get.ts +7 -0
  18. package/layers/feeds/server/routes/feed/style.xsl.get.ts +206 -0
  19. package/layers/feeds/server/utils/cache.ts +8 -0
  20. package/layers/feeds/server/utils/content-adapter.ts +39 -0
  21. package/layers/feeds/server/utils/feed-service.ts +46 -0
  22. package/layers/feeds/server/utils/formats/atom.ts +40 -0
  23. package/layers/feeds/server/utils/formats/json.ts +21 -0
  24. package/layers/feeds/server/utils/formats/rss.ts +42 -0
  25. package/layers/feeds/server/utils/types.ts +26 -0
  26. package/layers/forms/server/api/contact.post.ts +34 -0
  27. package/layers/forms/server/api/forms/status.get.ts +11 -0
  28. package/layers/mailer/server/types.d.ts +11 -0
  29. package/layers/mailer/server/utils/config.ts +9 -0
  30. package/layers/mailer/server/utils/email.ts +28 -0
  31. package/layers/mailer/server/utils/hooks.ts +23 -0
  32. package/layers/routing/package.json +1 -1
  33. package/layers/theme/server/plugins/theme-fouc.ts +156 -0
  34. package/layers/visual/app/types/media.ts +2 -2
  35. package/package.json +3 -1
@@ -2,7 +2,6 @@ import {
2
2
  QUALITY_PRESETS,
3
3
  type QualityLevel,
4
4
  type QualitySettings,
5
- type RendererBackend,
6
5
  type RendererCapabilities,
7
6
  } from '#layers/canvas/types/renderer'
8
7
 
@@ -57,7 +57,7 @@
57
57
  color="neutral"
58
58
  variant="ghost"
59
59
  class="absolute top-4 right-4 text-white"
60
- @click="() => (open = false)"
60
+ @click="() => { open = false }"
61
61
  />
62
62
 
63
63
  <!-- Prev -->
@@ -0,0 +1,8 @@
1
+ export default defineEventHandler(async (event) => {
2
+ const collection = getRouterParam(event, 'collection')
3
+ const { items, config } = await buildFeed(event, collection, { unlimited: true })
4
+ const content = toAtom(items, config)
5
+ setFeedCacheHeaders(event, content)
6
+ setHeader(event, 'Content-Type', 'text/xml; charset=utf-8')
7
+ return content
8
+ })
@@ -0,0 +1,8 @@
1
+ export default defineEventHandler(async (event) => {
2
+ const collection = getRouterParam(event, 'collection')
3
+ const { items, config } = await buildFeed(event, collection)
4
+ const content = toAtom(items, config)
5
+ setFeedCacheHeaders(event, content)
6
+ setHeader(event, 'Content-Type', 'text/xml; charset=utf-8')
7
+ return content
8
+ })
@@ -0,0 +1,8 @@
1
+ export default defineEventHandler(async (event) => {
2
+ const collection = getRouterParam(event, 'collection')
3
+ const { items, config } = await buildFeed(event, collection, { unlimited: true })
4
+ const content = toJSONFeed(items, config)
5
+ setFeedCacheHeaders(event, JSON.stringify(content))
6
+ setHeader(event, 'Content-Type', 'application/feed+json; charset=utf-8')
7
+ return content
8
+ })
@@ -0,0 +1,9 @@
1
+ export default defineEventHandler(async (event) => {
2
+ const collection = getRouterParam(event, 'collection')
3
+ const { items, config } = await buildFeed(event, collection)
4
+ const body = toJSONFeed(items, config)
5
+ const content = JSON.stringify(body)
6
+ setFeedCacheHeaders(event, content)
7
+ setHeader(event, 'Content-Type', 'application/feed+json; charset=utf-8')
8
+ return body
9
+ })
@@ -0,0 +1,8 @@
1
+ export default defineEventHandler(async (event) => {
2
+ const collection = getRouterParam(event, 'collection')
3
+ const { items, config } = await buildFeed(event, collection, { unlimited: true })
4
+ const content = toRSS(items, config)
5
+ setFeedCacheHeaders(event, content)
6
+ setHeader(event, 'Content-Type', 'text/xml; charset=utf-8')
7
+ return content
8
+ })
@@ -0,0 +1,8 @@
1
+ export default defineEventHandler(async (event) => {
2
+ const collection = getRouterParam(event, 'collection')
3
+ const { items, config } = await buildFeed(event, collection)
4
+ const content = toRSS(items, config)
5
+ setFeedCacheHeaders(event, content)
6
+ setHeader(event, 'Content-Type', 'text/xml; charset=utf-8')
7
+ return content
8
+ })
@@ -0,0 +1,7 @@
1
+ export default defineEventHandler(async (event) => {
2
+ const { items, config } = await buildFeed(event, undefined, { unlimited: true })
3
+ const content = toAtom(items, config)
4
+ setFeedCacheHeaders(event, content)
5
+ setHeader(event, 'Content-Type', 'text/xml; charset=utf-8')
6
+ return content
7
+ })
@@ -0,0 +1,7 @@
1
+ export default defineEventHandler(async (event) => {
2
+ const { items, config } = await buildFeed(event)
3
+ const content = toAtom(items, config)
4
+ setFeedCacheHeaders(event, content)
5
+ setHeader(event, 'Content-Type', 'text/xml; charset=utf-8')
6
+ return content
7
+ })
@@ -0,0 +1,104 @@
1
+ const RSS_ICON = `<svg viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
2
+ <path d="M2.5 3a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5H3a.5.5 0 0 1-.5-.5V3Z"/>
3
+ <path d="M4 8a6 6 0 0 1 6 6H8.5A4.5 4.5 0 0 0 4 9.5V8Z"/>
4
+ <path d="M4 5a9 9 0 0 1 9 9h-1.5A7.5 7.5 0 0 0 4 6.5V5Z"/>
5
+ <circle cx="3.5" cy="13.5" r="1.5"/>
6
+ </svg>`
7
+
8
+ function escapeHtml(str: string): string {
9
+ return str
10
+ .replace(/&/g, '&amp;')
11
+ .replace(/</g, '&lt;')
12
+ .replace(/>/g, '&gt;')
13
+ .replace(/"/g, '&quot;')
14
+ }
15
+
16
+ function formatDate(date: Date): string {
17
+ return date.toLocaleDateString('en-GB', {
18
+ day: 'numeric',
19
+ month: 'long',
20
+ year: 'numeric',
21
+ })
22
+ }
23
+
24
+ export default defineEventHandler(async (event) => {
25
+ const appConfig = useAppConfig()
26
+ const feedConfig =
27
+ (appConfig as { feedsLayer?: { feed?: { defaultCollection?: string } } }).feedsLayer?.feed ?? {}
28
+ const defaultCollection = feedConfig.defaultCollection ?? 'blog'
29
+
30
+ const requestUrl = getRequestURL(event)
31
+ const origin = `${requestUrl.protocol}//${requestUrl.host}`
32
+ const subscribeUrl = `feed:${origin}/feed/${defaultCollection}/rss`
33
+
34
+ const { items, config } = await buildFeed(event, defaultCollection)
35
+
36
+ const itemsHtml = items
37
+ .map(
38
+ (item) => `
39
+ <li class="feed-item">
40
+ <article>
41
+ <h2 class="feed-item-title">
42
+ <a href="${escapeHtml(config.siteUrl + item.link)}">${escapeHtml(item.title)}</a>
43
+ </h2>
44
+ <div class="feed-item-meta">
45
+ <time>${formatDate(item.date)}</time>
46
+ ${item.author ? `<span class="feed-item-separator">·</span><span>${escapeHtml(item.author)}</span>` : ''}
47
+ </div>
48
+ ${item.description ? `<p class="feed-item-description">${escapeHtml(item.description)}</p>` : ''}
49
+ ${
50
+ item.tags?.length
51
+ ? `
52
+ <div class="feed-tags">
53
+ ${item.tags.map((t) => `<span class="feed-tag">${escapeHtml(t)}</span>`).join('')}
54
+ </div>`
55
+ : ''
56
+ }
57
+ </article>
58
+ </li>`
59
+ )
60
+ .join('')
61
+
62
+ const html = `<!DOCTYPE html>
63
+ <html lang="en">
64
+ <head>
65
+ <meta charset="UTF-8">
66
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
67
+ <title>${escapeHtml(config.title)} · Feed Preview</title>
68
+ <link rel="stylesheet" href="/feed/style.css">
69
+ </head>
70
+ <body>
71
+ <div class="feed-container">
72
+ <header class="feed-header">
73
+ <span class="feed-eyebrow">
74
+ ${RSS_ICON}
75
+ RSS
76
+ </span>
77
+ <h1 class="feed-title">
78
+ <a href="${escapeHtml(config.siteUrl)}">${escapeHtml(config.title)}</a>
79
+ </h1>
80
+ ${config.description ? `<p class="feed-description">${escapeHtml(config.description)}</p>` : ''}
81
+ <div class="feed-header-actions">
82
+ <div class="feed-meta">
83
+ <span>${items.length} items</span>
84
+ <a class="feed-meta-link" href="${escapeHtml(config.siteUrl)}">Visit site &rarr;</a>
85
+ </div>
86
+ <a class="feed-subscribe" href="${escapeHtml(subscribeUrl)}">
87
+ ${RSS_ICON}
88
+ Subscribe
89
+ </a>
90
+ </div>
91
+ </header>
92
+ <main>
93
+ <ul class="feed-items">
94
+ ${itemsHtml}
95
+ </ul>
96
+ </main>
97
+ </div>
98
+ </body>
99
+ </html>`
100
+
101
+ setHeader(event, 'Content-Type', 'text/html; charset=utf-8')
102
+ setHeader(event, 'Cache-Control', 'public, max-age=300, s-maxage=3600')
103
+ return html
104
+ })
@@ -0,0 +1,30 @@
1
+ export default defineEventHandler((event) => {
2
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3
+ const appConfig = useAppConfig() as any
4
+ const feedConfig = appConfig.feedsLayer?.feed ?? {}
5
+ const collections: string[] = feedConfig.collections ?? ['blog']
6
+
7
+ const requestUrl = getRequestURL(event)
8
+ // Always use the request origin so discovery URLs are reachable from wherever
9
+ // the request came from, not the configured canonical site URL.
10
+ const baseUrl = `${requestUrl.protocol}//${requestUrl.host}`
11
+
12
+ setHeader(event, 'Cache-Control', 'public, max-age=300, s-maxage=3600')
13
+
14
+ const formats = [
15
+ { format: 'RSS 2.0', ext: 'rss', contentType: 'application/rss+xml' },
16
+ { format: 'Atom 1.0', ext: 'atom', contentType: 'application/atom+xml' },
17
+ { format: 'JSON Feed 1.1', ext: 'json', contentType: 'application/feed+json' },
18
+ ]
19
+
20
+ return {
21
+ feeds: collections.flatMap((collection) =>
22
+ formats.map(({ format, ext, contentType }) => ({
23
+ collection,
24
+ format,
25
+ url: `${baseUrl}/feed/${collection}/${ext}`,
26
+ contentType,
27
+ }))
28
+ ),
29
+ }
30
+ })
@@ -0,0 +1,3 @@
1
+ export default defineEventHandler((event) => {
2
+ return sendRedirect(event, '/feed/rss', 302)
3
+ })
@@ -0,0 +1,7 @@
1
+ export default defineEventHandler(async (event) => {
2
+ const { items, config } = await buildFeed(event, undefined, { unlimited: true })
3
+ const content = toJSONFeed(items, config)
4
+ setFeedCacheHeaders(event, JSON.stringify(content))
5
+ setHeader(event, 'Content-Type', 'application/feed+json; charset=utf-8')
6
+ return content
7
+ })
@@ -0,0 +1,8 @@
1
+ export default defineEventHandler(async (event) => {
2
+ const { items, config } = await buildFeed(event)
3
+ const body = toJSONFeed(items, config)
4
+ const content = JSON.stringify(body)
5
+ setFeedCacheHeaders(event, content)
6
+ setHeader(event, 'Content-Type', 'application/feed+json; charset=utf-8')
7
+ return body
8
+ })
@@ -0,0 +1,7 @@
1
+ export default defineEventHandler(async (event) => {
2
+ const { items, config } = await buildFeed(event, undefined, { unlimited: true })
3
+ const content = toRSS(items, config)
4
+ setFeedCacheHeaders(event, content)
5
+ setHeader(event, 'Content-Type', 'text/xml; charset=utf-8')
6
+ return content
7
+ })
@@ -0,0 +1,7 @@
1
+ export default defineEventHandler(async (event) => {
2
+ const { items, config } = await buildFeed(event)
3
+ const content = toRSS(items, config)
4
+ setFeedCacheHeaders(event, content)
5
+ setHeader(event, 'Content-Type', 'text/xml; charset=utf-8')
6
+ return content
7
+ })
@@ -0,0 +1,206 @@
1
+ export default defineEventHandler((event) => {
2
+ setHeader(event, 'Content-Type', 'text/xsl; charset=utf-8')
3
+ setHeader(event, 'Cache-Control', 'public, max-age=3600')
4
+
5
+ // Single stylesheet handles both RSS 2.0 and Atom 1.0.
6
+ // The root-level <xsl:apply-templates/> dispatches to whichever
7
+ // format-specific template matches the document's root element.
8
+ return `<?xml version="1.0" encoding="UTF-8"?>
9
+ <xsl:stylesheet version="1.0"
10
+ xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
11
+ xmlns:atom="http://www.w3.org/2005/Atom"
12
+ xmlns:dc="http://purl.org/dc/elements/1.1/">
13
+
14
+ <xsl:output method="html" encoding="UTF-8" indent="yes"/>
15
+
16
+ <xsl:template match="/">
17
+ <xsl:apply-templates/>
18
+ </xsl:template>
19
+
20
+ <!-- ─── RSS 2.0 ───────────────────────────────────────────────────────── -->
21
+
22
+ <xsl:template match="rss">
23
+ <html lang="en">
24
+ <head>
25
+ <meta charset="UTF-8"/>
26
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
27
+ <title>
28
+ <xsl:value-of select="channel/title"/>
29
+ <xsl:text> · RSS Feed</xsl:text>
30
+ </title>
31
+ <link rel="stylesheet" type="text/css" href="/feed/style.css"/>
32
+ </head>
33
+ <body>
34
+ <div class="feed-container">
35
+ <header class="feed-header">
36
+ <span class="feed-eyebrow">
37
+ <svg viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
38
+ <path d="M2.5 3a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5H3a.5.5 0 0 1-.5-.5V3Z"/>
39
+ <path d="M4 8a6 6 0 0 1 6 6H8.5A4.5 4.5 0 0 0 4 9.5V8Z"/>
40
+ <path d="M4 5a9 9 0 0 1 9 9h-1.5A7.5 7.5 0 0 0 4 6.5V5Z"/>
41
+ <circle cx="3.5" cy="13.5" r="1.5"/>
42
+ </svg>
43
+ RSS
44
+ </span>
45
+ <h1 class="feed-title">
46
+ <a href="{channel/link}">
47
+ <xsl:value-of select="channel/title"/>
48
+ </a>
49
+ </h1>
50
+ <xsl:if test="channel/description">
51
+ <p class="feed-description">
52
+ <xsl:value-of select="channel/description"/>
53
+ </p>
54
+ </xsl:if>
55
+ <div class="feed-header-actions">
56
+ <div class="feed-meta">
57
+ <span>
58
+ <xsl:value-of select="count(channel/item)"/>
59
+ <xsl:text> items</xsl:text>
60
+ </span>
61
+ <a class="feed-meta-link" href="{channel/link}">Visit site &#x2192;</a>
62
+ </div>
63
+ <a class="feed-subscribe" id="feed-subscribe-btn" href="#">
64
+ <svg viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
65
+ <path d="M2.5 3a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5H3a.5.5 0 0 1-.5-.5V3Z"/>
66
+ <path d="M4 8a6 6 0 0 1 6 6H8.5A4.5 4.5 0 0 0 4 9.5V8Z"/>
67
+ <path d="M4 5a9 9 0 0 1 9 9h-1.5A7.5 7.5 0 0 0 4 6.5V5Z"/>
68
+ <circle cx="3.5" cy="13.5" r="1.5"/>
69
+ </svg>
70
+ Subscribe
71
+ </a>
72
+ </div>
73
+ </header>
74
+ <main>
75
+ <ul class="feed-items">
76
+ <xsl:for-each select="channel/item">
77
+ <li class="feed-item">
78
+ <article>
79
+ <h2 class="feed-item-title">
80
+ <a href="{link}">
81
+ <xsl:value-of select="title"/>
82
+ </a>
83
+ </h2>
84
+ <div class="feed-item-meta">
85
+ <xsl:if test="pubDate">
86
+ <time><xsl:value-of select="pubDate"/></time>
87
+ </xsl:if>
88
+ <xsl:if test="dc:creator">
89
+ <span class="feed-item-separator">&#xB7;</span>
90
+ <span><xsl:value-of select="dc:creator"/></span>
91
+ </xsl:if>
92
+ </div>
93
+ <xsl:if test="description">
94
+ <p class="feed-item-description">
95
+ <xsl:value-of select="description"/>
96
+ </p>
97
+ </xsl:if>
98
+ <xsl:if test="category">
99
+ <div class="feed-tags">
100
+ <xsl:for-each select="category">
101
+ <span class="feed-tag">
102
+ <xsl:value-of select="."/>
103
+ </span>
104
+ </xsl:for-each>
105
+ </div>
106
+ </xsl:if>
107
+ </article>
108
+ </li>
109
+ </xsl:for-each>
110
+ </ul>
111
+ </main>
112
+ </div>
113
+ <script>
114
+ <xsl:text>(function(){var a=document.getElementById('feed-subscribe-btn');if(a)a.href='feed:'+window.location.href;})();</xsl:text>
115
+ </script>
116
+ </body>
117
+ </html>
118
+ </xsl:template>
119
+
120
+ <!-- ─── Atom 1.0 ──────────────────────────────────────────────────────── -->
121
+
122
+ <xsl:template match="atom:feed">
123
+ <html lang="en">
124
+ <head>
125
+ <meta charset="UTF-8"/>
126
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
127
+ <title>
128
+ <xsl:value-of select="atom:title"/>
129
+ <xsl:text> · Atom Feed</xsl:text>
130
+ </title>
131
+ <link rel="stylesheet" type="text/css" href="/feed/style.css"/>
132
+ </head>
133
+ <body>
134
+ <div class="feed-container">
135
+ <header class="feed-header">
136
+ <span class="feed-eyebrow">
137
+ <svg viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
138
+ <circle cx="8" cy="8" r="2"/>
139
+ <path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm0 1.5a5.5 5.5 0 1 1 0 11 5.5 5.5 0 0 1 0-11z"/>
140
+ <path d="M8 4.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7zm0 1.5a2 2 0 1 1 0 4 2 2 0 0 1 0-4z"/>
141
+ </svg>
142
+ Atom
143
+ </span>
144
+ <h1 class="feed-title">
145
+ <a href="{atom:link[not(@rel)]/@href}">
146
+ <xsl:value-of select="atom:title"/>
147
+ </a>
148
+ </h1>
149
+ <div class="feed-header-actions">
150
+ <div class="feed-meta">
151
+ <span>
152
+ <xsl:value-of select="count(atom:entry)"/>
153
+ <xsl:text> items</xsl:text>
154
+ </span>
155
+ <a class="feed-meta-link" href="{atom:link[not(@rel)]/@href}">Visit site &#x2192;</a>
156
+ </div>
157
+ <a class="feed-subscribe" id="feed-subscribe-btn" href="#">
158
+ <svg viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
159
+ <path d="M2.5 3a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5H3a.5.5 0 0 1-.5-.5V3Z"/>
160
+ <path d="M4 8a6 6 0 0 1 6 6H8.5A4.5 4.5 0 0 0 4 9.5V8Z"/>
161
+ <path d="M4 5a9 9 0 0 1 9 9h-1.5A7.5 7.5 0 0 0 4 6.5V5Z"/>
162
+ <circle cx="3.5" cy="13.5" r="1.5"/>
163
+ </svg>
164
+ Subscribe
165
+ </a>
166
+ </div>
167
+ </header>
168
+ <main>
169
+ <ul class="feed-items">
170
+ <xsl:for-each select="atom:entry">
171
+ <li class="feed-item">
172
+ <article>
173
+ <h2 class="feed-item-title">
174
+ <a href="{atom:link/@href}">
175
+ <xsl:value-of select="atom:title"/>
176
+ </a>
177
+ </h2>
178
+ <div class="feed-item-meta">
179
+ <xsl:if test="atom:updated">
180
+ <time><xsl:value-of select="atom:updated"/></time>
181
+ </xsl:if>
182
+ <xsl:if test="atom:author/atom:name">
183
+ <span class="feed-item-separator">&#xB7;</span>
184
+ <span><xsl:value-of select="atom:author/atom:name"/></span>
185
+ </xsl:if>
186
+ </div>
187
+ <xsl:if test="atom:summary">
188
+ <p class="feed-item-description">
189
+ <xsl:value-of select="atom:summary"/>
190
+ </p>
191
+ </xsl:if>
192
+ </article>
193
+ </li>
194
+ </xsl:for-each>
195
+ </ul>
196
+ </main>
197
+ </div>
198
+ <script>
199
+ <xsl:text>(function(){var a=document.getElementById('feed-subscribe-btn');if(a)a.href='feed:'+window.location.href;})();</xsl:text>
200
+ </script>
201
+ </body>
202
+ </html>
203
+ </xsl:template>
204
+
205
+ </xsl:stylesheet>`
206
+ })
@@ -0,0 +1,8 @@
1
+ import { createHash } from 'node:crypto'
2
+ import type { H3Event } from 'h3'
3
+
4
+ export function setFeedCacheHeaders(event: H3Event, content: string): void {
5
+ const etag = `"${createHash('md5').update(content).digest('hex')}"`
6
+ setHeader(event, 'ETag', etag)
7
+ setHeader(event, 'Cache-Control', 'public, max-age=300, s-maxage=3600')
8
+ }
@@ -0,0 +1,39 @@
1
+ import { queryCollection } from '@nuxt/content/nitro'
2
+ import type { H3Event } from 'h3'
3
+
4
+ import type { FeedItem } from './types'
5
+
6
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
7
+ type AnyContent = Record<string, any>
8
+
9
+ export async function getContentFeedItems(
10
+ event: H3Event,
11
+ collection: string = 'blog',
12
+ limit: number = 30
13
+ ): Promise<FeedItem[]> {
14
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
15
+ const raw: AnyContent[] = await (queryCollection as any)(event, collection).all()
16
+
17
+ return raw
18
+ .filter((item) => !item.draft)
19
+ .sort(
20
+ (a, b) =>
21
+ new Date(b.date ?? b.createdAt ?? 0).getTime() -
22
+ new Date(a.date ?? a.createdAt ?? 0).getTime()
23
+ )
24
+ .slice(0, limit)
25
+ .map((item) => {
26
+ const firstAuthor = Array.isArray(item.authors)
27
+ ? item.authors[0]?.name
28
+ : (item.author?.name ?? (typeof item.author === 'string' ? item.author : undefined))
29
+ return {
30
+ title: item.title ?? item.stem ?? '',
31
+ description: item.description,
32
+ link: item.path ?? item._path ?? '',
33
+ id: item.path ?? item._path ?? '',
34
+ date: new Date(item.date ?? item.createdAt ?? Date.now()),
35
+ author: firstAuthor ?? undefined,
36
+ tags: Array.isArray(item.tags) ? item.tags : undefined,
37
+ }
38
+ })
39
+ }
@@ -0,0 +1,46 @@
1
+ import type { SiteConfig } from '#layers/core/app/types/site'
2
+ import type { H3Event } from 'h3'
3
+
4
+ import type { FeedConfig, FeedItem } from './types'
5
+
6
+ export async function buildFeed(
7
+ event: H3Event,
8
+ collection?: string,
9
+ options?: { unlimited?: boolean }
10
+ ): Promise<{ items: FeedItem[]; config: FeedConfig }> {
11
+ const appConfig = useAppConfig()
12
+ const site: SiteConfig = (appConfig as { site?: SiteConfig }).site ?? {}
13
+ const feedConfig =
14
+ (appConfig as { feedsLayer?: { feed?: { limit?: number; defaultCollection?: string } } })
15
+ .feedsLayer?.feed ?? {}
16
+ const limit: number = options?.unlimited ? Infinity : (feedConfig.limit ?? 30)
17
+ const defaultCollection = feedConfig.defaultCollection ?? 'blog'
18
+
19
+ const requestUrl = getRequestURL(event)
20
+ const origin = `${requestUrl.protocol}//${requestUrl.host}`
21
+ const siteUrl = (site.url as string | undefined)?.replace(/\/$/, '') || origin
22
+
23
+ const authorName = site.author?.name ?? ''
24
+ const resolvedCollection = collection ?? defaultCollection
25
+ const collectionLabel = collection
26
+ ? ` — ${collection.charAt(0).toUpperCase() + collection.slice(1)}`
27
+ : ''
28
+
29
+ const config: FeedConfig = {
30
+ title: `${site.title ?? 'My Site'}${collectionLabel}`,
31
+ description: site.description ?? '',
32
+ siteUrl,
33
+ author: authorName
34
+ ? { name: authorName, email: site.author?.email, link: site.author?.link }
35
+ : undefined,
36
+ image: site.image || undefined,
37
+ favicon: site.favicon ?? '/favicon.ico',
38
+ copyright:
39
+ site.copyright ||
40
+ (authorName ? `Copyright ${new Date().getFullYear()} ${authorName}` : undefined),
41
+ }
42
+
43
+ const items = await getContentFeedItems(event, resolvedCollection, limit)
44
+
45
+ return { items, config }
46
+ }
@@ -0,0 +1,40 @@
1
+ import type { FeedConfig, FeedItem } from '../types'
2
+
3
+ function escapeXml(str: string): string {
4
+ return str
5
+ .replace(/&/g, '&amp;')
6
+ .replace(/</g, '&lt;')
7
+ .replace(/>/g, '&gt;')
8
+ .replace(/"/g, '&quot;')
9
+ .replace(/'/g, '&apos;')
10
+ }
11
+
12
+ export function toAtom(items: FeedItem[], config: FeedConfig): string {
13
+ const updated = (items[0]?.date ?? new Date()).toISOString()
14
+
15
+ const entries = items
16
+ .map(
17
+ (item) => `
18
+ <entry>
19
+ <title>${escapeXml(item.title)}</title>
20
+ <id>${escapeXml(`${config.siteUrl}${item.id}`)}</id>
21
+ <link href="${escapeXml(`${config.siteUrl}${item.link}`)}" />
22
+ <updated>${item.date.toISOString()}</updated>
23
+ ${item.description ? `<summary>${escapeXml(item.description)}</summary>` : ''}
24
+ ${item.author ? `<author><name>${escapeXml(item.author)}</name></author>` : ''}
25
+ </entry>`
26
+ )
27
+ .join('')
28
+
29
+ return `<?xml version="1.0" encoding="utf-8"?>
30
+ <?xml-stylesheet type="text/xsl" href="/feed/style.xsl"?>
31
+ <feed xmlns="http://www.w3.org/2005/Atom">
32
+ <title>${escapeXml(config.title)}</title>
33
+ <id>${escapeXml(config.siteUrl)}</id>
34
+ <link href="${escapeXml(config.siteUrl)}" />
35
+ <link rel="self" href="${escapeXml(`${config.siteUrl}/feed/atom`)}" />
36
+ <updated>${updated}</updated>
37
+ ${config.author ? `<author><name>${escapeXml(config.author.name)}</name></author>` : ''}
38
+ ${entries}
39
+ </feed>`
40
+ }
@@ -0,0 +1,21 @@
1
+ import type { FeedConfig, FeedItem } from '../types'
2
+
3
+ export function toJSONFeed(items: FeedItem[], config: FeedConfig): object {
4
+ return {
5
+ version: 'https://jsonfeed.org/version/1.1',
6
+ title: config.title,
7
+ description: config.description,
8
+ home_page_url: config.siteUrl,
9
+ feed_url: `${config.siteUrl}/feed/json`,
10
+ authors: config.author ? [{ name: config.author.name, url: config.author.link }] : undefined,
11
+ items: items.map((item) => ({
12
+ id: `${config.siteUrl}${item.id}`,
13
+ url: `${config.siteUrl}${item.link}`,
14
+ title: item.title,
15
+ content_text: item.description,
16
+ date_published: item.date.toISOString(),
17
+ authors: item.author ? [{ name: item.author }] : undefined,
18
+ tags: item.tags,
19
+ })),
20
+ }
21
+ }
@@ -0,0 +1,42 @@
1
+ import { Feed } from 'feed'
2
+
3
+ import type { FeedConfig, FeedItem } from '../types'
4
+
5
+ export function toRSS(items: FeedItem[], config: FeedConfig): string {
6
+ const author = config.author
7
+ ? {
8
+ name: config.author.name,
9
+ ...(config.author.email ? { email: config.author.email } : {}),
10
+ ...(config.author.link ? { link: config.author.link } : {}),
11
+ }
12
+ : undefined
13
+
14
+ const feed = new Feed({
15
+ title: config.title,
16
+ description: config.description,
17
+ id: config.siteUrl,
18
+ link: config.siteUrl,
19
+ ...(config.image ? { image: config.image } : {}),
20
+ ...(config.favicon ? { favicon: config.favicon } : {}),
21
+ copyright: config.copyright ?? '',
22
+ updated: items[0]?.date ?? new Date(),
23
+ ...(author ? { author } : {}),
24
+ })
25
+
26
+ for (const item of items) {
27
+ feed.addItem({
28
+ title: item.title,
29
+ id: `${config.siteUrl}${item.id}`,
30
+ link: `${config.siteUrl}${item.link}`,
31
+ ...(item.description ? { description: item.description } : {}),
32
+ date: item.date,
33
+ ...(item.author ? { author: [{ name: item.author }] } : {}),
34
+ })
35
+ }
36
+
37
+ const raw = feed.rss2()
38
+ return raw.replace(
39
+ '<?xml version="1.0" encoding="UTF-8"?>',
40
+ '<?xml version="1.0" encoding="UTF-8"?>\n<?xml-stylesheet type="text/xsl" href="/feed/style.xsl"?>'
41
+ )
42
+ }
@@ -0,0 +1,26 @@
1
+ export type FeedItem = {
2
+ title: string
3
+ description?: string | undefined
4
+ link: string
5
+ id: string
6
+ date: Date
7
+ author?: string | undefined
8
+ tags?: string[] | undefined
9
+ }
10
+
11
+ export type FeedConfig = {
12
+ title: string
13
+ description: string
14
+ siteUrl: string
15
+ author?:
16
+ | {
17
+ name: string
18
+ email?: string | undefined
19
+ link?: string | undefined
20
+ }
21
+ | undefined
22
+ image?: string | undefined
23
+ favicon?: string | undefined
24
+ copyright?: string | undefined
25
+ limit?: number | undefined
26
+ }
@@ -0,0 +1,34 @@
1
+ import { z } from 'zod'
2
+ import { sendContactEmail } from '#layers/mailer/server/utils/email'
3
+ import { mailerLayerHooks } from '#layers/mailer/server/utils/hooks'
4
+
5
+ const contactSchema = z.object({
6
+ name: z.string().min(3, 'Name must be at least 3 characters'),
7
+ email: z.string().email('Please enter a valid email'),
8
+ message: z.string().min(8, 'Message must be at least 8 characters'),
9
+ })
10
+
11
+ export default defineEventHandler(async (event) => {
12
+ const body = await readBody(event)
13
+ const result = contactSchema.safeParse(body)
14
+
15
+ if (!result.success) {
16
+ throw createError({ statusCode: 400, data: result.error.flatten().fieldErrors })
17
+ }
18
+
19
+ const data = result.data
20
+ await mailerLayerHooks.callHook('contact:submitted', data)
21
+
22
+ const emailResult = await sendContactEmail(data)
23
+
24
+ if (emailResult.success) {
25
+ await mailerLayerHooks.callHook('contact:sent', { ...data, messageId: emailResult.messageId })
26
+ return { success: true }
27
+ }
28
+
29
+ await mailerLayerHooks.callHook('contact:failed', { ...data, error: emailResult.error })
30
+ throw createError({
31
+ statusCode: 500,
32
+ statusMessage: 'Failed to send message. Please try again later.',
33
+ })
34
+ })
@@ -0,0 +1,11 @@
1
+ import { useMailerConfig } from '#layers/mailer/server/utils/config'
2
+
3
+ export default defineEventHandler(() => {
4
+ const { resendApiKey, emailFrom, emailTo } = useMailerConfig()
5
+
6
+ return {
7
+ configured: Boolean(resendApiKey),
8
+ emailFrom: emailFrom || null,
9
+ emailTo: emailTo || null,
10
+ }
11
+ })
@@ -0,0 +1,11 @@
1
+ import 'nitropack'
2
+
3
+ declare module 'nitropack' {
4
+ interface NitroRuntimeConfig {
5
+ mailerLayer?: {
6
+ resendApiKey: string
7
+ emailFrom: string
8
+ emailTo: string
9
+ }
10
+ }
11
+ }
@@ -0,0 +1,9 @@
1
+ type MailerLayerConfig = {
2
+ resendApiKey: string
3
+ emailFrom: string
4
+ emailTo: string
5
+ }
6
+
7
+ export function useMailerConfig(): MailerLayerConfig {
8
+ return (useRuntimeConfig() as unknown as { mailerLayer: MailerLayerConfig }).mailerLayer
9
+ }
@@ -0,0 +1,28 @@
1
+ import { Resend } from 'resend'
2
+
3
+ export type ContactEmailData = {
4
+ name: string
5
+ email: string
6
+ message: string
7
+ }
8
+
9
+ export async function sendContactEmail(data: ContactEmailData) {
10
+ const { resendApiKey, emailFrom, emailTo } = useMailerConfig()
11
+
12
+ if (!resendApiKey) {
13
+ console.warn('[mailer] NUXT_MAILER_LAYER_RESEND_API_KEY not set — email skipped')
14
+ return { success: false as const, error: 'No API key configured' }
15
+ }
16
+
17
+ const resend = new Resend(resendApiKey)
18
+ const { data: result, error } = await resend.emails.send({
19
+ from: emailFrom ?? '',
20
+ to: emailTo ?? '',
21
+ replyTo: data.email,
22
+ subject: `Contact form submission from ${data.name}`,
23
+ text: `Name: ${data.name}\nEmail: ${data.email}\n\nMessage:\n${data.message}`,
24
+ })
25
+
26
+ if (error) return { success: false as const, error }
27
+ return { success: true as const, messageId: result?.id ?? '' }
28
+ }
@@ -0,0 +1,23 @@
1
+ import { createHooks } from 'hookable'
2
+
3
+ export type ContactSubmittedPayload = {
4
+ name: string
5
+ email: string
6
+ message: string
7
+ }
8
+
9
+ export type ContactSentPayload = {
10
+ messageId: string
11
+ } & ContactSubmittedPayload
12
+
13
+ export type ContactFailedPayload = {
14
+ error: unknown
15
+ } & ContactSubmittedPayload
16
+
17
+ export type MailerLayerHooks = {
18
+ 'contact:submitted': (payload: ContactSubmittedPayload) => void
19
+ 'contact:sent': (payload: ContactSentPayload) => void
20
+ 'contact:failed': (payload: ContactFailedPayload) => void
21
+ }
22
+
23
+ export const mailerLayerHooks = createHooks<MailerLayerHooks>()
@@ -1,5 +1,5 @@
1
1
  {
2
- "name": "@layers/routing",
2
+ "name": "kmcom-layer-routing",
3
3
  "version": "0.1.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
@@ -0,0 +1,156 @@
1
+ import twColors from 'tailwindcss/colors'
2
+
3
+ type DefaultColors = typeof twColors
4
+
5
+ /**
6
+ * Nitro render hook — prevents FOUC for all theme preferences by injecting:
7
+ *
8
+ * 1. A `<style>` tag with CSS rules mapping each `[data-theme-colour='X']` to
9
+ * the correct `--ui-color-primary-*`, `--ui-color-secondary-*`, and
10
+ * `--ui-color-info-*` values. Each accent selects a coordinated three-colour
11
+ * palette so primary, secondary, and accent (info) all change together.
12
+ *
13
+ * 2. A blocking `<script>` that reads localStorage and restores all
14
+ * `data-theme-*` attributes on `<html>` before first paint:
15
+ * - `data-theme-colour` from `theme-colour` (defaults to 'blue')
16
+ * - `data-theme-contrast` from `theme-contrast`
17
+ * - `data-theme-motion` from `theme-motion`
18
+ * - `data-theme-transparency` from `theme-transparency`
19
+ * - `data-theme-mode` from `theme-mode` (raw string, not JSON — set by Nuxt Color Mode)
20
+ *
21
+ * Our script runs before CSS link tags (via head.unshift), so setting data-theme-mode
22
+ * here prevents the dark-mode FOUC that would otherwise occur when Nuxt Color Mode's
23
+ * script runs after stylesheets have downloaded. Nuxt Color Mode's script will then
24
+ * set the same value again — this is idempotent and safe.
25
+ */
26
+
27
+ const ACCENTS = [
28
+ 'red',
29
+ 'orange',
30
+ 'amber',
31
+ 'yellow',
32
+ 'lime',
33
+ 'green',
34
+ 'emerald',
35
+ 'teal',
36
+ 'cyan',
37
+ 'sky',
38
+ 'blue',
39
+ 'indigo',
40
+ 'violet',
41
+ 'purple',
42
+ 'fuchsia',
43
+ 'pink',
44
+ 'rose',
45
+ ] as const
46
+
47
+ type AccentName = (typeof ACCENTS)[number]
48
+
49
+ const SHADES = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const
50
+
51
+ /**
52
+ * Coordinated three-colour palettes. Each accent colour selects a
53
+ * secondary and info (accent) colour that sit in the same temperature
54
+ * zone and feel cohesive together.
55
+ *
56
+ * primary = the user-selected accent
57
+ * secondary = a related colour in the same hue family
58
+ * info = a third complementary accent
59
+ */
60
+ const ACCENT_PALETTES: Record<AccentName, { secondary: AccentName; info: AccentName }> = {
61
+ red: { secondary: 'rose', info: 'orange' },
62
+ orange: { secondary: 'amber', info: 'red' },
63
+ amber: { secondary: 'orange', info: 'yellow' },
64
+ yellow: { secondary: 'lime', info: 'amber' },
65
+ lime: { secondary: 'green', info: 'yellow' },
66
+ green: { secondary: 'teal', info: 'emerald' },
67
+ emerald: { secondary: 'green', info: 'teal' },
68
+ teal: { secondary: 'cyan', info: 'emerald' },
69
+ cyan: { secondary: 'sky', info: 'teal' },
70
+ sky: { secondary: 'blue', info: 'cyan' },
71
+ blue: { secondary: 'indigo', info: 'sky' },
72
+ indigo: { secondary: 'violet', info: 'blue' },
73
+ violet: { secondary: 'purple', info: 'indigo' },
74
+ purple: { secondary: 'fuchsia', info: 'violet' },
75
+ fuchsia: { secondary: 'pink', info: 'purple' },
76
+ pink: { secondary: 'rose', info: 'fuchsia' },
77
+ rose: { secondary: 'pink', info: 'red' },
78
+ }
79
+
80
+ function getShades(name: AccentName): Record<number, string> | null {
81
+ const p = (twColors as DefaultColors)[name]
82
+ return !p || typeof p === 'string' ? null : (p as Record<number, string>)
83
+ }
84
+
85
+ // Pre-compute all CSS rules at startup (once)
86
+ function buildAccentCSS(): string {
87
+ let css = ''
88
+ for (const accent of ACCENTS) {
89
+ const primary = getShades(accent)
90
+ if (!primary) continue
91
+
92
+ const { secondary: secName, info: infoName } = ACCENT_PALETTES[accent]
93
+ const secondary = getShades(secName)
94
+ const info = getShades(infoName)
95
+
96
+ let vars = ''
97
+ for (const s of SHADES) {
98
+ vars += `--ui-color-primary-${s}:${primary[s]};`
99
+ }
100
+ if (secondary) {
101
+ for (const s of SHADES) {
102
+ vars += `--ui-color-secondary-${s}:${secondary[s]};`
103
+ }
104
+ }
105
+ if (info) {
106
+ for (const s of SHADES) {
107
+ vars += `--ui-color-info-${s}:${info[s]};`
108
+ }
109
+ }
110
+
111
+ css += `html[data-theme-colour="${accent}"]{${vars}}`
112
+ }
113
+ return css
114
+ }
115
+
116
+ const accentCSS = buildAccentCSS()
117
+
118
+ // Blocking init script — restores data-* attributes from localStorage before first paint.
119
+ // Written as a self-invoking function to avoid polluting the global scope.
120
+ // JSON.parse handles the quoted string that useLocalStorage writes.
121
+ // Note: theme-mode is stored as a raw string by Nuxt Color Mode (no JSON.stringify).
122
+ const initScript = `(function(){
123
+ try{
124
+ var h=document.documentElement;
125
+ var c=localStorage.getItem('theme-colour');
126
+ h.setAttribute('data-theme-colour',c?JSON.parse(c):'blue');
127
+ var ct=localStorage.getItem('theme-contrast');
128
+ var ctv=ct?JSON.parse(ct):'system';
129
+ if(ctv==='on'){h.setAttribute('data-theme-contrast','high')}
130
+ else if(ctv==='off'){h.setAttribute('data-theme-contrast','standard')}
131
+ else{h.setAttribute('data-theme-contrast',(window.matchMedia&&window.matchMedia('(prefers-contrast:more)').matches)?'high':'standard')}
132
+ var m=localStorage.getItem('theme-motion');
133
+ var mv=m?JSON.parse(m):'system';
134
+ if(mv==='on'){h.setAttribute('data-theme-motion','reduced')}
135
+ else if(mv==='off'){h.setAttribute('data-theme-motion','full')}
136
+ else{h.setAttribute('data-theme-motion',(window.matchMedia&&window.matchMedia('(prefers-reduced-motion:reduce)').matches)?'reduced':'full')}
137
+ var t=localStorage.getItem('theme-transparency');
138
+ var tv=t?JSON.parse(t):'system';
139
+ if(tv==='on'){h.setAttribute('data-theme-transparency','reduced')}
140
+ else if(tv==='off'){h.setAttribute('data-theme-transparency','full')}
141
+ else{h.setAttribute('data-theme-transparency',(window.matchMedia&&window.matchMedia('(prefers-reduced-transparency:reduce)').matches)?'reduced':'full')}
142
+ var dm=localStorage.getItem('theme-mode');
143
+ var dmv=dm||'system';
144
+ if(dmv==='dark'){h.setAttribute('data-theme-mode','dark')}
145
+ else if(dmv==='light'){h.setAttribute('data-theme-mode','light')}
146
+ else{h.setAttribute('data-theme-mode',(window.matchMedia&&window.matchMedia('(prefers-color-scheme:dark)').matches)?'dark':'light')}
147
+ }catch(e){}
148
+ })()`.replace(/\n\s*/g, '')
149
+
150
+ export default defineNitroPlugin((nitroApp) => {
151
+ nitroApp.hooks.hook('render:html', (html) => {
152
+ html.head.unshift(
153
+ `<style id="theme-accent-css">${accentCSS}</style>` + `<script>${initScript}</script>`
154
+ )
155
+ })
156
+ })
@@ -150,8 +150,8 @@ export type PictureProps = {
150
150
  fetchpriority?: 'high' | 'low' | 'auto'
151
151
 
152
152
  // Nuxt Image specific
153
- /** Image provider - defaults to 'ipx' (Nuxt's built-in image optimizer) */
154
- provider?: 'ipx'
153
+ /** Image provider override. Defaults to the app's configured Nuxt Image provider. */
154
+ provider?: string
155
155
  /** Nuxt Image preset name */
156
156
  preset?: string
157
157
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kmcom-nuxt-layers",
3
3
  "private": false,
4
- "version": "2.2.2",
4
+ "version": "2.2.4",
5
5
  "description": "Composable Nuxt 4 layers for building scalable Vue applications",
6
6
  "exports": {
7
7
  "./layers/core": "./layers/core/nuxt.config.ts",
@@ -23,6 +23,7 @@
23
23
  "./layers/forms": "./layers/forms/nuxt.config.ts",
24
24
  "./layers/theme": "./layers/theme/nuxt.config.ts",
25
25
  "./layers/content": "./layers/content/nuxt.config.ts",
26
+ "./layers/feeds": "./layers/feeds/nuxt.config.ts",
26
27
  "./layers/routing": "./layers/routing/nuxt.config.ts"
27
28
  },
28
29
  "files": [
@@ -32,6 +33,7 @@
32
33
  "layers/*/tsconfig.json",
33
34
  "layers/*/tailwind.config.*",
34
35
  "layers/*/app/**",
36
+ "layers/*/server/**",
35
37
  "docs/"
36
38
  ],
37
39
  "repository": {