@stnd/layout 0.2.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Meta.astro CHANGED
@@ -1,260 +1,196 @@
1
1
  ---
2
2
  /**
3
- * Meta.astro - Universal Meta Layout
3
+ * Meta.astro — Universal <head> Meta Tags
4
4
  *
5
- * A flexible meta tag component for any Astro project.
6
- * Supports standard meta tags, Open Graph, Twitter Cards, and Schema.org.
5
+ * Self-contained, zero-config. Reads site defaults from virtual:stnd/config
6
+ * (same pattern as manifest/robots/headers folios). Per-page props override.
7
+ *
8
+ * Usage:
9
+ * <Meta /> — site defaults from config
10
+ * <Meta title="My Page" /> — override title
11
+ * <Meta title="Article" type="article" created="2024-01-01" />
7
12
  */
8
- import { getExcerpt } from "@stnd/utils";
9
- // import { ClientRouter } from "astro:transitions";
10
-
11
- // Access site configuration from global
12
- const config = (globalThis['__STANDARD_CONFIG__'] as any) || {};
13
+ import config from "virtual:stnd/config";
13
14
 
14
- // Props interface for universal usage
15
15
  interface Props {
16
- // Core metadata
17
- frontmatter?: any;
18
- site?: any;
19
- page?: any;
20
- title?: string;
21
- description?: string;
22
- image?: string;
23
- created?: string;
24
- modified?: string;
25
- tags?: string[];
26
- securityHeaders?: any;
27
-
28
- // Advanced options
29
- dataSource?: any; // Optional data object with toViewModel() method for dynamic content
30
- rssFeedUrl?: string | null; // Custom RSS feed URL
31
- rssFeedTitle?: string | undefined; // Custom RSS feed title
32
- enableClientRouter?: boolean; // Enable Astro view transitions
33
- injectState?: boolean; // Inject state to window for client-side hydration
34
- faviconPath?: string; // Custom favicon path (defaults to /favicons or /assets/favicon)
35
- fontsUrl?: string; // Optional external fonts URL
36
- language?: string; // Page language override
37
- headExtensions?: string | string[]; // Plugin head extensions
16
+ // Per-page metadata (overrides config defaults)
17
+ title?: string;
18
+ description?: string;
19
+ image?: string;
20
+ author?: string;
21
+ language?: string;
22
+ created?: string;
23
+ modified?: string;
24
+ tags?: string[];
25
+
26
+ // Page type (controls og:type and Schema.org @type)
27
+ type?: "website" | "article" | "profile";
28
+
29
+ // Appearance
30
+ themeColor?: string | { light: string; dark: string };
31
+ tileColor?: string;
32
+
33
+ // Visibility
34
+ visibility?: "public" | "private" | "unlisted";
35
+
36
+ // Optional features
37
+ rssFeedUrl?: string | null;
38
+ rssFeedTitle?: string;
39
+ faviconPath?: string;
40
+ headExtensions?: string | string[];
41
+
42
+ // Absolute origin overriding Astro.site (Astro.site is a build-time
43
+ // constant — pass this when the actual per-request origin, e.g. a
44
+ // user's verified custom domain, differs from the build's default site).
45
+ siteUrl?: string;
38
46
  }
39
47
 
