Frontend notes
The Arc CMS website and admin area are one Angular 21 app built with AnalogJS. These are the conventions to follow when you change it, and the few places where it does not behave the way an Angular developer might expect.
The folders are described in A tour of the folders, and how the parts fit together in Architecture. An app built on Arc CMS adds its pages in the custom space instead: see Add pages and routes.
Components
- Every component is standalone. There are no NgModules: a component lists what it uses in
imports. - Admin and member pages start with the shared page header (below).
- Components that call Cloud Functions go through
arcCallable()insrc/app/core/config/arc-functions.ts, with the plain function name. It adds thearccms-prefix the functions are deployed under. - Firestore and Storage are injected, never created with
getFirestore(),initializeFirestore()orgetStorage():src/app/core/config/arc-firebase.tspoints them at the install's database and bucket, which may not be the defaults, and starts the offline cache when the install turns it on (Keep working offline). A test fails if another file does.
State: NgRx Signals stores
Page state lives in NgRx Signals stores (signalStore from @ngrx/signals), such as the auth store in src/app/pages/(auth)/auth.store.ts. A store for a Firestore collection is usually made with createGenericStore() from src/shared/services/generic-store.service.ts, which gives it loading, paging, sorting and the create, update and delete calls. update() does not change currentItem, so a page that fills a form from it is not refilled on every save; a page that keeps editing after a save calls refreshCurrentItem(id, written) to bring the stored copy up to date, as the content editor does. Templates read store state as signals.
Translations
The admin's and the members' text is translated with Transloco. English is the source language:
Add the key and its English text to
src/assets/i18n/en.json, and the translation to each other language file in that folder, such assrc/assets/i18n/hi.json.Use it:
{{ 'admin.nav.broadcasts' | transloco }}in a template, orinjectT()fromsrc/app/core/i18n/inject-t.tsin TypeScript.-
Regenerate the key type:
npm run i18n:keysIt writes
src/app/core/i18n/translation-keys.ts, so a mistyped key in TypeScript is a compile error.
A spec (src/test/i18n-parity.spec.ts) fails when a translation misses a key, has a key English does not, or the generated keys are out of date. A missing translation falls back to English in the app, so without that spec the gap would be invisible. Keys written in templates are plain strings to the compiler; the spec is their only check.
Text members see
Apps add languages for their members that core has never seen (see Member languages), so a member-facing string is never hard-coded, not even a fallback such as {{ name || 'Member' }}:
- Put its key under
member.*(oruser.*in the member area), inen.jsonandhi.json. A key elsewhere must be added toMEMBER_KEYSinsrc/app/core/i18n/member-keys.ts. - Add a new member screen's file to
MEMBER_SCREEN_FILESin the same file. - Write a sentence that shows a value as one key with
{{ value }}, never as words joined in code. To style the value, split the sentence withsentenceParts()fromsrc/app/core/i18n/sentence-parts.ts. - Format dates with the member's locale (
memberLocaleNow()fromsrc/app/core/i18n/member-language-now.ts), not the browser's.
src/app/core/i18n/member-screens-translated.spec.ts fails on English in a member screen's template or messages, and member-screens-pseudo.spec.ts renders screens in a pseudo language and fails on any plain English left. Values a person or an admin wrote (a name, the cookie banner's message) are data, not text to translate.
The languages a site publishes its content in are a separate setting: see Languages.
Routing
AnalogJS turns files into routes. A file ending in .page.ts with a default export, under src/app/pages/ (or src/custom/pages/ in an app), is a page: search.page.ts is /search, [contentTypeSlug] in a name is a parameter, and a folder in parentheses such as (settings) groups files without adding to the URL.
The explicit routes in src/app/app.routes.ts come before the file-based ones. Admin pages need one: wrap the page in the admin layout with the role guard, like the existing entries:
{
path: 'admin/email-logs',
loadComponent: () => import('./pages/admin.page').then((m) => m.default),
canActivate: [roleGuard],
data: { allowedRoles: ['admin'] },
children: [
{
path: '',
loadComponent: () =>
import('./pages/admin/(email-logs)/email-logs.page').then((m) => m.default),
},
],
},
An admin page that relies on its file alone may render on the server but not reach the browser's route table. The URL then falls through to the public content route and shows "Content Not Found" in the public layout, with HTTP 200, so a status check misses it. Give every admin page an explicit route.
A folder of admin pages with no page of its own, such as src/app/pages/admin/contents/, still gets an address, and it shows an empty admin page. Redirect it to the folder's main page with an explicit route, as admin (to the dashboard) and admin/contents (to Content types) do.
Every page URL also belongs to exactly one feature or to core, so a feature that is off can hide it. Add a new URL to the right list in src/app/core/features/feature-routes.ts; a test fails on one in no list (see Keep core generic).
Change detection without zone.js
The app is zoneless: src/main.ts and src/app/app.config.ts load no zone.js and provide no zone change detection, so Angular 21 runs without it. A view updates when a signal it reads changes, an input changes, an event it bound fires, or something marks it for check. A value changed in place, or a callback's answer that changed, does not by itself update an OnPush view.
Do not make a component OnPush when its output depends on callbacks that read page state or on objects changed in place. The row actions in tables (src/shared/components/row-actions/row-actions.component.ts) use default change detection for this reason: as OnPush they kept showing a Block button for a user who was already blocked. OnPush is fine when everything the view shows comes from signals or from inputs that are replaced, not mutated.
The tests load zone.js (src/test/setup.ts) for TestBed. A spec that changes a value in place calls markForCheck() before detectChanges(), as the app would need to.
The page header
Every admin and member page starts with <arc-page-header> (src/shared/components/page-header/page-header.component.ts): the title and subtitle on the left, the page's own buttons, the admin search box, the language picker and the notification bell on the right. It scrolls with the page, and looks the same in the admin and member layouts.
<arc-page-header title="Contacts" subtitle="Everyone you can email.">
<button mat-flat-button color="primary">Add</button>
</arc-page-header>
| Input or output | Default | Does |
|---|---|---|
title, subtitle | The heading and the line under it | |
showBell | true | The notification bell |
showLanguagePicker | true | The admin language picker. It hides itself outside the admin area when the app declares member languages |
showSearch | true | The admin search box, when the search feature is on |
showBack, (back) | false | A back arrow before the title, and its click |
A spec that renders a page with the header needs headerTestProviders(): see Testing.
Styles
- Libraries. Angular Material components with the indigo-pink theme (imported in
src/styles.css), and Bootstrap 5 for layout, utilities and some components.index.htmlloads Bootstrap, Bootstrap Icons, Font Awesome and the Material Icons font. Admin icons are mostly Font Awesome classes, such asfas fa-arrow-left. - Component styles first. Put a component's styles in the component. Global styles are in
src/styles.css, and everything admin-only there is scoped under.arc-adminso it never reaches public pages. The rules for full-screen pages are scoped underhtml.arc-full-screen(Add pages and routes). - The root component cannot style the page. The routed page is not in the template of
src/app/app.ts, so the root component's own styles do not reach it. The rule that sizes it is insrc/styles.css: the page takes the room between the message banner and the powered-by footer. - Sticky bars in an admin page. Give a bar that sticks to the top of an admin page
top: var(--arc-admin-sticky-top, 0px), so on a phone it sits below the menu bar (src/app/pages/admin.page.ts) instead of covering it. - Dialogs on a phone. Never give a dialog a
minWidthlarger than a phone: a min width beats the max width, so the dialog runs off the screen. Give itmaxWidth: '100vw'. The media picker's size ismediaPickerDialogConfig()insrc/app/pages/admin/(media)/media-picker-dialog.ts. - Shared admin pieces.
src/styles.csshas one set of admin page classes (.page-container, the status badges, side panels). Show a status with.status-badgeand the classstatusBadgeClass()returns (src/shared/utils/status-badge.ts), not with hand-picked Bootstrap colors. - The app's styles win.
src/custom/styles.cssis loaded last, so an app can restyle anything without editing core.