@texuli/astro 0.0.0-stage → 0.1.0-next.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dailybot, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,101 @@
1
- # Temporary Holding Version
1
+ # @texuli/astro
2
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.
3
+ Astro 7 integration, token stylesheet, static content components, and a closed
4
+ content-block parser for Texuli sites. The package is private and source-first
5
+ while Texuli is in development; it is not published to npm.
6
+
7
+ ## Install and configure
8
+
9
+ Add the Astro Svelte integration when the site uses Svelte islands, then add
10
+ Texuli's integration to the Astro config:
11
+
12
+ ```ts
13
+ import svelte from '@astrojs/svelte';
14
+ import { defineConfig } from 'astro/config';
15
+ import { texuli } from '@texuli/astro';
16
+
17
+ export default defineConfig({
18
+ integrations: [svelte(), texuli({ namespace: 'docs' })],
19
+ });
20
+ ```
21
+
22
+ Import the Texuli stylesheet once from a site-owned global stylesheet:
23
+
24
+ ```css
25
+ @import '@texuli/astro/styles.css';
26
+ ```
27
+
28
+ Use the same `namespace` in Texuli's appearance writer and the integration so
29
+ the early bootstrap reads the cache entry that the site writes. The default
30
+ namespace is `site`. See [integration setup and CSP](docs/integration.md) and
31
+ [stylesheet contents](docs/styles.md).
32
+
33
+ ## Entry points
34
+
35
+ | Import | Purpose | Documentation |
36
+ | --- | --- | --- |
37
+ | `@texuli/astro` | `texuli`, `themeBootstrapScript`, `TexuliAstroOptions`, density/radius types, and `parseTexuliBlocks` | [Integration](docs/integration.md), [content parser](docs/content.md) |
38
+ | `@texuli/astro/integration` | Integration and theme-bootstrap API | [Integration](docs/integration.md) |
39
+ | `@texuli/astro/content` | Closed-schema content-block parser | [Content parser](docs/content.md) |
40
+ | `@texuli/astro/site` | Validated site model, page metadata and sitemap for Protoyard static sites | [Site model](docs/site.md) |
41
+ | `@texuli/astro/styles.css` | Token base, forced-colors rules, density/radius variants, and print rules | [Stylesheet](docs/styles.md) |
42
+ | `@texuli/astro/layouts/ContentLayout` | Semantic HTML document shell | [ContentLayout](docs/layouts-content-layout.md) |
43
+ | `@texuli/astro/sections/Hero` | Static hero section | [Hero](docs/sections-hero.md) |
44
+ | `@texuli/astro/sections/Section` | Static width-constrained section | [Section](docs/sections-section.md) |
45
+
46
+ The package peers on Astro Svelte integration and Svelte 5 for sites that use
47
+ Svelte islands. The layout and section components in this package render static
48
+ HTML and do not hydrate themselves.
49
+
50
+ ## Components and content
51
+
52
+ ```astro
53
+ ---
54
+ import '@texuli/astro/styles.css';
55
+ import ContentLayout from '@texuli/astro/layouts/ContentLayout';
56
+ import Hero from '@texuli/astro/sections/Hero';
57
+ import Section from '@texuli/astro/sections/Section';
58
+ ---
59
+
60
+ <ContentLayout title="Stories" description="A collection of stories.">
61
+ <Hero title="Stories" align="center">
62
+ <a href="https://example.com/stories">Browse stories</a>
63
+ </Hero>
64
+ <Section width="reading">
65
+ <p>Static Astro content.</p>
66
+ </Section>
67
+ </ContentLayout>
68
+ ```
69
+
70
+ For structured content, `parseTexuliBlocks(value)` validates unknown input
71
+ against `@texuli/blocks` and returns either the typed blocks or schema issues.
72
+ It does not render HTML or interpret unknown block types. See [content
73
+ parsing](docs/content.md). For whole-site definitions — pages, navigation,
74
+ canonical metadata and a sitemap — `defineSite` validates the export and
75
+ returns the frozen model the layouts consume. See the [site
76
+ model](docs/site.md). The Protoyard `site` export that fills this model is
77
+ planned on the Protoyard side.
78
+
79
+ ## Fixtures and checks
80
+
81
+ The packed consumer fixtures live under `tests/consumers/fixtures/`:
82
+
83
+ - `astro-plain` installs the packed Astro package, uses this integration and
84
+ stylesheet, renders the layout and sections, and includes a Svelte smoke
85
+ island.
86
+ - `astro-tailwind` checks the Astro/Tailwind consumer path with token CSS; it
87
+ does not install `@texuli/astro`.
88
+
89
+ Package behavior is covered by `packages/astro/tests/content.test.ts`,
90
+ `packages/astro/tests/integration.test.ts`, and
91
+ `packages/astro/tests/site.test.ts`. The consumer harness builds and
92
+ serves the packed fixtures in `tests/consumers/consumers.spec.ts`.
93
+
94
+ ```bash
95
+ pnpm vitest run packages/astro
96
+ pnpm --filter @texuli/astro typecheck
97
+ npx biome check packages/astro
98
+ pnpm verify:packages --filter packages/astro
99
+ ```
100
+
101
+ The package source and exports are listed in [`package.json`](package.json).
@@ -0,0 +1,11 @@
1
+ /// <reference path="../css-modules.d.ts" />
2
+ import { type Block, type FieldIssue } from '@texuli/blocks/core';
3
+ export type ParsedTexuliBlocks = {
4
+ success: true;
5
+ blocks: Block[];
6
+ } | {
7
+ success: false;
8
+ issues: FieldIssue[];
9
+ };
10
+ /** Parse a content entry's block field using the closed Texuli block schema. */
11
+ export declare function parseTexuliBlocks(value: unknown): ParsedTexuliBlocks;
@@ -0,0 +1,8 @@
1
+ import { safeParseBlocks } from '@texuli/blocks/core';
2
+ /** Parse a content entry's block field using the closed Texuli block schema. */
3
+ export function parseTexuliBlocks(value) {
4
+ const parsed = safeParseBlocks(value);
5
+ if (!parsed.success)
6
+ return { success: false, issues: parsed.issues };
7
+ return { success: true, blocks: parsed.data };
8
+ }
@@ -0,0 +1 @@
1
+ declare module '*.css';
@@ -0,0 +1,4 @@
1
+ /// <reference path="./css-modules.d.ts" />
2
+ export { type ParsedTexuliBlocks, parseTexuliBlocks } from './content/index.js';
3
+ export { type TexuliAstroOptions, type TexuliDensityVariant, type TexuliRadiusVariant, texuli, themeBootstrapScript, } from './integration/index.js';
4
+ export { defineSite, SITE_KIND, sitePage, sitePageMeta, siteSitemap, type TexuliSite, type TexuliSiteInput, type TexuliSiteNavEntry, type TexuliSitePage, type TexuliSitePageInput, } from './site/index.js';
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { parseTexuliBlocks } from "./content/index.js";
2
+ export { texuli, themeBootstrapScript, } from "./integration/index.js";
3
+ export { defineSite, SITE_KIND, sitePage, sitePageMeta, siteSitemap, } from "./site/index.js";
@@ -0,0 +1,25 @@
1
+ /// <reference path="../css-modules.d.ts" />
2
+ import type { ColorMode } from '@texuli/tokens/modes';
3
+ import type { AstroIntegration } from 'astro';
4
+ export type TexuliDensityVariant = 'compact' | 'comfortable' | 'spacious';
5
+ export type TexuliRadiusVariant = 'compact' | 'comfortable' | 'rounded';
6
+ export interface TexuliAstroOptions {
7
+ /** Namespace shared with the appearance switcher that writes the token cache. */
8
+ namespace?: string;
9
+ /** Mode used without a cached appearance. Defaults to the active system preference. */
10
+ fallbackMode?: ColorMode;
11
+ /** Select a token density variant on the document root before styles paint. */
12
+ density?: TexuliDensityVariant;
13
+ /** Select a token radius scale on the document root before styles paint. */
14
+ radius?: TexuliRadiusVariant;
15
+ /** Use Astro's inline-script injection, or render the exported body with a host nonce. */
16
+ bootstrap?: 'injected' | 'manual';
17
+ }
18
+ /**
19
+ * Return the deterministic inline theme bootstrap body. It restores the cached
20
+ * appearance before paint, follows system preference changes when uncached,
21
+ * and applies same-origin storage updates without a reload or network request.
22
+ */
23
+ export declare function themeBootstrapScript(options?: TexuliAstroOptions): string;
24
+ /** Install the deterministic, CSP-compatible early theme bootstrap for Astro. */
25
+ export declare function texuli(options?: TexuliAstroOptions): AstroIntegration;
@@ -0,0 +1,72 @@
1
+ import { COLOR_MODE_NAMES } from '@texuli/tokens/modes';
2
+ import { appearanceCacheKey, noFlashScript } from '@texuli/tokens/no-flash';
3
+ const DENSITIES = ['compact', 'comfortable', 'spacious'];
4
+ const RADII = ['compact', 'comfortable', 'rounded'];
5
+ function optionJson(value) {
6
+ return JSON.stringify(value)
7
+ .replace(/</g, '\\u003c')
8
+ .replace(/>/g, '\\u003e')
9
+ .replace(/&/g, '\\u0026')
10
+ .replace(/\u2028/g, '\\u2028')
11
+ .replace(/\u2029/g, '\\u2029');
12
+ }
13
+ function validateOptions(options) {
14
+ const namespace = options.namespace ?? 'site';
15
+ const fallbackMode = options.fallbackMode ?? 'system';
16
+ const bootstrap = options.bootstrap ?? 'injected';
17
+ // Keep validation aligned with the token cache's namespaced contract.
18
+ appearanceCacheKey(namespace);
19
+ noFlashScript(namespace, fallbackMode);
20
+ if (!COLOR_MODE_NAMES.includes(fallbackMode)) {
21
+ throw new TypeError('Fallback mode must be a supported Texuli color mode.');
22
+ }
23
+ if (options.density !== undefined && !DENSITIES.includes(options.density)) {
24
+ throw new TypeError('Density must be compact, comfortable, or spacious.');
25
+ }
26
+ if (options.radius !== undefined && !RADII.includes(options.radius)) {
27
+ throw new TypeError('Radius must be compact, comfortable, or rounded.');
28
+ }
29
+ if (bootstrap !== 'injected' && bootstrap !== 'manual') {
30
+ throw new TypeError('Bootstrap must be injected or manual.');
31
+ }
32
+ return { namespace, fallbackMode, bootstrap, density: options.density, radius: options.radius };
33
+ }
34
+ /**
35
+ * Return the deterministic inline theme bootstrap body. It restores the cached
36
+ * appearance before paint, follows system preference changes when uncached,
37
+ * and applies same-origin storage updates without a reload or network request.
38
+ */
39
+ export function themeBootstrapScript(options = {}) {
40
+ const { namespace, fallbackMode, density, radius } = validateOptions(options);
41
+ const key = optionJson(appearanceCacheKey(namespace));
42
+ const densityJson = density ? optionJson(density) : 'null';
43
+ const radiusJson = radius ? optionJson(radius) : 'null';
44
+ const initial = noFlashScript(namespace, fallbackMode);
45
+ const sync = `(function(){var d=document.documentElement,k=${key},f=${optionJson(fallbackMode)},dv=${densityJson},rv=${radiusJson},applied=[];` +
46
+ `function validValue(v){return typeof v==='string'&&v.length<=2048&&!/[;{}<>]/.test(v)&&!/[\\x00-\\x1f\\x7f]/.test(v)&&!/(?:url\\s*\\(|expression\\s*\\(|var\\s*\\(|javascript:|@import)/i.test(v);}` +
47
+ `function read(raw){try{var c=JSON.parse(raw||'null');if(!c||c.v!==1||typeof c.themeId!=='string'||!/^[a-z][a-z0-9-]{0,79}$/.test(c.themeId)||(c.mode!=='light'&&c.mode!=='dark'&&c.mode!=='high-contrast')||!c.vars||typeof c.vars!=='object'||Array.isArray(c.vars))return null;var vars={};for(var n in c.vars){if(Object.prototype.hasOwnProperty.call(c.vars,n)&&/^--tx-[a-z0-9-]+$/.test(n)&&validValue(c.vars[n]))vars[n]=c.vars[n];}return {themeId:c.themeId,mode:c.mode,vars:vars,reducedMotion:c.reducedMotion,surface:c.surface,components:c.components||{}};}catch(e){return null;}}` +
48
+ `function modeFromSystem(){try{if(matchMedia('(prefers-contrast: more)').matches)return 'high-contrast';return matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}catch(e){return 'light';}}` +
49
+ `function setMode(m){d.dataset.txMode=m;d.classList.toggle('dark',m==='dark');d.classList.toggle('light',m==='light');d.style.colorScheme=m==='high-contrast'?'light dark':m;}` +
50
+ `function setVariant(){if(dv)d.dataset.txDensity=dv;else delete d.dataset.txDensity;if(rv)d.dataset.txRadius=rv;else delete d.dataset.txRadius;}` +
51
+ `function apply(c){for(var i=0;i<applied.length;i++)d.style.removeProperty(applied[i]);applied=[];if(!c){delete d.dataset.txTheme;delete d.dataset.txMotion;delete d.dataset.txSurface;delete d.dataset.txButtonStyle;delete d.dataset.txCardStyle;delete d.dataset.txFocusRingStyle;setMode(f==='system'?modeFromSystem():f);return;}d.dataset.txTheme=c.themeId;for(var n in c.vars){var v=c.vars[n];if(dv&&n==='--tx-density')v=String(${density === 'compact' ? '0.875' : density === 'spacious' ? '1.125' : '1'});if(rv&&n==='--tx-radius-scale')v=String(${radius === 'compact' ? '0.75' : radius === 'rounded' ? '1.25' : '1'});if(rv&&(n==='--tx-radius'||/^--tx-radius-step-[0-9]+$/.test(n)))v='calc('+v+' * '+(${radius === 'compact' ? '0.75' : radius === 'rounded' ? '1.25' : '1'})+')';d.style.setProperty(n,v);applied.push(n);}if(dv&&!c.vars['--tx-density']){d.style.setProperty('--tx-density',${density === 'compact' ? "'0.875'" : density === 'spacious' ? "'1.125'" : "'1'"});applied.push('--tx-density');}if(rv&&!c.vars['--tx-radius-scale']){d.style.setProperty('--tx-radius-scale',${radius === 'compact' ? "'0.75'" : radius === 'rounded' ? "'1.25'" : "'1'"});applied.push('--tx-radius-scale');}if(c.reducedMotion===true||c.reducedMotion===false)d.dataset.txMotion=c.reducedMotion?'reduce':'full';else delete d.dataset.txMotion;if(c.surface==='glass'||c.surface==='solid')d.dataset.txSurface=c.surface;else delete d.dataset.txSurface;var p=c.components;if(p.button==='pill'||p.button==='square')d.dataset.txButtonStyle=p.button;else delete d.dataset.txButtonStyle;if(p.card==='glass'||p.card==='bordered')d.dataset.txCardStyle=p.card;else delete d.dataset.txCardStyle;if(p.focusRing==='ring'||p.focusRing==='outline'||p.focusRing==='glow'||p.focusRing==='inset')d.dataset.txFocusRingStyle=p.focusRing;else delete d.dataset.txFocusRingStyle;setMode(c.mode);}` +
52
+ `function current(){try{return read(localStorage.getItem(k));}catch(e){return null;}}` +
53
+ `function syncSystem(){if(f==='system'&&!current())setMode(modeFromSystem());}` +
54
+ `var boot=current();if(boot)for(var name in boot.vars)if(/^--tx-[a-z0-9-]+$/.test(name)&&validValue(boot.vars[name]))applied.push(name);setVariant();apply(boot);if(typeof addEventListener==='function')addEventListener('storage',function(e){if(e.key===k)apply(read(e.newValue));});` +
55
+ `if(f==='system'&&typeof matchMedia==='function'){var q1=matchMedia('(prefers-color-scheme: dark)'),q2=matchMedia('(prefers-contrast: more)');if(q1.addEventListener)q1.addEventListener('change',syncSystem);else if(q1.addListener)q1.addListener(syncSystem);if(q2.addEventListener)q2.addEventListener('change',syncSystem);else if(q2.addListener)q2.addListener(syncSystem);}` +
56
+ `})();`;
57
+ return `${initial}${sync}`;
58
+ }
59
+ /** Install the deterministic, CSP-compatible early theme bootstrap for Astro. */
60
+ export function texuli(options = {}) {
61
+ const normalized = validateOptions(options);
62
+ const script = themeBootstrapScript(normalized);
63
+ return {
64
+ name: '@texuli/astro',
65
+ hooks: {
66
+ 'astro:config:setup': ({ injectScript }) => {
67
+ if (normalized.bootstrap === 'injected')
68
+ injectScript('head-inline', script);
69
+ },
70
+ },
71
+ };
72
+ }
@@ -0,0 +1,68 @@
1
+ ---
2
+ import { safeHref } from '@texuli/core/url-policy';
3
+ import type { TexuliDensityVariant, TexuliRadiusVariant } from '../integration/index.ts';
4
+
5
+ interface Props {
6
+ title: string;
7
+ description?: string;
8
+ lang?: string;
9
+ theme?: string;
10
+ canonical?: string;
11
+ /** Static token density variant; matches the integration's script-driven attribute. */
12
+ density?: TexuliDensityVariant;
13
+ /** Static token radius scale; matches the integration's script-driven attribute. */
14
+ radius?: TexuliRadiusVariant;
15
+ ogTitle?: string;
16
+ ogDescription?: string;
17
+ /** Open Graph image; rendered only when it passes the developer URL policy. */
18
+ ogImage?: string;
19
+ /** Asks crawlers to skip the page; use for drafts and staging mirrors. */
20
+ noindex?: boolean;
21
+ }
22
+
23
+ const DENSITIES: readonly TexuliDensityVariant[] = ['compact', 'comfortable', 'spacious'];
24
+ const RADII: readonly TexuliRadiusVariant[] = ['compact', 'comfortable', 'rounded'];
25
+
26
+ const {
27
+ title,
28
+ description,
29
+ lang = 'en',
30
+ theme = 'default',
31
+ canonical,
32
+ density,
33
+ radius,
34
+ ogTitle,
35
+ ogDescription,
36
+ ogImage,
37
+ noindex = false,
38
+ } = Astro.props;
39
+
40
+ if (density !== undefined && !DENSITIES.includes(density)) {
41
+ throw new TypeError('Density must be compact, comfortable, or spacious.');
42
+ }
43
+ if (radius !== undefined && !RADII.includes(radius)) {
44
+ throw new TypeError('Radius must be compact, comfortable, or rounded.');
45
+ }
46
+
47
+ const canonicalHref = safeHref(canonical, 'developer');
48
+ const ogImageHref = safeHref(ogImage, 'developer');
49
+ ---
50
+
51
+ <!doctype html>
52
+ <html lang={lang} data-tx-theme={theme} data-tx-density={density} data-tx-radius={radius}>
53
+ <head>
54
+ <meta charset="utf-8" />
55
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
56
+ <title>{title}</title>
57
+ {description && <meta name="description" content={description} />}
58
+ {canonicalHref && <link rel="canonical" href={canonicalHref} />}
59
+ {ogTitle && <meta property="og:title" content={ogTitle} />}
60
+ {ogDescription && <meta property="og:description" content={ogDescription} />}
61
+ {ogImageHref && <meta property="og:image" content={ogImageHref} />}
62
+ {noindex && <meta name="robots" content="noindex" />}
63
+ <slot name="head" />
64
+ </head>
65
+ <body>
66
+ <slot />
67
+ </body>
68
+ </html>
@@ -0,0 +1,3 @@
1
+ import type { AstroComponentFactory } from 'astro/runtime/server/index.js';
2
+ declare const _default: AstroComponentFactory;
3
+ export default _default;
@@ -0,0 +1,4 @@
1
+ // Astro's compiler resolves this source component when consuming the packed wrapper.
2
+ // @ts-expect-error Astro files are handled by Astro rather than TypeScript's resolver.
3
+ import ContentLayoutSource from './ContentLayout.astro';
4
+ export default ContentLayoutSource;
@@ -0,0 +1,34 @@
1
+ ---
2
+ interface Props {
3
+ eyebrow?: string;
4
+ title: string;
5
+ description?: string;
6
+ align?: 'start' | 'center';
7
+ /** `lede` (default) for page tops; `compact` for sub-pages and nested views. */
8
+ size?: 'lede' | 'compact';
9
+ }
10
+
11
+ const { eyebrow, title, description, align = 'start', size = 'lede' } = Astro.props;
12
+ ---
13
+
14
+ <header
15
+ class:list={['tx-astro-hero', `tx-astro-hero--${align}`, `tx-astro-hero--${size}`]}
16
+ >
17
+ {eyebrow && <p class="tx-astro-hero__eyebrow">{eyebrow}</p>}
18
+ <h1>{title}</h1>
19
+ {description && <p class="tx-astro-hero__description">{description}</p>}
20
+ <div class="tx-astro-hero__actions"><slot /></div>
21
+ </header>
22
+
23
+ <style>
24
+ .tx-astro-hero { display: grid; gap: 1rem; padding-block: clamp(3rem, 9vw, 7rem); }
25
+ .tx-astro-hero--center { text-align: center; justify-items: center; }
26
+ .tx-astro-hero__eyebrow { color: var(--tx-color-accent-foreground, inherit); font-weight: 600; margin: 0; }
27
+ h1 { font-size: clamp(2.5rem, 7vw, 5rem); line-height: 1.05; max-inline-size: 16ch; margin: 0; }
28
+ .tx-astro-hero__description { color: var(--tx-color-muted-foreground, currentColor); font-size: 1.125rem; line-height: 1.6; max-inline-size: 62ch; margin: 0; }
29
+ .tx-astro-hero__actions { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-block-start: 0.5rem; }
30
+
31
+ .tx-astro-hero--compact { gap: 0.75rem; padding-block: clamp(1.5rem, 5vw, 3rem); }
32
+ .tx-astro-hero--compact h1 { font-size: clamp(1.75rem, 4.5vw, 2.75rem); }
33
+ .tx-astro-hero--compact .tx-astro-hero__description { font-size: 1rem; }
34
+ </style>
@@ -0,0 +1,3 @@
1
+ import type { AstroComponentFactory } from 'astro/runtime/server/index.js';
2
+ declare const _default: AstroComponentFactory;
3
+ export default _default;
@@ -0,0 +1,4 @@
1
+ // Astro's compiler resolves this source component when consuming the packed wrapper.
2
+ // @ts-expect-error Astro files are handled by Astro rather than TypeScript's resolver.
3
+ import HeroSource from './Hero.astro';
4
+ export default HeroSource;
@@ -0,0 +1,42 @@
1
+ ---
2
+ interface Props {
3
+ id?: string;
4
+ label?: string;
5
+ width?: 'reading' | 'wide' | 'full';
6
+ /** `surface` paints the theme's surface color behind the section. */
7
+ tone?: 'default' | 'surface';
8
+ /** `none` removes the responsive inline padding, for nesting sections. */
9
+ padding?: 'normal' | 'none';
10
+ }
11
+
12
+ const { id, label, width = 'reading', tone = 'default', padding = 'normal' } = Astro.props;
13
+ ---
14
+
15
+ <section
16
+ id={id}
17
+ aria-label={label}
18
+ class:list={[
19
+ 'tx-astro-section',
20
+ `tx-astro-section--${width}`,
21
+ `tx-astro-section--${tone}`,
22
+ `tx-astro-section--padding-${padding}`,
23
+ ]}
24
+ >
25
+ <slot />
26
+ </section>
27
+
28
+ <style>
29
+ .tx-astro-section {
30
+ box-sizing: border-box;
31
+ margin-inline: auto;
32
+ padding-inline: clamp(1rem, 4vw, 2rem);
33
+ inline-size: 100%;
34
+ }
35
+
36
+ .tx-astro-section--reading { max-inline-size: 72rem; }
37
+ .tx-astro-section--wide { max-inline-size: 88rem; }
38
+ .tx-astro-section--full { max-inline-size: none; }
39
+
40
+ .tx-astro-section--surface { background: var(--tx-surface); }
41
+ .tx-astro-section--padding-none { padding-inline: 0; }
42
+ </style>
@@ -0,0 +1,3 @@
1
+ import type { AstroComponentFactory } from 'astro/runtime/server/index.js';
2
+ declare const _default: AstroComponentFactory;
3
+ export default _default;
@@ -0,0 +1,4 @@
1
+ // Astro's compiler resolves this source component when consuming the packed wrapper.
2
+ // @ts-expect-error Astro files are handled by Astro rather than TypeScript's resolver.
3
+ import SectionSource from './Section.astro';
4
+ export default SectionSource;
@@ -0,0 +1,86 @@
1
+ /// <reference path="../css-modules.d.ts" />
2
+ import { type Block } from '@texuli/blocks/core';
3
+ /**
4
+ * The site kind identifier that Protoyard static-site exports declare. A
5
+ * `defineSite` input may carry it to fail fast when a future revision of the
6
+ * contract changes shape; today only this revision exists.
7
+ */
8
+ export declare const SITE_KIND = "texuli.site/v1";
9
+ export interface TexuliSitePageInput {
10
+ /** kebab-case path segment; the home page's slug is ignored in favor of `home`. */
11
+ slug: string;
12
+ title: string;
13
+ description?: string;
14
+ /** ISO calendar date (YYYY-MM-DD) emitted as the sitemap `lastmod` when set. */
15
+ updated?: string;
16
+ /** Navigation label; a page with one joins `site.navigation` in declared order. */
17
+ nav?: string;
18
+ /** Marks the site's home page; exactly one page per site sets it. */
19
+ home?: boolean;
20
+ /** Page content validated against the closed Texuli block schema. */
21
+ blocks?: unknown;
22
+ }
23
+ export interface TexuliSiteInput {
24
+ /** Contract discriminator; when present it must be `texuli.site/v1`. */
25
+ kind?: string;
26
+ name: string;
27
+ /** Deployed origin plus optional base path, without a trailing slash. */
28
+ url: string;
29
+ /** BCP 47-ish document language; defaults to `en`. */
30
+ lang?: string;
31
+ pages: TexuliSitePageInput[];
32
+ }
33
+ export interface TexuliSitePage {
34
+ slug: string;
35
+ /** Root path for the home page, otherwise `/${slug}`. */
36
+ path: string;
37
+ title: string;
38
+ description?: string;
39
+ updated?: string;
40
+ nav?: string;
41
+ home: boolean;
42
+ /** Absolute same-origin URL built from the site base. */
43
+ href: string;
44
+ blocks: Block[];
45
+ }
46
+ export interface TexuliSiteNavEntry {
47
+ slug: string;
48
+ label: string;
49
+ href: string;
50
+ }
51
+ export interface TexuliSite {
52
+ name: string;
53
+ url: string;
54
+ lang: string;
55
+ pages: TexuliSitePage[];
56
+ navigation: TexuliSiteNavEntry[];
57
+ }
58
+ /**
59
+ * Validate a static-site definition and return the frozen, normalized model
60
+ * that Protoyard `site` exports hand to the layouts, navigation and sitemap
61
+ * helpers. Throws a `TypeError` naming the offending field when any part of
62
+ * the closed schema fails: one home page, unique lowercase kebab-case slugs,
63
+ * an http(s) base URL without query or fragment, and page content that
64
+ * passes the closed Texuli block schema. Blocks are validated but never
65
+ * rendered here; painting them stays with the host's sink-free renderer.
66
+ */
67
+ export declare function defineSite(input: TexuliSiteInput): TexuliSite;
68
+ /** Return the site's page for a slug, or `undefined` when absent. */
69
+ export declare function sitePage(site: TexuliSite, slug: string): TexuliSitePage | undefined;
70
+ /**
71
+ * Metadata for a page as ContentLayout props: the home page titles as the
72
+ * site name, others as `page title — site name`; the canonical is the
73
+ * page's absolute URL.
74
+ */
75
+ export declare function sitePageMeta(site: TexuliSite, slug: string): {
76
+ title: string;
77
+ description?: string;
78
+ canonical: string;
79
+ lang: string;
80
+ } | undefined;
81
+ /**
82
+ * Deterministic sitemap XML for the site: one entry per page in declared
83
+ * order, with `lastmod` only for pages that declare an `updated` date. The
84
+ * result is data for a `sitemap.xml` endpoint, never HTML.
85
+ */
86
+ export declare function siteSitemap(site: TexuliSite): string;
@@ -0,0 +1,182 @@
1
+ import { safeParseBlocks } from '@texuli/blocks/core';
2
+ import { safeHref } from '@texuli/core/url-policy';
3
+ /**
4
+ * The site kind identifier that Protoyard static-site exports declare. A
5
+ * `defineSite` input may carry it to fail fast when a future revision of the
6
+ * contract changes shape; today only this revision exists.
7
+ */
8
+ export const SITE_KIND = 'texuli.site/v1';
9
+ const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
10
+ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
11
+ const LANG_PATTERN = /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/;
12
+ function fail(message) {
13
+ throw new TypeError(`Invalid Texuli site: ${message}`);
14
+ }
15
+ function text(value, field, max) {
16
+ if (typeof value !== 'string' || value.trim() === '')
17
+ fail(`${field} must be a non-empty string.`);
18
+ const trimmed = value.trim();
19
+ if (trimmed.length > max)
20
+ fail(`${field} must be at most ${max} characters.`);
21
+ return trimmed;
22
+ }
23
+ function optionalText(value, field, max) {
24
+ if (value === undefined)
25
+ return undefined;
26
+ return text(value, field, max);
27
+ }
28
+ function xmlEscape(value) {
29
+ return value
30
+ .replace(/&/g, '&amp;')
31
+ .replace(/</g, '&lt;')
32
+ .replace(/>/g, '&gt;')
33
+ .replace(/"/g, '&quot;')
34
+ .replace(/'/g, '&apos;');
35
+ }
36
+ function normalizeBaseUrl(value) {
37
+ const raw = text(value, 'url', 2048);
38
+ // Site definitions are developer-authored, so the developer tier applies;
39
+ // the scheme check below still narrows to deployable http(s) origins.
40
+ const safe = safeHref(raw, 'developer');
41
+ if (safe === undefined)
42
+ fail('url must be a safe http(s) URL.');
43
+ let parsed;
44
+ try {
45
+ parsed = new URL(safe);
46
+ }
47
+ catch {
48
+ fail('url must be an absolute http(s) URL.');
49
+ }
50
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
51
+ fail('url must use the https or http scheme.');
52
+ }
53
+ if (parsed.search !== '')
54
+ fail('url must not carry a query string.');
55
+ if (parsed.hash !== '')
56
+ fail('url must not carry a fragment.');
57
+ const base = `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`;
58
+ return base;
59
+ }
60
+ function joinHref(base, path) {
61
+ return path === '/' ? `${base}/` : `${base}${path}`;
62
+ }
63
+ function validateBlocks(slug, value) {
64
+ const parsed = safeParseBlocks(value);
65
+ if (parsed.success)
66
+ return parsed.data;
67
+ const summarize = (issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`;
68
+ const listed = parsed.issues.slice(0, 8).map(summarize).join('; ');
69
+ const more = parsed.issues.length > 8 ? ` (+${parsed.issues.length - 8} more)` : '';
70
+ fail(`page "${slug}" has invalid blocks — ${listed}${more}`);
71
+ }
72
+ function validatePage(input, base, seenSlugs) {
73
+ if (typeof input !== 'object' || input === null)
74
+ fail('every page must be an object.');
75
+ const slug = text(input.slug, 'page slug', 100);
76
+ if (!SLUG_PATTERN.test(slug)) {
77
+ fail(`page slug "${slug}" must be lowercase kebab-case (a-z, 0-9, dashes).`);
78
+ }
79
+ if (seenSlugs.has(slug))
80
+ fail(`page slug "${slug}" is duplicated.`);
81
+ seenSlugs.add(slug);
82
+ const title = text(input.title, `page "${slug}" title`, 200);
83
+ const description = optionalText(input.description, `page "${slug}" description`, 300);
84
+ const nav = optionalText(input.nav, `page "${slug}" nav label`, 120);
85
+ let updated;
86
+ if (input.updated !== undefined) {
87
+ updated = text(input.updated, `page "${slug}" updated`, 10);
88
+ if (!DATE_PATTERN.test(updated) || Number.isNaN(Date.parse(`${updated}T00:00:00Z`))) {
89
+ fail(`page "${slug}" updated must be an ISO calendar date (YYYY-MM-DD).`);
90
+ }
91
+ }
92
+ const home = input.home === true;
93
+ const path = home ? '/' : `/${slug}`;
94
+ return Object.freeze({
95
+ slug,
96
+ path,
97
+ title,
98
+ ...(description === undefined ? {} : { description }),
99
+ ...(updated === undefined ? {} : { updated }),
100
+ ...(nav === undefined ? {} : { nav }),
101
+ home,
102
+ href: joinHref(base, path),
103
+ blocks: validateBlocks(slug, input.blocks ?? []),
104
+ });
105
+ }
106
+ function deepFreezeSite(site) {
107
+ for (const page of site.pages)
108
+ Object.freeze(page);
109
+ Object.freeze(site.pages);
110
+ Object.freeze(site.navigation);
111
+ return Object.freeze(site);
112
+ }
113
+ /**
114
+ * Validate a static-site definition and return the frozen, normalized model
115
+ * that Protoyard `site` exports hand to the layouts, navigation and sitemap
116
+ * helpers. Throws a `TypeError` naming the offending field when any part of
117
+ * the closed schema fails: one home page, unique lowercase kebab-case slugs,
118
+ * an http(s) base URL without query or fragment, and page content that
119
+ * passes the closed Texuli block schema. Blocks are validated but never
120
+ * rendered here; painting them stays with the host's sink-free renderer.
121
+ */
122
+ export function defineSite(input) {
123
+ if (typeof input !== 'object' || input === null)
124
+ fail('the site definition must be an object.');
125
+ if (input.kind !== undefined && input.kind !== SITE_KIND) {
126
+ fail(`kind must be "${SITE_KIND}" when present.`);
127
+ }
128
+ const name = text(input.name, 'name', 120);
129
+ const base = normalizeBaseUrl(input.url);
130
+ const lang = input.lang === undefined ? 'en' : text(input.lang, 'lang', 35);
131
+ if (!LANG_PATTERN.test(lang))
132
+ fail('lang must be a base language tag with optional subtags.');
133
+ if (!Array.isArray(input.pages))
134
+ fail('pages must be an array.');
135
+ if (input.pages.length === 0)
136
+ fail('a site needs at least one page.');
137
+ const seenSlugs = new Set();
138
+ const pages = input.pages.map((page) => validatePage(page, base, seenSlugs));
139
+ const homes = pages.filter((page) => page.home);
140
+ if (homes.length === 0)
141
+ fail('exactly one page must set home: true.');
142
+ if (homes.length > 1)
143
+ fail('only one page may set home: true.');
144
+ const navigation = pages
145
+ .filter((page) => page.nav !== undefined)
146
+ .map((page) => Object.freeze({ slug: page.slug, label: page.nav, href: page.href }));
147
+ return deepFreezeSite({ name, url: base, lang, pages, navigation });
148
+ }
149
+ /** Return the site's page for a slug, or `undefined` when absent. */
150
+ export function sitePage(site, slug) {
151
+ return site.pages.find((page) => page.slug === slug);
152
+ }
153
+ /**
154
+ * Metadata for a page as ContentLayout props: the home page titles as the
155
+ * site name, others as `page title — site name`; the canonical is the
156
+ * page's absolute URL.
157
+ */
158
+ export function sitePageMeta(site, slug) {
159
+ const page = sitePage(site, slug);
160
+ if (page === undefined)
161
+ return undefined;
162
+ return {
163
+ title: page.home ? site.name : `${page.title} — ${site.name}`,
164
+ ...(page.description === undefined ? {} : { description: page.description }),
165
+ canonical: page.href,
166
+ lang: site.lang,
167
+ };
168
+ }
169
+ /**
170
+ * Deterministic sitemap XML for the site: one entry per page in declared
171
+ * order, with `lastmod` only for pages that declare an `updated` date. The
172
+ * result is data for a `sitemap.xml` endpoint, never HTML.
173
+ */
174
+ export function siteSitemap(site) {
175
+ const entries = site.pages
176
+ .map((page) => {
177
+ const lastmod = page.updated === undefined ? '' : ` <lastmod>${page.updated}</lastmod>\n`;
178
+ return ` <url>\n <loc>${xmlEscape(page.href)}</loc>\n${lastmod} </url>`;
179
+ })
180
+ .join('\n');
181
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries}\n</urlset>\n`;
182
+ }
@@ -0,0 +1,36 @@
1
+ @import "@texuli/tokens/base.css";
2
+ @import "@texuli/tokens/forced-colors.css";
3
+
4
+ /* Integration-selected variants override per-theme density and shape scales. */
5
+ :root[data-tx-density="compact"] {
6
+ --tx-density: 0.875;
7
+ }
8
+
9
+ :root[data-tx-density="comfortable"] {
10
+ --tx-density: 1;
11
+ }
12
+
13
+ :root[data-tx-density="spacious"] {
14
+ --tx-density: 1.125;
15
+ }
16
+
17
+ :root[data-tx-radius="compact"] {
18
+ --tx-radius-scale: 0.75;
19
+ }
20
+
21
+ :root[data-tx-radius="comfortable"] {
22
+ --tx-radius-scale: 1;
23
+ }
24
+
25
+ :root[data-tx-radius="rounded"] {
26
+ --tx-radius-scale: 1.25;
27
+ }
28
+
29
+ @media print {
30
+ :root {
31
+ color-scheme: light;
32
+ --tx-motion: 0;
33
+ --tx-shadow: transparent;
34
+ print-color-adjust: economy;
35
+ }
36
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "@texuli/astro",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-next.0",
4
+ "description": "Astro integration, content layouts and Svelte islands for Texuli sites.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": [
8
+ "**/*.css"
9
+ ],
10
+ "exports": {
11
+ ".": "./dist/index.js",
12
+ "./content": "./dist/content/index.js",
13
+ "./integration": "./dist/integration/index.js",
14
+ "./site": "./dist/site/index.js",
15
+ "./styles.css": {
16
+ "types": "./dist/styles.css.d.ts",
17
+ "default": "./dist/styles.css"
18
+ },
19
+ "./layouts/ContentLayout": "./dist/layouts/ContentLayout.js",
20
+ "./sections/Hero": "./dist/sections/Hero.js",
21
+ "./sections/Section": "./dist/sections/Section.js"
22
+ },
23
+ "files": [
24
+ "dist"
25
+ ],
26
+ "dependencies": {
27
+ "@texuli/blocks": "0.1.0-next.0",
28
+ "@texuli/core": "0.1.0-next.0",
29
+ "@texuli/tokens": "0.1.0-next.0",
30
+ "astro": "^7.4.0"
31
+ },
32
+ "peerDependencies": {
33
+ "@astrojs/svelte": "^9.1.0",
34
+ "svelte": "^5.57.2"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public",
38
+ "tag": "next"
39
+ }
40
+ }