40
48
  const {
41
- frontmatter,
42
- site: propsSite,
43
- page = {},
44
- title: propsTitle,
45
- description: propsDescription,
46
- image: propsImage,
47
- created: propsCreated,
48
- modified: propsModified,
49
- tags: propsTags,
50
- securityHeaders: propsSecurityHeaders,
51
- dataSource,
52
- rssFeedUrl,
53
- rssFeedTitle,
54
- enableClientRouter = false,
55
- injectState = false,
56
- faviconPath,
57
- fontsUrl,
58
- language: propsLanguage,
59
- headExtensions,
60
- // Backward compatibility (deprecated)
61
- note,
62
- injectVisitorState,
63
- } = Astro.props as Props & { note?: any; injectVisitorState?: boolean };
64
-
65
- // Backward compatibility: map old prop names to new ones
66
- const effectiveDataSource = dataSource || note;
67
- const effectiveInjectState = injectState || injectVisitorState || false;
68
-
69
- // Access context data if available (framework-agnostic)
70
- const contextData = (Astro.locals as any)?.garden || (Astro.locals as any)?.context;
71
- const dataView = effectiveDataSource && typeof effectiveDataSource.toViewModel === 'function'
72
- ? await effectiveDataSource.toViewModel()
73
- : null;
74
-
75
- // Build site configuration (prefer props, fallback to config)
76
- const site = propsSite || config?.site || config || {
77
- title: "Site Title",
78
- url: Astro.site || "",
79
- };
80
-
81
- // Get user preferences if context is available
82
- const userPrefs = contextData?.visitor?.raw?.preferences || contextData?.preferences || {
83
- theme: "default",
84
- colorMode: "light",
85
- uiLanguage: "en",
86
- };
87
-
88
- // Extract frontmatter from data source if available
89
- const sourceFrontmatter = dataView?.frontmatter || effectiveDataSource?.raw?.frontmatter || {};
90
- const effectiveFrontmatter = { ...frontmatter, ...sourceFrontmatter };
91
-
92
- // Build metadata with dynamic data support
93
- const ogData = dataView?.og || {};
94
- const title = propsTitle || ogData.title || effectiveFrontmatter?.title || config?.title;
95
- const description = propsDescription || ogData.description || effectiveFrontmatter?.description || config?.description;
96
- const image = propsImage || ogData.image || effectiveFrontmatter?.image;
97
- const created = propsCreated || effectiveFrontmatter?.created;
98
- const modified = propsModified || effectiveFrontmatter?.modified;
99
- const tags = propsTags || effectiveFrontmatter?.tags;
100
- const securityHeaders = propsSecurityHeaders || effectiveFrontmatter?.securityHeaders || site?.securityHeaders;
101
-
102
- const fullTitle = title && title !== site.title ? `${title} · ${site.title}` : site.title;
103
-
104
- // Ensure we have a valid base URL
105
- const baseUrl = site.url || config?.url || Astro.site || 'http://localhost:4321';
106
- const pageUrl = new URL(Astro.url.pathname, baseUrl);
49
+ title: propsTitle,
50
+ description: propsDescription,
51
+ image: propsImage,
52
+ author: propsAuthor,
53
+ language: propsLanguage,
54
+ created: propsCreated,
55
+ modified: propsModified,
56
+ tags: propsTags,
57
+ type = "website",
58
+ themeColor,
59
+ tileColor,
60
+ visibility = "public",
61
+ rssFeedUrl,
62
+ rssFeedTitle,
63
+ faviconPath,
64
+ headExtensions,
65
+ siteUrl: propsSiteUrl,
66
+ } = Astro.props as Props;
67
+
68
+ // ── Site defaults from standard() config ──
69
+ const siteTitle = config.title || "";
70
+ const siteDescription = config.description || "";
71
+ const siteAuthor = config.author?.name || "";
72
+ const siteLanguage = config.language || "en";
73
+
74
+ // ── Resolved metadata — props override config ──
75
+ const title = propsTitle || siteTitle;
76
+ const description = propsDescription || siteDescription;
77
+ const authorName = propsAuthor || siteAuthor;
78
+ const pageLanguage = propsLanguage || siteLanguage;
79
+ const image = propsImage;
80
+ const created = propsCreated;
81
+ const modified = propsModified;
82
+ const tags = propsTags;
83
+
84
+ // ── URL construction ──
85
+ const baseUrl = (propsSiteUrl || Astro.site?.toString() || "").replace(/\/$/, "");
86
+ const pageUrl = new URL(Astro.url.pathname, baseUrl || "http://localhost:4321");
107
87
  const canonicalUrl = new URL(
108
- Astro.url.pathname.replace(/index\.html$/, ""),
109
- baseUrl,
88
+ Astro.url.pathname.replace(/index\.html$/, ""),
89
+ baseUrl || "http://localhost:4321",
110
90
  );
