@life-and-dev/mdsite 0.2.1 → 0.2.3

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.
@@ -15,6 +15,7 @@
15
15
  </template>
16
16
 
17
17
  <script setup lang="ts">
18
+ import { withBasePath } from '../../utils/base-url'
18
19
  const route = useRoute()
19
20
 
20
21
  // Query content using Nuxt Content v3 API
@@ -29,6 +30,7 @@ const { data: page, pending } = await useAsyncData(
29
30
  const siteConfig = useSiteConfig()
30
31
  const title = page.value?.title || 'Page'
31
32
  const description = page.value?.description || ''
33
+ const appBaseURL = useRuntimeConfig().app.baseURL
32
34
 
33
35
  useHead(() => ({
34
36
  title,
@@ -42,9 +44,9 @@ useHead(() => ({
42
44
  // Open Graph
43
45
  { property: 'og:title', content: title },
44
46
  { property: 'og:description', content: description },
45
- { property: 'og:url', content: `${siteConfig.siteCanonical}${route.path}` },
47
+ { property: 'og:url', content: siteConfig.siteCanonical ? `${siteConfig.siteCanonical}${route.path}` : withBasePath(route.path, appBaseURL) },
46
48
  { property: 'og:type', content: 'article' },
47
- { property: 'og:image', content: '/icon-512.png' }
49
+ { property: 'og:image', content: withBasePath('/icon-512.png', appBaseURL) }
48
50
  ],
49
51
  link: [
50
52
  ...(siteConfig.siteCanonical ? [{ rel: 'canonical', href: `${siteConfig.siteCanonical}${route.path}` }] : [])
@@ -15,6 +15,7 @@
15
15
  </template>
16
16
 
17
17
  <script setup lang="ts">
18
+ import { withBasePath } from '../../utils/base-url'
18
19
  // Query home page content using Nuxt Content v3 API
19
20
  // server: true = Only query during SSR/prerendering, never on client
20
21
  // This prevents 3.5MB database download - client uses prerendered HTML
@@ -27,6 +28,7 @@ const { data: page, pending } = await useAsyncData(
27
28
  const siteConfig = useSiteConfig()
28
29
  const title = page.value?.title || siteConfig.siteName || 'Home'
29
30
  const description = page.value?.description || ''
31
+ const appBaseURL = useRuntimeConfig().app.baseURL
30
32
 
31
33
  useHead(() => ({
32
34
  title,
@@ -40,9 +42,9 @@ useHead(() => ({
40
42
  // Open Graph
41
43
  { property: 'og:title', content: title },
42
44
  { property: 'og:description', content: description },
43
- { property: 'og:url', content: siteConfig.siteCanonical },
45
+ { property: 'og:url', content: siteConfig.siteCanonical || withBasePath('/', appBaseURL) },
44
46
  { property: 'og:type', content: 'website' },
45
- { property: 'og:image', content: '/icon-512.png' }
47
+ { property: 'og:image', content: withBasePath('/icon-512.png', appBaseURL) }
46
48
  ],
47
49
  link: [
48
50
  ...(siteConfig.siteCanonical ? [{ rel: 'canonical', href: siteConfig.siteCanonical }] : [])
@@ -0,0 +1,223 @@
1
+ <?xml version="1.0" encoding="UTF-8" standalone="no"?>
2
+ <!-- Created with Inkscape (http://www.inkscape.org/) -->
3
+
4
+ <svg
5
+ width="348.061"
6
+ height="340"
7
+ viewBox="0 0 34.8061 34"
8
+ version="1.1"
9
+ id="svg1"
10
+ inkscape:version="1.4.3 (1:1.4.3+202512261035+0d15f75042)"
11
+ sodipodi:docname="logo.svg"
12
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
13
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
14
+ xmlns:xlink="http://www.w3.org/1999/xlink"
15
+ xmlns="http://www.w3.org/2000/svg"
16
+ xmlns:svg="http://www.w3.org/2000/svg">
17
+ <sodipodi:namedview
18
+ id="namedview1"
19
+ pagecolor="#505050"
20
+ bordercolor="#eeeeee"
21
+ borderopacity="1"
22
+ inkscape:showpageshadow="0"
23
+ inkscape:pageopacity="0"
24
+ inkscape:pagecheckerboard="0"
25
+ inkscape:deskcolor="#505050"
26
+ inkscape:document-units="px"
27
+ showgrid="true"
28
+ inkscape:zoom="2.9179688"
29
+ inkscape:cx="181.11914"
30
+ inkscape:cy="144.27844"
31
+ inkscape:window-width="3032"
32
+ inkscape:window-height="1696"
33
+ inkscape:window-x="40"
34
+ inkscape:window-y="0"
35
+ inkscape:window-maximized="1"
36
+ inkscape:current-layer="layer1">
37
+ <inkscape:grid
38
+ id="grid1"
39
+ units="px"
40
+ originx="1.2100008"
41
+ originy="1"
42
+ spacingx="1"
43
+ spacingy="1"
44
+ empcolor="#0099e5"
45
+ empopacity="0.30196078"
46
+ color="#0099e5"
47
+ opacity="0.14901961"
48
+ empspacing="5"
49
+ enabled="true"
50
+ visible="true" />
51
+ </sodipodi:namedview>
52
+ <defs
53
+ id="defs1">
54
+ <linearGradient
55
+ id="linearGradient12"
56
+ inkscape:collect="always">
57
+ <stop
58
+ style="stop-color:#000000;stop-opacity:1;"
59
+ offset="0"
60
+ id="stop13" />
61
+ <stop
62
+ style="stop-color:#000000;stop-opacity:0;"
63
+ offset="1"
64
+ id="stop14" />
65
+ </linearGradient>
66
+ <linearGradient
67
+ id="linearGradient4"
68
+ inkscape:collect="always">
69
+ <stop
70
+ style="stop-color:#ffffff;stop-opacity:1;"
71
+ offset="0.49735323"
72
+ id="stop5" />
73
+ <stop
74
+ style="stop-color:#ffffff;stop-opacity:0.75119454;"
75
+ offset="1"
76
+ id="stop6" />
77
+ </linearGradient>
78
+ <linearGradient
79
+ id="linearGradient3"
80
+ inkscape:collect="always">
81
+ <stop
82
+ style="stop-color:#f9f9f9;stop-opacity:1;"
83
+ offset="0.49600685"
84
+ id="stop3" />
85
+ <stop
86
+ style="stop-color:#ffffff;stop-opacity:0.7457487;"
87
+ offset="1"
88
+ id="stop4" />
89
+ </linearGradient>
90
+ <radialGradient
91
+ inkscape:collect="always"
92
+ xlink:href="#linearGradient3"
93
+ id="radialGradient4"
94
+ cx="10.5"
95
+ cy="16"
96
+ fx="10.5"
97
+ fy="16"
98
+ r="10.5"
99
+ gradientTransform="matrix(1,0,0,1.3333333,0,-5.3333333)"
100
+ gradientUnits="userSpaceOnUse" />
101
+ <radialGradient
102
+ inkscape:collect="always"
103
+ xlink:href="#linearGradient4"
104
+ id="radialGradient6"
105
+ cx="24.514727"
106
+ cy="12"
107
+ fx="24.514727"
108
+ fy="12"
109
+ r="7.5147257"
110
+ gradientTransform="matrix(1,0,0,1.3307206,0,-3.9686472)"
111
+ gradientUnits="userSpaceOnUse" />
112
+ <radialGradient
113
+ inkscape:collect="always"
114
+ xlink:href="#linearGradient12"
115
+ id="radialGradient14"
116
+ cx="16"
117
+ cy="16"
118
+ fx="16"
119
+ fy="16"
120
+ r="16"
121
+ gradientUnits="userSpaceOnUse"
122
+ gradientTransform="matrix(1.0624999,0,0,1.0624999,0.21000074,0)" />
123
+ <filter
124
+ style="color-interpolation-filters:sRGB"
125
+ inkscape:label="Drop Shadow"
126
+ id="filter66"
127
+ x="-0.057619048"
128
+ y="-0.055841193"
129
+ width="1.132074"
130
+ height="1.1384681">
131
+ <feFlood
132
+ result="flood"
133
+ in="SourceGraphic"
134
+ flood-opacity="0.498039"
135
+ flood-color="rgb(0,0,0)"
136
+ id="feFlood65" />
137
+ <feGaussianBlur
138
+ result="blur"
139
+ in="SourceGraphic"
140
+ stdDeviation="0.400000"
141
+ id="feGaussianBlur65" />
142
+ <feOffset
143
+ result="offset"
144
+ in="blur"
145
+ dx="0.000000"
146
+ dy="1.000000"
147
+ id="feOffset65" />
148
+ <feComposite
149
+ result="comp1"
150
+ operator="in"
151
+ in="flood"
152
+ in2="offset"
153
+ id="feComposite65" />
154
+ <feComposite
155
+ result="comp2"
156
+ operator="over"
157
+ in="SourceGraphic"
158
+ in2="comp1"
159
+ id="feComposite66" />
160
+ </filter>
161
+ <filter
162
+ style="color-interpolation-filters:sRGB"
163
+ inkscape:label="Drop Shadow"
164
+ id="filter68"
165
+ x="-0.10403264"
166
+ y="-0.060500001"
167
+ width="1.2082745"
168
+ height="1.171">
169
+ <feFlood
170
+ result="flood"
171
+ in="SourceGraphic"
172
+ flood-opacity="0.498039"
173
+ flood-color="rgb(0,0,0)"
174
+ id="feFlood66" />
175
+ <feGaussianBlur
176
+ result="blur"
177
+ in="SourceGraphic"
178
+ stdDeviation="0.400000"
179
+ id="feGaussianBlur66" />
180
+ <feOffset
181
+ result="offset"
182
+ in="blur"
183
+ dx="0.000000"
184
+ dy="1.000000"
185
+ id="feOffset66" />
186
+ <feComposite
187
+ result="comp1"
188
+ operator="in"
189
+ in="flood"
190
+ in2="offset"
191
+ id="feComposite67" />
192
+ <feComposite
193
+ result="comp2"
194
+ operator="over"
195
+ in="SourceGraphic"
196
+ in2="comp1"
197
+ id="feComposite68" />
198
+ </filter>
199
+ </defs>
200
+ <rect
201
+ style="display:inline;fill:url(#radialGradient14);stroke:none;stroke-width:1.0625"
202
+ id="rect2"
203
+ width="34"
204
+ height="34"
205
+ x="0.21000074"
206
+ y="0" />
207
+ <g
208
+ inkscape:label="Layer 1"
209
+ inkscape:groupmode="layer"
210
+ id="layer1"
211
+ transform="translate(1.21,1)">
212
+ <path
213
+ style="fill:url(#radialGradient4);stroke:#000000;stroke-width:0.5;stroke-dasharray:none;stroke-opacity:1;filter:url(#filter66)"
214
+ d="M 0,22 V 2 l 8,8 8,-8 0.03029,21 H 21 l -7,7 -7,-7 h 5.030291 L 12,11 8,15 4,11 v 11 z"
215
+ id="path1"
216
+ sodipodi:nodetypes="cccccccccccccc" />
217
+ <path
218
+ style="fill:url(#radialGradient6);stroke:#000000;stroke-width:0.5;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1;filter:url(#filter68)"
219
+ d="m 21,18 -4,4 h 11 l 4,-4 V 14 L 28,10 H 23 L 21,8 23,6 h 5 L 32.029451,2 H 21.05087 L 17,5.8781794 V 10.014726 L 21,14 h 5 l 2,2 -2,2 z"
220
+ id="path2"
221
+ sodipodi:nodetypes="ccccccccccccccccccc" />
222
+ </g>
223
+ </svg>
@@ -0,0 +1,123 @@
1
+ import path from 'node:path'
2
+ import os from 'node:os'
3
+ import fs from 'node:fs'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
7
+
8
+ import { generateFavicons, resolveFaviconSource } from './generate-favicons.js'
9
+
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
11
+ const DEFAULT_FAVICON_PATH = path.resolve(__dirname, '..', 'assets', 'default-favicon.svg')
12
+
13
+ describe('generate-favicons', () => {
14
+ let tmpDir: string
15
+
16
+ beforeEach(() => {
17
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mdsite-favicon-'))
18
+ })
19
+
20
+ afterEach(() => {
21
+ fs.rmSync(tmpDir, { recursive: true, force: true })
22
+ })
23
+
24
+ describe('resolveFaviconSource', () => {
25
+ it('falls back to the bundled default favicon when favicon is an empty string', () => {
26
+ const result = resolveFaviconSource(tmpDir, '')
27
+
28
+ expect(result).not.toBeNull()
29
+ expect(result!.isDefault).toBe(true)
30
+ expect(path.basename(result!.sourcePath)).toBe('default-favicon.svg')
31
+ expect(fs.existsSync(result!.sourcePath)).toBe(true)
32
+ })
33
+
34
+ it('falls back to the bundled default favicon when favicon is only whitespace', () => {
35
+ const result = resolveFaviconSource(tmpDir, ' ')
36
+
37
+ expect(result).not.toBeNull()
38
+ expect(result!.isDefault).toBe(true)
39
+ expect(path.basename(result!.sourcePath)).toBe('default-favicon.svg')
40
+ })
41
+
42
+ it('uses the configured source when the favicon file exists under the content dir', () => {
43
+ const relPath = 'favicon.svg'
44
+ const absPath = path.join(tmpDir, relPath)
45
+ const customSvg =
46
+ '<?xml version="1.0" encoding="UTF-8"?>' +
47
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">' +
48
+ '<rect width="16" height="16" fill="red"/></svg>'
49
+ fs.writeFileSync(absPath, customSvg, 'utf8')
50
+
51
+ const result = resolveFaviconSource(tmpDir, relPath)
52
+
53
+ expect(result).not.toBeNull()
54
+ expect(result!.isDefault).toBe(false)
55
+ expect(result!.sourcePath).toBe(path.resolve(tmpDir, relPath))
56
+ })
57
+
58
+ it('falls back to the bundled default favicon when the configured file does not exist', () => {
59
+ const result = resolveFaviconSource(tmpDir, 'does-not-exist.svg')
60
+
61
+ expect(result).not.toBeNull()
62
+ expect(result!.isDefault).toBe(true)
63
+ expect(path.basename(result!.sourcePath)).toBe('default-favicon.svg')
64
+ expect(fs.existsSync(result!.sourcePath)).toBe(true)
65
+ })
66
+ })
67
+
68
+ describe('generateFavicons', () => {
69
+ it('uses the bundled default favicon and writes all expected assets when config.favicon is empty', async () => {
70
+ const outputDir = path.join(tmpDir, 'output')
71
+
72
+ const ok = await generateFavicons({
73
+ contentDir: tmpDir,
74
+ config: { favicon: '' },
75
+ outputDir,
76
+ })
77
+
78
+ expect(ok).toBe(true)
79
+
80
+ const expectedFiles = [
81
+ 'favicon.svg',
82
+ 'favicon.ico',
83
+ 'apple-touch-icon.png',
84
+ 'icon-192.png',
85
+ 'icon-512.png',
86
+ ]
87
+ for (const file of expectedFiles) {
88
+ const filePath = path.join(outputDir, file)
89
+ expect(fs.existsSync(filePath)).toBe(true)
90
+ expect(fs.statSync(filePath).size).toBeGreaterThan(0)
91
+ }
92
+
93
+ // The default source svg is copied verbatim as favicon.svg
94
+ const defaultSvgContent = fs.readFileSync(DEFAULT_FAVICON_PATH, 'utf8')
95
+ const writtenSvgContent = fs.readFileSync(path.join(outputDir, 'favicon.svg'), 'utf8')
96
+ expect(writtenSvgContent).toBe(defaultSvgContent)
97
+ })
98
+
99
+ it('uses the configured custom source svg over the bundled default', async () => {
100
+ const outputDir = path.join(tmpDir, 'output')
101
+ const customSvg =
102
+ '<?xml version="1.0" encoding="UTF-8"?>' +
103
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">' +
104
+ '<rect width="16" height="16" fill="blue"/></svg>'
105
+ fs.writeFileSync(path.join(tmpDir, 'favicon.svg'), customSvg, 'utf8')
106
+
107
+ const ok = await generateFavicons({
108
+ contentDir: tmpDir,
109
+ config: { favicon: 'favicon.svg' },
110
+ outputDir,
111
+ })
112
+
113
+ expect(ok).toBe(true)
114
+
115
+ const writtenSvgContent = fs.readFileSync(path.join(outputDir, 'favicon.svg'), 'utf8')
116
+ expect(writtenSvgContent).toBe(customSvg)
117
+
118
+ // And it is NOT the bundled default
119
+ const defaultSvgContent = fs.readFileSync(DEFAULT_FAVICON_PATH, 'utf8')
120
+ expect(writtenSvgContent).not.toBe(defaultSvgContent)
121
+ })
122
+ })
123
+ })
@@ -3,12 +3,14 @@
3
3
  import sharp from 'sharp'
4
4
  import fs from 'fs-extra'
5
5
  import path from 'path'
6
- import { fileURLToPath } from 'url'
6
+ import { fileURLToPath } from 'node:url'
7
7
  import { loadMdsiteConfigSync, resolveMdsiteConfigPath } from '../utils/mdsite-config.js'
8
8
 
9
9
  const __filename = fileURLToPath(import.meta.url)
10
10
  const __dirname = path.dirname(__filename)
11
11
 
12
+ const DEFAULT_FAVICON_PATH = fileURLToPath(new URL('../assets/default-favicon.svg', import.meta.url))
13
+
12
14
  const FAVICON_SIZES = {
13
15
  ico: [16, 32],
14
16
  appleTouchIcon: 180,
@@ -16,28 +18,56 @@ const FAVICON_SIZES = {
16
18
  pwaIcon512: 512
17
19
  } as const
18
20
 
21
+ export function resolveFaviconSource(
22
+ contentDir: string,
23
+ favicon: string,
24
+ ): { sourcePath: string; isDefault: boolean } | null {
25
+ if (typeof favicon === 'string' && favicon.trim().length > 0) {
26
+ const candidate = path.resolve(contentDir, favicon)
27
+ if (fs.pathExistsSync(candidate)) {
28
+ return { sourcePath: candidate, isDefault: false }
29
+ }
30
+ }
31
+
32
+ if (fs.pathExistsSync(DEFAULT_FAVICON_PATH)) {
33
+ return { sourcePath: DEFAULT_FAVICON_PATH, isDefault: true }
34
+ }
35
+
36
+ return null
37
+ }
38
+
39
+ export interface GenerateFaviconsOptions {
40
+ contentDir?: string
41
+ config?: { favicon?: string; site?: { name?: string } }
42
+ outputDir?: string
43
+ }
44
+
19
45
  /**
20
46
  * Generate favicons from the active mdsite config
21
47
  */
22
- export async function generateFavicons(): Promise<boolean> {
23
- const { contentDir, config } = loadMdsiteConfigSync()
48
+ export async function generateFavicons(options: GenerateFaviconsOptions = {}): Promise<boolean> {
49
+ const resolved = options.contentDir && options.config
50
+ ? { contentDir: options.contentDir, config: options.config }
51
+ : loadMdsiteConfigSync()
52
+ const { contentDir, config } = resolved
53
+ const siteName = (config as { site?: { name?: string } }).site?.name ?? 'site'
24
54
 
25
- if (!config.favicon || !config.favicon.trim()) {
26
- console.warn(`⚠️ No favicon source configured (config.favicon is empty). Skipping favicon generation.`)
27
- return false
28
- }
55
+ const resolvedSource = resolveFaviconSource(contentDir, config.favicon ?? '')
29
56
 
30
- const sourcePath = path.resolve(contentDir, config.favicon)
31
-
32
- if (!await fs.pathExists(sourcePath)) {
33
- console.error(`❌ Favicon source not found: ${sourcePath}`)
57
+ if (!resolvedSource) {
58
+ console.error('❌ No favicon source available (configured source missing AND bundled default not found).')
34
59
  return false
35
60
  }
36
61
 
37
- const publicDir = path.resolve(__dirname, '..', 'public')
62
+ const { sourcePath } = resolvedSource
63
+ const publicDir = options.outputDir ?? path.resolve(__dirname, '..', 'public')
38
64
  await fs.ensureDir(publicDir)
39
65
 
40
- console.log(`🎨 Generating favicons for site: ${config.site.name}`)
66
+ if (resolvedSource.isDefault) {
67
+ console.log('ℹ️ No favicon source configured (config.favicon empty or file not found). Using bundled default favicon.')
68
+ }
69
+
70
+ console.log(`🎨 Generating favicons for site: ${siteName}`)
41
71
  console.log(` Source: ${sourcePath}`)
42
72
  console.log(` Output: ${publicDir}`)
43
73
 
@@ -92,10 +122,10 @@ export async function generateFavicons(): Promise<boolean> {
92
122
  .toFile(icon512Path)
93
123
  console.log(` ✓ PWA Icon 512: icon-512.png`)
94
124
 
95
- console.log(`✅ Favicons generated successfully for ${config.site.name}\n`)
125
+ console.log(`✅ Favicons generated successfully for ${siteName}\n`)
96
126
  return true
97
127
  } catch (error) {
98
- console.error(`❌ Failed to generate favicons for ${config.site.name}:`, error)
128
+ console.error(`❌ Failed to generate favicons for ${siteName}:`, error)
99
129
  return false
100
130
  }
101
131
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@life-and-dev/mdsite",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "description": "Local-first CLI that orchestrates mdsite-nuxt",