astroidjs 0.1.2 → 0.3.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/README.md +240 -5
- package/bin/astroid.mjs +185 -9
- package/dist/analytics/index.d.ts +37 -0
- package/dist/analytics/index.js +108 -0
- package/dist/astro/csp.d.ts +64 -0
- package/dist/astro/csp.js +173 -0
- package/dist/astro/index.d.ts +1 -0
- package/dist/astro/index.js +7 -0
- package/dist/auth/index.d.ts +27 -0
- package/dist/auth/index.js +59 -0
- package/dist/commerce/adapters.d.ts +60 -0
- package/dist/commerce/adapters.js +90 -0
- package/dist/commerce/checkout-scaffold.d.ts +42 -0
- package/dist/commerce/checkout-scaffold.js +306 -0
- package/dist/commerce/checkout.d.ts +72 -0
- package/dist/commerce/checkout.js +124 -0
- package/dist/commerce/index.d.ts +8 -0
- package/dist/commerce/index.js +9 -0
- package/dist/commerce/loader.d.ts +71 -0
- package/dist/commerce/loader.js +90 -0
- package/dist/commerce/mirror.d.ts +69 -0
- package/dist/commerce/mirror.js +214 -0
- package/dist/commerce/roles.d.ts +38 -0
- package/dist/commerce/roles.js +93 -0
- package/dist/commerce/secrets.d.ts +74 -0
- package/dist/commerce/secrets.js +129 -0
- package/dist/commerce/sync.d.ts +86 -0
- package/dist/commerce/sync.js +154 -0
- package/dist/components/sections.d.ts +577 -0
- package/dist/components/sections.js +425 -0
- package/dist/config.d.ts +239 -12
- package/dist/config.js +49 -1
- package/dist/email/index.d.ts +4 -0
- package/dist/email/index.js +5 -0
- package/dist/email/inquiry.d.ts +33 -0
- package/dist/email/inquiry.js +63 -0
- package/dist/email/send.d.ts +120 -0
- package/dist/email/send.js +196 -0
- package/dist/email/templates.d.ts +24 -0
- package/dist/email/templates.js +184 -0
- package/dist/email/theme.d.ts +24 -0
- package/dist/email/theme.js +150 -0
- package/dist/errors.d.ts +14 -0
- package/dist/errors.js +17 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +15 -0
- package/dist/map/index.d.ts +3 -0
- package/dist/map/index.js +4 -0
- package/dist/map/pmtiles.d.ts +92 -0
- package/dist/map/pmtiles.js +130 -0
- package/dist/map/scaffold.d.ts +29 -0
- package/dist/map/scaffold.js +212 -0
- package/dist/map/style.d.ts +58 -0
- package/dist/map/style.js +154 -0
- package/dist/portal/config.d.ts +26 -0
- package/dist/portal/config.js +56 -0
- package/dist/portal/guard.d.ts +54 -0
- package/dist/portal/guard.js +64 -0
- package/dist/portal/index.d.ts +5 -0
- package/dist/portal/index.js +6 -0
- package/dist/portal/nav.d.ts +26 -0
- package/dist/portal/nav.js +35 -0
- package/dist/portal/scaffold.d.ts +28 -0
- package/dist/portal/scaffold.js +140 -0
- package/dist/portal/session.d.ts +36 -0
- package/dist/portal/session.js +86 -0
- package/dist/portfolio/index.d.ts +1 -0
- package/dist/portfolio/index.js +4 -0
- package/dist/portfolio/scaffold.d.ts +9 -0
- package/dist/portfolio/scaffold.js +93 -0
- package/dist/project/actions.d.ts +3 -0
- package/dist/project/actions.js +121 -0
- package/dist/project/generate.d.ts +15 -0
- package/dist/project/generate.js +144 -2
- package/dist/project/index.d.ts +2 -0
- package/dist/project/index.js +2 -0
- package/dist/project/scaffold.d.ts +29 -0
- package/dist/project/scaffold.js +166 -0
- package/dist/pwa/generate.d.ts +49 -0
- package/dist/pwa/generate.js +218 -0
- package/dist/pwa/index.d.ts +1 -0
- package/dist/pwa/index.js +2 -0
- package/dist/queues/consumer.d.ts +29 -0
- package/dist/queues/consumer.js +37 -0
- package/dist/queues/index.d.ts +4 -0
- package/dist/queues/index.js +5 -0
- package/dist/queues/messages.d.ts +60 -0
- package/dist/queues/messages.js +71 -0
- package/dist/queues/scaffold.d.ts +44 -0
- package/dist/queues/scaffold.js +204 -0
- package/dist/queues/webhook.d.ts +60 -0
- package/dist/queues/webhook.js +81 -0
- package/dist/realtime/index.d.ts +1 -0
- package/dist/realtime/index.js +4 -0
- package/dist/realtime/scaffold.d.ts +30 -0
- package/dist/realtime/scaffold.js +159 -0
- package/dist/schema/collections.d.ts +41 -7
- package/dist/schema/collections.js +110 -12
- package/dist/schema/framework.js +5 -0
- package/dist/schema/generate.js +17 -1
- package/dist/secrets.d.ts +54 -0
- package/dist/secrets.js +80 -0
- package/dist/security/index.d.ts +1 -0
- package/dist/security/index.js +2 -0
- package/dist/security/rate-rules.d.ts +21 -0
- package/dist/security/rate-rules.js +110 -0
- package/dist/seo/index.d.ts +3 -0
- package/dist/seo/index.js +4 -0
- package/dist/seo/resolve.d.ts +68 -0
- package/dist/seo/resolve.js +73 -0
- package/dist/seo/routes.d.ts +44 -0
- package/dist/seo/routes.js +104 -0
- package/dist/seo/structured-data.d.ts +51 -0
- package/dist/seo/structured-data.js +105 -0
- package/dist/status.d.ts +51 -0
- package/dist/status.js +113 -0
- package/dist/worker/generate.d.ts +18 -10
- package/dist/worker/generate.js +353 -42
- package/dist/worker/routes.d.ts +1 -1
- package/dist/worker/routes.js +42 -0
- package/dist/workflow/advance.d.ts +102 -0
- package/dist/workflow/advance.js +145 -0
- package/dist/workflow/config.d.ts +60 -0
- package/dist/workflow/config.js +73 -0
- package/dist/workflow/generate.d.ts +22 -0
- package/dist/workflow/generate.js +138 -0
- package/dist/workflow/index.d.ts +3 -0
- package/dist/workflow/index.js +4 -0
- package/package.json +21 -4
- package/src/components/Editable.astro +33 -9
- package/src/components/JustifiedGallery.astro +254 -0
- package/src/components/MediaSlot.astro +178 -0
- package/src/components/PortalShell.astro +80 -0
- package/src/components/RegisterSW.astro +45 -0
- package/src/components/Section.astro +101 -35
- package/src/components/Sections.astro +64 -0
- package/src/components/Seo.astro +57 -0
- package/src/components/StageBar.astro +137 -0
- package/src/components/StructuredData.astro +33 -0
- package/src/components/justify.ts +170 -0
- package/src/components/media-meta.ts +174 -0
- package/src/components/sections/AboutIntro.astro +46 -0
- package/src/components/sections/Banner.astro +31 -0
- package/src/components/sections/Contact.astro +22 -9
- package/src/components/sections/Cta.astro +33 -10
- package/src/components/sections/Faq.astro +50 -0
- package/src/components/sections/FeatureGrid.astro +40 -11
- package/src/components/sections/Gallery.astro +46 -0
- package/src/components/sections/Hero.astro +40 -12
- package/src/components/sections/LocationHours.astro +59 -0
- package/src/components/sections/Media.astro +44 -0
- package/src/components/sections/PricingTiers.astro +79 -0
- package/src/components/sections/ProductGrid.astro +73 -0
- package/src/components/sections/SplitImage.astro +61 -0
- package/src/components/sections/Steps.astro +58 -0
- package/src/components/sections/Testimonial.astro +51 -0
- package/src/components/sections.ts +452 -67
|
@@ -5,13 +5,25 @@
|
|
|
5
5
|
// Astroid decides WHICH collections and fields exist (its opinions); Louise's
|
|
6
6
|
// codegen decides HOW they become D1 tables. Dependency flows one way — this
|
|
7
7
|
// imports `louise-toolkit/content`, never the reverse.
|
|
8
|
-
// `content/define` rather than the `content` barrel: the
|
|
9
|
-
// codegen/localApi/validation chunks, and those import
|
|
10
|
-
// *optional* peer of louise-toolkit, so importing it
|
|
11
|
-
//
|
|
12
|
-
//
|
|
8
|
+
// `content/define` and `content/sections` rather than the `content` barrel: the
|
|
9
|
+
// barrel eagerly pulls the codegen/localApi/validation chunks, and those import
|
|
10
|
+
// drizzle-orm for real — an *optional* peer of louise-toolkit, so importing it
|
|
11
|
+
// here would force a package on consumers who only DESCRIBE content (e.g.
|
|
12
|
+
// create-astroid's schema generators, which call this function but never run the
|
|
13
|
+
// beforeChange hook below). Both entries are drizzle-free: `content/define` for
|
|
14
|
+
// the config types/builders, and `content/sections` for the write-time section
|
|
15
|
+
// validators. That second entry is what the Rule-evaluator split
|
|
16
|
+
// (louise-toolkit/src/core/content/rule.ts) added, so this hook can import the
|
|
17
|
+
// validators STATICALLY instead of the dynamic `import("louise-toolkit/content")`
|
|
18
|
+
// it used to need to keep the CLI's graph drizzle-free.
|
|
13
19
|
import { defineCollection, } from "louise-toolkit/content/define";
|
|
20
|
+
import { assertValidSections, sanitizeSectionsRichText } from "louise-toolkit/content/sections";
|
|
14
21
|
import { sanitizeRichHtml } from "louise-toolkit/security";
|
|
22
|
+
// The catalog is the single declaration of what a section IS — the same object
|
|
23
|
+
// the on-canvas editor mounts with and this hook validates against. It lives
|
|
24
|
+
// beside the components (it ships as source for them) and is imported here so
|
|
25
|
+
// the two can't drift.
|
|
26
|
+
import { astroidSectionCatalog } from "../components/sections.js";
|
|
15
27
|
/**
|
|
16
28
|
* The opinionated `pages` collection — the EDITABLE page fields, versioned
|
|
17
29
|
* drafts, and full-text search. Keyed to the same names as Louise's `pagesColumns`
|
|
@@ -22,12 +34,89 @@ import { sanitizeRichHtml } from "louise-toolkit/security";
|
|
|
22
34
|
* Validated by `defineCollection` at build time, so a malformed field shape throws
|
|
23
35
|
* here rather than at codegen.
|
|
24
36
|
*/
|
|
37
|
+
/** The media base a `pages` write sanitizes rich content against (config, `/media` default). */
|
|
38
|
+
function pageMediaBase(config) {
|
|
39
|
+
return config.deploy?.mediaBase ?? "/media";
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The section catalog a `pages` write is validated + sanitized against: the
|
|
43
|
+
* site's own (`config.sectionCatalog`) when it registered bespoke sections, else
|
|
44
|
+
* Astroid's built-in vocabulary. This is what lets a site with its own section
|
|
45
|
+
* designs (coracle's 13) keep the same write contract as a stock Astroid site.
|
|
46
|
+
*/
|
|
47
|
+
function resolveSectionCatalog(config) {
|
|
48
|
+
return config.sectionCatalog ?? astroidSectionCatalog;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Return a copy of a `pages` write payload with its `sections` rich-text fields
|
|
52
|
+
* sanitized against the project media base — a no-op when the write carries no
|
|
53
|
+
* `sections`. Pure; leaves every other field (and a partial PATCH's absent ones)
|
|
54
|
+
* untouched.
|
|
55
|
+
*
|
|
56
|
+
* Exported because two write paths need it: the collection's `beforeChange` hook
|
|
57
|
+
* below, AND the raw `pagesRoute` (which does not run collection hooks — see
|
|
58
|
+
* {@link astroidPagesWriteHooks}).
|
|
59
|
+
*/
|
|
60
|
+
export function sanitizeAstroidPageSections(config, data) {
|
|
61
|
+
if (data.sections === undefined)
|
|
62
|
+
return data;
|
|
63
|
+
const mediaBase = pageMediaBase(config);
|
|
64
|
+
const sections = sanitizeSectionsRichText(data.sections, resolveSectionCatalog(config), (html) => sanitizeRichHtml(html, { mediaBase }));
|
|
65
|
+
return { ...data, sections };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Validate the (already-sanitized) `sections` of a `pages` write against the
|
|
69
|
+
* catalog, throwing `LouiseValidationError` — an unknown `_type`, a field of the
|
|
70
|
+
* wrong shape, or a setting outside its declared options is rejected with a 422
|
|
71
|
+
* carrying the per-field violations. A no-op when the write carries no
|
|
72
|
+
* `sections`, so a partial PATCH of other fields isn't spuriously validated.
|
|
73
|
+
*/
|
|
74
|
+
export async function assertAstroidPageSections(config, data, operation = "update") {
|
|
75
|
+
if (data.sections === undefined)
|
|
76
|
+
return;
|
|
77
|
+
await assertValidSections(resolveSectionCatalog(config), data.sections, {
|
|
78
|
+
operation,
|
|
79
|
+
mediaBase: pageMediaBase(config),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The write-time hooks the raw `pagesRoute` (louise-toolkit/editor) needs to
|
|
84
|
+
* enforce the same section contract as the draft path.
|
|
85
|
+
*
|
|
86
|
+
* `pagesRoute` writes straight to the table and — unlike `versionsRoute` — takes
|
|
87
|
+
* no collection config, so it never runs the `beforeChange` hook below. Left
|
|
88
|
+
* bare (as it was), a direct `POST` / `PATCH /api/louise/pages/:id` persists an
|
|
89
|
+
* unknown section `_type`, a setting outside its options, or unsanitized section
|
|
90
|
+
* rich text: exactly what the hook exists to stop, silently missing from the one
|
|
91
|
+
* route the on-canvas *structural* edits flow through. `<Sections>` then skips
|
|
92
|
+
* the bad `_type`, so the section just vanishes with no error anywhere.
|
|
93
|
+
*
|
|
94
|
+
* These wire the SAME sanitize + validate the hook uses into `pagesRoute`'s
|
|
95
|
+
* `sanitize` / `transform` / `validate` seams, so both write paths enforce one
|
|
96
|
+
* contract. Spread into the route config:
|
|
97
|
+
*
|
|
98
|
+
* pagesRoute({ table: pages, resolveEditor, fields, ...astroidPagesWriteHooks(config) })
|
|
99
|
+
*/
|
|
100
|
+
export function astroidPagesWriteHooks(config) {
|
|
101
|
+
const mediaBase = pageMediaBase(config);
|
|
102
|
+
return {
|
|
103
|
+
// `body` is a richField, so it goes through pagesRoute's own sanitize seam —
|
|
104
|
+
// with the project media base, matching the hook rather than the toolkit
|
|
105
|
+
// default sanitizer that knows no media base.
|
|
106
|
+
sanitize: (html) => sanitizeRichHtml(html, { mediaBase }),
|
|
107
|
+
// `sections` is not a richField, so it's sanitized here in the transform,
|
|
108
|
+
// which pagesRoute runs BEFORE validate — the hook's sanitize-then-validate
|
|
109
|
+
// order.
|
|
110
|
+
transform: (data) => sanitizeAstroidPageSections(config, data),
|
|
111
|
+
validate: (data, ctx) => assertAstroidPageSections(config, data, ctx.operation),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
25
114
|
export function astroidPagesCollection(config) {
|
|
26
115
|
// The `body` is rich HTML edited in place (`<Editable type="richtext">`) and
|
|
27
116
|
// staged as a draft, so sanitize it on every write — never store raw HTML. A
|
|
28
117
|
// pasted `<img>` pointing off-origin (a hotlink) is dropped: body images must
|
|
29
118
|
// live in the media library. Mirrors the reference site's pages-collection hook.
|
|
30
|
-
const mediaBase = config
|
|
119
|
+
const mediaBase = pageMediaBase(config);
|
|
31
120
|
const fields = {};
|
|
32
121
|
fields.slug = { type: "text", required: true };
|
|
33
122
|
fields.title = { type: "text", required: true };
|
|
@@ -38,19 +127,28 @@ export function astroidPagesCollection(config) {
|
|
|
38
127
|
fields.ogImage = { type: "text" };
|
|
39
128
|
fields.noindex = { type: "checkbox" };
|
|
40
129
|
fields.sortOrder = { type: "number" };
|
|
41
|
-
// Structured page-builder blocks (the editable home)
|
|
42
|
-
// the section catalog on write
|
|
130
|
+
// Structured page-builder blocks (the editable home), deep-validated against
|
|
131
|
+
// the section catalog on write — see the beforeChange hook below.
|
|
43
132
|
fields.sections = { type: "json" };
|
|
44
133
|
return defineCollection({
|
|
45
134
|
slug: "pages",
|
|
46
135
|
fields,
|
|
47
136
|
hooks: {
|
|
48
137
|
beforeChange: [
|
|
49
|
-
({ data }) => {
|
|
50
|
-
|
|
51
|
-
|
|
138
|
+
async ({ data }) => {
|
|
139
|
+
let next = data;
|
|
140
|
+
if (typeof next.body === "string") {
|
|
141
|
+
next = { ...next, body: sanitizeRichHtml(next.body, { mediaBase }) };
|
|
52
142
|
}
|
|
53
|
-
|
|
143
|
+
// Sanitize BEFORE validating: a richText field stores HTML, and
|
|
144
|
+
// validating the raw value would pass content the sanitizer is about
|
|
145
|
+
// to change. Same order as the body above. Both steps are shared with
|
|
146
|
+
// the raw pagesRoute (see astroidPagesWriteHooks) so the two write
|
|
147
|
+
// paths can't diverge — the sanitize throws nothing, the assert throws
|
|
148
|
+
// LouiseValidationError → 422 with per-field violations.
|
|
149
|
+
next = sanitizeAstroidPageSections(config, next);
|
|
150
|
+
await assertAstroidPageSections(config, next, "update");
|
|
151
|
+
return next;
|
|
54
152
|
},
|
|
55
153
|
],
|
|
56
154
|
},
|
package/dist/schema/framework.js
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* wholesale-inquiry module. Shared by table selection (here) and route selection
|
|
10
10
|
* (the worker route plan). */
|
|
11
11
|
export function capturesInquiries(config) {
|
|
12
|
+
// Explicit override wins — a site whose inquiry surface is a bespoke section
|
|
13
|
+
// (coracle's custom `contactForm`) can't be detected from the built-in
|
|
14
|
+
// vocabulary, so it says so directly.
|
|
15
|
+
if (typeof config.inquiries === "boolean")
|
|
16
|
+
return config.inquiries;
|
|
12
17
|
const wantsWholesale = (mods) => (mods ?? []).includes("wholesaleInquiry");
|
|
13
18
|
return ((config.sections ?? []).includes("contact") ||
|
|
14
19
|
wantsWholesale(config.modules) ||
|
package/dist/schema/generate.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// `generateSchemaSource`: the caller (the Astroid CLI, a later slice) writes the
|
|
11
11
|
// result to disk and formats it. Import ordering matches the site's schema.ts so
|
|
12
12
|
// a formatter never re-flags the generated file.
|
|
13
|
+
import { generateCatalogTable } from "../commerce/mirror.js";
|
|
13
14
|
import { astroidFrameworkTables } from "./framework.js";
|
|
14
15
|
/**
|
|
15
16
|
* Generate the TypeScript source of a site's Drizzle schema from an Astroid
|
|
@@ -22,10 +23,17 @@ export function generateAstroidSchema(config) {
|
|
|
22
23
|
// pagesColumns is spread into `pages`; the framework tables are re-exported.
|
|
23
24
|
// One import brings them all in (matching the site) so nothing is unused.
|
|
24
25
|
const dbImports = [...framework, "pagesColumns"].sort();
|
|
26
|
+
const catalog = generateCatalogTable(config);
|
|
27
|
+
// Only import the column builders the emitted source actually uses — an
|
|
28
|
+
// unused import is a lint error in the project we're generating into, and a
|
|
29
|
+
// missing one (`real`, from the catalog's price/sortOrder) is a type error.
|
|
30
|
+
const drizzleImports = ["integer", "sqliteTable", "text"];
|
|
31
|
+
if (catalog?.includes("real("))
|
|
32
|
+
drizzleImports.push("real");
|
|
25
33
|
return [
|
|
26
34
|
"// Generated by astroidjs — do not hand-edit.",
|
|
27
35
|
"// Source: your defineAstroid config.",
|
|
28
|
-
|
|
36
|
+
`import { ${drizzleImports.sort().join(", ")} } from "drizzle-orm/sqlite-core";`,
|
|
29
37
|
'import { collectionVersionsTable, type JsonValue } from "louise-toolkit/content";',
|
|
30
38
|
`import { ${dbImports.join(", ")} } from "louise-toolkit/db";`,
|
|
31
39
|
"",
|
|
@@ -42,7 +50,15 @@ export function generateAstroidSchema(config) {
|
|
|
42
50
|
"// JSON blob — so the table only needs the slug.",
|
|
43
51
|
'export const pagesVersions = collectionVersionsTable({ slug: "pages", fields: {} });',
|
|
44
52
|
"",
|
|
53
|
+
...(catalog ? [catalog] : []),
|
|
45
54
|
`export { ${framework.join(", ")} };`,
|
|
46
55
|
"",
|
|
56
|
+
"// Site-owned tables (the ones Astroid doesn't manage): a project declares",
|
|
57
|
+
"// its own Drizzle tables in src/schema.site.ts and they're re-exported here",
|
|
58
|
+
"// so drizzle-kit sees them and the generated worker can import them. The file",
|
|
59
|
+
"// is scaffold-once — empty until a project adds a table — so this re-export is",
|
|
60
|
+
"// always safe.",
|
|
61
|
+
'export * from "./schema.site.js";',
|
|
62
|
+
"",
|
|
47
63
|
].join("\n");
|
|
48
64
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type SecretSource } from "louise-toolkit/security";
|
|
2
|
+
export type { SecretSource };
|
|
3
|
+
/**
|
|
4
|
+
* The placeholder every Astroid scaffold seeds its unprovisioned secrets with.
|
|
5
|
+
* Reading it back means "not configured yet", never a credential — the value is
|
|
6
|
+
* deliberately loud so it is obvious in a Secrets Store listing or a log line.
|
|
7
|
+
*
|
|
8
|
+
* It matches the sentinel `louise-toolkit`'s Turnstile gate already recognizes,
|
|
9
|
+
* so the captcha pair follows the same convention as every Astroid module.
|
|
10
|
+
*/
|
|
11
|
+
export declare const ASTROID_SECRET_PLACEHOLDER = "DUMMY_REPLACE_ME";
|
|
12
|
+
/**
|
|
13
|
+
* Read one secret under the Astroid convention: `null` unless it holds a real,
|
|
14
|
+
* non-placeholder value. Thin by design — the reason to call this rather than
|
|
15
|
+
* `readSecret` directly is that it binds Astroid's sentinel for you.
|
|
16
|
+
*/
|
|
17
|
+
export declare function readModuleSecret(source: SecretSource): Promise<string | null>;
|
|
18
|
+
/** The resolved secret set for one module, plus the gate derived from it. */
|
|
19
|
+
export interface ModuleSecrets<K extends string> {
|
|
20
|
+
/**
|
|
21
|
+
* True when EVERY secret the module declared resolved to a real value. The
|
|
22
|
+
* module's `isConfigured()` should be exactly this — partial provisioning is
|
|
23
|
+
* treated as dormant, since a half-configured integration fails at the worst
|
|
24
|
+
* possible moment (mid-checkout) rather than at boot.
|
|
25
|
+
*/
|
|
26
|
+
configured: boolean;
|
|
27
|
+
/** Each declared secret's resolved value, or `null` where unprovisioned. */
|
|
28
|
+
values: Record<K, string | null>;
|
|
29
|
+
/** The still-unprovisioned names, in declaration order — the "why not" list. */
|
|
30
|
+
missing: K[];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Resolve a module's secrets in one pass.
|
|
34
|
+
*
|
|
35
|
+
* ```ts
|
|
36
|
+
* const secrets = await resolveModuleSecrets({
|
|
37
|
+
* SQUARE_ACCESS_TOKEN: env.SQUARE_ACCESS_TOKEN,
|
|
38
|
+
* SQUARE_WEBHOOK_SECRET: env.SQUARE_WEBHOOK_SECRET,
|
|
39
|
+
* });
|
|
40
|
+
* if (!secrets.configured) return simulatedCheckout(); // dormant path
|
|
41
|
+
* ```
|
|
42
|
+
*
|
|
43
|
+
* Reads run concurrently: a Secrets Store `.get()` is a real await, and a module
|
|
44
|
+
* with four secrets should not pay for four sequential round-trips on the
|
|
45
|
+
* request path.
|
|
46
|
+
*/
|
|
47
|
+
export declare function resolveModuleSecrets<K extends string>(sources: Record<K, SecretSource>): Promise<ModuleSecrets<K>>;
|
|
48
|
+
/**
|
|
49
|
+
* A one-line, human-readable status for a module — what `astroid doctor`, a dev
|
|
50
|
+
* server banner, or a health endpoint should print. Naming the missing secrets
|
|
51
|
+
* is the whole point: "commerce is off" sends someone reading source, "commerce
|
|
52
|
+
* is dormant — set SQUARE_ACCESS_TOKEN" does not.
|
|
53
|
+
*/
|
|
54
|
+
export declare function describeModuleStatus<K extends string>(module: string, status: ModuleSecrets<K>): string;
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// The dormant-until-provisioned convention.
|
|
4
|
+
//
|
|
5
|
+
// Astroid's optional modules are opt-in at the CONFIG level but not at the
|
|
6
|
+
// account level: switching `commerce` on in `defineAstroid` must not require a
|
|
7
|
+
// Square account before `pnpm dev` will boot. So every module here follows one
|
|
8
|
+
// rule — a module whose secrets are unprovisioned is DORMANT: it renders, it
|
|
9
|
+
// serves, it says out loud that it is simulated, and it never calls upstream
|
|
10
|
+
// with a dummy credential.
|
|
11
|
+
//
|
|
12
|
+
// The mechanism is `readSecret` from `louise-toolkit/security` (a secret that is
|
|
13
|
+
// absent / unreadable / empty / a placeholder reads as `null`). What lives here
|
|
14
|
+
// is the *convention* over it, which is Astroid's opinion, not the toolkit's:
|
|
15
|
+
//
|
|
16
|
+
// 1. ONE sentinel — `ASTROID_SECRET_PLACEHOLDER` — seeded by `create-astroid`
|
|
17
|
+
// into every secret a scaffold declares, so a fresh clone has a complete,
|
|
18
|
+
// valid binding set and zero real credentials.
|
|
19
|
+
// 2. `resolveModuleSecrets` collapses a module's whole secret set into one
|
|
20
|
+
// `configured` gate plus the list of what's still missing, so a module's
|
|
21
|
+
// `isConfigured()` and its "why not" message come from the same read.
|
|
22
|
+
//
|
|
23
|
+
// This mirrors what all three consuming sites converged on independently.
|
|
24
|
+
import { readSecret } from "louise-toolkit/security";
|
|
25
|
+
/**
|
|
26
|
+
* The placeholder every Astroid scaffold seeds its unprovisioned secrets with.
|
|
27
|
+
* Reading it back means "not configured yet", never a credential — the value is
|
|
28
|
+
* deliberately loud so it is obvious in a Secrets Store listing or a log line.
|
|
29
|
+
*
|
|
30
|
+
* It matches the sentinel `louise-toolkit`'s Turnstile gate already recognizes,
|
|
31
|
+
* so the captcha pair follows the same convention as every Astroid module.
|
|
32
|
+
*/
|
|
33
|
+
export const ASTROID_SECRET_PLACEHOLDER = "DUMMY_REPLACE_ME";
|
|
34
|
+
/**
|
|
35
|
+
* Read one secret under the Astroid convention: `null` unless it holds a real,
|
|
36
|
+
* non-placeholder value. Thin by design — the reason to call this rather than
|
|
37
|
+
* `readSecret` directly is that it binds Astroid's sentinel for you.
|
|
38
|
+
*/
|
|
39
|
+
export function readModuleSecret(source) {
|
|
40
|
+
return readSecret(source, { placeholder: ASTROID_SECRET_PLACEHOLDER });
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Resolve a module's secrets in one pass.
|
|
44
|
+
*
|
|
45
|
+
* ```ts
|
|
46
|
+
* const secrets = await resolveModuleSecrets({
|
|
47
|
+
* SQUARE_ACCESS_TOKEN: env.SQUARE_ACCESS_TOKEN,
|
|
48
|
+
* SQUARE_WEBHOOK_SECRET: env.SQUARE_WEBHOOK_SECRET,
|
|
49
|
+
* });
|
|
50
|
+
* if (!secrets.configured) return simulatedCheckout(); // dormant path
|
|
51
|
+
* ```
|
|
52
|
+
*
|
|
53
|
+
* Reads run concurrently: a Secrets Store `.get()` is a real await, and a module
|
|
54
|
+
* with four secrets should not pay for four sequential round-trips on the
|
|
55
|
+
* request path.
|
|
56
|
+
*/
|
|
57
|
+
export async function resolveModuleSecrets(sources) {
|
|
58
|
+
const names = Object.keys(sources);
|
|
59
|
+
const resolved = await Promise.all(names.map((name) => readModuleSecret(sources[name])));
|
|
60
|
+
const values = {};
|
|
61
|
+
const missing = [];
|
|
62
|
+
names.forEach((name, i) => {
|
|
63
|
+
const value = resolved[i] ?? null;
|
|
64
|
+
values[name] = value;
|
|
65
|
+
if (value === null)
|
|
66
|
+
missing.push(name);
|
|
67
|
+
});
|
|
68
|
+
return { configured: missing.length === 0, values, missing };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* A one-line, human-readable status for a module — what `astroid doctor`, a dev
|
|
72
|
+
* server banner, or a health endpoint should print. Naming the missing secrets
|
|
73
|
+
* is the whole point: "commerce is off" sends someone reading source, "commerce
|
|
74
|
+
* is dormant — set SQUARE_ACCESS_TOKEN" does not.
|
|
75
|
+
*/
|
|
76
|
+
export function describeModuleStatus(module, status) {
|
|
77
|
+
if (status.configured)
|
|
78
|
+
return `${module}: configured`;
|
|
79
|
+
return `${module}: dormant (simulated) — unprovisioned secret(s): ${status.missing.join(", ")}`;
|
|
80
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ASTROID_CHECKOUT_PATH, ASTROID_PORTAL_BASE_PATH, astroidRateRules, type RateRule, } from "./rate-rules.js";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { RateRule } from "louise-toolkit/security";
|
|
2
|
+
import type { AstroidConfig } from "../config.js";
|
|
3
|
+
export type { RateRule };
|
|
4
|
+
/**
|
|
5
|
+
* Base path the customer/portal Better Auth instance mounts at — its own
|
|
6
|
+
* handler, separate from the editor's `/api/auth`. Fixed by Astroid so the rate
|
|
7
|
+
* rules, the middleware, and the portal routes can't drift apart.
|
|
8
|
+
*/
|
|
9
|
+
export declare const ASTROID_PORTAL_BASE_PATH = "/api/portal-auth";
|
|
10
|
+
/** Path the commerce module's checkout POSTs to. */
|
|
11
|
+
export declare const ASTROID_CHECKOUT_PATH = "/api/checkout";
|
|
12
|
+
/**
|
|
13
|
+
* The rule set for a project, derived from its config: the editor sign-in
|
|
14
|
+
* surface always, the portal's credential surfaces when a portal is enabled, and
|
|
15
|
+
* checkout when commerce is configured.
|
|
16
|
+
*
|
|
17
|
+
* Rules are matched first-wins, and `security.rateRules` from the config are
|
|
18
|
+
* placed FIRST — so a site tightens or loosens any default by declaring its own
|
|
19
|
+
* rule for that path, rather than losing the whole set to override one budget.
|
|
20
|
+
*/
|
|
21
|
+
export declare function astroidRateRules(config: AstroidConfig): RateRule[];
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// Rate-limit rules as data.
|
|
4
|
+
//
|
|
5
|
+
// The limiter mechanism lives in `louise-toolkit/security` and is deliberately
|
|
6
|
+
// unopinionated: which routes, and which budgets, are policy. But the policy
|
|
7
|
+
// turned out not to vary. All three consuming sites independently wrote the same
|
|
8
|
+
// `RateRule[]` — the same public POST surfaces, the same 10-minute windows,
|
|
9
|
+
// budgets within a factor of one of each other — differing only where the site
|
|
10
|
+
// had a surface the others didn't (a portal, a checkout). That is a default, not
|
|
11
|
+
// a per-site decision, so Astroid derives the whole set from the config.
|
|
12
|
+
//
|
|
13
|
+
// What's in scope: the public, UNAUTHENTICATED POST surfaces. Editor endpoints
|
|
14
|
+
// (`/api/louise/*`) are session-gated and stay out on purpose — a limiter that
|
|
15
|
+
// can lock the owner out of their own studio is worse than the abuse it stops.
|
|
16
|
+
// The contact form is also absent by design: it's a worker route with its own
|
|
17
|
+
// per-form limiter, and worker routes are matched before Astro's middleware ever
|
|
18
|
+
// runs, so a rule here would never fire.
|
|
19
|
+
/**
|
|
20
|
+
* Base path the customer/portal Better Auth instance mounts at — its own
|
|
21
|
+
* handler, separate from the editor's `/api/auth`. Fixed by Astroid so the rate
|
|
22
|
+
* rules, the middleware, and the portal routes can't drift apart.
|
|
23
|
+
*/
|
|
24
|
+
export const ASTROID_PORTAL_BASE_PATH = "/api/portal-auth";
|
|
25
|
+
/** Path the commerce module's checkout POSTs to. */
|
|
26
|
+
export const ASTROID_CHECKOUT_PATH = "/api/checkout";
|
|
27
|
+
/** Ten minutes. Every default budget uses this window — long enough that a
|
|
28
|
+
* burst can't wait it out, short enough that a false positive self-heals. */
|
|
29
|
+
const WINDOW = 600;
|
|
30
|
+
const exact = (path) => (p) => p === path;
|
|
31
|
+
/**
|
|
32
|
+
* The rule set for a project, derived from its config: the editor sign-in
|
|
33
|
+
* surface always, the portal's credential surfaces when a portal is enabled, and
|
|
34
|
+
* checkout when commerce is configured.
|
|
35
|
+
*
|
|
36
|
+
* Rules are matched first-wins, and `security.rateRules` from the config are
|
|
37
|
+
* placed FIRST — so a site tightens or loosens any default by declaring its own
|
|
38
|
+
* rule for that path, rather than losing the whole set to override one budget.
|
|
39
|
+
*/
|
|
40
|
+
export function astroidRateRules(config) {
|
|
41
|
+
const rules = [...(config.security?.rateRules ?? [])];
|
|
42
|
+
// Magic-link sign-in is the email-bombing target: without a cap, anyone who
|
|
43
|
+
// knows an editor's address can trigger unbounded sign-in mail (their inbox,
|
|
44
|
+
// your Email + Worker spend). Tightest budget in the set.
|
|
45
|
+
rules.push({
|
|
46
|
+
name: "magic-link",
|
|
47
|
+
method: "POST",
|
|
48
|
+
match: exact("/api/auth/sign-in/magic-link"),
|
|
49
|
+
limit: 5,
|
|
50
|
+
windowSec: WINDOW,
|
|
51
|
+
});
|
|
52
|
+
// Everything else Better Auth serves (passkey challenges, sign-out, callbacks)
|
|
53
|
+
// behind a looser catch-all. Ordered after the specific rule above, which
|
|
54
|
+
// matters: `matchRateRule` takes the first match.
|
|
55
|
+
rules.push({
|
|
56
|
+
name: "auth",
|
|
57
|
+
method: "POST",
|
|
58
|
+
match: (p) => p.startsWith("/api/auth/"),
|
|
59
|
+
limit: 30,
|
|
60
|
+
windowSec: WINDOW,
|
|
61
|
+
});
|
|
62
|
+
if (config.portal?.enabled) {
|
|
63
|
+
// The portal's mount is configurable (a site may already ship a second
|
|
64
|
+
// instance at its own path), so the credential-surface rules must track the
|
|
65
|
+
// resolved base path, not the default constant — mirrors `astroidPortal`.
|
|
66
|
+
const base = config.portal.basePath ?? ASTROID_PORTAL_BASE_PATH;
|
|
67
|
+
// Customer credentials, unlike the editor's, are password-based — so these
|
|
68
|
+
// guard credential stuffing and enumeration, not just mail volume.
|
|
69
|
+
rules.push({
|
|
70
|
+
name: "portal-signup",
|
|
71
|
+
method: "POST",
|
|
72
|
+
match: exact(`${base}/sign-up/email`),
|
|
73
|
+
limit: 10,
|
|
74
|
+
windowSec: WINDOW,
|
|
75
|
+
});
|
|
76
|
+
rules.push({
|
|
77
|
+
name: "portal-signin",
|
|
78
|
+
method: "POST",
|
|
79
|
+
match: exact(`${base}/sign-in/email`),
|
|
80
|
+
limit: 15,
|
|
81
|
+
windowSec: WINDOW,
|
|
82
|
+
});
|
|
83
|
+
rules.push({
|
|
84
|
+
name: "portal-reset-request",
|
|
85
|
+
method: "POST",
|
|
86
|
+
match: exact(`${base}/request-password-reset`),
|
|
87
|
+
limit: 5,
|
|
88
|
+
windowSec: WINDOW,
|
|
89
|
+
});
|
|
90
|
+
rules.push({
|
|
91
|
+
name: "portal-reset",
|
|
92
|
+
method: "POST",
|
|
93
|
+
match: exact(`${base}/reset-password`),
|
|
94
|
+
limit: 15,
|
|
95
|
+
windowSec: WINDOW,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (config.commerce) {
|
|
99
|
+
// Loosest budget in the set: a real shopper retries, edits, and re-submits a
|
|
100
|
+
// cart, so this is abuse control, not flow control.
|
|
101
|
+
rules.push({
|
|
102
|
+
name: "checkout",
|
|
103
|
+
method: "POST",
|
|
104
|
+
match: exact(ASTROID_CHECKOUT_PATH),
|
|
105
|
+
limit: 20,
|
|
106
|
+
windowSec: WINDOW,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return rules;
|
|
110
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { type AstroidSeoOptions, type AstroidSeoSettings, type PageSeoInput, type ResolvedSeo, resolvePageSeo, } from "./resolve.js";
|
|
2
|
+
export { astroidNoindexPaths, astroidRobotsTxt, astroidSitemapXml, type RobotsOptions, type SitemapEntry, type SitemapOptions, } from "./routes.js";
|
|
3
|
+
export { ARCHETYPE_BUSINESS_TYPE, astroidStructuredData, escapeJsonLd, type JsonLdNode, type StructuredDataInput, } from "./structured-data.js";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
export { resolvePageSeo, } from "./resolve.js";
|
|
3
|
+
export { astroidNoindexPaths, astroidRobotsTxt, astroidSitemapXml, } from "./routes.js";
|
|
4
|
+
export { ARCHETYPE_BUSINESS_TYPE, astroidStructuredData, escapeJsonLd, } from "./structured-data.js";
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `site_settings` subset the SEO layer reads. Structurally typed, so a
|
|
3
|
+
* composed settings table (or a plain object in a test) satisfies it without
|
|
4
|
+
* Astroid knowing anything about the rest of the row.
|
|
5
|
+
*/
|
|
6
|
+
export interface AstroidSeoSettings {
|
|
7
|
+
siteName?: string | null;
|
|
8
|
+
tagline?: string | null;
|
|
9
|
+
metaDescription?: string | null;
|
|
10
|
+
defaultOgImageUrl?: string | null;
|
|
11
|
+
/** Site-wide kill switch — noindex every page (staging, pre-launch). */
|
|
12
|
+
disableIndexing?: boolean | null;
|
|
13
|
+
}
|
|
14
|
+
/** Per-page SEO: an editor's overrides, or a built-in page's own defaults. */
|
|
15
|
+
export interface PageSeoInput {
|
|
16
|
+
/** Bare title, pre-template. Omit for the site-wide default. */
|
|
17
|
+
title?: string | null;
|
|
18
|
+
description?: string | null;
|
|
19
|
+
/** Absolute URL or a site-root path; resolved against the serving origin. */
|
|
20
|
+
ogImage?: string | null;
|
|
21
|
+
/** `"website"` (default), or `"article"` / `"product"` for a richer card. */
|
|
22
|
+
ogType?: string | null;
|
|
23
|
+
/** Keep this page out of the index (cart, checkout, account, auth). */
|
|
24
|
+
noindex?: boolean | null;
|
|
25
|
+
}
|
|
26
|
+
export interface AstroidSeoOptions {
|
|
27
|
+
/** Canonical URL of the page being rendered — absolute. */
|
|
28
|
+
canonical: string;
|
|
29
|
+
/**
|
|
30
|
+
* Title template, `%s` standing in for the page title. Applied ONLY when the
|
|
31
|
+
* page supplies a title. Default `"%s | <siteName>"`.
|
|
32
|
+
*/
|
|
33
|
+
titleTemplate?: string;
|
|
34
|
+
/** `@<handle>` for Twitter/X card attribution. */
|
|
35
|
+
twitterHandle?: string;
|
|
36
|
+
/** OG locale, e.g. `"en_US"`. */
|
|
37
|
+
locale?: string;
|
|
38
|
+
}
|
|
39
|
+
/** Everything a `<head>` needs, fully resolved and absolute. */
|
|
40
|
+
export interface ResolvedSeo {
|
|
41
|
+
/** Final `<title>` — templated when the page supplied one. */
|
|
42
|
+
title: string;
|
|
43
|
+
/** Untemplated page title, for OG/Twitter (which shouldn't carry the site
|
|
44
|
+
* suffix twice — the OG `site_name` already says it). */
|
|
45
|
+
bareTitle: string;
|
|
46
|
+
description?: string;
|
|
47
|
+
canonical: string;
|
|
48
|
+
ogType: string;
|
|
49
|
+
ogImage?: string;
|
|
50
|
+
ogImageAlt?: string;
|
|
51
|
+
siteName?: string;
|
|
52
|
+
locale?: string;
|
|
53
|
+
twitterHandle?: string;
|
|
54
|
+
noindex: boolean;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Resolve a page's SEO against the site settings.
|
|
58
|
+
*
|
|
59
|
+
* ```ts
|
|
60
|
+
* const seo = resolvePageSeo(settings, { title: page.seoTitle, noindex: page.noindex }, {
|
|
61
|
+
* canonical: Astro.url.href,
|
|
62
|
+
* });
|
|
63
|
+
* ```
|
|
64
|
+
*
|
|
65
|
+
* `settings.disableIndexing` wins over everything: it's the site-wide kill
|
|
66
|
+
* switch, so a page that asks to be indexed on a staging deploy still isn't.
|
|
67
|
+
*/
|
|
68
|
+
export declare function resolvePageSeo(settings: AstroidSeoSettings, page: PageSeoInput | undefined, options: AstroidSeoOptions): ResolvedSeo;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// SEO resolution — settings defaults + per-page overrides, collapsed into the
|
|
4
|
+
// exact set of values a `<head>` needs.
|
|
5
|
+
//
|
|
6
|
+
// Both sites that hand-built this layer converged on the same three-level
|
|
7
|
+
// fallback (page override → the page's computed default → the site-wide
|
|
8
|
+
// setting) and the same non-obvious details: an empty string is *unset*, not a
|
|
9
|
+
// blank tag, so clearing a field in the editor falls back instead of publishing
|
|
10
|
+
// an empty `<meta>`; and the title template applies only when a page supplies
|
|
11
|
+
// its own title, so the home page reads "Acme Coffee" rather than
|
|
12
|
+
// "Acme Coffee | Acme Coffee".
|
|
13
|
+
//
|
|
14
|
+
// Kept as a pure function rather than baked into the component so it can be
|
|
15
|
+
// unit-tested, and so a route that isn't rendering a page (an OG-image endpoint,
|
|
16
|
+
// a feed) can resolve the same values.
|
|
17
|
+
/** Trimmed value, or undefined — an empty/whitespace string counts as unset. */
|
|
18
|
+
const clean = (value) => {
|
|
19
|
+
const trimmed = value?.trim();
|
|
20
|
+
return trimmed ? trimmed : undefined;
|
|
21
|
+
};
|
|
22
|
+
/** Absolute URL for a path or URL, or undefined if it can't be resolved. */
|
|
23
|
+
function absolute(value, base) {
|
|
24
|
+
const raw = clean(value);
|
|
25
|
+
if (!raw)
|
|
26
|
+
return undefined;
|
|
27
|
+
try {
|
|
28
|
+
return new URL(raw, base).toString();
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Resolve a page's SEO against the site settings.
|
|
36
|
+
*
|
|
37
|
+
* ```ts
|
|
38
|
+
* const seo = resolvePageSeo(settings, { title: page.seoTitle, noindex: page.noindex }, {
|
|
39
|
+
* canonical: Astro.url.href,
|
|
40
|
+
* });
|
|
41
|
+
* ```
|
|
42
|
+
*
|
|
43
|
+
* `settings.disableIndexing` wins over everything: it's the site-wide kill
|
|
44
|
+
* switch, so a page that asks to be indexed on a staging deploy still isn't.
|
|
45
|
+
*/
|
|
46
|
+
export function resolvePageSeo(settings, page = {}, options) {
|
|
47
|
+
const siteName = clean(settings.siteName);
|
|
48
|
+
const pageTitle = clean(page.title);
|
|
49
|
+
const template = options.titleTemplate ?? (siteName ? `%s | ${siteName}` : "%s");
|
|
50
|
+
// No page title → the site-wide title stands alone. Templating it would read
|
|
51
|
+
// "Acme Coffee | Acme Coffee".
|
|
52
|
+
const bareTitle = pageTitle ?? siteName ?? "";
|
|
53
|
+
const title = pageTitle ? template.replace("%s", pageTitle) : bareTitle;
|
|
54
|
+
const description = clean(page.description) ?? clean(settings.metaDescription);
|
|
55
|
+
const ogImage = absolute(page.ogImage, options.canonical) ??
|
|
56
|
+
absolute(settings.defaultOgImageUrl, options.canonical);
|
|
57
|
+
return {
|
|
58
|
+
title,
|
|
59
|
+
bareTitle,
|
|
60
|
+
description,
|
|
61
|
+
canonical: options.canonical,
|
|
62
|
+
ogType: clean(page.ogType) ?? "website",
|
|
63
|
+
ogImage,
|
|
64
|
+
ogImageAlt: ogImage
|
|
65
|
+
? [siteName, bareTitle].filter(Boolean).join(" — ") || undefined
|
|
66
|
+
: undefined,
|
|
67
|
+
siteName,
|
|
68
|
+
locale: clean(options.locale),
|
|
69
|
+
twitterHandle: clean(options.twitterHandle),
|
|
70
|
+
// The site-wide switch is deliberately not overridable per page.
|
|
71
|
+
noindex: Boolean(settings.disableIndexing) || Boolean(page.noindex),
|
|
72
|
+
};
|
|
73
|
+
}
|