111
91
 
112
- // Description logic
113
- const pageExcerpt = page.rawInput ? String(page.rawInput) : "";
114
- const metaDescription = (description ||
115
- getExcerpt(pageExcerpt, { maxChars: 150 }) ||
116
- site.description ||
117
- "").toString();
92
+ // ── Description ──
93
+ const metaDescription = (description || "").toString();
118
94
 
119
- // Image logic
95
+ // ── Image ──
120
96
  const imageUrl = image
121
- ? `${String(site.url)}/assets/img/${image}`
122
- : ogData.image || (site.url
123
- ? `${String(site.url).replace(/\/$/, "")}/assets/img/default.png`
124
- : "/assets/img/default.png");
125
-
126
- const imageAlt = title || site.title;
127
-
128
- // Date logic
129
- const datePublished = ogData.date
130
- ? new Date(ogData.date).toISOString()
131
- : created
132
- ? new Date(created).toISOString()
133
- : dataView?.date
134
- ? new Date(dataView.date).toISOString()
135
- : null;
136
- const dateModified = modified || created
137
- ? new Date(modified || created).toISOString()
97
+ ? image.startsWith("http")
98
+ ? image
99
+ : `${baseUrl}/assets/img/${image}`
100
+ : baseUrl
101
+ ? `${baseUrl}/assets/img/default.png`
102
+ : "/assets/img/default.png";
103
+ const imageAlt = title || "";
104
+
105
+ // ── Dates ──
106
+ const datePublished = created ? new Date(created).toISOString() : null;
107
+ const dateModified = modified
108
+ ? new Date(modified).toISOString()
109
+ : created
110
+ ? new Date(created).toISOString()
138
111
  : null;
139
112
 
140
- const authorName = ogData.author || site.author?.name || site.author || "";
141
- const twitterUsername = site.twitter_username || site.twitter || "";
142
-
143
- // Determine language
144
- const pageLanguage = propsLanguage || userPrefs.uiLanguage || site.language || "en";
145
-
146
- // Determine favicon path structure
147
- const usesAssetsFavicon = faviconPath === "/assets/favicon" || (contextData && !faviconPath);
148
- const faviconBasePath = faviconPath || (usesAssetsFavicon ? "/assets/favicon" : `${site.url || ""}/favicons`);
149
-
150
- // Process head extensions
151
- const headExtraHtml = Array.isArray(headExtensions) ? headExtensions.join("\n") : (headExtensions || "");
113
+ // ── Favicon ──
114
+ const faviconBase = faviconPath || "/assets/favicon";
115
+
116
+ // ── Theme colors ──
117
+ const resolvedThemeColor =
118
+ typeof themeColor === "string"
119
+ ? { light: themeColor, dark: themeColor }
120
+ : themeColor || { light: "#ffffff", dark: "#1a1a1a" };
121
+ const resolvedTileColor = tileColor || "#ffffff";
122
+
123
+ // ── OG & Schema.org types ──
124
+ const ogType = type;
125
+ const schemaType =
126
+ type === "article"
127
+ ? "BlogPosting"
128
+ : type === "profile"
129
+ ? "ProfilePage"
130
+ : "WebSite";
131
+
132
+ // ── Head extensions ──
133
+ const headExtraHtml = Array.isArray(headExtensions)
134
+ ? headExtensions.join("\n")
135
+ : headExtensions || "";
152
136
  ---
153
137
 
154
138
  <meta charset="utf-8" />
155
139
  {headExtraHtml && <Fragment set:html={headExtraHtml} />}
156
- <!-- Standard Framework | https://standard.ffp.co -->
157
140
 
158
141
  <meta
159
- name="viewport"
160
- content="width=device-width, initial-scale=1, viewport-fit=cover"
142
+ name="viewport"
143
+ content="width=device-width, initial-scale=1, viewport-fit=cover"
161
144
  />
162
145
 
