Framework Codegen Modules

@swift-web/framework ships six build-time utilities under src/codegen/ — the unified scaffolding capability for Swift Web projects. Appliers call them through thin wrappers (one or two lines of import + one call). The core logic lives in the framework; upgrading the framework package automatically rolls out the latest implementation.

Design Principles

  • No core logic in appliers: applier scripts/*.ts only pass arguments. Never copy generation algorithms downstream.
  • Centralized constants: image widths, quality, etc. live in @swift-web/framework/codegen/image-config so multiple modules share one source of truth.
  • Idempotent reruns: outputs are deterministic for the same inputs. Re-running produces no diff unless the source changes.

The Six Modules

ModuleSub-pathPurpose
generate-api@swift-web/framework/codegen/generate-apiGenerate TS request functions and types from a doc source (YApi / OpenAPI spec)
generate-icons@swift-web/framework/codegen/generate-iconsDerive favicon / iOS home-screen icons / PWA icons (192/512) from app/assets/icon.{svg,png}
inject-icons@swift-web/framework/codegen/inject-iconsBatch-inject SVG icons into the v-if template block of AppIcon.vue
optimize-images@swift-web/framework/codegen/optimize-imagesPre-generate webp/avif multi-size variants from public/images/ source files
check-og-image@swift-web/framework/codegen/check-og-imagePrebuild hook — assert the default OG share image exists and is ≤ 8 MB
image-config@swift-web/framework/codegen/image-configShared image constants (WIDTHS / FORMATS / QUALITY), used by SwImage + optimize-images

Typical Wiring

The starter template already includes every thin wrapper. A typical applier scripts/ directory looks like this:

// scripts/generate-api.ts
import { generateApi } from '@swift-web/framework/codegen/generate-api'
await generateApi({ apiDir: 'app/api' })

// scripts/generate-icons.ts
import { generateIcons } from '@swift-web/framework/codegen/generate-icons'
await generateIcons()

// scripts/inject-icons.ts
import { injectIcons } from '@swift-web/framework/codegen/inject-icons'
injectIcons()

// scripts/optimize-images.ts
import { optimizeImages } from '@swift-web/framework/codegen/optimize-images'
await optimizeImages()

// scripts/check-og-image.ts (prebuild)
import { checkOgImage } from '@swift-web/framework/codegen/check-og-image'
try { checkOgImage() } catch (e) { console.error(e.message); process.exit(1) }

Register them as npm scripts in package.json:

{
  "scripts": {
    "generate-api": "tsx scripts/generate-api.ts",
    "generate-icons": "tsx scripts/generate-icons.ts",
    "inject-icons": "tsx scripts/inject-icons.ts",
    "optimize-images": "tsx scripts/optimize-images.ts",
    "prebuild": "tsx scripts/check-og-image.ts"
  }
}

sw run will list these scripts automatically — AI- and terminal-friendly.

generate-api dual sources (YApi / OpenAPI) and where config lives

generate-api supports two documentation sources with identical output shape (modules/ + types/ + index.ts). Configuration is split by nature — non-secrets go into nuxt.config.ts › swiftConfig.apiDocs (committed, typed, and the file you already edit when wiring the backend; swift.config.json only holds what sw create can decide at creation time), the only secret stays in .env as API_DOCS_TOKEN:

Sourcenuxt.config.ts → swiftConfig.apiDocs.envUse case
yapi{ source: 'yapi', url: '<site root>', project: <project id> }API_DOCS_TOKEN (required)YApi platform
openapi{ source: 'openapi', url: '<spec URL or local JSON>', pathVars: { type: 'movie' } }API_DOCS_TOKEN (only when the spec sits behind auth; sent as Bearer)OpenAPI 3 spec — the self-hosted swift-web server's /c-api/docs-json is this source
  • Priority: generateApi(options) > process env API_DOCS_SOURCE / API_DOCS_URL / API_DOCS_PROJECT / API_DOCS_PATH_VARS / API_DOCS_TOKEN (temporary overrides, normally unset) > nuxt.config.ts. Both sources share the same field names; no per-source prefixes. The first line printed is 文档源:openapi · <url>(来自 nuxt.config.ts › swiftConfig.apiDocs), so a misconfiguration is visible the moment you run it.
  • Every generated file starts with an @generated machine marker (// @generated by @swift-web/framework generate-api — DO NOT EDIT): mr-review skips such files and the cli MCP recognizes them (standards "generated files" rule). modules/ and types/ are owned wholesale by codegen and are wiped and rebuilt on every run.
  • Hand-written modules go in app/api/manual/: generate-api never reads, deletes or rewrites that directory; it only registers each file into the api object by camelCased file name when it writes index.ts (manual/search-extra.ts → api.searchExtra), so they are consumed exactly like generated modules. Keep their types inside the file, not in types/. A manual module whose name collides with a spec module fails before anything is cleaned. A hand-written file dropped into modules/ still runs, but the next regeneration deletes it (with a console.warn naming it first).
  • source is required and never inferred; a token inside apiDocs throws (secrets must not be committed); a leftover apiDocs block in swift.config.json throws too (it moved).
  • OpenAPI modules are grouped by the first path segment only (same rule as the YApi source): tags are Swagger UI grouping labels and never affect module names — paths are segmented and unique, and a backend re-tagging an endpoint must not rename api.<module> on the frontend.
  • Legacy keys API_SOURCE / YAPI_* / OPENAPI_* throw on sight with an "old key → new location" table — no dual reading, no warn-and-continue.

OpenAPI source notes:

  • Dictionary types: a type: object with no fixed properties and a schema-valued additionalProperties generates Record<string, ValueType>. Primitive types, $ref, nested dictionaries, and allOf + nullable are supported (for example, Record<string, RetentionCell | null>). A nullable dictionary itself generates Record<...> | null separately. Without a value type, the fallback remains Record<string, unknown>. Objects combining fixed properties with additionalProperties currently emit only their fixed properties.
  • Path variable substitution: parameterized paths like /{type}/navBlock are resolved at generation time via apiDocs.pathVars (env form API_DOCS_PATH_VARS=type=movie, comma-separated pairs); unresolved parameters become required arguments of the generated function.
  • Visibility annotation: the server-side x-sw-visibility (public/private) is emitted as a @swVisibility JSDoc tag on generated functions — the basis for the "ISR pages must not call private APIs" guardrail.
  • Envelope-transparent: the spec describes the clean payload inside the envelope; generated code calls request() as usual, with encryption handled by the framework request layer.
  • CI drift check --check (fw#245): tsx scripts/generate-api.ts --check (or generateApi({ check: true })) generates into a temp dir and diffs against the existing output — passes when identical, prints the drifted file list and exits non-zero otherwise. Wired into the verify CI job, "server changed the contract but the applier forgot to regenerate" fails immediately; with the source in apiDocs, CI inlines nothing.
  • Visibility file split & guardrail manifest (fw#243): modules with mixed visibility are split into modules/<m>.public.ts / <m>.private.ts (the index merges the namespace, so page consumption via api.<m>.<fn> is unchanged); a app/api/visibility.json manifest (<module>.<fn> → public|private) is generated alongside — the swift-web/isr-no-private-api eslint rule uses it to block "ISR/prerender pages calling private APIs" (error in recommended; silently inactive when the manifest is absent, so YApi-source projects are unaffected). Private functions carry a visibility meta option; calling them in an SSR context during dev logs a console warning.
// nuxt.config.ts (openapi source example)
swiftConfig: {
  ...swiftConfig.framework,
  apiDocs: { source: 'openapi', url: 'https://api.your-host.com/docs/c.json', pathVars: { type: 'movie' } },
},

🔑 Building your own backend? x-sw-visibility buys you two development-time checks

The artifacts of x-sw-visibility are described above, but not why it is worth the effort. For projects that skip the legacy company backend and build their own API, the difference is concrete:

Annotated with x-sw-visibility (OpenAPI source)Not annotated (YAPI source, or an unannotated OpenAPI spec)
swift-web/isr-no-private-api (static lint)✅ fails while you write the code❌ no manifest ⇒ silently no-ops
Runtime warning when SSR calls a private API in dev✅ visible as soon as you run dev❌ no metadata to judge with
Runtime guard L5 (strips identity on the SSR path of cacheable pages)✅✅ both have it

The difference is when you find out: annotated, the problem surfaces while writing code / running lint; unannotated, only the runtime guard L5 remains — it does hold the line (it is the primary defence for legacy YAPI projects), but only once the app actually runs, and only on the SSR path of cacheable pages.

The cost is near zero: one OpenAPI extension field per endpoint. Mark it once on your server's route decorators and generate-api emits visibility.json + JSDoc + the .public.ts / .private.ts split automatically.

Only yapi and openapi are supported today (ApiSource = 'yapi' | 'openapi' — two adapters hard-coded in the source). x-sw-visibility is an extension field inside the OpenAPI document and is understood only by the openapi source; it is not a cross-format protocol. Supporting a third document format (Swagger 2, an Apifox export, a custom JSON) means writing an adapter — the architecture leaves room for it (source adapter, fw#242), but it is code, not configuration.

For the full semantics of "private", and which of the three defence layers applies to which backend source, see Rendering Strategy Guide · Public / Private API Separation.

YApi path parameters and failure protection

YApi supports {name}, :name, and parameters with a static prefix such as /m3u8/p{token}. Parameter names start with a letter or underscore, followed by letters, digits, or underscores. Path parameters become required string fields combined with the query/body type. Pass raw values: generated request functions encode them with encodeURIComponent before substituting them into the URL.

For example, /m3u8/p{token} generates api.m3u8.pToken({ token: 'abc' }), requesting /m3u8/pabc. Static-path function names remain unchanged.

The YApi generator fetches every interface detail, parses it, and generates all file contents before replacing existing output. Failures during these stages leave index.ts, modules/, types/, and visibility.json unchanged; manual/ is never overwritten. This does not guarantee atomic recovery from filesystem write failures or forced process termination.

Request body boundary (YApi): endpoints with runtime path parameters require a plain object body supported by the YApi parser; object fields may contain arrays. Non-object bodies such as arrays or strings, nullable schemas, and schemas that cannot be identified as objects are rejected before existing output is changed. Bodies are not wrapped or converted automatically. Path-only endpoints send no query/body; otherwise only non-path fields are forwarded.

Parameters in the first segment also produce normalized module names: /api/{tenant}/users → api.tenant.users(), /p{tenant}/users → api.pTenant.users(). If a path parameter shares a name with a declared top-level query/body field, generation fails before replacing existing output. Identical names inside nested objects are allowed.

If two YApi endpoints produce the same final export name or Params/Response type name within a module (for example, GET /book/foo and GET /book/Foo), generation fails with both endpoints, the conflicting name and remediation options, preserving existing output. Adjust the endpoint paths, or remove the conflicting endpoints from the generation source and implement them in a non-conflicting module under app/api/manual/. There is no interactive prompt, automatic renaming or silent skipping.

These restrictions apply to YApi only; OpenAPI retains its existing behavior. YApi does not infer $ref, allOf, oneOf, or anyOf body schemas. Invalid module bindings and multi-method object exports, including reserved words such as delete, fail before replacing output and report the affected endpoints with the same remediation options. Existing $ suffix escaping for single-method functions is preserved.

Upgrade Experience

After a framework release, appliers just run sw upgrade (or bump the range by hand and run pnpm install --lockfile-only && pnpm install --frozen-lockfile; avoid pnpm update, which re-resolves the whole tree and churns the lockfile). No need to touch scripts/, no need to hand-sync algorithms.

Extension Rules

When the business has a codegen need beyond the defaults:

  • Generalize into the framework if possible: file an issue against the framework so the logic ships once and all appliers benefit.
  • Truly business-specific only: build a separate script in the applier's scripts/, but never fork the framework codegen and patch it locally.
  • See root CLAUDE.md "Principle 9 — Extend by wrapping, not rewriting".