@stnd/layout 0.2.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/Base.astro +165 -0
- package/CHANGELOG.md +13 -0
- package/Encrypted.astro +196 -0
- package/Meta.astro +402 -0
- package/package.json +18 -0
package/Base.astro
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Meta from "./Meta.astro";
|
|
3
|
+
import Menu from "../../components/Menu.astro";
|
|
4
|
+
import Logo from "../../components/Logo.astro";
|
|
5
|
+
import Comments from "../../components/Comments.astro";
|
|
6
|
+
import Toast from "../../components/Toast.astro";
|
|
7
|
+
// import { ClientRouter } from "astro:transitions";
|
|
8
|
+
import Log from "@stnd/log";
|
|
9
|
+
import { Image } from "astro:assets";
|
|
10
|
+
|
|
11
|
+
// Create logger for this layout
|
|
12
|
+
const log = Log({ scope: "Base Layout" });
|
|
13
|
+
|
|
14
|
+
// Access site configuration from global
|
|
15
|
+
import type { StandardFrameworkConfig } from "../types/standard";
|
|
16
|
+
const config =
|
|
17
|
+
(globalThis["__STANDARD_CONFIG__"] as StandardFrameworkConfig) || {};
|
|
18
|
+
|
|
19
|
+
// ========================================
|
|
20
|
+
// PROPS INTERFACE
|
|
21
|
+
// ========================================
|
|
22
|
+
|
|
23
|
+
interface Props {
|
|
24
|
+
frontmatter?: {
|
|
25
|
+
title?: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
image?: string;
|
|
28
|
+
created?: string;
|
|
29
|
+
modified?: string;
|
|
30
|
+
tags?: string[];
|
|
31
|
+
securityHeaders?: boolean;
|
|
32
|
+
backlinks?: {
|
|
33
|
+
inbound?: string[];
|
|
34
|
+
outbound?: string[];
|
|
35
|
+
meta?: Record<string, any>;
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
site?: StandardFrameworkConfig;
|
|
39
|
+
title?: string;
|
|
40
|
+
description?: string;
|
|
41
|
+
image?: string;
|
|
42
|
+
created?: string;
|
|
43
|
+
modified?: string;
|
|
44
|
+
tags?: string[];
|
|
45
|
+
securityHeaders?: boolean;
|
|
46
|
+
class?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Use config from virtual module, with fallback to props for backward compatibility
|
|
50
|
+
const {
|
|
51
|
+
frontmatter,
|
|
52
|
+
site = config || {}, // Use config as default, allow override via props
|
|
53
|
+
title = frontmatter?.title || config?.title,
|
|
54
|
+
description = frontmatter?.description || config?.description,
|
|
55
|
+
image = frontmatter?.image,
|
|
56
|
+
created = frontmatter?.created,
|
|
57
|
+
modified = frontmatter?.modified,
|
|
58
|
+
tags = frontmatter?.tags || config?.tags || [],
|
|
59
|
+
securityHeaders = frontmatter?.securityHeaders,
|
|
60
|
+
class: className,
|
|
61
|
+
} = Astro.props as Props;
|
|
62
|
+
|
|
63
|
+
// ✨ Get backlinks from the page's frontmatter/data
|
|
64
|
+
const backlinks = frontmatter?.backlinks || {};
|
|
65
|
+
const { inbound = [], outbound = [], meta = {} } = backlinks;
|
|
66
|
+
|
|
67
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
68
|
+
const _unused = { inbound, outbound, meta };
|
|
69
|
+
|
|
70
|
+
const year = new Date().getFullYear();
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
<!doctype html>
|
|
74
|
+
<html
|
|
75
|
+
class="rhythm"
|
|
76
|
+
lang={site.language || config?.language || "en"}
|
|
77
|
+
data-standard-version={site.version || config?.version}
|
|
78
|
+
data-theme="system"
|
|
79
|
+
>
|
|
80
|
+
<head>
|
|
81
|
+
<meta name="astro-view-transitions-enabled" content="true" />
|
|
82
|
+
<Meta
|
|
83
|
+
site={site}
|
|
84
|
+
title={title}
|
|
85
|
+
description={description}
|
|
86
|
+
image={image}
|
|
87
|
+
created={created}
|
|
88
|
+
modified={modified}
|
|
89
|
+
tags={tags}
|
|
90
|
+
securityHeaders={securityHeaders}
|
|
91
|
+
/>
|
|
92
|
+
<!-- <ClientRouter /> -->
|
|
93
|
+
<script
|
|
94
|
+
is:inline
|
|
95
|
+
data-astro-rerun
|
|
96
|
+
src="/assets/standard/standard.bundle.full.js"
|
|
97
|
+
transition:persist></script>
|
|
98
|
+
<!-- Check if lab is enabled in config -->
|
|
99
|
+
{
|
|
100
|
+
config.lab ? (
|
|
101
|
+
<script
|
|
102
|
+
is:inline
|
|
103
|
+
src="/assets/standard/standard.lab.js"
|
|
104
|
+
transition:persist
|
|
105
|
+
/>
|
|
106
|
+
) : null
|
|
107
|
+
}
|
|
108
|
+
<script is:inline src="/assets/standard/standard.lab.js" transition:persist
|
|
109
|
+
></script>
|
|
110
|
+
</head>
|
|
111
|
+
|
|
112
|
+
<body>
|
|
113
|
+
<!-- Persistent container for StandardLab UI that survives DOM swaps -->
|
|
114
|
+
<!--div id="standard-lab-root" transition:persist></div>
|
|
115
|
+
|
|
116
|
+
<a href="#main" class="skip-link">Skip to content</a-->
|
|
117
|
+
|
|
118
|
+
<header
|
|
119
|
+
class="flex font-interface"
|
|
120
|
+
style="justify-content: space-between; max-width: 100%;"
|
|
121
|
+
>
|
|
122
|
+
<p>
|
|
123
|
+
<a href="/" class="no-underline"><Logo /></a>
|
|
124
|
+
</p>
|
|
125
|
+
<Menu
|
|
126
|
+
items={site.nav?.header || config?.nav?.header || []}
|
|
127
|
+
class="horizontal"
|
|
128
|
+
/>
|
|
129
|
+
</header>
|
|
130
|
+
|
|
131
|
+
<main id="main" class="rhythm prose mt-6">
|
|
132
|
+
<slot />
|
|
133
|
+
|
|
134
|
+
<Comments />
|
|
135
|
+
</main>
|
|
136
|
+
|
|
137
|
+
<hr />
|
|
138
|
+
|
|
139
|
+
<footer class="smaller font-interface">
|
|
140
|
+
<p>
|
|
141
|
+
<Image
|
|
142
|
+
src="https://img.shields.io/npm/v/@stnd/astro"
|
|
143
|
+
alt="Standard Version Badge"
|
|
144
|
+
width="94"
|
|
145
|
+
height="20"
|
|
146
|
+
/>
|
|
147
|
+
</p>
|
|
148
|
+
<p>
|
|
149
|
+
© {year}
|
|
150
|
+
{site.author?.name || site.author || config?.author?.name} ·
|
|
151
|
+
<a href="/">{site.title || config?.title}</a> ·
|
|
152
|
+
<a
|
|
153
|
+
href="https://github.com/ZeFish/Standard/blob/main/LICENSE"
|
|
154
|
+
class="external-link">MIT License</a
|
|
155
|
+
>
|
|
156
|
+
<a href="https://standard.garden/" class="external-link"
|
|
157
|
+
>Standard::Garden</a
|
|
158
|
+
>
|
|
159
|
+
</p>
|
|
160
|
+
</footer>
|
|
161
|
+
|
|
162
|
+
<!-- Universal Toast Notification System -->
|
|
163
|
+
<Toast />
|
|
164
|
+
</body>
|
|
165
|
+
</html>
|
package/CHANGELOG.md
ADDED
package/Encrypted.astro
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
---
|
|
2
|
+
import "../../styles/standard.scss";
|
|
3
|
+
|
|
4
|
+
const { site = {}, encryptedData } = Astro.props;
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<!doctype html>
|
|
8
|
+
<html lang={site.language || "en"} class="rhythm" data-theme="system">
|
|
9
|
+
<head>
|
|
10
|
+
<meta charset="utf-8" />
|
|
11
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
12
|
+
<title>Protected Content</title>
|
|
13
|
+
</head>
|
|
14
|
+
<body>
|
|
15
|
+
<main class="prose md">
|
|
16
|
+
<div class="encrypted-container">
|
|
17
|
+
<h2>Cette page est protégée par un mot de passe</h2>
|
|
18
|
+
|
|
19
|
+
<form id="decrypt-form">
|
|
20
|
+
<div class="form-group">
|
|
21
|
+
<input
|
|
22
|
+
type="password"
|
|
23
|
+
id="password"
|
|
24
|
+
placeholder="Mot de passe"
|
|
25
|
+
required
|
|
26
|
+
autocomplete="current-password"
|
|
27
|
+
/>
|
|
28
|
+
</div>
|
|
29
|
+
<button type="submit" id="unlock-btn">
|
|
30
|
+
<span class="btn-text">Déverrouiller</span>
|
|
31
|
+
<span class="btn-loading" style="display: none;"
|
|
32
|
+
>Déverrouillage...</span
|
|
33
|
+
>
|
|
34
|
+
</button>
|
|
35
|
+
</form>
|
|
36
|
+
|
|
37
|
+
<div id="error" class="error">
|
|
38
|
+
Mot de passe incorrect. Veuillez réessayer.
|
|
39
|
+
</div>
|
|
40
|
+
<div id="loading" class="loading">
|
|
41
|
+
Déchiffrement du contenu...
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<div id="encrypted-content" style="display: none;">
|
|
46
|
+
{encryptedData}
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<script is:inline>
|
|
50
|
+
const encryptedData =
|
|
51
|
+
document.getElementById("encrypted-content").textContent;
|
|
52
|
+
const maxAttempts = 5;
|
|
53
|
+
let attempts = 0;
|
|
54
|
+
|
|
55
|
+
const form = document.getElementById("decrypt-form");
|
|
56
|
+
const passwordInput = document.getElementById("password");
|
|
57
|
+
const unlockBtn = document.getElementById("unlock-btn");
|
|
58
|
+
const errorDiv = document.getElementById("error");
|
|
59
|
+
const loadingDiv = document.getElementById("loading");
|
|
60
|
+
const btnText = unlockBtn.querySelector(".btn-text");
|
|
61
|
+
const btnLoading = unlockBtn.querySelector(".btn-loading");
|
|
62
|
+
|
|
63
|
+
form.addEventListener("submit", async function (e) {
|
|
64
|
+
e.preventDefault();
|
|
65
|
+
const password = passwordInput.value;
|
|
66
|
+
if (!password) return;
|
|
67
|
+
await attemptDecryption(password);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
function setLoading(loading) {
|
|
71
|
+
unlockBtn.disabled = loading;
|
|
72
|
+
btnText.style.display = loading ? "none" : "inline";
|
|
73
|
+
btnLoading.style.display = loading ? "inline" : "none";
|
|
74
|
+
loadingDiv.style.display = loading ? "block" : "none";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function showError(message) {
|
|
78
|
+
errorDiv.textContent = message;
|
|
79
|
+
errorDiv.style.display = "block";
|
|
80
|
+
errorDiv.style.animation = "shake 0.5s ease-in-out";
|
|
81
|
+
setTimeout(() => {
|
|
82
|
+
errorDiv.style.animation = "";
|
|
83
|
+
}, 500);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function hideError() {
|
|
87
|
+
errorDiv.style.display = "none";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function decrypt(encryptedData, password) {
|
|
91
|
+
try {
|
|
92
|
+
const encrypted = Uint8Array.from(
|
|
93
|
+
atob(encryptedData),
|
|
94
|
+
(c) => c.charCodeAt(0),
|
|
95
|
+
);
|
|
96
|
+
const encoder = new TextEncoder();
|
|
97
|
+
const passwordBytes = encoder.encode(password);
|
|
98
|
+
const hashBuffer = await crypto.subtle.digest(
|
|
99
|
+
"SHA-256",
|
|
100
|
+
passwordBytes,
|
|
101
|
+
);
|
|
102
|
+
const key = new Uint8Array(hashBuffer);
|
|
103
|
+
|
|
104
|
+
const decrypted = new Uint8Array(encrypted.length);
|
|
105
|
+
for (let i = 0; i < encrypted.length; i++) {
|
|
106
|
+
decrypted[i] = encrypted[i] ^ key[i % key.length];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const result = new TextDecoder().decode(decrypted);
|
|
110
|
+
return result;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
throw new Error("Decryption failed");
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isValidHTML(str) {
|
|
117
|
+
const hasHTMLTags = /<[a-z][\s\S]*>/i.test(str);
|
|
118
|
+
const hasDoctype = /<!DOCTYPE/i.test(str);
|
|
119
|
+
const hasHtmlTag = /<html/i.test(str);
|
|
120
|
+
const nonPrintable = str.match(
|
|
121
|
+
/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F-\x9F]/g,
|
|
122
|
+
);
|
|
123
|
+
const nonPrintableRatio = nonPrintable
|
|
124
|
+
? nonPrintable.length / str.length
|
|
125
|
+
: 0;
|
|
126
|
+
return (
|
|
127
|
+
(hasHTMLTags || hasDoctype || hasHtmlTag) &&
|
|
128
|
+
nonPrintableRatio < 0.1
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function attemptDecryption(password) {
|
|
133
|
+
if (attempts >= maxAttempts) {
|
|
134
|
+
showError(
|
|
135
|
+
"Trop de tentatives. Veuillez rafraîchir la page.",
|
|
136
|
+
);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
setLoading(true);
|
|
141
|
+
hideError();
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const decrypted = await decrypt(
|
|
145
|
+
encryptedData,
|
|
146
|
+
password,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
if (!isValidHTML(decrypted)) {
|
|
150
|
+
throw new Error("Invalid decryption result");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const container = document.querySelector(
|
|
154
|
+
".encrypted-container",
|
|
155
|
+
);
|
|
156
|
+
container.outerHTML = decrypted;
|
|
157
|
+
} catch (error) {
|
|
158
|
+
attempts++;
|
|
159
|
+
const remaining = maxAttempts - attempts;
|
|
160
|
+
|
|
161
|
+
if (remaining > 0) {
|
|
162
|
+
showError(
|
|
163
|
+
`Mot de passe incorrect. Veuillez réessayer. (${remaining} tentatives restantes)`,
|
|
164
|
+
);
|
|
165
|
+
} else {
|
|
166
|
+
showError(
|
|
167
|
+
"Trop de tentatives. Veuillez rafraîchir la page.",
|
|
168
|
+
);
|
|
169
|
+
unlockBtn.disabled = true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
passwordInput.value = "";
|
|
173
|
+
passwordInput.focus();
|
|
174
|
+
} finally {
|
|
175
|
+
setLoading(false);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const hash = window.location.hash;
|
|
180
|
+
if (hash && hash.length > 1) {
|
|
181
|
+
const passwordFromHash = hash.substring(1);
|
|
182
|
+
if (passwordFromHash) {
|
|
183
|
+
history.replaceState(
|
|
184
|
+
null,
|
|
185
|
+
null,
|
|
186
|
+
window.location.pathname + window.location.search,
|
|
187
|
+
);
|
|
188
|
+
attemptDecryption(passwordFromHash);
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
passwordInput.focus();
|
|
192
|
+
}
|
|
193
|
+
</script>
|
|
194
|
+
</main>
|
|
195
|
+
</body>
|
|
196
|
+
</html>
|
package/Meta.astro
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Meta.astro - Universal Meta Layout
|
|
4
|
+
*
|
|
5
|
+
* A flexible meta tag component for any Astro project.
|
|
6
|
+
* Supports standard meta tags, Open Graph, Twitter Cards, and Schema.org.
|
|
7
|
+
*/
|
|
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
|
+
|
|
14
|
+
// Props interface for universal usage
|
|
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
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
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);
|
|
107
|
+
const canonicalUrl = new URL(
|
|
108
|
+
Astro.url.pathname.replace(/index\.html$/, ""),
|
|
109
|
+
baseUrl,
|
|
110
|
+
);
|
|
111
|
+
|
|
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();
|
|
118
|
+
|
|
119
|
+
// Image logic
|
|
120
|
+
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()
|
|
138
|
+
: null;
|
|
139
|
+
|
|
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 || "");
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
<meta charset="utf-8" />
|
|
155
|
+
{headExtraHtml && <Fragment set:html={headExtraHtml} />}
|
|
156
|
+
<!-- Standard Framework | https://standard.ffp.co -->
|
|
157
|
+
|
|
158
|
+
<meta
|
|
159
|
+
name="viewport"
|
|
160
|
+
content="width=device-width, initial-scale=1, viewport-fit=cover"
|
|
161
|
+
/>
|
|
162
|
+
|
|
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>
|
|
180
|
+
<meta name="description" content={metaDescription} />
|
|
181
|
+
<meta name="author" content={authorName} />
|
|
182
|
+
<meta name="language" content={pageLanguage} />
|
|
183
|
+
<meta name="referrer" content="strict-origin-when-cross-origin" />
|
|
184
|
+
|
|
185
|
+
<!-- Canonical URL -->
|
|
186
|
+
<link rel="canonical" href={canonicalUrl} />
|
|
187
|
+
|
|
188
|
+
<!-- Feed & Sitemap -->
|
|
189
|
+
{
|
|
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
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
{
|
|
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
|
+
)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
<!-- Robots Meta -->
|
|
242
|
+
{
|
|
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
|
+
)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
<!-- Open Graph -->
|
|
254
|
+
<meta property="og:title" content={title || site.title} />
|
|
255
|
+
<meta property="og:type" content={title ? "article" : "website"} />
|
|
256
|
+
<meta property="og:locale" content={pageLanguage} />
|
|
257
|
+
<meta property="og:site_name" content={site.title} />
|
|
258
|
+
<meta property="og:url" content={pageUrl} />
|
|
259
|
+
<meta property="og:description" content={metaDescription} />
|
|
260
|
+
|
|
261
|
+
<meta property="og:image" content={imageUrl} />
|
|
262
|
+
<meta property="og:image:alt" content={imageAlt} />
|
|
263
|
+
<meta property="og:image:width" content="1200" />
|
|
264
|
+
<meta property="og:image:height" content="630" />
|
|
265
|
+
|
|
266
|
+
{
|
|
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
|
+
)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
<!-- Twitter Cards -->
|
|
280
|
+
<meta name="twitter:card" content={imageUrl ? "summary_large_image" : "summary"} />
|
|
281
|
+
<meta name="twitter:image" content={imageUrl} />
|
|
282
|
+
<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} />
|
|
286
|
+
<meta name="twitter:description" content={metaDescription} />
|
|
287
|
+
|
|
288
|
+
<!-- Schema.org -->
|
|
289
|
+
<script
|
|
290
|
+
type="application/ld+json"
|
|
291
|
+
set:html={JSON.stringify({
|
|
292
|
+
"@context": "https://schema.org",
|
|
293
|
+
"@type": title ? "BlogPosting" : "WebSite",
|
|
294
|
+
...(title
|
|
295
|
+
? {
|
|
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
|
+
},
|
|
320
|
+
}
|
|
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
|
+
})}
|
|
332
|
+
/>
|
|
333
|
+
|
|
334
|
+
<!-- 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
|
+
)}
|
|
381
|
+
|
|
382
|
+
<!-- Mobile -->
|
|
383
|
+
<meta
|
|
384
|
+
name="theme-color"
|
|
385
|
+
content="#ffffff"
|
|
386
|
+
media="(prefers-color-scheme: light)"
|
|
387
|
+
/>
|
|
388
|
+
<meta
|
|
389
|
+
name="theme-color"
|
|
390
|
+
content="#000000"
|
|
391
|
+
media="(prefers-color-scheme: dark)"
|
|
392
|
+
/>
|
|
393
|
+
|
|
394
|
+
<!-- Windows Tiles -->
|
|
395
|
+
<meta name="msapplication-TileColor" content="#000000" />
|
|
396
|
+
<meta
|
|
397
|
+
name="msapplication-config"
|
|
398
|
+
content={`${site.url || ""}/favicons/browserconfig.xml`}
|
|
399
|
+
/>
|
|
400
|
+
|
|
401
|
+
<!-- Client Router (Astro view transitions) -->
|
|
402
|
+
<!-- {enableClientRouter && <ClientRouter />} -->
|
package/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stnd/layout",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
"./*": "./*.astro"
|
|
7
|
+
},
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"@stnd/utils": "0.2.0",
|
|
10
|
+
"@stnd/log": "0.2.0"
|
|
11
|
+
},
|
|
12
|
+
"peerDependencies": {
|
|
13
|
+
"astro": ">=5.0.0"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
}
|
|
18
|
+
}
|