163
- <!-- iOS PWA support for safe-area-inset -->
164
- {contextData && (
165
- <>
166
- <meta name="mobile-web-app-capable" content="yes" />
167
- <meta
168
- name="apple-mobile-web-app-status-bar-style"
169
- content="black-translucent"
170
- />
171
- </>
172
- )}
173
-
174
- {fontsUrl && (
175
- <!-- External fonts -->
176
- <link rel="stylesheet" href={fontsUrl} />
177
- )}
178
-
179
- <title>{fullTitle}</title>
146
+ <title>{title}</title>
180
147
  <meta name="description" content={metaDescription} />
181
- <meta name="author" content={authorName} />
148
+ {authorName && <meta name="author" content={authorName} />}
182
149
  <meta name="language" content={pageLanguage} />
183
150
  <meta name="referrer" content="strict-origin-when-cross-origin" />
184
151
 
185
- <!-- Canonical URL -->
186
152
  <link rel="canonical" href={canonicalUrl} />
187
153
 
188
- <!-- Feed & Sitemap -->
189
154
  {
190
- (site?.url || rssFeedUrl) ? (
191
- <>
192
- <link
193
- rel="alternate"
194
- type="application/atom+xml"
195
- title={rssFeedTitle || site.title}
196
- href={rssFeedUrl || `${site.url}/feed.xml`}
197
- />
198
- {site?.url && (
199
- <link
200
- rel="sitemap"
201
- type="application/xml"
202
- title="Sitemap"
203
- href={`${site.url}/sitemap.xml`}
204
- />
205
- )}
206
- </>
207
- ) : null
155
+ rssFeedUrl && (
156
+ <link
157
+ rel="alternate"
158
+ type="application/atom+xml"
159
+ title={rssFeedTitle || title}
160
+ href={rssFeedUrl}
161
+ />
162
+ )
208
163
  }
209
164
 
210
165
  {
211
- securityHeaders && (
212
- <>
213
- {securityHeaders.csp && (
214
- <meta
215
- http-equiv="Content-Security-Policy"
216
- content={securityHeaders.csp}
217
- />
218
- )}
219
- {securityHeaders.referrerPolicy && (
220
- <meta
221
- http-equiv="Referrer-Policy"
222
- content={securityHeaders.referrerPolicy}
223
- />
224
- )}
225
- {securityHeaders.permissions && (
226
- <meta
227
- http-equiv="Permissions-Policy"
228
- content={securityHeaders.permissions}
229
- />
230
- )}
231
- {securityHeaders.xContentTypeOptions && (
232
- <meta
233
- http-equiv="X-Content-Type-Options"
234
- content={securityHeaders.xContentTypeOptions}
235
- />
236
- )}
237
- </>
238
- )
166
+ baseUrl && (
167
+ <link
168
+ rel="sitemap"
169
+ type="application/xml"
170
+ title="Sitemap"
171
+ href={`${baseUrl}/sitemap.xml`}
172
+ />
173
+ )
239
174
  }
240
175
 
241
- <!-- Robots Meta -->
176
+ <!-- CSP is handled natively by Astro 6 via security.csp config -->
177
+
242
178
  {
243
- site?.visibility === "private" ? (
244
- <meta name="robots" content="noindex, nofollow" />
245
- ) : (
246
- <meta
247
- name="robots"
248
- content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1"
249
- />
250
- )
179
+ visibility === "private" || visibility === "unlisted" ? (
180
+ <meta name="robots" content="noindex, nofollow" />
181
+ ) : (
182
+ <meta
183
+ name="robots"
184
+ content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1"
185
+ />
186
+ )
251
187
  }
252
188
 
253
189
  <!-- Open Graph -->
254
- <meta property="og:title" content={title || site.title} />
255
- <meta property="og:type" content={title ? "article" : "website"} />
190
+ <meta property="og:title" content={title} />
191
+ <meta property="og:type" content={ogType} />
256
192
  <meta property="og:locale" content={pageLanguage} />
257
- <meta property="og:site_name" content={site.title} />
193
+ {siteTitle && <meta property="og:site_name" content={siteTitle} />}
258
194
  <meta property="og:url" content={pageUrl} />
