WIP: [web] shared template contracts and preparation #121

Closed
eleith-gitea-agent Bot wants to merge 1 commits from feat/template-preparation into reference/template-runtime-proof
pull from: feat/template-preparation
13 changed files with 447 additions and 4 deletions

No files matched your search

+11
View File
@@ -6,6 +6,10 @@ on:
- main
paths:
- "packages/web/templates/**"
- "packages/web/pieces/**"
- "packages/web/db/**"
- "packages/web/config/**"
- "apps/core/**"
- "pnpm-workspace.yaml"
- "pnpm-lock.yaml"
- ".github/workflows/web-templates.yml"
@@ -14,6 +18,10 @@ on:
- main
paths:
- "packages/web/templates/**"
- "packages/web/pieces/**"
- "packages/web/db/**"
- "packages/web/config/**"
- "apps/core/**"
- "pnpm-workspace.yaml"
- "pnpm-lock.yaml"
- ".github/workflows/web-templates.yml"
@@ -37,6 +45,9 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build contract type dependencies
run: pnpm --filter @luzzle/core --filter @luzzle/web.config --filter @luzzle/web.db --filter @luzzle/web.pieces build
- name: Build runtime
run: pnpm --filter @luzzle/web.templates build
+33
View File
@@ -10,6 +10,7 @@ validation.
After installing workspace dependencies, from the repository root:
```sh
pnpm --filter @luzzle/core --filter @luzzle/web.config --filter @luzzle/web.db --filter @luzzle/web.pieces build
pnpm --filter @luzzle/web.templates build
pnpm --filter @luzzle/web.templates lint
pnpm --filter @luzzle/web.templates test
@@ -35,6 +36,38 @@ Build output is cleaned before each release build. Tests and reusable helpers
create artifacts only in caller-selected directories; package tests use
`node_modules/.cache/` and clean their scratch trees. Nothing uses `/tmp`.
## Shared role contracts
These internal exports contain types only, not executable modules:
| Export | Types |
| -------------------- | ------------------------------------------------------------ |
| `./pieces/props` | Piece page, icon, Open Graph props; icon component inputs |
| `./site/props` | Root, feed, and error page props |
| `./components/props` | Navigation/component inputs, resolved sizes, page components |
| `./helpers` | Bound piece helpers and the common helper interface |
They keep `data`, `options`, `helpers`, and approved `components` separate. Piece
records retain the canonical `PublicWebPiece` shape, including supplied assets
and metadata. Type names are arbitrary strings, not a built-in allowlist. A type
parameter can narrow that string without changing the record's public shape.
These are shared consumer contracts, not a standalone author SDK or generated
schema-derived editor declarations.
Component-call icon inputs permit omitted height/lazy/active values. Authored
icon options require resolved height and explicit defaults. Pages have normalized
tags; icons/Open Graph props do not expose page components or unrelated props.
The low-level renderer below is not a role-specific input producer or validator.
Declaration emission runs after Rollup, which cleans `dist`. Emission uses
TypeScript's isolated declarations and `--noCheck` so the lean runtime-only Docker
proof does not need workspace declaration builds. This does **not** replace
semantic verification: `check` and `test` run mandatory checked compilation,
including compile-only positive/negative contract fixtures. Build the workspace
type dependencies shown above before running them. Emitted declarations reference
`@luzzle/web.pieces` and Svelte types; both are available in the internal developer
workspace, not dependencies of the isolated runtime installation.
## Minimal runtime API
```js
+2
View File
@@ -1,12 +1,14 @@
import js from '@eslint/js';
import prettier from 'eslint-config-prettier';
import svelte from 'eslint-plugin-svelte';
import ts from 'typescript-eslint';
import globals from 'globals';
export default [
{ ignores: ['dist/**', 'node_modules/**'] },
js.configs.recommended,
...svelte.configs['flat/recommended'],
...ts.configs.recommended.map((config) => ({ ...config, files: ['**/*.ts'] })),
prettier,
...svelte.configs['flat/prettier'],
{ languageOptions: { globals: globals.node } }
+19 -3
View File
@@ -13,10 +13,11 @@
"README.md"
],
"scripts": {
"build": "rollup --config",
"build": "rollup --config && tsc -p tsconfig.types.json --noCheck",
"check": "tsc -p tsconfig.check.json",
"lint": "eslint . && prettier --check .",
"format": "prettier --write .",
"test": "node --conditions=node --conditions=production --test test/*.test.js"
"test": "tsc -p tsconfig.check.json && node --conditions=node --conditions=production --test test/*.test.js"
},
"exports": {
".": {
@@ -32,6 +33,18 @@
"default": "./dist/compatibility.js"
},
"./compatibility.json": "./dist/compatibility.json",
"./pieces/props": {
"types": "./dist/pieces/props.d.ts"
},
"./site/props": {
"types": "./dist/site/props.d.ts"
},
"./components/props": {
"types": "./dist/components/props.d.ts"
},
"./helpers": {
"types": "./dist/helpers.d.ts"
},
"./runtime/svelte": {
"development": "./dist/runtime/svelte.js",
"default": "./dist/runtime/svelte.js"
@@ -47,6 +60,7 @@
},
"devDependencies": {
"@eslint/js": "catalog:",
"@luzzle/web.pieces": "workspace:*",
"@rollup/plugin-node-resolve": "16.0.3",
"acorn": "8.16.0",
"eslint": "catalog:",
@@ -57,6 +71,8 @@
"prettier": "catalog:",
"prettier-plugin-svelte": "3.3.2",
"rollup": "4.60.4",
"svelte": "5.55.9"
"svelte": "5.55.9",
"typescript": "catalog:",
"typescript-eslint": "catalog:"
}
}
@@ -0,0 +1,36 @@
import type { PublicWebPiece } from '@luzzle/web.pieces';
import type { Component } from 'svelte';
/** Resolved positive pixel dimensions supplied to an authored template. */
export interface TemplateSize {
readonly width: number;
readonly height: number;
}
export interface NavBannerInputs {
readonly background?: string;
readonly color?: string;
readonly hoverColor?: string;
readonly showHome?: boolean;
readonly showSearch?: boolean;
readonly showThemeToggle?: boolean;
readonly showProgress?: boolean;
readonly showRandom?: boolean;
}
/** Inputs at a page's component call, not the icon template's props. */
export interface PieceIconInputs<T extends string = string> {
readonly piece: PublicWebPiece & { readonly type: T };
readonly size: {
readonly width: number;
/** Omission resolves to width * 1.5 before rendering the icon. */
readonly height?: number;
};
readonly lazy?: boolean;
readonly active?: boolean;
}
export interface PageComponents {
readonly NavBanner: Component<NavBannerInputs>;
readonly PieceIcon: Component<PieceIconInputs>;
}
+20
View File
@@ -0,0 +1,20 @@
import type { PieceIconPalette, PublicWebPiece } from '@luzzle/web.pieces';
export type ImageFormat = 'jpg' | 'avif' | 'webp' | 'png';
export interface BoundPieceHelpers {
getUrl(): string;
getPalette(): PieceIconPalette | undefined;
getImageUrl(
assetKey: string | null | undefined,
minWidth: number,
format: ImageFormat
): string | undefined;
getAssetUrl(assetKey: string, transformation: string): string | undefined;
getAssetContent(assetKey: string, transformation: string): string | undefined;
}
export interface TemplateHelpers {
forPiece(piece: PublicWebPiece): BoundPieceHelpers;
getStaticAssetUrl(relativePath: string): string | undefined;
}
@@ -0,0 +1,39 @@
import type { PublicWebPiece } from '@luzzle/web.pieces';
import type { PageComponents, TemplateSize } from '../components/props.js';
import type { TemplateHelpers } from '../helpers.js';
export type { PieceIconInputs } from '../components/props.js';
export interface PieceTag {
readonly tag: string;
readonly slug: string;
}
interface PieceData<T extends string> {
// Keep the canonical public shape. Schema-derived author typings are separate.
readonly piece: PublicWebPiece & { readonly type: T };
}
export interface PiecePageProps<T extends string = string> {
readonly data: PieceData<T> & { readonly tags: readonly PieceTag[] };
readonly helpers: TemplateHelpers;
readonly components: PageComponents;
}
export interface PieceIconOptions {
readonly size: TemplateSize;
readonly lazy: boolean;
readonly active: boolean;
}
export interface PieceIconProps<T extends string = string> {
readonly data: PieceData<T>;
readonly options: PieceIconOptions;
readonly helpers: TemplateHelpers;
}
export interface PieceOpengraphProps<T extends string = string> {
readonly data: PieceData<T>;
readonly options: { readonly size: TemplateSize };
readonly helpers: TemplateHelpers;
}
+37
View File
@@ -0,0 +1,37 @@
import type { PublicWebPiece } from '@luzzle/web.pieces';
import type { PageComponents } from '../components/props.js';
import type { TemplateHelpers } from '../helpers.js';
export interface RootPageProps {
readonly data: {
readonly types: readonly string[];
readonly pieces: readonly PublicWebPiece[];
};
readonly helpers: TemplateHelpers;
readonly components: PageComponents;
}
export interface FeedReference {
readonly url: string;
readonly type: 'piece' | 'tag';
readonly label: string;
}
export interface FeedPageProps {
readonly data: {
readonly feed: FeedReference;
readonly pieces: readonly PublicWebPiece[];
};
readonly helpers: TemplateHelpers;
readonly components: PageComponents;
}
export interface ErrorPageProps {
readonly data: {
readonly status: number;
/** Safe public text, never a private debugging message or stack trace. */
readonly message: string;
};
readonly helpers: TemplateHelpers;
readonly components: PageComponents;
}
+15 -1
View File
@@ -25,14 +25,28 @@ test('manifest always selects built JS and the runtime installation has only loc
const manifest = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
assert.equal(manifest.dependencies, undefined);
assert.equal(manifest.peerDependencies, undefined);
const typeExports = {
'./pieces/props': './dist/pieces/props.d.ts',
'./site/props': './dist/site/props.d.ts',
'./components/props': './dist/components/props.d.ts',
'./helpers': './dist/helpers.d.ts'
};
for (const [name, conditions] of Object.entries(manifest.exports)) {
if (name === './compatibility.json') continue;
if (Object.hasOwn(typeExports, name)) {
assert.deepEqual(conditions, { types: typeExports[name] });
assert.ok((await readFile(path.join(root, conditions.types), 'utf8')).length);
await assert.rejects(import(`${manifest.name}/${name.slice(2)}`), {
code: 'ERR_PACKAGE_PATH_NOT_EXPORTED'
});
continue;
}
assert.equal(conditions.development, conditions.default);
assert.match(conditions.default, /^\.\/dist\/.*\.js$/);
}
const dist = path.join(root, 'dist');
for (const filename of await files(dist)) {
assert.match(filename, /\.(js|json)$/); // no compiler/toolchain sources or sourcemaps
assert.match(filename, /\.(js|json)$|\.d\.ts$/); // declarations, never executable TS or maps
if (!filename.endsWith('.js')) continue;
const code = await readFile(path.join(dist, filename), 'utf8');
assert.ok(!code.includes(root), 'absolute checkout path entered a runtime chunk');
@@ -0,0 +1,186 @@
/**
* Compile-only tests: TypeScript checks every function body; nothing executes.
* Accepted scenarios must compile. Each @ts-expect-error marks a rejected input
* that must remain invalid. Exporting the scenarios avoids unused-variable noise.
*/
import type { PublicWebPiece, PieceIconPalette } from '@luzzle/web.pieces';
import type {
PiecePageProps,
PieceIconProps,
PieceIconInputs,
PieceIconOptions,
PieceOpengraphProps
} from '@luzzle/web.templates/pieces/props';
import type {
RootPageProps,
FeedPageProps,
FeedReference,
ErrorPageProps
} from '@luzzle/web.templates/site/props';
import type { NavBannerInputs, PageComponents } from '@luzzle/web.templates/components/props';
import type { TemplateHelpers } from '@luzzle/web.templates/helpers';
// Typed stand-ins, not real archive records. Both type names are arbitrary.
declare const piece: PublicWebPiece & { type: 'custom-kind' };
declare const otherPiece: PublicWebPiece & { type: 'another-kind' };
declare const helpers: TemplateHelpers;
declare const components: PageComponents;
// Accepted template inputs -----------------------------------------------------
export function acceptsIconCallWithoutOptionalDefaults(): PieceIconInputs<'custom-kind'> {
return { piece, size: { width: 80 } };
}
export function acceptsIconPropsWithResolvedOptions(): PieceIconProps<'custom-kind'> {
return {
data: { piece },
options: { size: { width: 80, height: 120 }, lazy: false, active: true },
helpers
};
}
export function acceptsPiecePageWithNormalizedTags(): PiecePageProps<'custom-kind'> {
return {
data: { piece, tags: [{ tag: 'quick note', slug: 'quick-note' }] },
helpers,
components
};
}
export function acceptsOpengraphProps(): PieceOpengraphProps<'another-kind'> {
return {
data: { piece: otherPiece },
options: { size: { width: 1200, height: 630 } },
helpers
};
}
export function acceptsRootPageWithMixedPieceTypes(): RootPageProps {
return {
data: { types: ['custom-kind', 'another-kind'], pieces: [piece, otherPiece] },
helpers,
components
};
}
export function acceptsTagFeed(): FeedPageProps {
return {
data: {
feed: { url: '/tags/quick-note', type: 'tag', label: 'Quick note' },
pieces: [piece, otherPiece]
},
helpers,
components
};
}
export function acceptsPublicErrorPage(): ErrorPageProps {
return { data: { status: 404, message: 'Not found' }, helpers, components };
}
export function preservesCanonicalPieceShape(page: PiecePageProps): PublicWebPiece {
return page.data.piece;
}
export function helpersReturnExpectedValues(): {
url: string;
palette: PieceIconPalette | undefined;
image: string | undefined;
asset: string | undefined;
content: string | undefined;
staticAsset: string | undefined;
} {
const bound = helpers.forPiece(piece);
return {
url: bound.getUrl(),
palette: bound.getPalette(),
image: bound.getImageUrl(null, 80, 'avif'),
asset: bound.getAssetUrl('attachment', 'image.original'),
content: bound.getAssetContent('body', 'markdown'),
staticAsset: helpers.getStaticAssetUrl('images/logo.svg')
};
}
// Rejected icon inputs --------------------------------------------------------
export function rejectsIconCallWithoutSize(): PieceIconInputs {
// @ts-expect-error Every icon component call requires a size.
return { piece };
}
export function rejectsIconCallWithWrongPieceType(): PieceIconInputs<'custom-kind'> {
// @ts-expect-error The requested type must match the supplied piece.
return { piece: otherPiece, size: { width: 80 } };
}
export function rejectsAuthoredIconWithoutResolvedHeight(): PieceIconOptions {
// @ts-expect-error Authored icons require height; only callers may omit it.
return { size: { width: 80 }, lazy: false, active: false };
}
export function rejectsAuthoredIconWithoutDefaults(): PieceIconOptions {
// @ts-expect-error Authored icons require explicit lazy and active values.
return { size: { width: 80, height: 120 } };
}
// Props unavailable to a role --------------------------------------------------
export function rejectsLegacyFlatPieceProp(page: PiecePageProps): unknown {
// @ts-expect-error The piece belongs under data.piece, not page.piece.
return page.piece;
}
export function rejectsPageOptions(page: PiecePageProps): unknown {
// @ts-expect-error Piece pages have no options prop.
return page.options;
}
export function rejectsIconComponents(icon: PieceIconProps): unknown {
// @ts-expect-error Icons cannot request navigation or nested piece icons.
return icon.components;
}
export function rejectsSeparateIconTags(icon: PieceIconProps): unknown {
// @ts-expect-error Icons have no separate tags prop.
return icon.tags;
}
export function rejectsOpengraphComponents(opengraph: PieceOpengraphProps): unknown {
// @ts-expect-error Open Graph images receive no page components.
return opengraph.components;
}
// Rejected content and helper inputs ------------------------------------------
export function rejectsDatabaseEncodedTags(): PiecePageProps {
return {
data: {
piece,
// @ts-expect-error Page tags must be normalized { tag, slug } records.
tags: ['quick note']
},
helpers,
components
};
}
export function rejectsPieceTypeAsFeedKind(): FeedReference {
// @ts-expect-error Feed kind is 'piece' or 'tag', not a piece type name.
return { url: '/feed', type: 'custom-kind', label: 'Feed' };
}
export function rejectsCustomNavigationSlots(): NavBannerInputs {
// @ts-expect-error Navigation does not accept custom snippet slots.
return { items: {} };
}
export function rejectsUnsupportedImageFormat(): string | undefined {
// @ts-expect-error Image formats are limited to jpg, avif, webp, and png.
return helpers.forPiece(piece).getImageUrl('cover', 80, 'gif');
}
export function rejectsNumericAssetKey(): string | undefined {
// @ts-expect-error Asset keys must be strings (or null/undefined for images).
return helpers.forPiece(piece).getImageUrl(12, 80, 'jpg');
}
@@ -0,0 +1,17 @@
{
"extends": "./tsconfig.types.json",
"compilerOptions": {
"rootDir": ".",
"noEmit": true,
"emitDeclarationOnly": false,
"noCheck": false,
"noUnusedLocals": true
},
"include": [
"src/components/props.ts",
"src/pieces/props.ts",
"src/site/props.ts",
"src/helpers.ts",
"test/types/**/*.ts"
]
}
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"verbatimModuleSyntax": true,
"isolatedDeclarations": true,
"declaration": true,
"emitDeclarationOnly": true,
"declarationMap": false,
"rootDir": "src",
"outDir": "dist",
"types": [],
"skipLibCheck": true
},
"include": [
"src/components/props.ts",
"src/pieces/props.ts",
"src/site/props.ts",
"src/helpers.ts"
]
}
+9
View File
@@ -682,6 +682,9 @@ importers:
'@eslint/js':
specifier: 'catalog:'
version: 9.22.0
'@luzzle/web.pieces':
specifier: workspace:*
version: link:../pieces
'@rollup/plugin-node-resolve':
specifier: 16.0.3
version: 16.0.3(rollup@4.60.4)
@@ -715,6 +718,12 @@ importers:
svelte:
specifier: 5.55.9
version: 5.55.9(@typescript-eslint/types@8.19.1)
typescript:
specifier: 'catalog:'
version: 5.7.3
typescript-eslint:
specifier: 'catalog:'
version: 8.19.1(eslint@9.22.0)(typescript@5.7.3)
packages/web/theme:
dependencies: