@stnd/layout 0.2.0 → 0.5.1
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 +110 -130
- package/CHANGELOG.md +36 -0
- package/Encrypted.astro +187 -188
- package/Footer.astro +192 -0
- package/Header.astro +542 -0
- package/Meta.astro +281 -328
- package/README.md +546 -0
- package/StndInit.astro +74 -0
- package/package.json +14 -4
package/Encrypted.astro
CHANGED
|
@@ -1,196 +1,195 @@
|
|
|
1
1
|
---
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* @component Encrypted
|
|
4
|
+
* @description A standalone password-gate page layout — not composed with
|
|
5
|
+
* `<Base>`, renders its own full `<html>`. Shows a password form; on
|
|
6
|
+
* submit, XORs the decoded `encryptedData` against a SHA-256 hash of the
|
|
7
|
+
* typed password entirely client-side (no server round-trip) and, if the
|
|
8
|
+
* result looks like real HTML, replaces the form with it in place. Wrong
|
|
9
|
+
* guesses are rate-limited to 5 attempts per page load.
|
|
10
|
+
*
|
|
11
|
+
* @property {Object} [site] - Site info; only `site.language` is read (sets `<html lang>`, default "en").
|
|
12
|
+
* @property {string} encryptedData - Base64-encoded, XOR-"encrypted" HTML payload to reveal on success.
|
|
13
|
+
*/
|
|
14
|
+
import "virtual:stnd/styles";
|
|
3
15
|
|
|
4
16
|
const { site = {}, encryptedData } = Astro.props;
|
|
5
17
|
---
|
|
6
18
|
|
|
7
19
|
<!doctype html>
|
|
8
20
|
<html lang={site.language || "en"} class="rhythm" data-theme="system">
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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>
|
|
21
|
+
<head>
|
|
22
|
+
<meta charset="utf-8" />
|
|
23
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
24
|
+
<title>Protected Content</title>
|
|
25
|
+
</head>
|
|
26
|
+
<body>
|
|
27
|
+
<main class="prose md">
|
|
28
|
+
<div class="encrypted-container">
|
|
29
|
+
<h2>Cette page est protégée par un mot de passe</h2>
|
|
30
|
+
|
|
31
|
+
<form id="decrypt-form">
|
|
32
|
+
<div class="form-group">
|
|
33
|
+
<input
|
|
34
|
+
type="password"
|
|
35
|
+
id="password"
|
|
36
|
+
placeholder="Mot de passe"
|
|
37
|
+
required
|
|
38
|
+
autocomplete="current-password"
|
|
39
|
+
/>
|
|
40
|
+
</div>
|
|
41
|
+
<button type="submit" id="unlock-btn">
|
|
42
|
+
<span class="btn-text">Déverrouiller</span>
|
|
43
|
+
<span class="btn-loading" style="display: none;"
|
|
44
|
+
>Déverrouillage...</span
|
|
45
|
+
>
|
|
46
|
+
</button>
|
|
47
|
+
</form>
|
|
48
|
+
|
|
49
|
+
<div id="error" class="error">
|
|
50
|
+
Mot de passe incorrect. Veuillez réessayer.
|
|
51
|
+
</div>
|
|
52
|
+
<div id="loading" class="loading">Déchiffrement du contenu...</div>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<div id="encrypted-content" style="display: none;">
|
|
56
|
+
{encryptedData}
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<script is:inline>
|
|
60
|
+
const encryptedData =
|
|
61
|
+
document.getElementById("encrypted-content").textContent;
|
|
62
|
+
const maxAttempts = 5;
|
|
63
|
+
let attempts = 0;
|
|
64
|
+
|
|
65
|
+
const form = document.getElementById("decrypt-form");
|
|
66
|
+
const passwordInput = document.getElementById("password");
|
|
67
|
+
const unlockBtn = document.getElementById("unlock-btn");
|
|
68
|
+
const errorDiv = document.getElementById("error");
|
|
69
|
+
const loadingDiv = document.getElementById("loading");
|
|
70
|
+
const btnText = unlockBtn.querySelector(".btn-text");
|
|
71
|
+
const btnLoading = unlockBtn.querySelector(".btn-loading");
|
|
72
|
+
|
|
73
|
+
form.addEventListener("submit", async function (e) {
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
const password = passwordInput.value;
|
|
76
|
+
if (!password) return;
|
|
77
|
+
await attemptDecryption(password);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
function setLoading(loading) {
|
|
81
|
+
unlockBtn.disabled = loading;
|
|
82
|
+
btnText.style.display = loading ? "none" : "inline";
|
|
83
|
+
btnLoading.style.display = loading ? "inline" : "none";
|
|
84
|
+
loadingDiv.style.display = loading ? "block" : "none";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function showError(message) {
|
|
88
|
+
errorDiv.textContent = message;
|
|
89
|
+
errorDiv.style.display = "block";
|
|
90
|
+
errorDiv.style.animation = "shake 0.5s ease-in-out";
|
|
91
|
+
setTimeout(() => {
|
|
92
|
+
errorDiv.style.animation = "";
|
|
93
|
+
}, 500);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function hideError() {
|
|
97
|
+
errorDiv.style.display = "none";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function decrypt(encryptedData, password) {
|
|
101
|
+
try {
|
|
102
|
+
const encrypted = Uint8Array.from(atob(encryptedData), (c) =>
|
|
103
|
+
c.charCodeAt(0),
|
|
104
|
+
);
|
|
105
|
+
const encoder = new TextEncoder();
|
|
106
|
+
const passwordBytes = encoder.encode(password);
|
|
107
|
+
const hashBuffer = await crypto.subtle.digest(
|
|
108
|
+
"SHA-256",
|
|
109
|
+
passwordBytes,
|
|
110
|
+
);
|
|
111
|
+
const key = new Uint8Array(hashBuffer);
|
|
112
|
+
|
|
113
|
+
const decrypted = new Uint8Array(encrypted.length);
|
|
114
|
+
for (let i = 0; i < encrypted.length; i++) {
|
|
115
|
+
decrypted[i] = encrypted[i] ^ key[i % key.length];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const result = new TextDecoder().decode(decrypted);
|
|
119
|
+
return result;
|
|
120
|
+
} catch (error) {
|
|
121
|
+
throw new Error("Decryption failed");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isValidHTML(str) {
|
|
126
|
+
const hasHTMLTags = /<[a-z][\s\S]*>/i.test(str);
|
|
127
|
+
const hasDoctype = /<!DOCTYPE/i.test(str);
|
|
128
|
+
const hasHtmlTag = /<html/i.test(str);
|
|
129
|
+
const nonPrintable = str.match(
|
|
130
|
+
/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F-\x9F]/g,
|
|
131
|
+
);
|
|
132
|
+
const nonPrintableRatio = nonPrintable
|
|
133
|
+
? nonPrintable.length / str.length
|
|
134
|
+
: 0;
|
|
135
|
+
return (
|
|
136
|
+
(hasHTMLTags || hasDoctype || hasHtmlTag) && nonPrintableRatio < 0.1
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function attemptDecryption(password) {
|
|
141
|
+
if (attempts >= maxAttempts) {
|
|
142
|
+
showError("Trop de tentatives. Veuillez rafraîchir la page.");
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
setLoading(true);
|
|
147
|
+
hideError();
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const decrypted = await decrypt(encryptedData, password);
|
|
151
|
+
|
|
152
|
+
if (!isValidHTML(decrypted)) {
|
|
153
|
+
throw new Error("Invalid decryption result");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const container = document.querySelector(".encrypted-container");
|
|
157
|
+
container.outerHTML = decrypted;
|
|
158
|
+
} catch (error) {
|
|
159
|
+
attempts++;
|
|
160
|
+
const remaining = maxAttempts - attempts;
|
|
161
|
+
|
|
162
|
+
if (remaining > 0) {
|
|
163
|
+
showError(
|
|
164
|
+
`Mot de passe incorrect. Veuillez réessayer. (${remaining} tentatives restantes)`,
|
|
165
|
+
);
|
|
166
|
+
} else {
|
|
167
|
+
showError("Trop de tentatives. Veuillez rafraîchir la page.");
|
|
168
|
+
unlockBtn.disabled = true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
passwordInput.value = "";
|
|
172
|
+
passwordInput.focus();
|
|
173
|
+
} finally {
|
|
174
|
+
setLoading(false);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const hash = window.location.hash;
|
|
179
|
+
if (hash && hash.length > 1) {
|
|
180
|
+
const passwordFromHash = hash.substring(1);
|
|
181
|
+
if (passwordFromHash) {
|
|
182
|
+
history.replaceState(
|
|
183
|
+
null,
|
|
184
|
+
null,
|
|
185
|
+
window.location.pathname + window.location.search,
|
|
186
|
+
);
|
|
187
|
+
attemptDecryption(passwordFromHash);
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
passwordInput.focus();
|
|
191
|
+
}
|
|
192
|
+
</script>
|
|
193
|
+
</main>
|
|
194
|
+
</body>
|
|
196
195
|
</html>
|
package/Footer.astro
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* @component Footer
|
|
4
|
+
* @description Generic, flexible site footer. Reads defaults from
|
|
5
|
+
* `virtual:stnd/config` (title, navigation) so it works with zero props;
|
|
6
|
+
* a `grid-8` layout (3 columns brand/info, 5 for custom link columns),
|
|
7
|
+
* responsive out of the box. Slots: `brand`, `description`, `copyright`,
|
|
8
|
+
* `bottom-left`, `bottom-right`, and default (extra link columns).
|
|
9
|
+
*
|
|
10
|
+
* @example astro
|
|
11
|
+
* <Footer />
|
|
12
|
+
* <Footer title="My Garden">
|
|
13
|
+
* <div class="col-1">
|
|
14
|
+
* <h4>Section</h4>
|
|
15
|
+
* <ul><li><a href="/">Home</a></li></ul>
|
|
16
|
+
* </div>
|
|
17
|
+
* </Footer>
|
|
18
|
+
*/
|
|
19
|
+
import config from "virtual:stnd/config";
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
/** Brand name — defaults to the site config's own title, then "Home". */
|
|
23
|
+
title?: string;
|
|
24
|
+
class?: string;
|
|
25
|
+
hideBrand?: boolean;
|
|
26
|
+
/** Hide the nav column entirely (built from `config.nav.footer` / `config.nav.header`). */
|
|
27
|
+
hideNav?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const {
|
|
31
|
+
title = config.title || "Home",
|
|
32
|
+
class: className,
|
|
33
|
+
hideBrand = false,
|
|
34
|
+
hideNav = false,
|
|
35
|
+
} = Astro.props;
|
|
36
|
+
|
|
37
|
+
const footerNav =
|
|
38
|
+
config.nav?.footer ??
|
|
39
|
+
config.nav?.header ??
|
|
40
|
+
(Array.isArray(config.nav) ? config.nav : []);
|
|
41
|
+
const year = new Date().getFullYear();
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
<footer class:list={["stnd-footer", "compact", className]}>
|
|
45
|
+
<div class="grid-8 sm:grid-1">
|
|
46
|
+
<!-- Column 1: Brand & Info -->
|
|
47
|
+
{
|
|
48
|
+
!hideBrand && (
|
|
49
|
+
<div class="col-2 sm:col-1">
|
|
50
|
+
<h4 class="footer-title mb-2">
|
|
51
|
+
<a href={config.url} class="no-decoration">{title}</a>
|
|
52
|
+
</h4>
|
|
53
|
+
|
|
54
|
+
{
|
|
55
|
+
footerNav.length > 0 && !hideNav && (
|
|
56
|
+
<ul class="no-bullet">
|
|
57
|
+
{footerNav.map((item: any) => (
|
|
58
|
+
<li>
|
|
59
|
+
<a href={item.url}>{item.title}</a>
|
|
60
|
+
</li>
|
|
61
|
+
))}
|
|
62
|
+
</ul>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
{
|
|
67
|
+
config.description && (
|
|
68
|
+
<p class="text-2xs mt-half compact">{config.description}</p>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
<p class="text-2xs mt-half">© {year}.</p>
|
|
73
|
+
</div>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
<!-- Column 2: Navigation -->
|
|
78
|
+
{/*
|
|
79
|
+
footerNav.length > 0 && (
|
|
80
|
+
<div class="col-1">
|
|
81
|
+
<h4>Navigation</h4>
|
|
82
|
+
<ul class="no-bullet">
|
|
83
|
+
{footerNav.map((item: any) => (
|
|
84
|
+
<li>
|
|
85
|
+
<a href={item.url}>{item.title}</a>
|
|
86
|
+
</li>
|
|
87
|
+
))}
|
|
88
|
+
</ul>
|
|
89
|
+
</div>
|
|
90
|
+
)
|
|
91
|
+
*/}
|
|
92
|
+
|
|
93
|
+
<!-- Column 3, 4, etc. -->
|
|
94
|
+
<slot />
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{/* Bottom Bar (Optional) */}
|
|
98
|
+
{
|
|
99
|
+
(Astro.slots.has("bottom-left") || Astro.slots.has("bottom-right")) && (
|
|
100
|
+
<div class="footer-bottom flex-far justify-between items-center no-decoration sm:flex-col sm:items-start text-2xs">
|
|
101
|
+
<div class="footer-bottom-left">
|
|
102
|
+
<slot name="bottom-left" />
|
|
103
|
+
</div>
|
|
104
|
+
<div class="footer-bottom-right flex">
|
|
105
|
+
<slot name="bottom-right" />
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
</footer>
|
|
111
|
+
|
|
112
|
+
<style is:global>
|
|
113
|
+
.stnd-footer {
|
|
114
|
+
font-family: var(--font-interface);
|
|
115
|
+
color: var(--color-subtle);
|
|
116
|
+
border-top: var(--border);
|
|
117
|
+
margin-top: calc(var(--space) * 12);
|
|
118
|
+
padding-block-start: var(--gap-footer) !important;
|
|
119
|
+
h1,
|
|
120
|
+
h2,
|
|
121
|
+
h3,
|
|
122
|
+
h4,
|
|
123
|
+
h5,
|
|
124
|
+
h6 {
|
|
125
|
+
font-size: var(--size-base);
|
|
126
|
+
font-family: var(--font-interface);
|
|
127
|
+
margin-block-end: var(--space-half) !important;
|
|
128
|
+
font-weight: var(--font-header-weight);
|
|
129
|
+
margin-top: 0 !important;
|
|
130
|
+
}
|
|
131
|
+
h4 {
|
|
132
|
+
font-size: var(--size-sm);
|
|
133
|
+
}
|
|
134
|
+
li {
|
|
135
|
+
margin-block-end: var(--leading) !important;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* Styling for link lists inside footer */
|
|
139
|
+
ul {
|
|
140
|
+
list-style: none;
|
|
141
|
+
padding: 0;
|
|
142
|
+
margin: 0 !important;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
ul li {
|
|
146
|
+
font-size: var(--size-2xs);
|
|
147
|
+
margin-bottom: var(--space-2);
|
|
148
|
+
padding-inline-start: 0;
|
|
149
|
+
margin-top: 0 !important;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
ul li a {
|
|
153
|
+
color: inherit;
|
|
154
|
+
text-decoration: none;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
ul li::before {
|
|
158
|
+
display: none;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.footer-title {
|
|
163
|
+
font-family: var(--font-heading);
|
|
164
|
+
font-size: var(--size-base);
|
|
165
|
+
font-weight: var(--font-header-weight);
|
|
166
|
+
color: var(--color-foreground);
|
|
167
|
+
margin: 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.stnd-footer a {
|
|
171
|
+
color: inherit;
|
|
172
|
+
text-decoration: none;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.stnd-footer a:hover {
|
|
176
|
+
color: var(--color-accent, var(--color-foreground));
|
|
177
|
+
opacity: 1;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.footer-bottom {
|
|
181
|
+
margin-top: calc(var(--space) * 6);
|
|
182
|
+
margin-bottom: calc((var(--gap-footer) * -1));
|
|
183
|
+
padding-bottom: var(--space);
|
|
184
|
+
|
|
185
|
+
strong {
|
|
186
|
+
color: inherit;
|
|
187
|
+
}
|
|
188
|
+
em {
|
|
189
|
+
color: inherit;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
</style>
|