Repository navigation
Developer Frontend
Stream Engine has two browser surfaces: progressively enhanced public pages
rendered by Twig, and a React administration application. Both are written in
TypeScript, share framework-neutral utilities, and are built by Vite into
committed files under public/assets/.
This chapter documents source ownership, entry points, runtime integration, admin extensions, localization, testing, and the generated-asset contract. Theme-owned markup and framework adapters are covered separately in Themes.
| Surface | Source | Runtime model |
|---|---|---|
| Public site shell | assets-src/site/ |
site.js initializes shared behavior on server-rendered HTML. |
| Public page features | assets-src/pages/ |
A controller includes only the entry needed by that page. |
| Shared browser utilities | assets-src/shared/ |
Imported by site, page, admin, and module entries. No standalone entry unless Vite creates a shared chunk. |
| Administration application | assets-src/admin/ |
React and Mantine mounted below the /admin shell with a HashRouter. |
| Root site modules | modules/<Name>/assets/ |
Optional top-level .ts or .tsx entries discovered by Vite. |
| Theme behavior | Theme manifest plus an adapter entry | Loaded only by that theme; must use the shared UI contract. |
| Tests | tests/js/*.test.ts |
Vitest in jsdom with explicit imports from vitest. |
Browser code does not establish trust. Server routes still authenticate, authorize, validate input, verify CSRF for mutations, and sanitize any HTML that the browser is allowed to insert.
tsconfig.json checks application sources, tests, and Vite/Vitest
configuration together. It uses strict mode, noImplicitReturns,
noUnusedLocals, DOM libraries, and noEmit. Vite transpiles the source but
does not perform the complete type check, so a successful production build
does not replace:
npm run typecheckUse .ts for DOM and utility code and .tsx for React components. Keep
framework-neutral utilities in assets-src/shared/; keep page behavior close
to its public feature in assets-src/pages/; and keep React administration
code in assets-src/admin/. Do not put server policy or trusted validation in
any of them.
Engine browser code can import by a relative path. Root module and site-owned
code can use @stream-engine/..., which Vite and Vitest resolve to the active
engine's assets-src/ directory. The root TypeScript configuration must map
the same alias and include any site-owned module sources it type-checks.
Avoid adding values to window. The public shell deliberately exposes one
compatibility surface, window.CMS, for separately loaded page and theme
entries. New reusable code should normally be an exported module first; expose
it through CMS only when independent entries must consume it at runtime.
The root vite.config.ts builds explicit entries with stable output names:
-
adminfor the administration SPA; -
sitefor shared public behavior and public-site CSS; - page entries such as
register,search,feedback,messages,retrieve,profile,users,forums, andblog-post-form; and - theme-support entries such as
base,bootstrap,bootstrap-rtl,bootstrap-skin, andui-bootstrap.
Entry JavaScript is written to /assets/js/<name>.js; extracted CSS, images,
fonts, and shared chunks use the configured css/, img/, fonts/, and
js/chunks/ directories. Only entry filenames are a stable integration
surface. Do not link a generated chunk directly: its existence and name depend
on the current import graph.
The build has no Vite HTML entry and the PHP application does not read a Vite manifest. Twig and controllers link the known entry files themselves. It also means Vite does not inject stylesheets or preload tags into public pages.
Vite scans only immediate .ts and .tsx children of
modules/<Name>/assets/. A .d.ts file is ignored and subdirectories are for
helpers imported by an entry. The filename becomes a global output name:
modules/Notes/assets/notes.ts → public/assets/js/notes.js
Names must not collide with another module entry or the built-in reserved entries. Duplicate module names and reserved names fail the build. Removing a module directory removes its discovered entry on the next build.
This discovery applies to root site modules, not controllers installed inside
the engine's src/Modules/ tree. An engine-owned page entry must be declared
explicitly in vite.config.ts.
The theme manifest loads shared site assets. A feature controller adds its
page-specific entry through trusted head_ext view data:
return ViewModel::fromPage($page, 'modules/notes/index.twig', [
'head_ext' => [
'<script type="module" src="/assets/js/notes.js" defer></script>',
],
]);layouts/base.twig renders head_ext raw. Populate it only with fixed,
developer-owned tags; never concatenate administrator or request input into an
asset tag. Use type="module" for generated JavaScript and load an entry only
on pages that need it.
An entry that imports CSS may produce a dedicated stylesheet or contribute to
a shared stylesheet after Vite code splitting. Because there is no generated
HTML to inject that CSS, inspect the production build and explicitly link every
required extracted stylesheet. Trix is the current example: both Forums and
the blog editor import it, so their controllers explicitly include
/assets/css/trix.css as well as their JavaScript entry.
Do not guess or link internal chunks to work around a missing style. Fix the entry/import arrangement or link the stable extracted CSS output deliberately.
assets-src/site/main.ts imports the public style graph, installs the shared
CMS API, starts hash-route observation, and initializes global behavior on
DOMContentLoaded. Page entries are independent modules and may execute
before or after that callback.
Initialization functions that scan the DOM must be safe when their markup is
absent. When a function may run more than once, mark initialized elements with
a stable data-*-ready hook or otherwise make the operation idempotent. Prefer
event delegation for dynamic descendants and return unsubscribe functions from
reusable listeners where practical.
Public pages are server-rendered first. JavaScript should enhance a usable
semantic baseline, preserve form submission and accessibility where the
feature permits, and avoid owning markup that belongs to Twig. For API-loaded
repeated items, follow the <template data-ui> and slot contract in
Themes.
Use CMS.hashRoute when several features share public-page fragment state. It
treats the hash as parameters, preserves keys owned by other features,
distinguishes navigation from programmatic writes, and keeps Back/Forward
behavior coherent. The admin application has its own React HashRouter; do
not mix the two routing models.
Before adding another local implementation, check assets-src/shared/ and the
public CMS bridge. Current shared contracts include:
| Utility | Purpose |
|---|---|
csrf.ts |
Read the CSRF cookie and create the mutation header. |
api-errors.ts |
Normalize current API failure payloads into displayable errors. |
i18n.ts and plural.ts
|
Translation, interpolation, locale fallback, number formatting, and plural selection. |
ui.ts |
Theme-neutral modal, toast, tooltip, tab, toggle, and template behavior. |
uploads.ts |
Upload calls and their CSRF handling. |
navigation-url.ts |
Resolve safe same-origin navigation targets. |
date-time-format.ts |
Format using document locale/date/time preferences. |
bytes.ts |
Format byte quantities consistently. |
feed-types.ts |
Read feed-type labels generated from the PHP module registry. |
escape.ts |
Escape text for the limited places that must assemble HTML strings. |
Prefer DOM textContent or React text interpolation to HTML strings. If HTML
insertion is required, its provenance and sanitization boundary must be
explicit. CMS.escapeHtml protects text inserted into a deliberately built
HTML fragment; it does not make arbitrary server HTML safe. Use
data-slot-html only for content sanitized by the server.
Mutating calls send X-CSRF-Token. Public JSON calls normally use CMS.api();
uploads use the shared upload helpers because FormData must set its own
content boundary. The admin bundle does not load site/app.ts, so admin calls
use csrfHeaders() directly. Do not copy the cookie parser into a feature.
API and security
defines response, authorization, validation, and CSRF rules. Preserve that
server contract and use getApiErrorMessage() or getApiResponseError()
instead of inventing another error shape parser.
assets-src/site/main.ts is the root of shared public-site CSS. Module-owned
default styles currently live below assets-src/site/modules/ and are imported
there. Page-only vendor CSS may be imported by its page entry and linked as a
separate build output.
Public feature markup remains framework-free. Do not add Bootstrap or another
theme framework's class names to src/Modules/*/views or construct them in
shared/page TypeScript. A theme can replace the Twig component and register a
framework adapter while behavior continues to target stable semantic IDs,
data-* attributes, slots, hidden, and ARIA state.
The React administration application is not part of the public theme. Its Mantine styles are imported by the admin entry, and it may use admin-specific React components and styling without extending the public theme contract.
PHP serves the shell at /admin; assets-src/admin/main.tsx mounts React into
#root. mountAdmin() installs Mantine providers, notifications, direction
from the document, and React Router's HashRouter. Built-in client routes live
after the hash, for example /admin/#/themes.
Keep page components under assets-src/admin/pages/, shared React components
under assets-src/admin/components/, and pure helpers under
assets-src/admin/lib/. Extract data transformations from components when
they can be tested without rendering. Lazy-load unusually heavy or infrequent
pages; FileBrowser and Cron demonstrate the current lazy() and
Suspense pattern.
Route visibility is not authorization. Hiding a navigation item or protecting a React route never replaces permissions in its API controller.
A site module may describe a lazy route in modules/<Name>/admin/index.ts:
import type { AdminPage } from "@stream-engine/admin/module-pages";
export default [
{
path: "/notes",
labelKey: "js.notes.admin.navigation",
load: () => import("./Notes"),
},
] satisfies AdminPage[];Paths are static absolute lowercase paths made of letters, digits, and
hyphens. Parameters and trailing slashes are not supported. A route cannot
duplicate another module or use a built-in first segment such as feeds,
pages, menus, settings, themes, widgets, file-browser, uploads,
or users. Labels use js.* translation keys. Route metadata loads eagerly;
the component returned by load() is lazy.
The shipped assets-src/admin/main.tsx currently calls mountAdmin() without
a module map, so it does not discover root module admin pages automatically. A
site that needs them must own or adapt the admin entry, collect them, and pass
the result:
import { mountAdmin } from "@stream-engine/admin/admin";
const pages = import.meta.glob("/modules/*/admin/index.ts", { eager: true });
mountAdmin(pages);Point the site's admin Vite input at that entry and include the site sources
in its TypeScript configuration. Do not document module admin discovery as
automatic until the shipped entry actually performs it.
Browser messages come from src/Lang/<locale>.php. The Vite translation plugin
executes PHP during build, loads each catalog, and includes only keys beginning
with js. in the virtual virtual:translations module. Therefore frontend
work that adds a visible string must add its key to every supported server
catalog; a build is required after catalog changes.
trans() falls back to English and then the key. Locale resolution normalizes
case and underscores, tries the exact locale and its base language, and reads
the requested value from <html lang>. Use transChoice() for plural forms
and transChoiceWithCount() when the formatted number should be part of the
label. Do not concatenate translated sentence fragments.
The complete catalog, plural, date/time, RTL, bidi, semantic markup, keyboard, and assistive-technology contract is in Localization and accessibility.
There is currently no module-local translation catalog discovery. A site-owned module must customize the translation integration or accept visible fallback keys. This limitation applies to module admin page labels too.
The second build plugin executes the Composer autoloader and ModuleRegistry
to generate virtual:feed-types. Consequently, a production frontend build
requires a working php executable, installed Composer dependencies, and an
autoload file. Rebuild after changing language catalogs, module discovery, or
feed-type declarations, even if no .ts file changed.
Vitest uses vitest.config.ts, not the production build configuration. It
resolves the same engine alias and build-data virtual modules, runs
tests/**/*.test.ts in jsdom, restores mocks between tests, and limits workers
for predictable CI memory use. Tests import describe, it, expect, and
mock helpers explicitly; globals are disabled. See
Testing for
fixture isolation, frontend regression design, coverage, and CI parity.
Put pure behavior behind exported functions and inject browser/API dependencies
when doing so makes failure paths deterministic. Reset modified DOM, timers,
translations, adapters, and global state in afterEach. Cover the regression,
not only the successful branch: malformed payloads, missing markup, rejected
requests, repeated initialization, navigation, accessibility state, and stale
asynchronous responses commonly need explicit tests.
Run one file while iterating:
npm test -- --run tests/js/example.test.tsThen run the frontend gate:
npm test
npm run typecheck
npm run buildThe build is a separate check because tests do not exercise entry discovery, name collisions, CSS extraction, PHP-backed virtual modules, asset output, or third-party license generation.
npm run build writes production files to public/assets/ with
emptyOutDir: true. It deletes that directory's previous contents before
writing, so never store hand-maintained files there. Source assets belong in
an imported source directory or another intentional public location.
The generated assets and THIRD_PARTY_LICENSES.md are tracked in Git and are
part of release packages. After any source, dependency, entry, translation, or
build-configuration change:
- run a clean production build;
- review added, removed, and unexpectedly renamed outputs;
- commit the matching
public/assets/changes with the source changes; and - deploy PHP/templates and compiled assets from the same revision.
Do not edit generated JavaScript or CSS directly. A generated-only fix is lost on the next build and leaves the source incorrect. Stable entry names also mean deployments must replace related files together; serving an old chunk beside a new entry is not a supported rolling compatibility mechanism.
npm run dev starts the standalone Vite development server, but Stream Engine
continues to reference compiled /assets/ URLs and does not automatically use
HMR. For browser verification through the application use:
npm run build -- --watchor run npm run build after each source change.
- The change belongs to the public shell, one page entry, a shared utility, the admin SPA, a root module entry, or a theme adapter, with no duplicated owner.
- Entry names are unique; page controllers link only stable entry outputs and every extracted stylesheet they require.
- Initializers tolerate missing markup, repeated initialization where needed, dynamic content, and asynchronous teardown or navigation.
- Public markup remains theme-neutral and accessible; browser code preserves the Twig/slot/UI-adapter contract.
- Visible text uses translated
js.*keys in every catalog and correct plural handling. - HTML insertion, same-origin navigation, API errors, uploads, and CSRF use the shared utilities and have an explicit trust boundary.
- Admin navigation and route guards are not treated as server authorization.
- Vitest covers success and relevant failure/regression paths; strict TypeScript passes.
- A production build succeeds, generated assets and licenses are reviewed, and the source and output changes are committed together.
For bundle and response-cost measurement, follow Performance. For clean release builds, version synchronization, and public artifact checks, follow Releases.