[web] introduce shared template contracts package #123
No files matched your search
@@ -22,6 +22,7 @@
|
||||
"@luzzle/web.db",
|
||||
"@luzzle/web.jobs",
|
||||
"@luzzle/web.pieces",
|
||||
"@luzzle/web.templates",
|
||||
"@luzzle/web.theme"
|
||||
],
|
||||
"privatePackages": {
|
||||
|
||||
@@ -27,3 +27,4 @@ apps/web/luzzle
|
||||
apps/web/static/images/pieces
|
||||
|
||||
.pnpm-store
|
||||
.plans
|
||||
@@ -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
|
||||
@@ -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 🛠️
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
dist/
|
||||
node_modules/
|
||||
@@ -0,0 +1,2 @@
|
||||
dist/
|
||||
node_modules/
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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 } }
|
||||
];
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"noEmit": true,
|
||||
"emitDeclarationOnly": false,
|
||||
"noUnusedLocals": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
Generated
+30
@@ -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':
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user