From 1a0594956c9bfaadb9318b914a410fe0db3e24be Mon Sep 17 00:00:00 2001 From: eleith Date: Mon, 19 Oct 2026 07:19:50 -0700 Subject: [PATCH] [web] introduce shared template contracts package Reviewed and explicitly approved by the user before staging. Add only role/component/helper types, checked TypeScript declaration builds, named type tests, and normal workspace/CI integration. No runtime renderer, Svelte bundling, application wiring, or helper implementation; future preparation tooling belongs to the worker. --- .changeset/config.json | 1 + .dockerignore | 1 + .github/workflows/web-templates.yml | 58 ++++++ README.md | 1 + packages/web/templates/.gitignore | 2 + packages/web/templates/.prettierignore | 2 + packages/web/templates/.prettierrc.json | 6 + packages/web/templates/README.md | 42 ++++ packages/web/templates/eslint.config.js | 12 ++ packages/web/templates/package.json | 44 +++++ .../web/templates/src/components/props.ts | 36 ++++ packages/web/templates/src/helpers.ts | 20 ++ packages/web/templates/src/pieces/props.ts | 39 ++++ packages/web/templates/src/site/props.ts | 37 ++++ .../templates/test/types/props.typecheck.ts | 186 ++++++++++++++++++ packages/web/templates/tsconfig.json | 16 ++ packages/web/templates/tsconfig.test.json | 10 + pnpm-lock.yaml | 30 +++ pnpm-workspace.yaml | 1 + 19 files changed, 544 insertions(+) create mode 100644 .github/workflows/web-templates.yml create mode 100644 packages/web/templates/.gitignore create mode 100644 packages/web/templates/.prettierignore create mode 100644 packages/web/templates/.prettierrc.json create mode 100644 packages/web/templates/README.md create mode 100644 packages/web/templates/eslint.config.js create mode 100644 packages/web/templates/package.json create mode 100644 packages/web/templates/src/components/props.ts create mode 100644 packages/web/templates/src/helpers.ts create mode 100644 packages/web/templates/src/pieces/props.ts create mode 100644 packages/web/templates/src/site/props.ts create mode 100644 packages/web/templates/test/types/props.typecheck.ts create mode 100644 packages/web/templates/tsconfig.json create mode 100644 packages/web/templates/tsconfig.test.json diff --git a/.changeset/config.json b/.changeset/config.json index 5b0f6724..3df83e99 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -22,6 +22,7 @@ "@luzzle/web.db", "@luzzle/web.jobs", "@luzzle/web.pieces", + "@luzzle/web.templates", "@luzzle/web.theme" ], "privatePackages": { diff --git a/.dockerignore b/.dockerignore index a73d96fd..ac2b4d8e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -27,3 +27,4 @@ apps/web/luzzle apps/web/static/images/pieces .pnpm-store +.plans diff --git a/.github/workflows/web-templates.yml b/.github/workflows/web-templates.yml new file mode 100644 index 00000000..09e7398b --- /dev/null +++ b/.github/workflows/web-templates.yml @@ -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 diff --git a/README.md b/README.md index 583a0f0c..60a71835 100644 --- a/README.md +++ b/README.md @@ -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 🛠️ diff --git a/packages/web/templates/.gitignore b/packages/web/templates/.gitignore new file mode 100644 index 00000000..1eae0cf6 --- /dev/null +++ b/packages/web/templates/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/packages/web/templates/.prettierignore b/packages/web/templates/.prettierignore new file mode 100644 index 00000000..1eae0cf6 --- /dev/null +++ b/packages/web/templates/.prettierignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/packages/web/templates/.prettierrc.json b/packages/web/templates/.prettierrc.json new file mode 100644 index 00000000..ff2677ef --- /dev/null +++ b/packages/web/templates/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "useTabs": true, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100 +} diff --git a/packages/web/templates/README.md b/packages/web/templates/README.md new file mode 100644 index 00000000..8c351a72 --- /dev/null +++ b/packages/web/templates/README.md @@ -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. diff --git a/packages/web/templates/eslint.config.js b/packages/web/templates/eslint.config.js new file mode 100644 index 00000000..85679053 --- /dev/null +++ b/packages/web/templates/eslint.config.js @@ -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 } } +]; diff --git a/packages/web/templates/package.json b/packages/web/templates/package.json new file mode 100644 index 00000000..3ad4dd09 --- /dev/null +++ b/packages/web/templates/package.json @@ -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:" + } +} diff --git a/packages/web/templates/src/components/props.ts b/packages/web/templates/src/components/props.ts new file mode 100644 index 00000000..03b753a1 --- /dev/null +++ b/packages/web/templates/src/components/props.ts @@ -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 { + 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; + readonly PieceIcon: Component; +} diff --git a/packages/web/templates/src/helpers.ts b/packages/web/templates/src/helpers.ts new file mode 100644 index 00000000..81749a4a --- /dev/null +++ b/packages/web/templates/src/helpers.ts @@ -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; +} diff --git a/packages/web/templates/src/pieces/props.ts b/packages/web/templates/src/pieces/props.ts new file mode 100644 index 00000000..6954a76b --- /dev/null +++ b/packages/web/templates/src/pieces/props.ts @@ -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 { + // Keep the canonical public shape. Schema-derived author typings are separate. + readonly piece: PublicWebPiece & { readonly type: T }; +} + +export interface PiecePageProps { + readonly data: PieceData & { 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 { + readonly data: PieceData; + readonly options: PieceIconOptions; + readonly helpers: TemplateHelpers; +} + +export interface PieceOpengraphProps { + readonly data: PieceData; + readonly options: { readonly size: TemplateSize }; + readonly helpers: TemplateHelpers; +} diff --git a/packages/web/templates/src/site/props.ts b/packages/web/templates/src/site/props.ts new file mode 100644 index 00000000..7107bf6d --- /dev/null +++ b/packages/web/templates/src/site/props.ts @@ -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; +} diff --git a/packages/web/templates/test/types/props.typecheck.ts b/packages/web/templates/test/types/props.typecheck.ts new file mode 100644 index 00000000..2c170622 --- /dev/null +++ b/packages/web/templates/test/types/props.typecheck.ts @@ -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'); +} diff --git a/packages/web/templates/tsconfig.json b/packages/web/templates/tsconfig.json new file mode 100644 index 00000000..6b058591 --- /dev/null +++ b/packages/web/templates/tsconfig.json @@ -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"] +} diff --git a/packages/web/templates/tsconfig.test.json b/packages/web/templates/tsconfig.test.json new file mode 100644 index 00000000..cb2fb3cc --- /dev/null +++ b/packages/web/templates/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true, + "emitDeclarationOnly": false, + "noUnusedLocals": true + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2cbf3e36..0c121afb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d588d7d1..b51274fc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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 -- 2.54.0