55 |
56 | {% endblock %}
57 |
--------------------------------------------------------------------------------
/modules/vite-react/index.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from '@apostrophecms/vite/vite';
2 | import react from '@vitejs/plugin-react';
3 |
4 | // NOTE: The Vite configuration is needed only when building
5 | // (task or development application boot).
6 | //
7 | // In a production boot (`NODE_ENV=production node app`), the configuration is never used.
8 | // You might want to use dynamic imports here (`await import(...)`) to
9 | // prevent development dependencies from being included in production mode.
10 | // This can be achieved by conditionally assign `enableReact = defineConfig({ ... })`
11 | // based on an environment variable. Keep in mind root level async/await is supported
12 | // only in ESM projects ("type": "module" in package.json).
13 | //
14 | // For example the following only sends configuration
15 | // to Vite when not in production or when in a CI environment:
16 | // let enableReact = {};
17 | // if (process.env.NODE_ENV !== 'production' || process.env.CI === '1') {
18 | // const { defineConfig } = await import('@apostrophecms/vite/vite');
19 | // const react = await import('@vitejs/plugin-react');
20 | // enableReact = defineConfig({ plugins: [ react.default() ] });
21 | // }
22 | //
23 | // and below in the build object:
24 | // vite: {
25 | // extensions: {
26 | // enableReact
27 | // }
28 | // }
29 |
30 | export default {
31 | build: {
32 | vite: {
33 | extensions: {
34 | enableReact: defineConfig({
35 | plugins: [ react() ]
36 | })
37 | // This is the same as:
38 | // enableReact: {
39 | // plugins: [ react() ]
40 | // }
41 | }
42 | }
43 | },
44 | init(self) {
45 | // Add the React Refresh runtime to the head of the page
46 | // but only in HMR mode.
47 | self.apos.template.prepend({
48 | where: 'head',
49 | when: 'hmr:public',
50 | bundler: 'vite',
51 | component: 'vite-react:reactRefresh'
52 | });
53 | },
54 | components(self) {
55 | return {
56 | // Our async server component, see `./views/reactRefresh.html`.
57 | reactRefresh(req, data) {
58 | return {};
59 | }
60 | };
61 | }
62 | };
63 |
--------------------------------------------------------------------------------
/deployment/start:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Make the site live again, for instance by tweaking a .htaccess file
4 | # or starting a node server. In this example we also set up a
5 | # data/port file so that sc-proxy.js can figure out what port
6 | # to forward traffic to for this site. The idea is that every
7 | # folder in /var/webapps represents a separate project with a separate
8 | # node process, each listening on a specific port, and they all
9 | # need traffic forwarded from a reverse proxy server on port 80
10 |
11 | # Useful for debugging
12 | #set -x verbose
13 |
14 | # Express should not reveal information on errors,
15 | # also optimizes Express performance
16 | export NODE_ENV=production
17 |
18 | if [ ! -f "app.js" ]; then
19 | echo "I don't see app.js in the current directory."
20 | exit 1
21 | fi
22 |
23 | # Assign a port number if we don't yet have one
24 |
25 | if [ -f "data/port" ]; then
26 | PORT=`cat data/port`
27 | else
28 | # No port set yet for this site. Scan and sort the existing port numbers if any,
29 | # grab the highest existing one
30 | PORT=`cat ../../../*/data/port 2>/dev/null | sort -n | tail -1`
31 | if [ "$PORT" == "" ]; then
32 | echo "First app ever, assigning port 3000"
33 | PORT=3000
34 | else
35 | # Bash is much nicer than sh! We can do math without tears!
36 | let PORT+=1
37 | fi
38 | echo $PORT > data/port
39 | echo "First startup, chose port $PORT for this site"
40 | fi
41 |
42 | # Run the app via 'forever' so that it restarts automatically if it fails
43 | # Use `pwd` to make sure we have a full path, forever is otherwise easily confused
44 | # and will stop every server with the same filename
45 |
46 | # Use a "for" loop. A classic single-port file will do the
47 | # right thing, but so will a file with multiple port numbers
48 | # for load balancing across multiple cores
49 | for port in $PORT
50 | do
51 | export PORT=$port
52 | forever --minUptime=1000 --spinSleepTime=10000 -o data/console.log -e data/error.log start `pwd`/app.js && echo "Site started"
53 | done
54 |
55 | # Run the app without 'forever'. Record the process id so 'stop' can kill it later.
56 | # We recommend installing 'forever' instead for node apps. For non-node apps this code
57 | # may be helpful
58 | #
59 | # node app.js >> data/console.log 2>&1 &
60 | # PID=$!
61 | # echo $PID > data/pid
62 | #
63 | #echo "Site started"
64 |
--------------------------------------------------------------------------------
/views/layout.html:
--------------------------------------------------------------------------------
1 | {# Automatically extends the right outer layout and also handles AJAX siutations #}
2 | {% extends data.outerLayout %}
3 |
4 | {% set title = data.piece.title or data.page.title %}
5 | {% block title %}
6 | {{ title }}
7 | {% if not title %}
8 | {{ apos.log('Looks like you forgot to override the title block in a template that does not have access to an Apostrophe page or piece.') }}
9 | {% endif %}
10 | {% endblock %}
11 |
12 | {% block extraHead %}
13 |
17 | {% endblock %}
18 |
19 | {% block beforeMain %}
20 |
21 |
22 |
23 |
24 |
25 | {# children of the home page #}
26 |
36 | {% if not data.user %}
37 | Login
38 | {% endif %}
39 |
40 |
41 | {% endblock %}
42 |
43 | {% block main %}
44 | {#
45 | Usually, your page templates in the @apostrophecms/pages module will override
46 | this block. It is safe to assume this is where your page-specific content
47 | should go.
48 | #}
49 | {% endblock %}
50 |
51 | {% block afterMain %}
52 |
53 |
62 |
73 |
74 | {/* A server error message will appear here */}
75 | {message &&
[Server Message] {message}
}
76 |
77 | {/* The Button. No tailwind CSS because we grab it directly
78 | from the vite template installs. */}
79 |
80 |
83 |
84 |
85 | {/* A toggle for debugging - show App props (coming from the server) */}
86 |
87 |
90 |
91 |
92 |
93 | {debug}
94 |
95 |
96 |
97 |
98 | );
99 | }
100 |
101 | export default App;
102 |
--------------------------------------------------------------------------------
/modules/counter/index.js:
--------------------------------------------------------------------------------
1 | export default {
2 | async init(self) {
3 | // Create a custom mongodb collection to store counter data
4 | self.db = await self.apos.db.collection('counterData');
5 | await self.db.createIndex({ type: 1 });
6 |
7 | // A nunjucks filter to convert an object to a `data-*` attribute value
8 | self.apos.template.addFilter({
9 | toAttributeValue: self.toAttributeValue
10 | });
11 | },
12 |
13 | // Define server side async components
14 | components(self) {
15 | return {
16 | // This component is generating the markup used for mounting every counter
17 | // app. It also serializes the data to be used in the client-side app and
18 | // assigns it to the `data-` attributes of the root element.
19 | // The client side code then reads and deserializes this data and
20 | // sends it to the respective `App.xxx` component via `props`.
21 | // See the component template `./views/counterApp.html`.
22 | // The component accespts the following arguments:
23 | // - framework: The framework used in the app
24 | // - widget: The current widget data object
25 | // - page: The current page data object
26 | // - options: The widget options as defined in the current page schema
27 | async counterApp(req, {
28 | framework, widget, page, options
29 | }) {
30 | const counter = (await self.apos.modules.counter
31 | .getWidgetCounter(widget._id)) ?? {};
32 | return {
33 | framework,
34 | widget,
35 | page,
36 | options,
37 | counter
38 | };
39 | }
40 | };
41 | },
42 | methods(self) {
43 | return {
44 | // Get counter data from the database, used in the async server component.
45 | // See `modules/asset/index.js`
46 | async getWidgetCounter(id) {
47 | return self.db.findOne({ _id: id });
48 | },
49 | // A helper to convert an object to an HTML element attribute
50 | toAttributeValue(obj) {
51 | if (typeof obj === 'undefined' || obj === null) {
52 | obj = '';
53 | }
54 | const json = JSON.stringify(obj);
55 | return self.apos.template.safe(
56 | self.apos.util.escapeHtml(json, { single: true })
57 | );
58 | }
59 | };
60 | },
61 | apiRoutes(self) {
62 | return {
63 | post: {
64 | // A custom API route to update the counter data per widget.
65 | // The route path is automatically prefixed with `/api/v1/`,
66 | // the module name and the lowercase, slugified method name.
67 | // POST /api/v1/counter/count
68 | async count(req) {
69 | const {
70 | count, id, type
71 | } = req.body;
72 |
73 | if (!id) {
74 | throw self.apos.error('invalid', 'Missing widget ID', {
75 | invalid: [ 'id' ]
76 | });
77 | }
78 | // Test and showcase frontend error handling
79 | if (count % 9 === 0) {
80 | throw self.apos.error('invalid', {
81 | message: 'I don\'t like numbers that divide by 9 so I\'m rejecting it!',
82 | invalid: [ 'id' ]
83 | });
84 | }
85 | self.db.updateOne({ _id: id }, {
86 | $set: {
87 | count,
88 | type
89 | }
90 | }, { upsert: true });
91 |
92 | return {
93 | ok: true,
94 | count,
95 | type
96 | };
97 | }
98 | }
99 | };
100 | }
101 | };
102 |
--------------------------------------------------------------------------------
/modules/counter-react-widget/ui/src/app/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ApostropheCMS & Vite Demo (based on the Essentials Starter Kit)
2 |
3 | This is a demo project that showcases advanced features of ApostropheCMS and Vite integration. It is based on the [Essentials Starter Kit](https://github.com/apostrophecms/starter-kit-assembly-essentials/tree/next). The Vite integration is still experimental (beta) and this demo uses a "nightly" version of ApostropheCMS and Apostrophe Vite module. The goal of this demo is to showcase the familiar for Vite based products features (like HMR, fast builds, and modern front-end frameworks support), configuration (the usual root level configuration files), but also some more powerful ways to integrate Vite specific features from within the ApostropheCMS project.
4 |
5 | - [Installation](#installation)
6 | - [GitHub Codespaces](#github-codespaces)
7 | - [Local installation](#local-installation)
8 | - [The demo](#the-demo)
9 | - [The frameworks setup](#the-frameworks-setup)
10 | - ["Smarter" Counter apps as widgets](#smarter-counter-apps-as-widgets)
11 | - [In depth setup explanation](#in-depth-setup-explanation)
12 | - [`modules/asset`](#modulesasset)
13 | - [`modules/counter`](#modulescounter)
14 | - [`modules/counter-page`](#modulescounter-page)
15 | - [`modules/@apostrophecms/home-page`](#modulesapostrophecmshome-page)
16 | - [`modules/counter-{vue|svelte|react}-widget`](#modulescounter-vuesveltereact-widget)
17 | - [Tailwind CSS configuration steps](#tailwind-css-configuration-steps)
18 | - [How it works (for nerds)](#how-it-works-for-nerds)
19 | - [Sources discovery](#sources-discovery)
20 | - [Sources aggregation](#sources-aggregation)
21 | - [Synthetic entrypoints](#synthetic-entrypoints)
22 | - [Build pipelines](#build-pipelines)
23 | - [Dev Server \& HMR](#dev-server--hmr)
24 | - [The known problems and limitations](#the-known-problems-and-limitations)
25 |
26 |
27 | ## Installation
28 |
29 | ### GitHub Codespaces
30 |
31 | You can use GitHub Codespaces to run this demo in the cloud. Click the button below to create a new Codespace:
32 |
33 | [](https://codespaces.new/apostrophecms/vite-demo/tree/PRO-6807-demo-features)
34 |
35 | Open a new terminal when the Codespace is ready and run the following commands:
36 |
37 | ```bash
38 | $ node app @apostrophecms/user:add admin admin
39 | ## Type `admin` as the password when prompted
40 | $ npm run dev
41 | ```
42 |
43 | **Only if in the web VSCode editor:**
44 | The easiest way to test HMR is to open the VSCode Preview (Ports tab in the bottom panel, click the Preview icon in the Forwarded Address section for port 3000). You can also open the app in your browser (VSCode will open a new tab with the preview URL) but in order to see the HMR in action you need to make port 3000 Public accessible (right click on the port number in the Ports tab and select Port Visibility -> Public).
45 |
46 | ApostropheCMS uses MongoDB to store and manage data. The container comes with pre-installed MongoDB. Although not necessary for the demo, the container also has the VSCode extension for MongoDB installed so you can inspect the DB. You can use the `mongodb://llocalhost:27017` connection string and directly browse the dbu or use a mongodb playground to run e.g.:
47 |
48 | ```js
49 | /* global use, db */
50 | use('a4-playground');
51 | db.getCollection('aposDocs').findOne({
52 | type: '@apostrophecms/home-page',
53 | aposMode: 'draft'
54 | });
55 | ```
56 |
57 | ### Local installation
58 |
59 | Clone the repository and install the dependencies:
60 |
61 | ```bash
62 | $ git clone https://github.com/apostrophecms/vite-demo.git
63 | $ cd vite-demo
64 | $ npm install
65 | ```
66 |
67 | If you don't have a MongoDB server running and you have docker compose installed, you can start a MongoDB server with:
68 |
69 | ```bash
70 | $ docker compose up -d --remove-orphans
71 | ```
72 |
73 | You can stop the MongoDB server later with:
74 |
75 | ```bash
76 | $ docker-compose down
77 | ```
78 |
79 | If this is the first time you are running the application, you will need to create an admin user:
80 |
81 | ```bash
82 | $ node app @apostrophecms/user:add admin admin
83 | ```
84 | Type `admin` as the password when prompted.
85 |
86 | Finally, start the ApostropheCMS application:
87 |
88 | ```bash
89 | $ npm run dev
90 | ```
91 |
92 | You can test a production build with:
93 |
94 | ```bash
95 | $ npm run build
96 | $ npm run serve
97 | ```
98 |
99 | ## The demo
100 |
101 | Open your browser and navigate to `http://localhost:3000`. Follow the login link and login with the username `admin` and the password `admin`.
102 |
103 | Hit Edit on the home page and add any desired number of "Vue Counter App", "Svelte Counter App", and "React Counter App" widgets by clicking on the "Add Content" button. After publishing using the button in the upper right corner (Update then Preview), you will see the counter apps in action.
104 |
105 | You can also create a new page of type "Counter Apps Page" by going to the pages menu, selecting "New Page" and then selecting the page type from the menu on the right. Choose a title, publish and navigate to the page. Edit the page and add the "Vue Counter App", "React Counter App", and "Svelte Counter App" widgets to the main area. Widgets can be shared between basically any document type.
106 |
107 | The counter apps will "remember" their state (until the application is restarted) even if you navigate away from the page or reload it. You can add multiple instances of the same widget to the page and they will work independently.
108 |
109 | The apps are not loading the counter state via HTTP requests, but are using the server-side rendered initial data.
110 |
111 | Open your favourite code editor and navigate to the `modules` folder. Inside you will find the code for each of the widgets and some other modules we will discuss. The `ui/src/app` directories of the `counter-react-widget`, `counter-vue-widget`, and `counter-svelte-widget` modules contain the files for the main app code of each. You can modify the counter apps (`App.vue`, `App.svelte` and `App.jsx`) and see the changes reflected in the browser without a full page reload (HMR).
112 |
113 | ## The frameworks setup
114 |
115 | All frameworks except ReactJS are integrated via single project level `apos.vite.config.mjs` file. Any additional configuration files are also supported (e.g. `svelte.config.js`, `postcss.config.js`, etc).
116 |
117 | For demonstration purposes (and because by default it requires additional page injection), ReactJS is configured via its own project module `vite-react`. Looking inside the `index.js` file of that module, this is accomplished using the Apostrophe Vite `build.vite` configuration that can be added to any project module. The module also injects the React refresh runtime required for React HMR, using the new conditional injection feature within the `init(self)` block. You can read more about this in the [documentation](https://docs.apostrophecms.org/guide/vite.html#development-specific-features).
118 |
119 | This demo also has Tailwind CSS integrated site-wide and can be used in both front-end and back-end (Nunjucks) code. The configuration steps used while creating the demo are described below.
120 |
121 | ## "Smarter" Counter apps as widgets
122 |
123 | The default template when creating a Vite app for React, Vue, or Svelte is a counter app. In this demo those are ported to the ApostropheCMS widgets: `counter-react-widget`, `counter-vue-widget`, and `counter-svelte-widget` respectively. The respective UI code can be found in `ui/src` directories of these modules. Every widget has its own bundle, which is loaded only when the widget is present on the page (and no user is logged in).
124 |
125 | The widgets are registering [widget players](https://docs.apostrophecms.org/guide/custom-widgets.html#client-side-javascript-for-widgets) as a standard approach in ApostropheCMS. These players are client-side code that is registered by ApostropheCMS to be handled during page edit or refresh. This ensures that our apps will be re-mounted when the page is reloaded, but also when widget configuration changes.
126 |
127 | Additionally, the default counter apps are enhanced to get initial data (props) from the server and save their state back to the server.
128 |
129 | The Counter Apps are made available in the Home page widget area.
130 |
131 | A page module `modules/counter-page` is created to demonstrate sharing these widgets between different document types. It has an area `main` where the widgets are registered.
132 |
133 | ### In depth setup explanation
134 |
135 | Let's demystify the counter apps and follow their integration step by step. There is ApostropheCMS specific context along the way, that I'll try to explain in the most simple way.
136 |
137 | #### `modules/asset`
138 |
139 | The module is inherited from the original Starter Kit Essentials repository and is simply a convenience for organizing some of our assets. It provides the original CSS used in the starter kit. For the purposes of this demo, we added the Tailwind CSS entrypoint (see `ui/src/index.js`) and a common `svg` asset files (`ui/svg`) referenced by the Counter App UI components.
140 |
141 | #### `modules/counter`
142 |
143 | This module contributes the back-end logic required to save the counter value per App Counter widget (on counter button click). The module provides:
144 | - A simple API endpoint using the Apostrophe `apiRoutes(self)` configuration method to save the counter value in the MongoDB database per widget instance.
145 | - A method, `getWidgetCounter(id)`, to get the counter value per widget instance, used in the async server component to pass that value as a prop to the front-end app.
146 | - A Nunjucks helper filter (`toAttributeValue(obj)`) and a server component. The component is defined in the `counterApp()` component method and the template is located in the `views` folder. In ApostropheCMS, components act much like they do in other frameworks, allowing you to add specific functionality to any of your templates. In this case, we are serializing server-side data and sending it to the front-end app via `data-*` attributes.
147 |
148 | #### `modules/counter-page`
149 |
150 | A simple Apostrophe page that provides a widget area containing only the Counter App widgets. In ApostropheCMS, you can configure many modules site-wide by configuring the `options` object in the `index.js` file of the module. Many widgets also allow for configuration options to be added "per area". In other words, each area can have widgets with different configurations. This module demonstrates sending `options` defined within the widget configuration object to the front-end app. The `example` property can be seen in the JSON object seen by the "Show Debug" toggle for any of the counters.
151 |
152 | #### `modules/@apostrophecms/home-page`
153 |
154 | It [improves](https://docs.apostrophecms.org/reference/module-api/module-overview.html#improve) the Apostrophe core Home page module. It's originally used by the Starter Kit to provide a styled home page. In this demo, we are adding the Counter App widgets to the widget area, alongside the existing Rich Text, Image, and Video widgets.
155 |
156 | #### `modules/counter-{vue|svelte|react}-widget`
157 |
158 | `counter-vue-widget`, `counter-svelte-widget`, and `counter-react-widget` are the Counter App widgets. They use an identical setup, with the only difference being the front-end framework used (`App.vue`, `App.svelte`, and `App.jsx` respectively). The widgets are registering a widget player within the `ui/src` folder that mounts the front-end app on the page, importing the App from the `ui/src/app` folder. No initial HTTP requests for the counter value are made, the initial data is passed from the server to the front-end app via `data-*` attributes. On every counter button click, the counter value is saved to the server. After refreshing the page, the counter server value is used as the initial value for the counter app.
159 |
160 | **Be careful**, the counter back-end doesn't like the number `9` for some reason! The reason is artificial for the purposes of this demo. However, the UI apps are smart enough to handle server errors and display a message to the user.
161 |
162 | Let's look at the `counter-vue-widget` as an example:
163 |
164 | - `index.js` - The module definition. It adds a single schema field `title` to the widget. You can [extend the schema with additional fields](https://docs.apostrophecms.org/reference/field-types/). The widget data is sent to `App.vue` as the `widget` prop. There is also `build.vite.bundles` configuration that tells ApostropheCMS to bundle the UI source of this widget separately and load it only when the widget is present on the page (if an editor is logged in, all bundles are loaded). The UI entrypoint becomes the bundle name `ui/src/counter-vue.js` instead of the default `ui/src/index.js`.
165 | - `views/widget.html` - The widget template. It invokes the Nunjucks server component `counterApp` created in `modules/counter` to generate the markup for the widget.
166 | - `ui/src/counter-vue.js` - The entrypoint for the widget UI. Every module UI entrypoint should have a default export function that acts as an "application" bootstrap. In this case, the entrypoint registers a [Widget Player](https://docs.apostrophecms.org/guide/custom-widgets.html#client-side-javascript-for-widgets) (a selector and handling function) that mounts the Vue app on the page and passing server data as props.
167 | - `ui/src/app/App.vue` - The Vue Counter app. It receives the `widget` prop with the initial data from the server. The app has a single `counter` state that is updated on the button click. The counter value is saved to the server on every button click. The app is using the `svg` assets provided by the `modules/asset` module. The "Show Debug" toggle shows the component props received from the server.
168 |
169 | ## Tailwind CSS configuration steps
170 |
171 | The following steps were performed to integrate Tailwind CSS with ApostropheCMS, following the official guide: https://tailwindcss.com/docs/guides/vite
172 |
173 | It's not necessary to follow these steps to use the demo. They are provided as a reference for those who want to integrate Tailwind CSS with ApostropheCMS.
174 |
175 | 1. Install Tailwind CSS (we skip `postcss` because it's internally managed by `vite`):
176 | ```bash
177 | npm install -D tailwindcss autoprefixer
178 | ```
179 |
180 | 2. Init
181 | ```bash
182 | npx tailwindcss init -p
183 | ```
184 |
185 | 3. Edit the created `tailwind.config.js` to become:
186 | ```js
187 | /** @type {import('tailwindcss').Config} */
188 | module.exports = {
189 | content: [
190 | './apos-build/@apostrophecms/vite/default/src/**/*.{js,jsx}',
191 | './modules/**/views/**/*.html',
192 | './views/**/*.html',
193 | ],
194 | theme: {
195 | extend: {},
196 | },
197 | plugins: [],
198 | }
199 | ```
200 | Edit `apos.vite.config.js` to exclude the nunjucks templates from triggering page reloads:
201 | ```js
202 | // ...
203 | server: {
204 | watch: {
205 | // So that Tailwind CSS changes in the nunjucks templates do not trigger
206 | // page reloads. This is done by `nodemon` because we need a process restart.
207 | ignored: [
208 | path.join(__dirname, 'modules/views/**/*.html'),
209 | path.join(__dirname, 'views/**/*.html')
210 | ]
211 | }
212 | }
213 | // ...
214 | ```
215 |
216 | 4. Create `./modules/asset/ui/src/tailwind.css` with the following content:
217 | ```css
218 | @tailwind base;
219 | @tailwind components;
220 | @tailwind utilities;
221 | ```
222 |
223 | 5. Edit `./modules/asset/ui/src/index.js` to import the CSS file:
224 | ```js
225 | import './tailwind.css'
226 | // The rest is the same
227 | ```
228 | > The `tailwind.css` file could also be imported into a `./modules/asset/ui/src/index.scss` file. Here we were adapting a site with existing styling, so it was cleaner to bring it into the `index.js` file.
229 |
230 | 6. Edit `./modules/@apostrophecms/home-page/views/page.html` and add (server side rendering testing):
231 | ```html
232 |
233 |
234 | Hello World From Tailwind CSS
235 |
236 |
237 | ```
238 |
239 | 7. `npm run dev`
240 |
241 | Tailwind now works for both server-side and client-side rendering (HMR included). The original starter kit styles are preserved.
242 |
243 | ## How it works (for nerds)
244 |
245 | The demo uses our brand new `@apostrophecms/vite` module to integrate Vite with ApostropheCMS. In order for us to achieve that, we developed a brand new system in the core to support "external build tools" and went from hardcoded page script injection to a manifest-based approach. Additionally, we added an abstract public API to the core, that simplifies source discovery and synthetic entrypoints, so that build tools can concentrate on bundle vendor specific logic. This architecture allows us to support multiple build tools and configurations in the future, if the need for that arises.
246 |
247 | The internal Apostrophe Webpack build is still fully supported, using the legacy build system.
248 |
249 | ### Sources discovery
250 |
251 | ApostropheCMS is a fully "module-based" platform. Every piece of code is contributed by an Apostrophe module - both front and server side. Modules can be `npm` packages or local directories. Local modules live in `./modules` directory of the project. Local modules can also extend or improve other modules, including npm and ApostropheCMS core modules.
252 |
253 | Every module can contribute to the front-end code. The front-end code is located in the `ui/` directory of the module. A module can also extend the Apostrophe admin UI, by providing/overriding additional Vue components, CSS, or JavaScript code.
254 |
255 | There is a clear distinction between "public" and "admin" UI code. We call them `public` and `apos` builds respectively. We are building those in separate pipelines, with separate configurations, and we are serving the code from separate directories. The project can only configure the `public` build, the `apos` build is managed entirely internally.
256 |
257 | The entrypoints for the `public` build are discovered by scanning the `ui/src` directories of all modules registered in `app.js`. The default entrypoint is `ui/src/index.js`, but modules can also define `bundles` in their configuration (which is done in this demo) that results in `ui/src/[bundle-name].js` being used as an entrypoint. Additionally, bundles are only loaded when the module that defines them is present on the page (or if a user is logged in).
258 |
259 | Every entrypoint should have a default export function that acts as "application" bootstrap - it's internally called by ApostropheCMS when the page is loaded.
260 |
261 | The `apos` build sources are scanned in a similar way, but in `ui/apos` directories. I'm not going to deep dive into the `apos` build specifics here, for those interested there is [extensive documentation in the ApostropheCMS documentation site](https://docs.apostrophecms.org/guide/custom-ui.html#components-with-a-logic-mixin-are-safer-and-easier-to-override).
262 |
263 | ### Sources aggregation
264 |
265 | It's impractical to build/watch sources scattered across multiple directory trees, including inside `node_modules/`. Furthermore, smart bundlers are optimizing sources located in `node_modules/` and doesn't allow HMR for them - something that we don't want in some cases. Keep in mind that the Apostrophe admin UI is also not pre-built, the entire UI is built in and for the project, so that any module can modify it.
266 |
267 | We are aggregating all sources into a single directory tree, that is then used by Vite to build the final bundles. This is done by computing something we call `build metadata` that contains every file considered a UI `source` and its relation to an Apostrophe module. This opens a lot of awesome possibilities, but also (there is no free lunch) introduces unique (fun) problems to solve.
268 |
269 | All sources are copied to `./apos-build` directory of the project. To be more precise - `./apos-build/@apostrophecms/vite/default/src` is the exact location, where that same path excluding the `src` folder is the build (Vite) root. The namespacing is required to avoid conflicts between different build tools (in the future) and configurations (configured project namespace, Apostrophe Assembly multisite just to name a few). When copying the sources, we are preserving the original directory structure by only "skipping" the `ui/` part of the path. This way building the sources becomes a trivial task for Vite. The "smart copy" is also handling (in an extremely efficient way) source overrides as a result of module inheritance (extend/improve).
270 |
271 | We can also easily support editor autocompletion and other goodies, by introducing a universal alias `@/` that points to the `./apos-build/@apostrophecms/vite/default/src` directory and configure it in `jsconfig.json` or `tsconfig.json` of the project (to make editors happy).
272 |
273 | ### Synthetic entrypoints
274 |
275 | The `build metadata` is used to generate synthetic entrypoints for Vite. They are internally registered as `build.rollupOptions.input` in the Vite configuration.
276 |
277 | For `public` builds, every `input` is an auto-generated `[input-name].js` file that imports previously discovered module sources. All `ui/src/index.js` apps are imported and executed in a single `input`, while every configured `bundle` is imported and executed in a separate `input`. The import paths are relative to the `./apos-build/@apostrophecms/vite/default/src` directory.
278 |
279 | The `apos` build contains a single auto-generated `input` that handles everything, from component registration, 3rd party modules integration to admin UI specific `apps`.
280 |
281 | ### Build pipelines
282 |
283 | Historically, ApostropheCMS builds `apos` (admin UI) and `public` (site UI) code in separate pipelines. There is a good reason for that - we don't want the admin UI to interfere with the project UI and vice versa. The same problem exists with the Vite integration. We made an attempt to build everything in a single pipeline, but it was a disaster. The main problem comes from configration that can't be shared between the two builds, mostly Sass and PostCSS (generally CSS) configuration.
284 |
285 | Keep in mind that the `apos` bundle is loaded on a page only when editor is logged in, so the performance impact is not that big an issue.
286 |
287 | ### Dev Server & HMR
288 |
289 | As a consequence of the above, the dev server (Vite middleware) can run in only "one mode" - `public` or `apos` (Apostrophe `asset` module configuration). While not ideal, this was a good compromise that allows us to have a fast and reliable development experience.
290 |
291 | The Vite module is reusing (in-memory) the same metadata used for copying sources in order to deliver another feature that makes HMR possible - watch mode handling. The core system is using `chokidar` to watch for changes in every known `ui/` directory of the project or in symlinked npm package. The Vite module is using additional index to ensure fast reaction on changes including "smart copy" of the changed files to the `./apos-build` directory. This is the trigger for Vite to deliver HMR to the browser.
292 |
293 | ## The known problems and limitations
294 |
295 | - The `apos` HMR is not working when the `public` build contains Vue apps. The reason for that lies in the fact that the `apos` and `public` builds can't share the same Vue instance. As a result of that, HMR is available to the "first" Vue instance that is loaded on the page. There are some ideas that we are exploring to solve this problem.
296 | - No SSR yet. UI Apps can receive initial data from the server, but the real server-side rendering (render on the server per framework, mount and hydrate on the client) is yet to be planned and implemented.
297 | - Following alias imports (`@/path/to/file`) in an editor will lead to the `apos-build` directory, not the original source. This is confusing and far from a good DX. We are exploring ways to solve this problem for all editors that support Typescript configuration files.
298 |
299 |
--------------------------------------------------------------------------------