@janmarkuslanger/astro-strapi-platform 0.0.0-stage → 0.1.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/package.json CHANGED
@@ -1,6 +1,27 @@
1
1
  {
2
2
  "name": "@janmarkuslanger/astro-strapi-platform",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Astro + Strapi platform: domain model and block contract, Strapi adapter, Astro rendering and consent, Strapi schemas and bootstrap helpers.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "keywords": ["astro-component", "strapi"],
8
+ "exports": {
9
+ "./core": { "types": "./dist/core/index.d.ts", "import": "./dist/core/index.js" },
10
+ "./strapi": { "types": "./dist/strapi/index.d.ts", "import": "./dist/strapi/index.js" },
11
+ "./astro": "./src/astro/index.ts",
12
+ "./astro/BlockRenderer.astro": "./src/astro/BlockRenderer.astro",
13
+ "./strapi-kit": { "types": "./dist/strapi-kit/index.d.ts", "default": "./dist/strapi-kit/index.js" },
14
+ "./strapi-kit/schemas-cli": "./dist/strapi-kit/schemas-cli.js"
15
+ },
16
+ "typesVersions": { "*": { "strapi-kit": ["dist/strapi-kit/index.d.ts"] } },
17
+ "files": ["dist", "src/astro", "schemas"],
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.json && tsc -p tsconfig.strapi-kit.json && node -e \"require('node:fs').copyFileSync('src/strapi-kit/package.json', 'dist/strapi-kit/package.json')\""
20
+ },
21
+ "dependencies": { "marked": "^14.1.3" },
22
+ "peerDependencies": { "astro": "^4.16.0", "@strapi/strapi": "^5.0.0" },
23
+ "peerDependenciesMeta": { "astro": { "optional": true }, "@strapi/strapi": { "optional": true } },
24
+ "devDependencies": { "typescript": "^5.6.3" },
25
+ "publishConfig": { "access": "public" },
26
+ "repository": { "type": "git", "url": "git+https://github.com/janmarkuslanger/astro-strapi-template.git", "directory": "packages/astro-strapi-platform" }
27
+ }
@@ -0,0 +1,40 @@
1
+ {
2
+ "kind": "singleType",
3
+ "collectionName": "globals",
4
+ "info": {
5
+ "singularName": "global",
6
+ "pluralName": "globals",
7
+ "displayName": "Global settings"
8
+ },
9
+ "options": {
10
+ "draftAndPublish": false
11
+ },
12
+ "pluginOptions": {
13
+ "content-type-builder": {
14
+ "visible": false
15
+ }
16
+ },
17
+ "attributes": {
18
+ "siteName": {
19
+ "type": "string",
20
+ "required": true
21
+ },
22
+ "claim": {
23
+ "type": "string"
24
+ },
25
+ "email": {
26
+ "type": "email",
27
+ "required": true
28
+ },
29
+ "navigation": {
30
+ "type": "component",
31
+ "repeatable": true,
32
+ "component": "shared.link"
33
+ },
34
+ "footerLinks": {
35
+ "type": "component",
36
+ "repeatable": true,
37
+ "component": "shared.link"
38
+ }
39
+ }
40
+ }
@@ -0,0 +1,3 @@
1
+ import { factories } from "@strapi/strapi";
2
+
3
+ export default factories.createCoreController("api::global.global");
@@ -0,0 +1,3 @@
1
+ import { factories } from "@strapi/strapi";
2
+
3
+ export default factories.createCoreRouter("api::global.global");
@@ -0,0 +1,3 @@
1
+ import { factories } from "@strapi/strapi";
2
+
3
+ export default factories.createCoreService("api::global.global");
@@ -0,0 +1,39 @@
1
+ {
2
+ "kind": "collectionType",
3
+ "collectionName": "pages",
4
+ "info": {
5
+ "singularName": "page",
6
+ "pluralName": "pages",
7
+ "displayName": "Page"
8
+ },
9
+ "options": {
10
+ "draftAndPublish": true
11
+ },
12
+ "pluginOptions": {
13
+ "content-type-builder": {
14
+ "visible": false
15
+ }
16
+ },
17
+ "attributes": {
18
+ "title": {
19
+ "type": "string",
20
+ "required": true
21
+ },
22
+ "slug": {
23
+ "type": "uid",
24
+ "targetField": "title",
25
+ "required": true
26
+ },
27
+ "seo": {
28
+ "type": "component",
29
+ "repeatable": false,
30
+ "component": "shared.seo"
31
+ },
32
+ "blocks": {
33
+ "type": "dynamiczone",
34
+ "components": [
35
+ "blocks.reference"
36
+ ]
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,3 @@
1
+ import { factories } from "@strapi/strapi";
2
+
3
+ export default factories.createCoreController("api::page.page");
@@ -0,0 +1,3 @@
1
+ import { factories } from "@strapi/strapi";
2
+
3
+ export default factories.createCoreRouter("api::page.page");
@@ -0,0 +1,3 @@
1
+ import { factories } from "@strapi/strapi";
2
+
3
+ export default factories.createCoreService("api::page.page");
@@ -0,0 +1,28 @@
1
+ {
2
+ "kind": "collectionType",
3
+ "collectionName": "shared_blocks",
4
+ "info": {
5
+ "singularName": "shared-block",
6
+ "pluralName": "shared-blocks",
7
+ "displayName": "Reusable block"
8
+ },
9
+ "options": {
10
+ "draftAndPublish": true
11
+ },
12
+ "pluginOptions": {
13
+ "content-type-builder": {
14
+ "visible": false
15
+ }
16
+ },
17
+ "attributes": {
18
+ "name": {
19
+ "type": "string",
20
+ "required": true,
21
+ "unique": true
22
+ },
23
+ "blocks": {
24
+ "type": "dynamiczone",
25
+ "components": []
26
+ }
27
+ }
28
+ }
@@ -0,0 +1,3 @@
1
+ import { factories } from "@strapi/strapi";
2
+
3
+ export default factories.createCoreController("api::shared-block.shared-block");
@@ -0,0 +1,3 @@
1
+ import { factories } from "@strapi/strapi";
2
+
3
+ export default factories.createCoreRouter("api::shared-block.shared-block");
@@ -0,0 +1,3 @@
1
+ import { factories } from "@strapi/strapi";
2
+
3
+ export default factories.createCoreService("api::shared-block.shared-block");
@@ -0,0 +1,15 @@
1
+ {
2
+ "collectionName": "components_blocks_references",
3
+ "info": {
4
+ "displayName": "Reference",
5
+ "icon": "link"
6
+ },
7
+ "options": {},
8
+ "attributes": {
9
+ "block": {
10
+ "type": "relation",
11
+ "relation": "oneToOne",
12
+ "target": "api::shared-block.shared-block"
13
+ }
14
+ }
15
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "collectionName": "components_shared_links",
3
+ "info": { "displayName": "Link", "icon": "link" },
4
+ "options": {},
5
+ "attributes": {
6
+ "label": { "type": "string", "required": true },
7
+ "href": { "type": "string", "required": true }
8
+ }
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "collectionName": "components_shared_seos",
3
+ "info": { "displayName": "SEO", "icon": "search" },
4
+ "options": {},
5
+ "attributes": {
6
+ "title": { "type": "string" },
7
+ "description": { "type": "text", "maxLength": 200 }
8
+ }
9
+ }
@@ -0,0 +1,29 @@
1
+ ---
2
+ /**
3
+ * Renders domain blocks through the site's block registry. Names no block
4
+ * type.
5
+ *
6
+ * A block without a definition renders NOTHING rather than throwing: an
7
+ * unknown block must never take a page down. Together with the adapter (which
8
+ * already skips unknown CMS components), a block added in the CMS ahead of
9
+ * the frontend is harmless.
10
+ */
11
+ import {
12
+ definitionFor,
13
+ type BaseBlock,
14
+ type BlockRegistry,
15
+ } from "@janmarkuslanger/astro-strapi-platform/core";
16
+
17
+ interface Props<B extends BaseBlock> {
18
+ blocks: B[];
19
+ registry: BlockRegistry<B>;
20
+ }
21
+ const { blocks, registry } = Astro.props;
22
+ ---
23
+
24
+ {
25
+ blocks.map((block) => {
26
+ const Component = definitionFor(registry, block)?.component;
27
+ return Component ? <Component block={block} /> : null;
28
+ })
29
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Google Analytics 4 consumer of the consent system.
3
+ *
4
+ * gtag.js is loaded only once the "analytics" service has been granted. On
5
+ * revocation GA is switched off through its official opt-out flag.
6
+ *
7
+ * NEVER paste a gtag.js snippet into a layout instead of this. A snippet loads
8
+ * on page view — before consent — which defeats the entire system. The loader
9
+ * below is created in code, after the decision. See CLAUDE.md §7.
10
+ *
11
+ * Browser-only.
12
+ */
13
+
14
+ import { onConsentChange } from "./consent";
15
+
16
+ declare global {
17
+ interface Window {
18
+ dataLayer?: unknown[];
19
+ gtag?: (...args: unknown[]) => void;
20
+ [key: `ga-disable-${string}`]: boolean | undefined;
21
+ }
22
+ }
23
+
24
+ let loaded = false;
25
+
26
+ function loadAnalytics(measurementId: string): void {
27
+ if (loaded) return;
28
+ loaded = true;
29
+
30
+ window.dataLayer = window.dataLayer || [];
31
+ // Must push `arguments`, not a rest-parameter array: gtag.js only executes
32
+ // Arguments objects and silently ignores arrays — no config, no hits.
33
+ window.gtag = function gtag() {
34
+ window.dataLayer!.push(arguments);
35
+ };
36
+ window.gtag("js", new Date());
37
+ window.gtag("config", measurementId, { anonymize_ip: true });
38
+
39
+ const script = document.createElement("script");
40
+ script.async = true;
41
+ script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(measurementId)}`;
42
+ document.head.appendChild(script);
43
+ }
44
+
45
+ /**
46
+ * Connects GA to the consent manager (service id "analytics").
47
+ * Idempotent — safe to call once per page.
48
+ */
49
+ export function bindAnalyticsToConsent(measurementId: string): void {
50
+ if (!measurementId) return;
51
+
52
+ onConsentChange("analytics", (granted) => {
53
+ if (granted) {
54
+ window[`ga-disable-${measurementId}`] = false;
55
+ loadAnalytics(measurementId);
56
+ } else {
57
+ // Official GA opt-out: an already loaded gtag stops sending.
58
+ window[`ga-disable-${measurementId}`] = true;
59
+ }
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Fires a GA4 event — a silent no-op without consent or without GA loaded.
65
+ *
66
+ * Keep the event list short and tied to conversions. Pageviews, scrolls and
67
+ * outbound clicks come from GA4 Enhanced Measurement automatically, as long as
68
+ * the site does full page loads. With a client-side router you must fire
69
+ * `page_view` yourself — see CLAUDE.md §6.
70
+ */
71
+ export function trackEvent(
72
+ name: string,
73
+ params: Record<string, string | number> = {}
74
+ ): void {
75
+ window.gtag?.("event", name, params);
76
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Behaviour of the consent banner and embed gates. Markup, styling and copy
3
+ * belong to the site (they are design, and in the audience's language); this
4
+ * module only wires them up through data attributes:
5
+ *
6
+ * banner input[name="consent-service"][value=<id>] one per service
7
+ * [data-consent-accept-all] [data-consent-decline] [data-consent-save]
8
+ * page [data-consent-settings] reopens the banner
9
+ * gate [data-consent-gate=<id>] containing
10
+ * [data-consent-placeholder] [data-consent-allow]
11
+ * <template data-consent-content> revealed after consent
12
+ *
13
+ * "Accept all" and "Decline" must carry equal visual weight in the markup —
14
+ * no dark patterns (CLAUDE.md §7).
15
+ *
16
+ * Browser-only.
17
+ */
18
+ import { getChoices, onConsentChange, resetConsent, saveChoices } from "./consent";
19
+ import { trackEvent } from "./analytics";
20
+
21
+ export interface ConsentBannerOptions {
22
+ /** Class that hides the banner. Default: "hidden". */
23
+ hiddenClass?: string;
24
+ }
25
+
26
+ /** Wires a banner element for the given service ids. */
27
+ export function initConsentBanner(
28
+ banner: HTMLElement,
29
+ serviceIds: string[],
30
+ { hiddenClass = "hidden" }: ConsentBannerOptions = {}
31
+ ): void {
32
+ const boxes = () =>
33
+ Array.from(
34
+ banner.querySelectorAll<HTMLInputElement>('input[name="consent-service"]')
35
+ );
36
+
37
+ const show = () => {
38
+ const stored = getChoices() ?? {};
39
+ for (const box of boxes()) box.checked = stored[box.value] === true;
40
+ banner.classList.remove(hiddenClass);
41
+ };
42
+ const hide = () => banner.classList.add(hiddenClass);
43
+
44
+ const decide = (value: (id: string) => boolean) => {
45
+ saveChoices(Object.fromEntries(serviceIds.map((id) => [id, value(id)])));
46
+ hide();
47
+ };
48
+
49
+ // Ask only while at least one registered service has no decision yet.
50
+ const stored = getChoices();
51
+ const undecided = serviceIds.some((id) => !stored || !(id in stored));
52
+ if (undecided) show();
53
+
54
+ banner
55
+ .querySelector("[data-consent-accept-all]")
56
+ ?.addEventListener("click", () => decide(() => true));
57
+
58
+ banner
59
+ .querySelector("[data-consent-decline]")
60
+ ?.addEventListener("click", () => decide(() => false));
61
+
62
+ banner.querySelector("[data-consent-save]")?.addEventListener("click", () => {
63
+ const checked = new Set(boxes().filter((b) => b.checked).map((b) => b.value));
64
+ decide((id) => checked.has(id));
65
+ });
66
+
67
+ // "Cookie settings": clear decisions and ask again.
68
+ document.querySelectorAll("[data-consent-settings]").forEach((el) =>
69
+ el.addEventListener("click", () => {
70
+ resetConsent();
71
+ show();
72
+ })
73
+ );
74
+ }
75
+
76
+ /**
77
+ * Two-click gates for third-party embeds. The slot content sits in a
78
+ * <template> and enters the DOM only after consent — a hidden iframe would
79
+ * still load.
80
+ */
81
+ export function initConsentGates(root: ParentNode = document): void {
82
+ root.querySelectorAll<HTMLElement>("[data-consent-gate]").forEach((gate) => {
83
+ const service = gate.dataset.consentGate!;
84
+ const placeholder = gate.querySelector<HTMLElement>("[data-consent-placeholder]");
85
+ const template = gate.querySelector<HTMLTemplateElement>("[data-consent-content]");
86
+
87
+ const reveal = () => {
88
+ if (!template || gate.dataset.revealed === "true") return;
89
+ gate.dataset.revealed = "true";
90
+ placeholder?.remove();
91
+ gate.appendChild(template.content.cloneNode(true));
92
+ };
93
+
94
+ onConsentChange(service, (granted) => granted && reveal());
95
+
96
+ gate.querySelector("[data-consent-allow]")?.addEventListener("click", () => {
97
+ saveChoices({ [service]: true });
98
+ });
99
+ });
100
+ }
101
+
102
+ /** Email clicks are a conversion path worth measuring. A no-op without consent. */
103
+ export function trackMailtoClicks(): void {
104
+ document.addEventListener("click", (event) => {
105
+ const link = (event.target as Element | null)?.closest?.('a[href^="mailto:"]');
106
+ if (link) trackEvent("mailto_click", { page_path: location.pathname });
107
+ });
108
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Generic consent manager (GDPR opt-in).
3
+ *
4
+ * Services are registered by the site (its consent-services list); this store
5
+ * only keeps decisions and notifies consumers. It knows nothing about Google Analytics
6
+ * & co., which is what makes it reusable for any service (analytics, embeds,
7
+ * maps, …).
8
+ *
9
+ * Browser-only.
10
+ */
11
+
12
+ // localStorage is per origin, so no site prefix is needed. Bump the suffix if
13
+ // the stored shape ever changes — an old value must never be read as a new one.
14
+ const STORAGE_KEY = "consent-v1";
15
+ const EVENT_NAME = "consent-changed";
16
+
17
+ /** serviceId → granted? */
18
+ export type ConsentChoices = Record<string, boolean>;
19
+
20
+ /** All stored decisions — null when nothing has been decided yet. */
21
+ export function getChoices(): ConsentChoices | null {
22
+ try {
23
+ const raw = localStorage.getItem(STORAGE_KEY);
24
+ if (!raw) return null;
25
+ const parsed = JSON.parse(raw);
26
+ return typeof parsed === "object" && parsed !== null ? parsed : null;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ /** Decision for a single service — null means "not asked yet". */
33
+ export function getConsent(serviceId: string): boolean | null {
34
+ const choices = getChoices();
35
+ if (!choices || !(serviceId in choices)) return null;
36
+ return choices[serviceId] === true;
37
+ }
38
+
39
+ /** Stores decisions (merged with existing ones) and notifies every consumer. */
40
+ export function saveChoices(update: ConsentChoices): void {
41
+ const merged = { ...(getChoices() ?? {}), ...update };
42
+ try {
43
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(merged));
44
+ } catch {
45
+ // Storage unavailable (private mode, quota) — the decision then holds for
46
+ // this page view only. Better than throwing in the middle of a click.
47
+ }
48
+ window.dispatchEvent(new CustomEvent(EVENT_NAME, { detail: merged }));
49
+ }
50
+
51
+ /** Clears all decisions — used by the footer "cookie settings" link. */
52
+ export function resetConsent(): void {
53
+ try {
54
+ localStorage.removeItem(STORAGE_KEY);
55
+ } catch {
56
+ /* noop */
57
+ }
58
+ window.dispatchEvent(new CustomEvent(EVENT_NAME, { detail: {} }));
59
+ }
60
+
61
+ /**
62
+ * Subscribes to changes for one service. The callback fires immediately with
63
+ * the current state (if already decided) and on every change afterwards — so
64
+ * consumers never need to care whether consent was stored earlier or granted
65
+ * just now.
66
+ */
67
+ export function onConsentChange(
68
+ serviceId: string,
69
+ callback: (granted: boolean) => void
70
+ ): void {
71
+ const current = getConsent(serviceId);
72
+ if (current !== null) callback(current);
73
+
74
+ window.addEventListener(EVENT_NAME, (event) => {
75
+ const choices = (event as CustomEvent<ConsentChoices>).detail;
76
+ if (serviceId in choices) callback(choices[serviceId] === true);
77
+ });
78
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./consent";
2
+ export * from "./analytics";
3
+ export * from "./consent-ui";
4
+ export * from "./routes";
@@ -0,0 +1,76 @@
1
+ import type { APIContext } from "astro";
2
+ import {
3
+ buildLlmsTxt,
4
+ HOME_SLUG,
5
+ type BaseBlock,
6
+ type BlockRegistry,
7
+ type CmsRepository,
8
+ } from "@janmarkuslanger/astro-strapi-platform/core";
9
+
10
+ /**
11
+ * Route logic shared by every site. A site's route file stays a typed
12
+ * one-liner, so platform fixes arrive with a version bump; a site that needs
13
+ * something else replaces its file — nothing here is required.
14
+ *
15
+ * // src/pages/llms.txt.ts
16
+ * export const GET: APIRoute = llmsTxtRoute(cms, BLOCKS);
17
+ */
18
+
19
+ type Route = (context: Pick<APIContext, "site">) => Promise<Response>;
20
+
21
+ const text = (body: string) =>
22
+ new Response(body, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
23
+
24
+ const siteUrlOf = (site: URL | undefined) => String(site ?? "").replace(/\/$/, "");
25
+
26
+ /**
27
+ * `getStaticPaths` for `[...slug].astro`: every CMS page, the home page at
28
+ * "/" (see HOME_SLUG). New pages need no code.
29
+ */
30
+ export async function pagePaths<B extends BaseBlock, P extends object, G extends object>(
31
+ cms: CmsRepository<B, P, G>
32
+ ) {
33
+ const pages = await cms.listPages();
34
+ return pages.map((page) => ({
35
+ params: { slug: page.slug === HOME_SLUG ? undefined : page.slug },
36
+ props: { page },
37
+ }));
38
+ }
39
+
40
+ /** `llms.txt`, generated from the CMS at build time, never hand-maintained. */
41
+ export function llmsTxtRoute<B extends BaseBlock, P extends object, G extends object>(
42
+ cms: CmsRepository<B, P, G>,
43
+ blocks: BlockRegistry<B>
44
+ ): Route {
45
+ return async ({ site }) => {
46
+ const [global, pages] = await Promise.all([cms.getGlobal(), cms.listPages()]);
47
+ return text(buildLlmsTxt(global, pages, siteUrlOf(site), blocks));
48
+ };
49
+ }
50
+
51
+ export interface RobotsOptions {
52
+ /**
53
+ * AI crawlers (GPTBot, ClaudeBot, PerplexityBot, Google-Extended) are
54
+ * allowed by default: the site wants to be found by generative engines
55
+ * (see llms.txt). `false` disallows them.
56
+ */
57
+ aiCrawlers?: boolean;
58
+ }
59
+
60
+ const AI_CRAWLERS = ["GPTBot", "ClaudeBot", "PerplexityBot", "Google-Extended"];
61
+
62
+ export function robotsTxt(siteUrl: string, { aiCrawlers = true }: RobotsOptions = {}): string {
63
+ const rule = aiCrawlers ? "Allow: /" : "Disallow: /";
64
+ return [
65
+ "User-agent: *",
66
+ "Allow: /",
67
+ "",
68
+ ...AI_CRAWLERS.flatMap((agent) => [`User-agent: ${agent}`, rule, ""]),
69
+ `Sitemap: ${siteUrl}/sitemap-index.xml`,
70
+ "",
71
+ ].join("\n");
72
+ }
73
+
74
+ export function robotsTxtRoute(options: RobotsOptions = {}): Route {
75
+ return async ({ site }) => text(robotsTxt(siteUrlOf(site), options));
76
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.