Keep core generic
Arc CMS is the core that many websites and apps share. When you change Arc CMS itself, keep it free of anything that only one of them needs, so every copy can take the update.
This page is for work on Arc CMS. If you are building an app on a copy of it, you work the other way round: see Core and custom and Keep core untouched.
The rules
Nothing specific to one app. No app's names, roles, collections, pages or wording in core code, rules, tests or docs.
An app's need becomes a general feature or a plug point. When an app needs something core does not offer, build the general version in core: a feature any site could switch on, or a plug point the app fills in from its custom space. Document the plug point where apps look for it (Use the custom space).
Leave the custom space empty. Core ships only the empty starter files there and never changes or deletes one once shipped (see below).
Docs and tests are part of the change. See Docs and tests.
The custom space stays empty
These paths belong to the app built on a copy of Arc CMS. npm run check:core counts them as the app's own, and pulling an Arc CMS update never touches them:
| Path | What core ships |
|---|---|
src/custom/ | Empty starter files: features.ts, routes.ts, nav.ts, nav-changes.ts, home.ts, app-accounts.ts, sign-in.ts, user-dashboard.ts, pwa.ts, languages.ts, styles.css, the i18n/ files, an empty pages/ folder and a README |
functions/src/custom/ | Empty index.ts, search-sources.ts and public-callables.txt |
firestore.app.rules, storage.app.rules, firestore.app.indexes.json | Nothing: the rules build treats them as optional |
A custom folder in tests/rules/, docs/custom/ and custom/ at the root | Nothing beyond the README in docs/custom/ and in custom/ |
.github/ | Nothing: CI workflows are the app's (Deploy from CI) |
Each starter file exports an empty value (an empty list, an empty object, or null) that core reads as "nothing added". Put nothing else in them in core, not even an example. Once a starter file has shipped, it is never changed again: not its code, not its comment, not a README row. Every app has its own version of these files, and a change to any line the app also changed conflicts on its next update. So examples live in the docs (Use the custom space and the plug point's own page), and a starter's comment only says what the file is for and which page explains it.
scripts/custom-starters.mjs records every file shipped in the custom space with a hash of its contents (SHIPPED_FILES). In Arc CMS's own repository, src/app/custom-space.spec.ts fails when one of them changed or was deleted, naming the file and this rule, and when a new file there is not recorded yet, printing the line to record it. In an app the check is skipped: the files are the app's to change.
Add a plug point
Add a new starter file that exports an empty value. A new plug point is always a new file, never a new export in a file already shipped, since that edit could conflict with an app's version of the file. List the new file, with its empty exports, in
CUSTOM_STARTERSinscripts/custom-starters.mjs, record its hash inSHIPPED_FILES(the failing check prints the line), and stub it insrc/test/setup.ts(orfunctions/src/__tests__/setup.ts) like the others, so core specs see it empty whatever an app puts there (Testing).Make core read it and behave exactly as before when it is empty.
Explain it in the app docs, with examples. The file's comment says in a line or two what it is for and links the page. Get the comment right before it ships: it cannot change afterwards.
For example, functions/src/custom/search-sources.ts exports empty SEARCH_COLLECTIONS and CUSTOM_SEARCH_SOURCES lists, and core search indexes whatever an app adds to them.
Features and their needs
A feature is a part of Arc CMS an app can switch off (or, for the installable app, on) in src/custom/features.ts. Everything that is not a feature is core and always on. The list is in src/app/core/features/feature-registry.ts:
FEATURE_IDS: the ids.FEATURE_INFO: for each id, alabelfor build messages,defaultOn, andneeds, the features that must be on for it to work (signup forms and email marketing need audience).resolveFeatures(): turns the app's choice into the set of features that are on, and stops the build with a plain message for an unknown id, an id in both lists, or a feature whose need is off.
The file has no imports, because vite.config.ts and scripts/arc-features.mjs read it with Node as well as the app. What each feature covers is in Feature ids.
Add a feature
Make something a feature when a site or app could reasonably do without it. Then every part of it must follow the switch:
Register it. Add the id to
FEATURE_IDSand toFEATURE_INFOinsrc/app/core/features/feature-registry.ts, with its label, default and needs. Add the same id, in the same place, toFEATURE_IDSinfunctions/src/feature-flags.ts.Claim its pages. Add its URLs to
FEATURE_URLSinsrc/app/core/features/feature-routes.ts(a/**foraand everything below it). A route whose URL starts with a parameter cannot be told apart by URL: wrap it inwhenOn('your-id', [...])insrc/app/app.routes.tsand list it inFEATURE_PARAM_ROUTES, or give a file-based pagecanActivate: [featureGuard('your-id')]in itsrouteMeta.Tag its menu items and settings tabs. Set
featureon its items insrc/app/core/nav/admin-menu.ts(orsrc/app/core/nav/member-menu.tsfor the member menu) and on its tabs insrc/app/pages/admin/(settings)/settings.page.ts. Items of a feature that is off are left out.Move its Cloud Functions. Export them from
functions/src/features/<id>.ts, a file that only re-exports, and not fromfunctions/src/all.ts. Functions that need two features go in a file named after both, such asfunctions/src/features/search+content.ts. Before every functions build,scripts/arc-features.mjswritesfeature-exports.gen.ts, which exports only the files whose features are all on.Guard the crossings. Where core code calls into the feature, check it first:
isOn(id)fromsrc/app/core/features/features.tsin the browser,isFeatureOn(id)fromfunctions/src/feature-flags.tsin functions. Decide what core does when it is off, and make that safe: code that erases a person's data, for example, still reaches data a feature left behind.Document it. Write
docs/features/<id>.htmlwith the feature page sections, and add the id to Feature ids. The docs checks fail until both exist.
The tests that keep features complete
These fail when a new piece is not claimed by a feature or by core, so nothing ships outside the switch:
| Test | Fails when |
|---|---|
src/app/core/features/feature-registry.spec.ts | An id has no info, a need is not a feature, needs form a cycle, or a feature other than the PWA is off by default |
src/app/core/features/feature-coverage.spec.ts | A page URL (explicit or file-based) belongs to no feature and is not in CORE_URLS, or to two; or a listed URL no longer exists |
src/shared/components/side-navbar/side-navbar.component.spec.ts | A menu item's feature differs from its page's, or no route serves its page. An app's own page (src/custom/routes.ts) may be linked by an item of any feature |
src/app/pages/admin/(settings)/settings.page.spec.ts | A settings tab's feature differs from its page's |
functions/src/__tests__/featureFlags.spec.ts | The functions' ids differ from the browser's, a feature file is misnamed, a module is exported from both core and a feature, or a file that defines a function is exported from neither or from two |
A new core page goes in CORE_URLS in the same file as FEATURE_URLS.
Docs and tests
A change to Arc CMS is finished when the docs, the tests and the whole suite agree with it: find the pages your change affects, fix them, add the tests that would have caught a break, and run everything. The steps are in Docs and tests are part of done, and how to write a page is in Writing docs. Keep the docs generic too: they describe Arc CMS for every site and app, and an app's own pages go in docs/custom/.