259
195
  <meta property="og:description" content={metaDescription} />
260
196
 
@@ -264,139 +200,101 @@ const headExtraHtml = Array.isArray(headExtensions) ? headExtensions.join("\n")
264
200
  <meta property="og:image:height" content="630" />
265
201
 
266
202
  {
267
- datePublished && (
268
- <>
269
- <meta property="article:published_time" content={datePublished} />
270
- <meta property="article:author" content={authorName} />
271
- {tags &&
272
- tags.map((tag: string) => (
273
- <meta property="article:tag" content={tag} />
274
- ))}
275
- </>
276
- )
203
+ type === "article" && datePublished && (
204
+ <>
205
+ <meta property="article:published_time" content={datePublished} />
206
+ {authorName && <meta property="article:author" content={authorName} />}
207
+ {tags &&
208
+ tags.map((tag: string) => (
209
+ <meta property="article:tag" content={tag} />
210
+ ))}
211
+ </>
212
+ )
277
213
  }
278
214
 
279
215
  <!-- Twitter Cards -->
280
- <meta name="twitter:card" content={imageUrl ? "summary_large_image" : "summary"} />
216
+ <meta name="twitter:card" content={image ? "summary_large_image" : "summary"} />
281
217
  <meta name="twitter:image" content={imageUrl} />
282
218
  <meta name="twitter:image:alt" content={imageAlt} />
283
- <meta name="twitter:site" content={`@${twitterUsername}`} />
284
- <meta name="twitter:creator" content={`@${twitterUsername}`} />
285
- <meta name="twitter:title" content={fullTitle} />
219
+ <meta name="twitter:title" content={title} />
286
220
  <meta name="twitter:description" content={metaDescription} />
287
221
 
288
222
  <!-- Schema.org -->
289
223
  <script
290
- type="application/ld+json"
291
- set:html={JSON.stringify({
292
- "@context": "https://schema.org",
293
- "@type": title ? "BlogPosting" : "WebSite",
294
- ...(title
224
+ type="application/ld+json"
225
+ set:html={JSON.stringify({
226
+ "@context": "https://schema.org",
227
+ "@type": schemaType,
228
+ ...(type === "article"
229
+ ? {
230
+ headline: title,
231
+ description: metaDescription,
232
+ ...(authorName
233
+ ? {
234
+ author: { "@type": "Person", name: authorName },
235
+ publisher: { "@type": "Person", name: authorName },
236
+ }
237
+ : {}),
238
+ ...(datePublished ? { datePublished } : {}),
239
+ ...(dateModified ? { dateModified } : {}),
240
+ image: {
241
+ "@type": "ImageObject",
242
+ url: imageUrl,
243
+ width: 1200,
244
+ height: 630,
245
+ },
246
+ mainEntityOfPage: { "@type": "WebPage", "@id": pageUrl },
247
+ }
248
+ : {
249
+ name: siteTitle || title,
250
+ description: metaDescription,
251
+ url: baseUrl,
252
+ ...(authorName
295
253
  ? {
296
- headline: title,
297
- description: metaDescription,
298
- author: {
299
- "@type": "Person",
300
- name: authorName,
301
- url: `${site.url}/now/`,
302
- },
303
- publisher: {
304
- "@type": "Person",
305
- name: authorName,
306
- url: site.url,
307
- },
308
- ...(datePublished ? { datePublished } : {}),
309
- ...(dateModified ? { dateModified } : {}),
310
- image: {
311
- "@type": "ImageObject",
312
- url: imageUrl,
313
- width: 1200,
314
- height: 630,
315
- },
316
- mainEntityOfPage: {
317
- "@type": "WebPage",
318
- "@id": pageUrl,
319
- },
254
+ author: { "@type": "Person", name: authorName },
320
255
  }
321
- : {
322
- name: site.title,
323
- description: site.description,
324
- url: site.url,
325
- author: {
326
- "@type": "Person",
327
- name: authorName,
328
- url: site.url,
329
- },
330
- }),
331
- })}
256
+ : {}),
257
+ }),
258
+ })}
332
259
  />
