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.
- package/layers/canvas/app/composables/useRendererCapabilities.ts +0 -1
- package/layers/content/app/components/Gallery/Lightbox.vue +1 -1
- package/layers/feeds/server/routes/feed/[collection]/atom/all.get.ts +8 -0
- package/layers/feeds/server/routes/feed/[collection]/atom.get.ts +8 -0
- package/layers/feeds/server/routes/feed/[collection]/json/all.get.ts +8 -0
- package/layers/feeds/server/routes/feed/[collection]/json.get.ts +9 -0
- package/layers/feeds/server/routes/feed/[collection]/rss/all.get.ts +8 -0
- package/layers/feeds/server/routes/feed/[collection]/rss.get.ts +8 -0
- package/layers/feeds/server/routes/feed/atom/all.get.ts +7 -0
- package/layers/feeds/server/routes/feed/atom.get.ts +7 -0
- package/layers/feeds/server/routes/feed/demo.get.ts +104 -0
- package/layers/feeds/server/routes/feed/discovery.get.ts +30 -0
- package/layers/feeds/server/routes/feed/index.get.ts +3 -0
- package/layers/feeds/server/routes/feed/json/all.get.ts +7 -0
- package/layers/feeds/server/routes/feed/json.get.ts +8 -0
- package/layers/feeds/server/routes/feed/rss/all.get.ts +7 -0
- package/layers/feeds/server/routes/feed/rss.get.ts +7 -0
- package/layers/feeds/server/routes/feed/style.xsl.get.ts +206 -0
- package/layers/feeds/server/utils/cache.ts +8 -0
- package/layers/feeds/server/utils/content-adapter.ts +39 -0
- package/layers/feeds/server/utils/feed-service.ts +46 -0
- package/layers/feeds/server/utils/formats/atom.ts +40 -0
- package/layers/feeds/server/utils/formats/json.ts +21 -0
- package/layers/feeds/server/utils/formats/rss.ts +42 -0
- package/layers/feeds/server/utils/types.ts +26 -0
- package/layers/forms/server/api/contact.post.ts +34 -0
- package/layers/forms/server/api/forms/status.get.ts +11 -0
- package/layers/mailer/server/types.d.ts +11 -0
- package/layers/mailer/server/utils/config.ts +9 -0
- package/layers/mailer/server/utils/email.ts +28 -0
- package/layers/mailer/server/utils/hooks.ts +23 -0
- package/layers/routing/package.json +1 -1
- package/layers/theme/server/plugins/theme-fouc.ts +156 -0
- package/layers/visual/app/types/media.ts +2 -2
- package/package.json +3 -1
|
@@ -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,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, '&')
|
|
11
|
+
.replace(/</g, '<')
|
|
12
|
+
.replace(/>/g, '>')
|
|
13
|
+
.replace(/"/g, '"')
|
|
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 →</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,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,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 →</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">·</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 →</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">·</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, '&')
|
|
6
|
+
.replace(/</g, '<')
|
|
7
|
+
.replace(/>/g, '>')
|
|
8
|
+
.replace(/"/g, '"')
|
|
9
|
+
.replace(/'/g, ''')
|
|
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,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>()
|
|
@@ -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
|
|
154
|
-
provider?:
|
|
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.
|
|
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": {
|