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

  1. Nothing specific to one app. No app's names, roles, collections, pages or wording in core code, rules, tests or docs.

  2. 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).

  3. Leave the custom space empty. Core ships only the empty starter files there and never changes or deletes one once shipped (see below).

  4. 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:

PathWhat 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.jsonNothing: the rules build treats them as optional
A custom folder in tests/rules/, docs/custom/ and custom/ at the rootNothing 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

  1. 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_STARTERS in scripts/custom-starters.mjs, record its hash in SHIPPED_FILES (the failing check prints the line), and stub it in src/test/setup.ts (or functions/src/__tests__/setup.ts) like the others, so core specs see it empty whatever an app puts there (Testing).

  2. Make core read it and behave exactly as before when it is empty.

  3. 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:

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:

  1. Register it. Add the id to FEATURE_IDS and to FEATURE_INFO in src/app/core/features/feature-registry.ts, with its label, default and needs. Add the same id, in the same place, to FEATURE_IDS in functions/src/feature-flags.ts.

  2. Claim its pages. Add its URLs to FEATURE_URLS in src/app/core/features/feature-routes.ts (a/** for a and everything below it). A route whose URL starts with a parameter cannot be told apart by URL: wrap it in whenOn('your-id', [...]) in src/app/app.routes.ts and list it in FEATURE_PARAM_ROUTES, or give a file-based page canActivate: [featureGuard('your-id')] in its routeMeta.

  3. Tag its menu items and settings tabs. Set feature on its items in src/app/core/nav/admin-menu.ts (or src/app/core/nav/member-menu.ts for the member menu) and on its tabs in src/app/pages/admin/(settings)/settings.page.ts. Items of a feature that is off are left out.

  4. Move its Cloud Functions. Export them from functions/src/features/<id>.ts, a file that only re-exports, and not from functions/src/all.ts. Functions that need two features go in a file named after both, such as functions/src/features/search+content.ts. Before every functions build, scripts/arc-features.mjs writes feature-exports.gen.ts, which exports only the files whose features are all on.

  5. Guard the crossings. Where core code calls into the feature, check it first: isOn(id) from src/app/core/features/features.ts in the browser, isFeatureOn(id) from functions/src/feature-flags.ts in 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.

  6. Document it. Write docs/features/<id>.html with 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:

TestFails when
src/app/core/features/feature-registry.spec.tsAn 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.tsA 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.tsA 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.tsA settings tab's feature differs from its page's
functions/src/__tests__/featureFlags.spec.tsThe 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/.