stack-site-builder 1.12.0 → 1.14.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/CHANGELOG.md +33 -0
- package/README.md +80 -1
- package/index.d.ts +10 -0
- package/index.mjs +29 -1
- package/package.json +1 -1
- package/src/components/ArticleCard.astro +9 -3
- package/src/components/ArticleLink.astro +7 -2
- package/src/components/BlogIndex.astro +2 -0
- package/src/components/CategoryIndex.astro +2 -0
- package/src/components/CodeSamples.astro +6 -1
- package/src/components/ConceptCard.astro +9 -3
- package/src/components/ConceptIndex.astro +2 -0
- package/src/components/ConceptLink.astro +7 -2
- package/src/components/DeckView.astro +17 -2
- package/src/components/DetailTabs.astro +6 -1
- package/src/components/Home.astro +2 -0
- package/src/components/MermaidLoader.astro +3 -1
- package/src/components/PricingSection.astro +4 -1
- package/src/components/PrivateGate.astro +135 -0
- package/src/components/ProjectViewer.astro +24 -13
- package/src/components/SlidesIndex.astro +8 -2
- package/src/components/StackCard.astro +15 -3
- package/src/components/StackDetail.astro +9 -0
- package/src/components/TagIndex.astro +2 -0
- package/src/components/TocRail.astro +12 -6
- package/src/components/VendorIndex.astro +2 -0
- package/src/content.ts +21 -0
- package/src/i18n/ui.ts +14 -0
- package/src/layouts/BaseLayout.astro +21 -5
- package/src/lib/private-client.ts +160 -0
- package/src/lib/private.ts +129 -0
- package/src/lib/reinit.ts +12 -0
- package/src/lib/sections.ts +34 -0
- package/src/pages/[...lang]/[page].astro +7 -2
- package/src/pages/[...lang]/article/[...id].astro +7 -2
- package/src/pages/[...lang]/concept/[...id].astro +7 -2
- package/src/pages/[...lang]/stack/[...id].astro +7 -2
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build-side half of private (login-gated) content. Runs only during
|
|
3
|
+
* `astro build` / `astro dev` (Node): derives the site content key, wraps it
|
|
4
|
+
* per user, and encrypts rendered HTML. The browser half (login + decrypt)
|
|
5
|
+
* lives in ./private-client.ts; the whole design is documented in
|
|
6
|
+
* docs/private-content-design.md.
|
|
7
|
+
*
|
|
8
|
+
* Env contract (see .env.sample in a consuming site):
|
|
9
|
+
* AAS_PRIVATE_USERS "id:password,id2:password2" (plaintext passwords)
|
|
10
|
+
* AAS_PRIVATE_MASTER_SECRET stable secret K derives from; rotate = global logout
|
|
11
|
+
* AAS_PRIVATE_SESSION_DAYS client session lifetime in days (0 = never; default 30)
|
|
12
|
+
*/
|
|
13
|
+
import { createCipheriv, createHash, hkdfSync, pbkdf2Sync, randomBytes } from 'node:crypto';
|
|
14
|
+
|
|
15
|
+
/** Must match private-client.ts. */
|
|
16
|
+
export const KDF_ITERATIONS = 600_000;
|
|
17
|
+
const HKDF_INFO = 'aas-private-v1';
|
|
18
|
+
const MIN_PASSWORD_LENGTH = 10;
|
|
19
|
+
|
|
20
|
+
export interface PrivateUserRecord {
|
|
21
|
+
/** SHA-256(buildSalt + lowercase(id)), hex — the deployed site never carries raw ids. */
|
|
22
|
+
h: string;
|
|
23
|
+
/** PBKDF2 salt, base64. */
|
|
24
|
+
s: string;
|
|
25
|
+
/** AES-GCM IV for the wrapped key, base64. */
|
|
26
|
+
iv: string;
|
|
27
|
+
/** wrappedK = AES-256-GCM(KEK, K) with auth tag appended, base64. */
|
|
28
|
+
w: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const b64 = (b: Buffer | Uint8Array) => Buffer.from(b).toString('base64');
|
|
32
|
+
|
|
33
|
+
interface PrivateConfig {
|
|
34
|
+
key: Buffer; // K — the site content key
|
|
35
|
+
users: PrivateUserRecord[];
|
|
36
|
+
buildSalt: string; // base64; used by the client to hash the entered id
|
|
37
|
+
sessionDays: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let cached: PrivateConfig | null = null;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse env + derive keys, once per build. Throws (failing the build loudly)
|
|
44
|
+
* when private entries exist but the env contract isn't met — a misconfigured
|
|
45
|
+
* build must not ship silently locked-forever pages.
|
|
46
|
+
*/
|
|
47
|
+
function getConfig(): PrivateConfig {
|
|
48
|
+
if (cached) return cached;
|
|
49
|
+
|
|
50
|
+
const secret = process.env.AAS_PRIVATE_MASTER_SECRET?.trim();
|
|
51
|
+
const usersRaw = process.env.AAS_PRIVATE_USERS?.trim();
|
|
52
|
+
if (!secret || !usersRaw) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
'[private] this site has `private: true` entries, but AAS_PRIVATE_MASTER_SECRET ' +
|
|
55
|
+
'and/or AAS_PRIVATE_USERS is not set. Set both in .env (locally) or as CI ' +
|
|
56
|
+
'secrets — see docs/private-content-design.md.',
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// K derives from the master secret (not random): ordinary redeploys keep
|
|
61
|
+
// logged-in devices working; rotating the secret is the global-logout switch.
|
|
62
|
+
const key = Buffer.from(hkdfSync('sha256', secret, 'aas-private', HKDF_INFO, 32));
|
|
63
|
+
|
|
64
|
+
const buildSaltBytes = randomBytes(16);
|
|
65
|
+
const users: PrivateUserRecord[] = [];
|
|
66
|
+
const seen = new Set<string>();
|
|
67
|
+
for (const pair of usersRaw.split(',')) {
|
|
68
|
+
const idx = pair.indexOf(':');
|
|
69
|
+
if (idx < 1) throw new Error(`[private] AAS_PRIVATE_USERS entry is not "id:password": "${pair.trim()}"`);
|
|
70
|
+
const id = pair.slice(0, idx).trim().toLowerCase();
|
|
71
|
+
const password = pair.slice(idx + 1).trim();
|
|
72
|
+
if (seen.has(id)) throw new Error(`[private] duplicate user id "${id}" in AAS_PRIVATE_USERS`);
|
|
73
|
+
seen.add(id);
|
|
74
|
+
if (password.length < MIN_PASSWORD_LENGTH)
|
|
75
|
+
throw new Error(
|
|
76
|
+
`[private] password for user "${id}" is shorter than ${MIN_PASSWORD_LENGTH} chars — ` +
|
|
77
|
+
'wrapped keys can be brute-forced offline, use a long passphrase',
|
|
78
|
+
);
|
|
79
|
+
const salt = randomBytes(16);
|
|
80
|
+
const kek = pbkdf2Sync(password, salt, KDF_ITERATIONS, 32, 'sha256');
|
|
81
|
+
const iv = randomBytes(12);
|
|
82
|
+
const cipher = createCipheriv('aes-256-gcm', kek, iv);
|
|
83
|
+
const wrapped = Buffer.concat([cipher.update(key), cipher.final(), cipher.getAuthTag()]);
|
|
84
|
+
users.push({
|
|
85
|
+
h: createHash('sha256').update(Buffer.concat([buildSaltBytes, Buffer.from(id)])).digest('hex'),
|
|
86
|
+
s: b64(salt),
|
|
87
|
+
iv: b64(iv),
|
|
88
|
+
w: b64(wrapped),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const daysRaw = process.env.AAS_PRIVATE_SESSION_DAYS?.trim();
|
|
93
|
+
const sessionDays = daysRaw === undefined || daysRaw === '' ? 30 : Number(daysRaw);
|
|
94
|
+
if (!Number.isFinite(sessionDays) || sessionDays < 0)
|
|
95
|
+
throw new Error(`[private] AAS_PRIVATE_SESSION_DAYS must be a number ≥ 0 (got "${daysRaw}")`);
|
|
96
|
+
|
|
97
|
+
console.warn(
|
|
98
|
+
'[private] this build contains private entries — remember: the SOURCE repo must be ' +
|
|
99
|
+
'private (the .mdx files are plaintext); only the built output is encrypted.',
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
cached = { key, users, buildSalt: b64(buildSaltBytes), sessionDays };
|
|
103
|
+
return cached;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Everything PrivateGate embeds for the client (records are ~120 bytes/user). */
|
|
107
|
+
export function privateClientData(): { users: PrivateUserRecord[]; salt: string; days: number } {
|
|
108
|
+
const { users, buildSalt, sessionDays } = getConfig();
|
|
109
|
+
return { users, salt: buildSalt, days: sessionDays };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** AES-256-GCM encrypt rendered HTML with the site content key. */
|
|
113
|
+
export function encryptHtml(html: string): { iv: string; ct: string } {
|
|
114
|
+
const { key } = getConfig();
|
|
115
|
+
const iv = randomBytes(12);
|
|
116
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
117
|
+
const ct = Buffer.concat([cipher.update(html, 'utf8'), cipher.final(), cipher.getAuthTag()]);
|
|
118
|
+
return { iv: b64(iv), ct: b64(ct) };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Pathname registry for the sitemap filter: PrivateGate records every private
|
|
123
|
+
* page it renders; index.mjs (a separate module graph — hence globalThis) reads
|
|
124
|
+
* it in the sitemap `filter`, which @astrojs/sitemap runs after the pages built.
|
|
125
|
+
*/
|
|
126
|
+
export function registerPrivatePath(pathname: string): void {
|
|
127
|
+
const g = globalThis as { __aasPrivatePaths?: Set<string> };
|
|
128
|
+
(g.__aasPrivatePaths ??= new Set()).add(pathname.replace(/\/?$/, '/'));
|
|
129
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run a DOM-wiring routine now AND again whenever private content is decrypted
|
|
3
|
+
* and injected (the injected HTML wasn't there when page scripts first ran).
|
|
4
|
+
* Component scripts whose targets can sit inside a private gate wrap their init
|
|
5
|
+
* in this. `fn` must be idempotent or guard against double-wiring itself.
|
|
6
|
+
*/
|
|
7
|
+
export const PRIVATE_DECRYPTED_EVENT = 'aas:private-decrypted';
|
|
8
|
+
|
|
9
|
+
export function initOnReady(fn: () => void): void {
|
|
10
|
+
fn();
|
|
11
|
+
document.addEventListener(PRIVATE_DECRYPTED_EVENT, () => fn());
|
|
12
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { site } from '@aas-data/site';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Optional content sections a site can turn off. The core catalog (home, stack
|
|
5
|
+
* detail, categories, tags, vendors) is always on; these are opt-out. Disabling
|
|
6
|
+
* one removes both its routes and its header-nav item (routes are skipped in the
|
|
7
|
+
* integration, the nav link in BaseLayout).
|
|
8
|
+
*
|
|
9
|
+
* `pages` is the standalone-pages collection (About/소개, contact, …); turning it
|
|
10
|
+
* off drops every page and its nav item at once — finer control is per-page via
|
|
11
|
+
* the `nav` / `draft` frontmatter, or by not authoring the page.
|
|
12
|
+
*
|
|
13
|
+
* A site sets overrides in `src/data/site.ts` (`sections`), which astro.config
|
|
14
|
+
* also forwards to the theme integration for route filtering. Omitted = enabled.
|
|
15
|
+
*/
|
|
16
|
+
export type SectionKey = 'concepts' | 'articles' | 'samples' | 'slides' | 'glossary' | 'pages';
|
|
17
|
+
|
|
18
|
+
const DEFAULTS: Record<SectionKey, boolean> = {
|
|
19
|
+
concepts: true,
|
|
20
|
+
articles: true,
|
|
21
|
+
samples: true,
|
|
22
|
+
slides: true,
|
|
23
|
+
glossary: true,
|
|
24
|
+
pages: true,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Whether each optional section is enabled, after applying the site's overrides. */
|
|
28
|
+
export const sections: Record<SectionKey, boolean> = {
|
|
29
|
+
...DEFAULTS,
|
|
30
|
+
...((site as { sections?: Partial<Record<SectionKey, boolean>> }).sections ?? {}),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** True unless the site explicitly turned `key` off. */
|
|
34
|
+
export const sectionEnabled = (key: SectionKey): boolean => sections[key];
|
|
@@ -3,6 +3,7 @@ import { site } from '@aas-data/site';
|
|
|
3
3
|
import type { GetStaticPaths } from 'astro';
|
|
4
4
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
5
5
|
import PageDetail from '../../components/PageDetail.astro';
|
|
6
|
+
import PrivateGate from '../../components/PrivateGate.astro';
|
|
6
7
|
import { getPages, pageSlugOf } from '../../lib/pages';
|
|
7
8
|
import { allLocales, langParam } from '../../lib/locales';
|
|
8
9
|
|
|
@@ -18,13 +19,17 @@ export const getStaticPaths = (async () => {
|
|
|
18
19
|
|
|
19
20
|
const { lang, entry } = Astro.props;
|
|
20
21
|
const slug = pageSlugOf(entry);
|
|
22
|
+
const priv = entry.data.private;
|
|
21
23
|
---
|
|
22
24
|
|
|
23
25
|
<BaseLayout
|
|
24
26
|
title={`${entry.data.title} — ${site.name}`}
|
|
25
|
-
description={entry.data.description}
|
|
27
|
+
description={priv ? entry.data.teaser : entry.data.description}
|
|
26
28
|
lang={lang}
|
|
27
29
|
path={`${slug}/`}
|
|
30
|
+
noindex={priv}
|
|
28
31
|
>
|
|
29
|
-
<
|
|
32
|
+
<PrivateGate enabled={priv} lang={lang} title={entry.data.title} teaser={entry.data.teaser}>
|
|
33
|
+
<PageDetail entry={entry} lang={lang} />
|
|
34
|
+
</PrivateGate>
|
|
30
35
|
</BaseLayout>
|
|
@@ -3,6 +3,7 @@ import { site } from '@aas-data/site';
|
|
|
3
3
|
import type { GetStaticPaths } from 'astro';
|
|
4
4
|
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
|
5
5
|
import ArticleDetail from '../../../components/ArticleDetail.astro';
|
|
6
|
+
import PrivateGate from '../../../components/PrivateGate.astro';
|
|
6
7
|
import { getArticles, articleSlugOf } from '../../../lib/articles';
|
|
7
8
|
import { allLocales, langParam } from '../../../lib/locales';
|
|
8
9
|
|
|
@@ -18,13 +19,17 @@ export const getStaticPaths = (async () => {
|
|
|
18
19
|
|
|
19
20
|
const { lang, entry } = Astro.props;
|
|
20
21
|
const slug = articleSlugOf(entry);
|
|
22
|
+
const priv = entry.data.private;
|
|
21
23
|
---
|
|
22
24
|
|
|
23
25
|
<BaseLayout
|
|
24
26
|
title={`${entry.data.title} — ${site.name}`}
|
|
25
|
-
description={entry.data.description}
|
|
27
|
+
description={priv ? entry.data.teaser : entry.data.description}
|
|
26
28
|
lang={lang}
|
|
27
29
|
path={`article/${slug}/`}
|
|
30
|
+
noindex={priv}
|
|
28
31
|
>
|
|
29
|
-
<
|
|
32
|
+
<PrivateGate enabled={priv} lang={lang} title={entry.data.title} teaser={entry.data.teaser}>
|
|
33
|
+
<ArticleDetail entry={entry} lang={lang} />
|
|
34
|
+
</PrivateGate>
|
|
30
35
|
</BaseLayout>
|
|
@@ -3,6 +3,7 @@ import { site } from '@aas-data/site';
|
|
|
3
3
|
import type { GetStaticPaths } from 'astro';
|
|
4
4
|
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
|
5
5
|
import ConceptDetail from '../../../components/ConceptDetail.astro';
|
|
6
|
+
import PrivateGate from '../../../components/PrivateGate.astro';
|
|
6
7
|
import { getConcepts, conceptSlugOf } from '../../../lib/concepts';
|
|
7
8
|
import { allLocales, langParam } from '../../../lib/locales';
|
|
8
9
|
|
|
@@ -18,13 +19,17 @@ export const getStaticPaths = (async () => {
|
|
|
18
19
|
|
|
19
20
|
const { lang, entry } = Astro.props;
|
|
20
21
|
const slug = conceptSlugOf(entry);
|
|
22
|
+
const priv = entry.data.private;
|
|
21
23
|
---
|
|
22
24
|
|
|
23
25
|
<BaseLayout
|
|
24
26
|
title={`${entry.data.title} — ${site.name}`}
|
|
25
|
-
description={entry.data.description}
|
|
27
|
+
description={priv ? entry.data.teaser : entry.data.description}
|
|
26
28
|
lang={lang}
|
|
27
29
|
path={`concept/${slug}/`}
|
|
30
|
+
noindex={priv}
|
|
28
31
|
>
|
|
29
|
-
<
|
|
32
|
+
<PrivateGate enabled={priv} lang={lang} title={entry.data.title} teaser={entry.data.teaser}>
|
|
33
|
+
<ConceptDetail entry={entry} lang={lang} />
|
|
34
|
+
</PrivateGate>
|
|
30
35
|
</BaseLayout>
|
|
@@ -4,6 +4,7 @@ import type { GetStaticPaths } from 'astro';
|
|
|
4
4
|
import { getRelativeLocaleUrl } from 'astro:i18n';
|
|
5
5
|
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
|
6
6
|
import StackDetail from '../../../components/StackDetail.astro';
|
|
7
|
+
import PrivateGate from '../../../components/PrivateGate.astro';
|
|
7
8
|
import { getStacks, getStackAliases, slugOf, type StackEntry } from '../../../lib/stacks';
|
|
8
9
|
import { allLocales, langParam } from '../../../lib/locales';
|
|
9
10
|
|
|
@@ -28,6 +29,7 @@ const { lang, entry, redirectTo } = Astro.props as {
|
|
|
28
29
|
redirectTo: string | null;
|
|
29
30
|
};
|
|
30
31
|
const target = redirectTo ? getRelativeLocaleUrl(lang, `stack/${redirectTo}/`) : null;
|
|
32
|
+
const priv = entry?.data.private ?? false;
|
|
31
33
|
---
|
|
32
34
|
|
|
33
35
|
{
|
|
@@ -48,11 +50,14 @@ const target = redirectTo ? getRelativeLocaleUrl(lang, `stack/${redirectTo}/`) :
|
|
|
48
50
|
) : (
|
|
49
51
|
<BaseLayout
|
|
50
52
|
title={`${entry!.data.name} — ${site.name}`}
|
|
51
|
-
description={entry!.data.description}
|
|
53
|
+
description={priv ? entry!.data.teaser : entry!.data.description}
|
|
52
54
|
lang={lang}
|
|
53
55
|
path={`stack/${slugOf(entry!)}/`}
|
|
56
|
+
noindex={priv}
|
|
54
57
|
>
|
|
55
|
-
<
|
|
58
|
+
<PrivateGate enabled={priv} lang={lang} title={entry!.data.name} teaser={entry!.data.teaser}>
|
|
59
|
+
<StackDetail entry={entry!} lang={lang} />
|
|
60
|
+
</PrivateGate>
|
|
56
61
|
</BaseLayout>
|
|
57
62
|
)
|
|
58
63
|
}
|