333
260
 
334
261
  <!-- Favicons -->
335
- {usesAssetsFavicon ? (
336
- <>
337
- <link
338
- rel="icon"
339
- type="image/png"
340
- href={`${faviconBasePath}/favicon-96x96.png`}
341
- sizes="96x96"
342
- />
343
- <link rel="icon" type="image/svg+xml" href={`${faviconBasePath}/favicon.svg`} />
344
- <link rel="shortcut icon" href={`${faviconBasePath}/favicon.ico`} />
345
- <link
346
- rel="apple-touch-icon"
347
- sizes="180x180"
348
- href={`${faviconBasePath}/apple-touch-icon.png`}
349
- />
350
- <meta name="apple-mobile-web-app-title" content={site.title} />
351
- <link rel="manifest" href={`${faviconBasePath}/site.webmanifest`} />
352
- </>
353
- ) : (
354
- <>
355
- <link
356
- rel="apple-touch-icon"
357
- sizes="180x180"
358
- href={`${faviconBasePath}/apple-touch-icon.png`}
359
- />
360
- <link
361
- rel="icon"
362
- type="image/png"
363
- sizes="32x32"
364
- href={`${faviconBasePath}/favicon-32x32.png`}
365
- />
366
- <link
367
- rel="icon"
368
- type="image/png"
369
- sizes="16x16"
370
- href={`${faviconBasePath}/favicon-16x16.png`}
371
- />
372
- <link rel="manifest" href="/site.webmanifest" />
373
- <link
374
- rel="mask-icon"
375
- href={`${faviconBasePath}/safari-pinned-tab.svg`}
376
- color="#000000"
377
- />
378
- <link rel="shortcut icon" href={`${faviconBasePath}/favicon.ico`} />
379
- </>
380
- )}
262
+ <link
263
+ rel="icon"
264
+ type="image/png"
265
+ href={`${faviconBase}/favicon-96x96.png`}
266
+ sizes="96x96"
267
+ />
268
+ <link rel="icon" type="image/svg+xml" href={`${faviconBase}/favicon.svg`} />
269
+ <link rel="shortcut icon" href={`${faviconBase}/favicon.ico`} />
270
+ <link
271
+ rel="apple-touch-icon"
272
+ sizes="180x180"
273
+ href={`${faviconBase}/apple-touch-icon.png`}
274
+ />
275
+ <meta name="apple-mobile-web-app-title" content={siteTitle || title || ""} />
276
+ <link rel="manifest" href="/site.webmanifest" />
381
277
 
382
278
  <!-- Mobile -->
383
279
  <meta
384
- name="theme-color"
385
- content="#ffffff"
386
- media="(prefers-color-scheme: light)"
280
+ name="theme-color"
281
+ content={resolvedThemeColor.light}
282
+ media="(prefers-color-scheme: light)"
387
283
  />
388
284
  <meta
389
- name="theme-color"
390
- content="#000000"
391
- media="(prefers-color-scheme: dark)"
285
+ name="theme-color"
286
+ content={resolvedThemeColor.dark}
287
+ media="(prefers-color-scheme: dark)"
288
+ />
289
+ <meta name="mobile-web-app-capable" content="yes" />
290
+ <meta
291
+ name="apple-mobile-web-app-status-bar-style"
292
+ content="black-translucent"
392
293
  />
393
294
 
394
295
  <!-- Windows Tiles -->
395
- <meta name="msapplication-TileColor" content="#000000" />
296
+ <meta name="msapplication-TileColor" content={resolvedTileColor} />
396
297
  <meta
397
- name="msapplication-config"
398
- content={`${site.url || ""}/favicons/browserconfig.xml`}
298
+ name="msapplication-config"
299
+ content={`${faviconBase}/browserconfig.xml`}
399
300
  />
400
-
401
- <!-- Client Router (Astro view transitions) -->
402
- <!-- {enableClientRouter && <ClientRouter />} -->