[web] introduce shared template contracts package #123

Merged
eleith merged 1 commits from feat/template-package into main 2026-10-19 14:27:29 +00:00
19 changed files with 544 additions and 0 deletions

No files matched your search

+1
View File
@@ -22,6 +22,7 @@
"@luzzle/web.db",
"@luzzle/web.jobs",
"@luzzle/web.pieces",
"@luzzle/web.templates",
"@luzzle/web.theme"
],
"privatePackages": {
+1
View File
@@ -27,3 +27,4 @@ apps/web/luzzle
apps/web/static/images/pieces
.pnpm-store
.plans
+58
View File
@@ -0,0 +1,58 @@
name: "@luzzle/web.templates"
on:
push:
branches:
- 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'
pull_request:
branches:
- 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'
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v6
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: '26'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build shared type dependencies
run: pnpm --filter @luzzle/core --filter @luzzle/web.config --filter @luzzle/web.db --filter @luzzle/web.pieces build
- name: Build
run: pnpm --filter @luzzle/web.templates build
- name: Lint
run: pnpm --filter @luzzle/web.templates lint
- name: Test
run: pnpm --filter @luzzle/web.templates test
+1
View File
@@ -81,6 +81,7 @@ and some shared packages used for the web project:
- `@luzzle/web.job` shared job definitions
- `@luzzle/web.theme` shared css styles and theme types
- `@luzzle/web.pieces` shared piece definitions and helpers
- `@luzzle/web.templates` shared template input and helper contracts
## Development & Philosophy 🛠️
+2
View File
@@ -0,0 +1,2 @@
dist/
node_modules/
+2
View File
@@ -0,0 +1,2 @@
dist/
node_modules/
+6
View File
@@ -0,0 +1,6 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100
}
+42
View File
@@ -0,0 +1,42 @@
# @luzzle/web.templates
Internal shared template contracts for web and worker. This first version
contains **types only**: role props, component inputs, and helper interfaces.
It reuses the reviewed contracts without introducing a rendering framework.
| Import | Contains |
| ---------------------------------------- | ---------------------------------------------- |
| `@luzzle/web.templates/pieces/props` | Piece page, icon, and Open Graph props |
| `@luzzle/web.templates/site/props` | Root, feed, and error page props |
| `@luzzle/web.templates/components/props` | Navigation/icon inputs and page components |
| `@luzzle/web.templates/helpers` | Bound piece and static-asset helper interfaces |
Use `import type`. These exports have no JavaScript implementation.
Piece data keeps the existing `PublicWebPiece` shape. Type names are arbitrary
strings; narrowing one does not generate schema-derived metadata typings.
## Ownership
- This package describes the shared inputs. It does not implement helpers yet.
- The worker owns template compilation and bundling, like its existing image
and Open Graph tools. No bundler is needed by this package.
- Web consumes prepared output. This package introduces no application wiring.
Svelte is a development dependency only for its `Component` type. There are no
runtime imports of Svelte, no bundled Svelte runtime, and no production dependencies.
This is not an author SDK or editor/schema declaration generator.
## Checks
Build the existing shared type dependencies first, then this package:
```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
```
`build` checks the source and emits declarations into `dist`. `test` checks the
named accepted/rejected examples in `test/types/props.typecheck.ts`; nothing in
that file executes. There is no custom bundling or unchecked declaration pass.
+12
View File
@@ -0,0 +1,12 @@
import js from '@eslint/js';
import ts from 'typescript-eslint';
import prettier from 'eslint-config-prettier';
import globals from 'globals';
export default [
{ ignores: ['dist/**', 'node_modules/**'] },
js.configs.recommended,
...ts.configs.recommended,
prettier,
{ languageOptions: { globals: globals.node } }
];
+44
View File
@@ -0,0 +1,44 @@
{
"name": "@luzzle/web.templates",
"version": "0.0.1",
"private": true,
"type": "module",
"description": "Shared template input and helper contracts for web and worker",
"engines": {
"node": ">=24"
},
"files": [
"dist"
],
"scripts": {
"prebuild": "rm -rf dist",
"build": "tsc -p tsconfig.json",
"lint": "eslint . && prettier --check .",
"test": "tsc -p tsconfig.test.json"
},
"exports": {
"./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"
}
},
"devDependencies": {
"@eslint/js": "catalog:",
"@luzzle/web.pieces": "workspace:*",
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
"globals": "catalog:",
"prettier": "catalog:",
"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;
}
@@ -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');
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"verbatimModuleSyntax": true,
"declaration": true,
"emitDeclarationOnly": true,
"rootDir": "src",
"outDir": "dist",
"types": [],
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"noEmit": true,
"emitDeclarationOnly": false,
"noUnusedLocals": true
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}
+30
View File
@@ -674,6 +674,36 @@ importers:
packages/web/proxy: {}
packages/web/templates:
devDependencies:
'@eslint/js':
specifier: 'catalog:'
version: 9.22.0
'@luzzle/web.pieces':
specifier: workspace:*
version: link:../pieces
eslint:
specifier: 'catalog:'
version: 9.22.0
eslint-config-prettier:
specifier: 'catalog:'
version: 9.1.0(eslint@9.22.0)
globals:
specifier: 'catalog:'
version: 16.0.0
prettier:
specifier: 'catalog:'
version: 3.4.2
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:
'@luzzle/web.config':
+1
View File
@@ -8,6 +8,7 @@ packages:
- packages/web/db
- packages/web/jobs
- packages/web/pieces
- packages/web/templates
- packages/web/worker
- packages/web/lsp
- packages/web/proxy