diff --git a/.github/workflows/web-templates.yml b/.github/workflows/web-templates.yml index 5a61c5b9..dd8d6bda 100644 --- a/.github/workflows/web-templates.yml +++ b/.github/workflows/web-templates.yml @@ -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 diff --git a/packages/web/templates/README.md b/packages/web/templates/README.md index 7f777140..ead6a016 100644 --- a/packages/web/templates/README.md +++ b/packages/web/templates/README.md @@ -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 diff --git a/packages/web/templates/eslint.config.js b/packages/web/templates/eslint.config.js index f389ed0f..e3c83d81 100644 --- a/packages/web/templates/eslint.config.js +++ b/packages/web/templates/eslint.config.js @@ -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 } } diff --git a/packages/web/templates/package.json b/packages/web/templates/package.json index 15ddf999..6f3d5b88 100644 --- a/packages/web/templates/package.json +++ b/packages/web/templates/package.json @@ -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:" } } 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/build.test.js b/packages/web/templates/test/build.test.js index 56ba0b7b..439d05e6 100644 --- a/packages/web/templates/test/build.test.js +++ b/packages/web/templates/test/build.test.js @@ -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'); 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.check.json b/packages/web/templates/tsconfig.check.json new file mode 100644 index 00000000..0c71d8f3 --- /dev/null +++ b/packages/web/templates/tsconfig.check.json @@ -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" + ] +} diff --git a/packages/web/templates/tsconfig.types.json b/packages/web/templates/tsconfig.types.json new file mode 100644 index 00000000..bd99bc1b --- /dev/null +++ b/packages/web/templates/tsconfig.types.json @@ -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" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e959c4ac..ca88f9e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: