├── test-deploy ├── .stylelintignore ├── .stylelintrc ├── cypress ├── support │ └── e2e.js ├── apos-db │ ├── default │ ├── dashboard │ └── site-demo ├── plugins │ └── index.js └── tests │ ├── dashboard.cy.js │ ├── site-demo.cy.js │ └── site-default.cy.js ├── .dockerignore ├── postcss.config.js ├── dashboard ├── locales │ └── en.json ├── modules │ ├── @apostrophecms │ │ ├── admin-bar │ │ │ └── index.js │ │ ├── asset │ │ │ └── index.js │ │ ├── file │ │ │ └── index.js │ │ ├── file-tag │ │ │ └── index.js │ │ ├── image-tag │ │ │ └── index.js │ │ ├── express │ │ │ └── index.js │ │ └── template │ │ │ └── views │ │ │ └── outerLayout.html │ └── site │ │ └── index.js ├── views │ └── layout.html └── index.js ├── sites ├── modules │ ├── theme-demo │ │ ├── ui │ │ │ └── src │ │ │ │ ├── scss │ │ │ │ ├── widgets │ │ │ │ │ └── _image-widget.scss │ │ │ │ ├── layout │ │ │ │ │ ├── _area.scss │ │ │ │ │ ├── _main.scss │ │ │ │ │ └── _grid.scss │ │ │ │ ├── utilities │ │ │ │ │ ├── _display.scss │ │ │ │ │ └── _accessibility.scss │ │ │ │ ├── global │ │ │ │ │ ├── _document.scss │ │ │ │ │ └── _typography.scss │ │ │ │ ├── components │ │ │ │ │ ├── _image.scss │ │ │ │ │ ├── _footer.scss │ │ │ │ │ ├── _header.scss │ │ │ │ │ ├── _code.scss │ │ │ │ │ ├── _placeholder.scss │ │ │ │ │ ├── _button.scss │ │ │ │ │ └── _link.scss │ │ │ │ ├── functions │ │ │ │ │ └── _rem.scss │ │ │ │ ├── settings │ │ │ │ │ ├── _color.scss │ │ │ │ │ └── _font.scss │ │ │ │ └── pages │ │ │ │ │ └── _welcome.scss │ │ │ │ ├── js │ │ │ │ └── placeholder.js │ │ │ │ ├── index.js │ │ │ │ └── index.scss │ │ ├── views │ │ │ ├── placeholder.html │ │ │ └── welcome.html │ │ └── index.js │ ├── @apostrophecms │ │ ├── page │ │ │ ├── views │ │ │ │ └── notFound.html │ │ │ └── index.js │ │ ├── express │ │ │ └── index.js │ │ ├── admin-bar │ │ │ └── index.js │ │ ├── home-page │ │ │ ├── views │ │ │ │ └── page.html │ │ │ └── index.js │ │ ├── asset │ │ │ └── index.js │ │ ├── template │ │ │ ├── views │ │ │ │ └── outerLayout.html │ │ │ └── index.js │ │ └── global │ │ │ └── index.js │ ├── widgets │ │ ├── column-widget │ │ │ ├── ui │ │ │ │ └── src │ │ │ │ │ └── index.scss │ │ │ ├── views │ │ │ │ └── widget.html │ │ │ └── index.js │ │ ├── link-widget │ │ │ ├── index.js │ │ │ └── views │ │ │ │ └── widget.html │ │ ├── modules.js │ │ ├── accordion-widget │ │ │ ├── views │ │ │ │ └── widget.html │ │ │ ├── ui │ │ │ │ └── src │ │ │ │ │ ├── index.js │ │ │ │ │ └── index.scss │ │ │ └── index.js │ │ ├── card-widget │ │ │ ├── ui │ │ │ │ └── src │ │ │ │ │ └── index.scss │ │ │ ├── index.js │ │ │ └── views │ │ │ │ └── widget.html │ │ ├── slideshow-widget │ │ │ ├── views │ │ │ │ └── widget.html │ │ │ ├── ui │ │ │ │ └── src │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.js │ │ │ └── index.js │ │ └── hero-widget │ │ │ ├── ui │ │ │ └── src │ │ │ │ └── index.scss │ │ │ ├── views │ │ │ └── widget.html │ │ │ └── index.js │ ├── theme-default │ │ ├── ui │ │ │ └── src │ │ │ │ ├── index.scss │ │ │ │ └── index.js │ │ └── index.js │ ├── websocket │ │ └── index.js │ ├── default-page │ │ ├── index.js │ │ └── views │ │ │ └── page.html │ ├── helpers │ │ └── index.js │ └── @apostrophecms-pro │ │ └── palette │ │ ├── lib │ │ └── configs │ │ │ ├── grid.js │ │ │ ├── typography.js │ │ │ └── color.js │ │ └── index.js ├── public │ └── images │ │ └── logo.png ├── lib │ ├── theme-default.js │ ├── theme-demo.js │ └── schema │ │ └── link.js ├── views │ ├── fragments │ │ └── link.html │ ├── includes │ │ └── attributes.html │ └── layout.html └── index.js ├── deployment ├── before-deploying └── rsync_exclude.txt ├── .editorconfig ├── themes.js ├── eslint.config.js ├── LICENSE.md ├── domains.js ├── scripts ├── for-each-theme └── wait-for-port ├── nodemon.json ├── cypress.config.js ├── .gitignore ├── telemetry.js ├── app.js ├── package.json ├── Dockerfile ├── self-hosting.md └── README.md /test-deploy: -------------------------------------------------------------------------------- 1 | 1 2 | -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | apos-build -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-apostrophe" 3 | } 4 | -------------------------------------------------------------------------------- /cypress/support/e2e.js: -------------------------------------------------------------------------------- 1 | import '@apostrophecms-pro/cypress-tools/commands'; 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | /sites/public/uploads 2 | /dashboard/public/uploads 3 | /node_modules 4 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | autoprefixer: {} 4 | } 5 | }; 6 | -------------------------------------------------------------------------------- /dashboard/locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "Server error, please try again.": "Server error, please try again." 3 | } -------------------------------------------------------------------------------- /dashboard/modules/@apostrophecms/admin-bar/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | options: { 3 | pageTree: false 4 | } 5 | }; 6 | -------------------------------------------------------------------------------- /cypress/apos-db/default: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apostrophecms/starter-kit-assembly-essentials/develop/cypress/apos-db/default -------------------------------------------------------------------------------- /cypress/apos-db/dashboard: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apostrophecms/starter-kit-assembly-essentials/develop/cypress/apos-db/dashboard -------------------------------------------------------------------------------- /cypress/apos-db/site-demo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apostrophecms/starter-kit-assembly-essentials/develop/cypress/apos-db/site-demo -------------------------------------------------------------------------------- /sites/modules/theme-demo/ui/src/scss/widgets/_image-widget.scss: -------------------------------------------------------------------------------- 1 | .image-widget { 2 | display: inline-block; 3 | max-width: 100%; 4 | } 5 | -------------------------------------------------------------------------------- /sites/public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apostrophecms/starter-kit-assembly-essentials/develop/sites/public/images/logo.png -------------------------------------------------------------------------------- /sites/modules/@apostrophecms/page/views/notFound.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block title %}404 - Page Not Found{% endblock %} 4 | -------------------------------------------------------------------------------- /sites/modules/widgets/column-widget/ui/src/index.scss: -------------------------------------------------------------------------------- 1 | .column-widget:not(:last-of-type) { 2 | margin-bottom: var(--palette-grid-margin); 3 | } 4 | -------------------------------------------------------------------------------- /sites/modules/theme-demo/ui/src/scss/layout/_area.scss: -------------------------------------------------------------------------------- 1 | [class*='-widget']:not(:is(:last-child, [class*='apos'])) { 2 | margin-bottom: 20px; 3 | } 4 | -------------------------------------------------------------------------------- /sites/modules/theme-default/ui/src/index.scss: -------------------------------------------------------------------------------- 1 | // Styles for this theme 2 | 3 | // If you need to share styles across themes, just @import them here 4 | 5 | -------------------------------------------------------------------------------- /sites/lib/theme-default.js: -------------------------------------------------------------------------------- 1 | export default function(site, config) { 2 | config.modules = { 3 | ...config.modules, 4 | 'theme-default': {} 5 | }; 6 | }; 7 | -------------------------------------------------------------------------------- /sites/modules/theme-demo/ui/src/scss/utilities/_display.scss: -------------------------------------------------------------------------------- 1 | .inline-block { 2 | display: inline-block; 3 | } 4 | 5 | .hidden, 6 | .is-hidden { 7 | display: none; 8 | } 9 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | import { plugin } from '@apostrophecms-pro/cypress-tools'; 2 | 3 | export default (on, config) => { 4 | plugin(on, config); 5 | return config; 6 | }; 7 | -------------------------------------------------------------------------------- /dashboard/modules/@apostrophecms/asset/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | options: { 3 | // When not in production, refresh the page on restart 4 | refreshOnRestart: true 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /deployment/before-deploying: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This project requires an asset build. Assembly will 4 | # run this script at the appropriate step in the deployment 5 | # process. 6 | 7 | npm run build || exit 1 8 | -------------------------------------------------------------------------------- /dashboard/modules/@apostrophecms/file/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | options: { 3 | quickCreate: false 4 | }, 5 | methods(self) { 6 | return { 7 | addToAdminBar() {} 8 | }; 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /dashboard/modules/@apostrophecms/file-tag/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | options: { 3 | quickCreate: false 4 | }, 5 | methods(self) { 6 | return { 7 | addToAdminBar() {} 8 | }; 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /dashboard/modules/@apostrophecms/image-tag/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | options: { 3 | quickCreate: false 4 | }, 5 | methods(self) { 6 | return { 7 | addToAdminBar() {} 8 | }; 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_size = 2 9 | indent_style = space 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | -------------------------------------------------------------------------------- /sites/modules/theme-demo/ui/src/scss/global/_document.scss: -------------------------------------------------------------------------------- 1 | body { 2 | min-height: 100vh; 3 | color: var(--palette-font-color); 4 | font-family: $font-sans-serif; 5 | background-color: var(--palette-color-background); 6 | } 7 | -------------------------------------------------------------------------------- /sites/lib/theme-demo.js: -------------------------------------------------------------------------------- 1 | export default function (site, config) { 2 | config.modules = { 3 | ...config.modules, 4 | 'theme-demo': { 5 | options: { 6 | shortName: site.shortName 7 | } 8 | } 9 | }; 10 | }; 11 | -------------------------------------------------------------------------------- /sites/modules/theme-demo/ui/src/scss/components/_image.scss: -------------------------------------------------------------------------------- 1 | figure { 2 | margin: 0 0 20px; 3 | 4 | img { 5 | max-width: 100%; 6 | } 7 | 8 | figcaption { 9 | margin-top: 5px; 10 | font-size: rem(14); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /sites/views/fragments/link.html: -------------------------------------------------------------------------------- 1 | {% fragment template(options) %} 2 | {{ rendercaller() }} 7 | {% endfragment %} 8 | -------------------------------------------------------------------------------- /sites/modules/@apostrophecms/express/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | options: { 3 | apiKeys: process.env.CI === '1' 4 | ? { 5 | cypressAPIKey: { 6 | role: 'admin' 7 | } 8 | } 9 | : {} 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /sites/modules/theme-demo/ui/src/scss/functions/_rem.scss: -------------------------------------------------------------------------------- 1 | // Converts a px font size to rem unit 2 | @use 'sass:math'; 3 | 4 | // stylelint-disable-next-line at-rule-disallowed-list 5 | @function rem($value) { 6 | @return math.div($value, 16) + rem; 7 | } 8 | -------------------------------------------------------------------------------- /dashboard/modules/@apostrophecms/express/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | options: { 3 | apiKeys: process.env.CI === '1' 4 | ? { 5 | cypressAPIKey: { 6 | role: 'admin' 7 | } 8 | } 9 | : {} 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /sites/views/includes/attributes.html: -------------------------------------------------------------------------------- 1 | {% for attribute, value in options.attributes %} 2 | {% if attribute | isBooleanAttr %} 3 | {% if value %} 4 | {{ attribute }} 5 | {% endif %} 6 | {% elseif value != '' %} 7 | {{ attribute }}="{{ value }}" 8 | {% endif %} 9 | {% endfor %} 10 | -------------------------------------------------------------------------------- /sites/modules/widgets/link-widget/index.js: -------------------------------------------------------------------------------- 1 | import link from '../../../lib/schema/link.js'; 2 | 3 | export default { 4 | extend: '@apostrophecms/widget-type', 5 | options: { 6 | label: 'Link', 7 | icon: 'cursor-default-click-icon' 8 | }, 9 | fields: { 10 | add: link 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /themes.js: -------------------------------------------------------------------------------- 1 | // Maintained in one place so we don't forget to add them to the 2 | // list-themes task, which apostrophe cloud needs to minify assets 3 | // for each theme 4 | 5 | export default [ 6 | { 7 | value: 'default', 8 | label: 'Default' 9 | }, 10 | { 11 | value: 'demo', 12 | label: 'Demo' 13 | } 14 | ]; 15 | -------------------------------------------------------------------------------- /sites/modules/theme-demo/ui/src/scss/settings/_color.scss: -------------------------------------------------------------------------------- 1 | $color-purple: #6236ff; 2 | $color-pink: #fe5599; 3 | $color-green: #0c8; 4 | $color-gold: #f7b500; 5 | 6 | $color-light-yellow: #ffffd8; 7 | 8 | $color-white: #fff; 9 | $color-gray-05: #eee; 10 | $color-gray-15: #dbdbdb; 11 | $color-gray-80: #2b2b2b; 12 | $color-black: #000; 13 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import apostrophe from 'eslint-config-apostrophe'; 2 | import pluginCypress from 'eslint-plugin-cypress'; 3 | import { defineConfig, globalIgnores } from 'eslint/config'; 4 | 5 | export default defineConfig([ 6 | globalIgnores([ 7 | '*.d.ts' 8 | ]), 9 | apostrophe, 10 | pluginCypress.configs.recommended 11 | ]); 12 | -------------------------------------------------------------------------------- /sites/modules/widgets/modules.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Apostrophe Pro Basic Widgets to kick start development. 3 | * Remove if not needed or add/modify to suit your specific needs. 4 | */ 5 | 6 | export default { 7 | 'accordion-widget': {}, 8 | 'card-widget': {}, 9 | 'column-widget': {}, 10 | 'hero-widget': {}, 11 | 'link-widget': {}, 12 | 'slideshow-widget': {} 13 | }; 14 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2020, 2021 Apostrophe Technologies. Licensed to enterprise customers of Apostrophe Technologies according to the terms of their individual agreements. This code is intended as a starting point for your own work and license holders may modify it freely for their own purposes related to operating a project based on the Apostrophe Assembly and Apostrophe multisite technologies. 2 | -------------------------------------------------------------------------------- /domains.js: -------------------------------------------------------------------------------- 1 | export default { 2 | local: 'localhost:3000', 3 | // Should be a real registered domain or subdomain with a 4 | // DNS wildcard pointing to the cloud 5 | staging: 'a3-assembly-staging.apostrophecms.com', 6 | // Should be a real registered domain or subdomain with a 7 | // DNS wildcard pointing to the cloud 8 | prod: 'a3-assembly-demo.apostrophecms.com' 9 | }; 10 | -------------------------------------------------------------------------------- /sites/modules/theme-demo/ui/src/scss/settings/_font.scss: -------------------------------------------------------------------------------- 1 | $font-monospace: menlo, monaco, consolas, 'Liberation Mono', 'Courier New', monospace; 2 | $font-sans-serif: -apple-system, blinkmacsystemfont, "Segoe UI", roboto, helvetica, arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 3 | 4 | $font-weight-light: 200; 5 | $font-weight-normal: 400; 6 | $font-weight-bold: 700; -------------------------------------------------------------------------------- /sites/modules/@apostrophecms/admin-bar/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | options: { 3 | groups: [ 4 | { 5 | name: 'media', 6 | label: 'Media', 7 | items: [ 8 | '@apostrophecms/image', 9 | '@apostrophecms/file', 10 | '@apostrophecms/image-tag', 11 | '@apostrophecms/file-tag' 12 | ] 13 | } 14 | ] 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /sites/modules/@apostrophecms/home-page/views/page.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | 3 | {# 4 | TODO: The markup below is for demonstration purposes only 5 | Modify this template to meet your specific needs 6 | #} 7 | 8 | {% block main %} 9 |
10 | {% if data.theme === 'demo' %} 11 | {% include 'theme-demo:welcome.html' %} 12 | {% endif %} 13 | 14 | {% area data.page, 'main' %} 15 |
16 | {% endblock %} -------------------------------------------------------------------------------- /sites/modules/theme-demo/ui/src/scss/components/_footer.scss: -------------------------------------------------------------------------------- 1 | .footer { 2 | padding: 40px 0; 3 | text-align: center; 4 | } 5 | 6 | .footer__header { 7 | margin-bottom: 20px; 8 | font-size: rem(18); 9 | } 10 | 11 | .footer__links { 12 | margin-top: 0; 13 | margin-bottom: 0; 14 | padding-left: 0; 15 | list-style: none; 16 | 17 | li { 18 | display: inline-block; 19 | 20 | &:not(:last-child) { 21 | margin-right: 20px; 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /sites/modules/websocket/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | methods(self) { 3 | return { 4 | connected(ws, req) { 5 | ws.on('message', m => { 6 | console.log(`message received: ${m}`); 7 | ws.send('I am the websocket server and now I will close the connection'); 8 | ws.close(); 9 | }); 10 | ws.on('close', () => { 11 | console.log('websocket closed'); 12 | }); 13 | } 14 | }; 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /deployment/rsync_exclude.txt: -------------------------------------------------------------------------------- 1 | # List files and folders that shouldn't be deployed (such as data folders 2 | # and runtime status files) here. Prefix things properly with / so we don't 3 | # have any effect on unrelated subdirectories of node_modules 4 | 5 | /data 6 | /public/uploads 7 | /public/apos-minified 8 | .git 9 | .gitignore 10 | # We DO deploy node_modules to the apostrophe cloud 11 | # We DO deploy release-id to the apostrophe cloud (from the worker 12 | # that generates it) 13 | -------------------------------------------------------------------------------- /sites/modules/theme-demo/ui/src/scss/pages/_welcome.scss: -------------------------------------------------------------------------------- 1 | .welcome { 2 | margin-bottom: 30px; 3 | text-align: center; 4 | 5 | p { 6 | max-width: 500px; 7 | margin-right: auto; 8 | margin-left: auto; 9 | } 10 | 11 | .button--cta { 12 | padding: 20px 30px; 13 | } 14 | 15 | .placeholder__help { 16 | margin-bottom: 0; 17 | } 18 | } 19 | 20 | .welcome__code { 21 | margin: 0 auto 30px; 22 | } 23 | 24 | .welcome__help { 25 | font-size: rem(22); 26 | } 27 | -------------------------------------------------------------------------------- /dashboard/views/layout.html: -------------------------------------------------------------------------------- 1 | {% extends data.outerLayout %} 2 | 3 | {% block startHead %} 4 | 5 | {% endblock %} 6 | 7 | {% block title %} 8 | {% if data.piece %} 9 | {{ data.piece.title }} 10 | {% elseif data.page %} 11 | {{ data.page.title }} 12 | {% else %} 13 | {{ 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.') }} 14 | {% endif %} 15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /scripts/for-each-theme: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { spawnSync as spawn } from 'child_process'; 4 | import themes from '../themes.js'; 5 | 6 | for (const theme of themes) { 7 | const args = [ 'app', ...process.argv.slice(2), '--temporary-site', `--theme=${theme.value}` ]; 8 | const result = spawn('node', args, { 9 | encoding: 'utf8', 10 | stdio: 'inherit' 11 | }); 12 | if (result.status !== 0) { 13 | throw new Error(result.status || ('exited on signal ' + result.signal)); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /sites/modules/default-page/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | extend: '@apostrophecms/page-type', 3 | fields: { 4 | add: { 5 | main: { 6 | type: 'area', 7 | label: 'Main', 8 | options: { 9 | widgets: { 10 | column: {} 11 | } 12 | } 13 | } 14 | }, 15 | group: { 16 | basics: { 17 | label: 'Basics', 18 | fields: [ 19 | 'title', 20 | 'main' 21 | ] 22 | } 23 | } 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /sites/modules/theme-demo/ui/src/scss/utilities/_accessibility.scss: -------------------------------------------------------------------------------- 1 | .sr-only:not(:focus, :active) { 2 | position: absolute; 3 | overflow: hidden; 4 | width: 1px; 5 | height: 1px; 6 | margin: -1px; 7 | padding: 0; 8 | clip: rect(0, 0, 0, 0); 9 | white-space: nowrap; 10 | border-width: 0; 11 | } 12 | 13 | .not-sr-only { 14 | position: static; 15 | overflow: visible; 16 | width: auto; 17 | height: auto; 18 | margin: 0; 19 | padding: 0; 20 | clip: auto; 21 | white-space: normal; 22 | } 23 | -------------------------------------------------------------------------------- /sites/modules/widgets/accordion-widget/views/widget.html: -------------------------------------------------------------------------------- 1 | {% set widget = data.widget %} 2 | 3 |
4 | 5 | {% for item in widget.items %} 6 |
7 |

8 | 9 |

10 |
11 | {% area item, 'content' %} 12 |
13 |
14 | {% endfor %} 15 | 16 |
-------------------------------------------------------------------------------- /sites/modules/widgets/column-widget/views/widget.html: -------------------------------------------------------------------------------- 1 | {% set widget = data.widget %} 2 | 3 | {% set columns = apos.columnsWidget.getColumns(widget.layout) %} 4 | 5 |
11 | {% for column in range(1, columns+1) %} 12 |
13 | {% area widget, 'column'+column %} 14 |
15 | {% endfor %} 16 |
-------------------------------------------------------------------------------- /sites/modules/theme-demo/ui/src/scss/layout/_main.scss: -------------------------------------------------------------------------------- 1 | $max-width: 1020px; 2 | 3 | .wrapper { 4 | display: flex; 5 | flex-direction: column; 6 | min-height: 100vh; 7 | justify-content: space-between; 8 | overflow-x: hidden; 9 | 10 | .is-logged-in & { 11 | min-height: calc(100vh - 112px); 12 | } 13 | } 14 | 15 | .header, 16 | .main, 17 | .footer, 18 | .content { 19 | margin-right: auto; 20 | margin-left: auto; 21 | } 22 | 23 | .header, 24 | .main, 25 | .footer { 26 | width: 100%; 27 | max-width: $max-width; 28 | } 29 | -------------------------------------------------------------------------------- /sites/modules/default-page/views/page.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | 3 | {# 4 | TODO: The markup below is for demonstration purposes only 5 | Modify this template to meet your specific needs 6 | #} 7 | 8 | {% block main %} 9 |
10 |

{{ data.page.title }}

11 | 12 | {% area data.page, 'main' %} 13 | 14 | {% if apos.area.isEmpty(data.page, 'main') and data.theme === 'demo' %} 15 | {% include 'theme-demo:placeholder.html' %} 16 | {% endif %} 17 |
18 | {% endblock %} 19 | -------------------------------------------------------------------------------- /sites/modules/@apostrophecms/asset/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | options: { 3 | // When not in production, refresh the page on restart 4 | refreshOnRestart: true, 5 | // Disable hot module replacement in Cypress test mode, 6 | // use HMR for `public` code in development mode. 7 | // Change `public` to `apos` for admin UI HMR. 8 | hmr: process.env.CI === '1' ? false : 'public' 9 | }, 10 | methods(self) { 11 | return { 12 | getNamespace() { 13 | return self.apos.options.theme; 14 | } 15 | }; 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /sites/modules/theme-demo/ui/src/scss/components/_header.scss: -------------------------------------------------------------------------------- 1 | .header { 2 | display: flex; 3 | flex-wrap: wrap; 4 | align-items: center; 5 | justify-content: space-between; 6 | margin-bottom: 10px; 7 | padding: 40px 0; 8 | 9 | .button { 10 | font-size: 0.9rem; 11 | } 12 | } 13 | 14 | .header__logo { 15 | display: block; 16 | width: 190px; 17 | max-width: 100%; 18 | object-fit: contain; 19 | } 20 | 21 | .navigation__link { 22 | display: inline-block; 23 | font-size: rem(18); 24 | 25 | &:not(:last-of-type) { 26 | margin-right: 15px; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /sites/modules/widgets/link-widget/views/widget.html: -------------------------------------------------------------------------------- 1 | {% import "fragments/link.html" as link %} 2 | 3 | {% set widget = data.widget %} 4 | 5 | {% set path = apos.template.linkPath(widget) %} 6 | 7 | {% set style = 'button' if widget.linkStyle === 'button' else '' %} 8 | {% set variant = 'color--' + widget.linkVariant %} 9 | 10 | -------------------------------------------------------------------------------- /dashboard/modules/@apostrophecms/template/views/outerLayout.html: -------------------------------------------------------------------------------- 1 | {% extends "outerLayoutBase.html" %} 2 | 3 | {% if data.piece %} 4 | {% if data.piece.seoTitle %} 5 | {% set title = data.piece.seoTitle %} 6 | {% else %} 7 | {% set title = data.piece.title %} 8 | {% endif %} 9 | {% else %} 10 | {% if data.page.seoTitle %} 11 | {% set title = data.page.seoTitle %} 12 | {% else %} 13 | {% set title = data.page.title %} 14 | {% endif %} 15 | {% endif %} 16 | 17 | {% block title %}{{ title }}{% endblock %} 18 | 19 | {% block extraHead %} 20 | 21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /sites/modules/widgets/card-widget/ui/src/index.scss: -------------------------------------------------------------------------------- 1 | .card-widget { 2 | overflow: hidden; 3 | border: 1px solid $color-gray-15; 4 | border-radius: 5px; 5 | 6 | .card-widget__link { 7 | text-decoration: none; 8 | color: unset; 9 | 10 | &:hover { 11 | text-decoration: none; 12 | } 13 | 14 | &:visited { 15 | color: unset; 16 | } 17 | } 18 | 19 | .image { 20 | width: 100%; 21 | height: 300px; 22 | object-fit: cover; 23 | } 24 | 25 | .card-widget__content { 26 | padding: 20px; 27 | 28 | p:last-of-type { 29 | margin-bottom: 0; 30 | } 31 | } 32 | 33 | .card-widget__actions { 34 | margin-top: 20px; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /sites/modules/theme-demo/ui/src/js/placeholder.js: -------------------------------------------------------------------------------- 1 | export default () => { 2 | const hiddenClass = 'is-hidden'; 3 | 4 | const adminBar = window.apos.modules?.['@apostrophecms/admin-bar']; 5 | 6 | if (adminBar) { 7 | setInterval(togglePlaceholder, 300); 8 | } 9 | 10 | function togglePlaceholder() { 11 | const editMode = adminBar.editMode || false; 12 | 13 | const $el = document.querySelector('[data-apos-placeholder]'); 14 | 15 | if ($el) { 16 | if (editMode && !$el.classList.contains(hiddenClass)) { 17 | apos.util.addClass($el, hiddenClass); 18 | } else if (!editMode && $el.classList.contains(hiddenClass)) { 19 | apos.util.removeClass($el, hiddenClass); 20 | } 21 | } 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /dashboard/index.js: -------------------------------------------------------------------------------- 1 | import themes from '../themes.js'; 2 | import baseUrlDomains from '../domains.js'; 3 | 4 | export default { 5 | root: import.meta, 6 | privateDashboards: true, 7 | modules: { 8 | '@apostrophecms/express': { 9 | options: { 10 | session: { 11 | secret: 'CHANGEME' 12 | } 13 | } 14 | }, 15 | 16 | '@apostrophecms/uploadfs': { 17 | options: { 18 | uploadfs: { 19 | disabledFileKey: 'CHANGEME' 20 | } 21 | } 22 | }, 23 | 24 | '@apostrophecms-pro/multisite-dashboard': {}, 25 | 26 | site: { 27 | options: { 28 | themes, 29 | baseUrlDomains 30 | } 31 | }, 32 | 'site-page': {}, 33 | '@apostrophecms/vite': {} 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /sites/modules/@apostrophecms/template/views/outerLayout.html: -------------------------------------------------------------------------------- 1 | {% extends "outerLayoutBase.html" %} 2 | 3 | {% block standardHead %} 4 | {# Use standardHead and super() rather than extraHead to ensure palette injects after it #} 5 | {{ super() }} 6 | {% if data.global.googleFontScript and data.global.fontFamilies.length %} 7 | 8 | 9 | 10 | {% endif %} 11 | {# Add a favicon to the page from the /public/images folder #} 12 | {# #} 13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "delay": 1000, 3 | "verbose": true, 4 | "watch": [ 5 | "./app.js", 6 | "./dashboard/index.js", 7 | "./dashboard/modules/**/*", 8 | "./dashboard/lib/**/*.js", 9 | "./dashboard/views/**/*.html", 10 | "./sites/index.js", 11 | "./sites/modules/**/*", 12 | "./sites/lib/**/*.js", 13 | "./sites/views/**/*.html" 14 | ], 15 | "ignoreRoot": [".git"], 16 | "ignore": [ 17 | "**/ui/", 18 | "sites/apos-build/**", 19 | "sites/public/uploads/**", 20 | "sites/public/apos-frontend/**", 21 | "dashboard/apos-build/**", 22 | "dashboard/public/uploads/**", 23 | "dashboard/public/apos-frontend/**", 24 | "locales/*.json", 25 | "data", 26 | "node_modules" 27 | ], 28 | "ext": "json, js, cjs, html" 29 | } 30 | -------------------------------------------------------------------------------- /sites/modules/theme-demo/ui/src/scss/components/_code.scss: -------------------------------------------------------------------------------- 1 | code { 2 | white-space: normal; 3 | } 4 | 5 | pre { 6 | position: relative; 7 | display: flex; 8 | overflow: auto; 9 | margin: 0 auto; 10 | margin-bottom: 20px; 11 | padding: 20px; 12 | color: $color-white; 13 | background: $color-gray-80; 14 | font-family: $font-monospace; 15 | font-size: rem(14); 16 | text-align: left; 17 | white-space: pre; 18 | word-spacing: normal; 19 | word-break: normal; 20 | word-wrap: normal; 21 | line-height: 1.8; 22 | tab-size: 4; 23 | hyphens: none; 24 | border-radius: 6px; 25 | max-width: 600px; 26 | } 27 | 28 | .code--inline { 29 | padding: 4px 6px; 30 | border: 1px solid $color-gray-15; 31 | font-size: rem(12); 32 | background-color: $color-gray-05; 33 | border-radius: 5px; 34 | } 35 | -------------------------------------------------------------------------------- /sites/modules/widgets/slideshow-widget/views/widget.html: -------------------------------------------------------------------------------- 1 | {% set widget = data.widget %} 2 | 3 |
4 | 5 |
6 | {% for image in widget.images %} 7 | {% set img = apos.image.first(image._image) %} 8 | {% set src = apos.attachment.url(img) %} 9 | 10 |
11 | {{ img._alt or '' }} 12 |
{{ image.caption }}
13 |
14 | {% endfor %} 15 |
16 | 17 |
18 | 19 |
20 |
21 | 22 |
23 | 24 | -------------------------------------------------------------------------------- /dashboard/modules/site/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | tasks(self) { 3 | 4 | return { 5 | ...(process.env.CI === '1' && { 6 | 'cypress-config': { 7 | usage: 'List Cypress configuration and CLI commands for creating DB dumps.\n' + 8 | '\nUsage: node app site:cypress-config [siteShortName]', 9 | async task(argv) { 10 | const task = await import( 11 | '@apostrophecms-pro/cypress-tools/apos/assembly-config.js' 12 | ); 13 | try { 14 | const result = await task.default(self.apos, argv); 15 | console.log(result); 16 | } catch (e) { 17 | console.error(e.message); 18 | return 1; 19 | } 20 | } 21 | } 22 | }) 23 | }; 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /sites/modules/theme-default/ui/src/index.js: -------------------------------------------------------------------------------- 1 | export default () => { 2 | // eslint-disable-next-line no-console 3 | console.log('Default theme project level js file'); 4 | 5 | /** 6 | * Uncomment to demonstrate a websocket connection 7 | * Simple test that works locally and in the cloud: http -> ws, https -> wss 8 | */ 9 | 10 | // const url = window.location.href.replace(/^http/, 'ws'); 11 | // const ws = new WebSocket(url); 12 | 13 | // ws.onopen = () => { 14 | // ws.send('message from websocket client'); 15 | // }; 16 | 17 | // ws.onmessage = m => { 18 | // console.log(`websocket server said: ${m.data}`); 19 | // }; 20 | 21 | // ws.onerror = e => { 22 | // console.error(e); 23 | // }; 24 | 25 | // ws.onclose = e => { 26 | // console.error('websocket closed'); 27 | // }; 28 | }; 29 | -------------------------------------------------------------------------------- /sites/modules/theme-demo/ui/src/index.js: -------------------------------------------------------------------------------- 1 | import placeholder from './js/placeholder.js'; 2 | 3 | export default () => { 4 | // eslint-disable-next-line no-console 5 | console.log('Demo theme project level js file'); 6 | 7 | // NOTE: Theme specific - 8 | // Adds a class to the body so we can adjust the layout height 9 | 10 | const isLoggedIn = 'is-logged-in'; 11 | 12 | const $body = document.getElementsByTagName('body')[0]; 13 | 14 | const adminBar = window.apos.modules?.['@apostrophecms/admin-bar']; 15 | 16 | if (adminBar && !$body.classList.contains(isLoggedIn)) { 17 | apos.util.addClass($body, isLoggedIn); 18 | } else { 19 | apos.util.removeClass($body, isLoggedIn); 20 | } 21 | 22 | // NOTE: Theme Specific - 23 | // Adds editor help text when first editing the Home Page 24 | 25 | placeholder(); 26 | }; 27 | -------------------------------------------------------------------------------- /cypress/tests/dashboard.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | describe('Dashboard', { baseUrl: 'http://dashboard.localhost:3000/' }, () => { 4 | before(() => { 5 | Cypress.session.clearAllSavedSessions(); 6 | cy.task('apos:dbReset', 'dashboard'); 7 | cy.addUser('admin', 'dashboard'); 8 | }); 9 | 10 | beforeEach(() => { 11 | cy.login('admin', { quick: true }); 12 | }); 13 | 14 | it('Visits the site', () => { 15 | cy.visit('/'); 16 | // Not needed, here to show that the baseUrl is correct 17 | cy.url().should('eq', 'http://dashboard.localhost:3000/'); 18 | // Showcase a random cy command working as expected 19 | cy.contains('h3', 'Site - Theme Default'); 20 | cy.contains('h3', 'Site - Theme Demo'); 21 | cy.openPieceModal('Sites', 'Manage Sites'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /sites/modules/widgets/slideshow-widget/ui/src/index.scss: -------------------------------------------------------------------------------- 1 | @import 'swiper/swiper-bundle.css'; 2 | 3 | :root { 4 | --swiper-pagination-bottom: 0; 5 | } 6 | 7 | .figure { 8 | margin: 0; 9 | 10 | .figure__image { 11 | width: 100%; 12 | height: 385px; 13 | object-fit: cover; 14 | } 15 | 16 | .figure__caption { 17 | padding-top: 10px; 18 | padding-bottom: 10px; 19 | font-size: rem(14); 20 | } 21 | } 22 | 23 | [class^='swiper-button'] { 24 | box-sizing: border-box; 25 | width: 30px; 26 | height: 30px; 27 | padding: 5px; 28 | color: $color-white; 29 | border-radius: 25px; 30 | background-color: var(--palette-color-primary); 31 | 32 | &::after { 33 | font-size: 1rem; 34 | line-height: 1; 35 | } 36 | } 37 | 38 | .swiper-pagination-bullet-active { 39 | background: var(--palette-color-primary); 40 | } 41 | -------------------------------------------------------------------------------- /sites/modules/theme-demo/ui/src/index.scss: -------------------------------------------------------------------------------- 1 | @import 'normalize.css'; 2 | 3 | @import './scss/functions/_rem'; 4 | 5 | @import './scss/settings/_color'; 6 | @import './scss/settings/_font'; 7 | 8 | @import './scss/utilities/_accessibility'; 9 | @import './scss/utilities/_display'; 10 | 11 | @import './scss/global/_document'; 12 | @import './scss/global/_typography'; 13 | 14 | @import './scss/components/_button'; 15 | @import './scss/components/_code'; 16 | @import './scss/components/_footer'; 17 | @import './scss/components/_header'; 18 | @import './scss/components/_image'; 19 | @import './scss/components/_link'; 20 | @import './scss/components/_placeholder'; 21 | 22 | @import './scss/widgets/_image-widget'; 23 | 24 | @import './scss/layout/_area'; 25 | @import './scss/layout/_grid'; 26 | @import './scss/layout/_main'; 27 | 28 | @import './scss/pages/_welcome'; 29 | -------------------------------------------------------------------------------- /sites/modules/helpers/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A Module that can be used to add utility functions 3 | * that are used across multiple modules 4 | */ 5 | 6 | export default { 7 | options: { 8 | alias: 'helpers' 9 | }, 10 | /** 11 | * NOTE: `helpers` can be added to any module 12 | * https://docs.apostrophecms.org/reference/module-api/module-overview.html#helpers-self 13 | */ 14 | helpers(self, options) { 15 | return { 16 | /** 17 | * If the following code is uncommented, the `isInt` helper 18 | * would be available in templates as apos.helpers.isInt() 19 | * 20 | * isInt: (n) => { 21 | * return n % 1 === 0; 22 | * }, 23 | * 24 | * NOTE: async functions are NOT allowed in helpers, you should 25 | * write an async component instead for such cases 26 | */ 27 | }; 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /sites/modules/widgets/slideshow-widget/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | extend: '@apostrophecms/widget-type', 3 | options: { 4 | label: 'Slideshow', 5 | icon: 'image-multiple' 6 | }, 7 | icons: { 8 | 'image-multiple': 'ImageMultiple' 9 | }, 10 | fields: { 11 | add: { 12 | images: { 13 | type: 'array', 14 | titleField: 'caption', 15 | min: 2, 16 | fields: { 17 | add: { 18 | _image: { 19 | type: 'relationship', 20 | label: 'Image', 21 | max: 1, 22 | required: true, 23 | withType: '@apostrophecms/image' 24 | }, 25 | caption: { 26 | type: 'string', 27 | label: 'Caption', 28 | textarea: true 29 | } 30 | } 31 | } 32 | } 33 | } 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /sites/modules/widgets/accordion-widget/ui/src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * For Demo Purposes this Accordion is powered by accordion-js 3 | * You could swap this out for another library or your own custom implementation 4 | */ 5 | 6 | import Accordion from 'accordion-js'; 7 | 8 | export default () => { 9 | 10 | apos.util.widgetPlayers.accordion = { 11 | selector: '[data-accordion-widget]', 12 | player: function (el) { 13 | 14 | /** 15 | * For all available options see: 16 | * https://www.npmjs.com/package/accordion-js#options 17 | */ 18 | const options = { 19 | duration: 300, 20 | ...(el.dataset.accordionWidget && { 21 | ...JSON.parse(el.dataset.accordionWidget).accordionjs 22 | }) 23 | }; 24 | 25 | // eslint-disable-next-line 26 | new Accordion('.accordion-container', options); 27 | } 28 | }; 29 | 30 | }; 31 | -------------------------------------------------------------------------------- /sites/modules/@apostrophecms/home-page/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | fields: { 3 | add: { 4 | main: { 5 | type: 'area', 6 | label: 'Main', 7 | options: { 8 | widgets: { 9 | hero: { 10 | /** 11 | * We could allow the editor choose a display option but 12 | * we always want the Hero to cover the width of the screen 13 | * on the Homepage. We're passing an option to our widget 14 | * here so that the appearance always stays consistent 15 | * for this area. 16 | */ 17 | fullWidth: true 18 | }, 19 | column: {} 20 | } 21 | } 22 | } 23 | }, 24 | group: { 25 | basics: { 26 | label: 'Basics', 27 | fields: [ 28 | 'title', 29 | 'main' 30 | ] 31 | } 32 | } 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /sites/modules/widgets/accordion-widget/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | extend: '@apostrophecms/widget-type', 3 | options: { 4 | label: 'Accordion', 5 | icon: 'arrow-down-drop-circle' 6 | }, 7 | icons: { 8 | 'arrow-down-drop-circle': 'ArrowDownDropCircle' 9 | }, 10 | fields: { 11 | add: { 12 | items: { 13 | type: 'array', 14 | label: 'Items', 15 | titleField: 'header', 16 | fields: { 17 | add: { 18 | header: { 19 | type: 'string', 20 | label: 'Header' 21 | }, 22 | content: { 23 | type: 'area', 24 | label: 'Content', 25 | options: { 26 | widgets: { 27 | '@apostrophecms/rich-text': {} 28 | }, 29 | max: 1 30 | } 31 | } 32 | } 33 | } 34 | } 35 | } 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /sites/modules/theme-demo/ui/src/scss/global/_typography.scss: -------------------------------------------------------------------------------- 1 | html { 2 | font-size: var(--palette-font-size); 3 | line-height: 1; 4 | text-size-adjust: 100%; 5 | } 6 | 7 | h1, 8 | h2, 9 | h3, 10 | h4, 11 | h5, 12 | h6, 13 | p { 14 | margin-top: 0; 15 | } 16 | 17 | h1, 18 | h2, 19 | h3, 20 | h4, 21 | h5, 22 | h6 { 23 | line-height: 1.15; 24 | } 25 | 26 | h2, 27 | h3, 28 | h4, 29 | h5, 30 | h6, 31 | p:not(:is(:last-child, [class*='apos-'])) { 32 | margin-bottom: 30px; 33 | } 34 | 35 | h1, 36 | h2, 37 | h3 { 38 | color: $color-black; 39 | } 40 | 41 | h4, 42 | h5, 43 | h6 { 44 | color: var(--palette-font-color); 45 | font-size: var(--palette-font-size); 46 | } 47 | 48 | h1 { 49 | margin-bottom: 50px; 50 | font-size: rem(64); 51 | font-weight: $font-weight-light; 52 | } 53 | 54 | h2 { 55 | font-size: 3rem; 56 | } 57 | 58 | h3 { 59 | font-size: 2rem; 60 | } 61 | 62 | p { 63 | line-height: 1.5; 64 | 65 | &:last-child { 66 | margin-bottom: 0; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /scripts/wait-for-port: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import net from 'node:net'; 4 | 5 | const port = parseInt(process.argv[process.argv.length - 1]); 6 | 7 | go(); 8 | 9 | async function go() { 10 | let connected = false; 11 | while (!connected) { 12 | try { 13 | await attempt('127.0.0.1', port); 14 | connected = true; 15 | } catch (e) { 16 | console.error(e); 17 | console.log(`Port ${port} not available yet, retrying in 1 second...`); 18 | await delay(1000); 19 | } 20 | } 21 | console.log(`Port ${port} connected`); 22 | } 23 | 24 | async function attempt(host, port) { 25 | return new Promise((resolve, reject) => { 26 | const client = net.createConnection(port, host); 27 | client.on('connect', () => { 28 | client.destroy(); 29 | resolve(); 30 | }); 31 | client.on('error', e => { 32 | reject(e); 33 | }); 34 | }); 35 | } 36 | 37 | function delay(ms) { 38 | return new Promise(resolve => setTimeout(resolve, ms)); 39 | } 40 | -------------------------------------------------------------------------------- /sites/modules/@apostrophecms/global/index.js: -------------------------------------------------------------------------------- 1 | import link from '../../../lib/schema/link.js'; 2 | 3 | const { 4 | linkText, linkType, linkUrl, _linkFile, _linkPage, linkTarget 5 | } = link; 6 | 7 | export default { 8 | fields: { 9 | add: { 10 | logo: { 11 | type: 'area', 12 | label: 'Logo', 13 | options: { 14 | widgets: { 15 | '@apostrophecms/image': {} 16 | }, 17 | max: 1 18 | } 19 | }, 20 | headerLinks: { 21 | type: 'array', 22 | label: 'Navigation', 23 | titleField: 'linkText', 24 | fields: { 25 | add: { 26 | linkText, 27 | linkType, 28 | linkUrl, 29 | _linkFile, 30 | _linkPage, 31 | linkTarget 32 | } 33 | } 34 | } 35 | }, 36 | group: { 37 | navigation: { 38 | label: 'Site Header', 39 | fields: [ 'logo', 'headerLinks' ] 40 | } 41 | } 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /sites/modules/theme-demo/ui/src/scss/layout/_grid.scss: -------------------------------------------------------------------------------- 1 | [class*='grid--']:not(.grid--100) { 2 | display: grid; 3 | gap: var(--palette-grid-margin); 4 | } 5 | 6 | .grid--50 { 7 | grid-template-columns: repeat(2, 1fr); 8 | } 9 | 10 | .grid--66-33, 11 | .grid--33-66, 12 | .grid--33 { 13 | grid-template-columns: repeat(3, 1fr); 14 | } 15 | 16 | .grid--66-33 { 17 | .grid__column-1 { 18 | grid-column: span 2; 19 | } 20 | } 21 | 22 | .grid--33-66 { 23 | .grid__column-2 { 24 | grid-column: span 2; 25 | } 26 | } 27 | 28 | .grid--75-25, 29 | .grid--25-75, 30 | .grid--25 { 31 | grid-template-columns: repeat(4, 1fr); 32 | } 33 | 34 | .grid--75-25 { 35 | .grid__column-1 { 36 | grid-column: span 3; 37 | } 38 | } 39 | 40 | .grid--25-75 { 41 | .grid__column-2 { 42 | grid-column: span 3; 43 | } 44 | } 45 | 46 | .full-width { 47 | position: relative; 48 | right: 50%; 49 | left: 50%; 50 | box-sizing: border-box; 51 | width: 100vw; 52 | margin-right: -50vw; 53 | margin-left: -50vw; 54 | } 55 | -------------------------------------------------------------------------------- /sites/modules/@apostrophecms-pro/palette/lib/configs/grid.js: -------------------------------------------------------------------------------- 1 | /** 2 | * For additional information on using and configuring Palette, see: 3 | * https://github.com/apostrophecms/palette 4 | */ 5 | 6 | export default { 7 | add: { 8 | gridMargin: { 9 | type: 'select', 10 | label: 'Margin', 11 | help: 'Space columns and widgets', 12 | selector: ':root', 13 | property: '--palette-grid-margin', 14 | unit: 'px', 15 | choices: [ 16 | { 17 | label: '16px', 18 | value: '16' 19 | }, 20 | { 21 | label: '20px', 22 | value: '20' 23 | }, 24 | { 25 | label: '24px', 26 | value: '24' 27 | }, 28 | { 29 | label: '28px', 30 | value: '28' 31 | }, 32 | { 33 | label: '32px', 34 | value: '32' 35 | } 36 | ], 37 | def: '20' 38 | } 39 | }, 40 | group: { 41 | grid: { 42 | label: 'Grid', 43 | fields: [ 44 | 'gridMargin' 45 | ] 46 | } 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /sites/modules/widgets/accordion-widget/ui/src/index.scss: -------------------------------------------------------------------------------- 1 | @import 'accordion-js/dist/accordion.min.css'; 2 | 3 | .ac { 4 | margin-top: 0; 5 | border: none; 6 | background: unset; 7 | 8 | &:not(:last-of-type) { 9 | margin-bottom: 10px; 10 | } 11 | 12 | .ac-header { 13 | border-bottom: 1px solid $color-gray-15; 14 | font-size: rem(16); 15 | font-weight: $font-weight-normal; 16 | } 17 | 18 | .ac-trigger { 19 | font: unset; 20 | padding: 0 30px 10px 0; 21 | 22 | &:hover { 23 | color: var(--palette-color-primary); 24 | } 25 | 26 | &:focus { 27 | color: unset; 28 | } 29 | 30 | &::after { 31 | width: unset; 32 | transform: translate(0, calc(-50% - 7px)); 33 | } 34 | } 35 | 36 | .ac-panel { 37 | transition-property: all; 38 | 39 | p:last-of-type { 40 | margin-bottom: 0; 41 | } 42 | } 43 | 44 | &.is-active { 45 | .ac-trigger { 46 | color: var(--palette-color-primary); 47 | } 48 | 49 | .ac-panel { 50 | padding-top: 10px; 51 | padding-bottom: 10px; 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /sites/modules/theme-demo/views/placeholder.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sites/modules/theme-demo/views/welcome.html: -------------------------------------------------------------------------------- 1 |
2 |

3 | Welcome to Apostrophe Pro 4 |

5 | 6 | {% if not data.user %} 7 |

First time spinning up ApostropheCMS?

8 | 9 |

10 | Use the credentials created during setup with the CLI tool or create a new user with the CLI command: 11 |

12 | 13 |
14 |       
15 |         node app @apostrophecms/user:add myUsername admin --site={{ apos.theme.options.shortName }}
16 |       
17 |     
18 | 19 |

20 | Then log in here 21 |

22 | {% endif %} 23 | 24 |

25 | For a guide on how to configure and customize your project, please check out the Apostrophe documentation. 26 |

27 | 28 | {% if data.user and apos.area.isEmpty(data.page, 'main') %} 29 | 30 |
31 | 32 | {% include 'placeholder.html' %} 33 | 34 |
35 | 36 | {% endif %} 37 |
-------------------------------------------------------------------------------- /sites/modules/widgets/slideshow-widget/ui/src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * For Demo Purposes this Slideshow is powered by Swiper.js 3 | * You could swap this out for another library or your own custom implementation 4 | */ 5 | 6 | import Swiper from 'swiper'; 7 | import { Navigation, Pagination } from 'swiper/modules'; 8 | 9 | export default () => { 10 | 11 | apos.util.widgetPlayers.slideshow = { 12 | selector: '[data-slideshow-widget]', 13 | player: function (el) { 14 | 15 | /** 16 | * For all available parameters see: 17 | * https://swiperjs.com/swiper-api#parameters 18 | */ 19 | const options = { 20 | navigation: { 21 | nextEl: '.swiper-button-next', 22 | prevEl: '.swiper-button-prev' 23 | }, 24 | 25 | pagination: { 26 | el: '.swiper-pagination' 27 | }, 28 | 29 | modules: [ Navigation, Pagination ], 30 | 31 | ...(el.dataset.slideshowWidget && { 32 | ...JSON.parse(el.dataset.slideshowWidget).swiper 33 | }) 34 | }; 35 | 36 | // eslint-disable-next-line 37 | new Swiper('.swiper', options); 38 | } 39 | }; 40 | 41 | }; 42 | -------------------------------------------------------------------------------- /cypress.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress'; 2 | import plugins from './cypress/plugins/index.js'; 3 | 4 | export default defineConfig({ 5 | viewportWidth: 1200, 6 | viewportHeight: 900, 7 | video: false, 8 | numTestsKeptInMemory: 20, 9 | defaultCommandTimeout: 10000, 10 | retries: { 11 | runMode: 2, 12 | openMode: 0 13 | }, 14 | env: { 15 | '@apostrophecms-pro/cypress-tools': { 16 | assembly: true, 17 | apiKey: 'cypressAPIKey', 18 | mongoURI: true, 19 | dbName: 'test-t0i63fmpmk34ylpu1b31ws6p', 20 | aposRoot: './sites', 21 | profiles: { 22 | 'site-demo': { 23 | dbName: 'test-snfxk8h1grlrrkldsdntc1hh', 24 | baseUrl: 'http://site-demo.localhost:3000' 25 | }, 26 | dashboard: { 27 | dbName: 'test-dashboard', 28 | aposRoot: './dashboard', 29 | baseUrl: 'http://dashboard.localhost:3000' 30 | } 31 | } 32 | } 33 | }, 34 | e2e: { 35 | baseUrl: 'http://site-default.localhost:3000', 36 | specPattern: 'cypress/tests/**/*.cy.{js,vue,ts}', 37 | setupNodeEvents(on, config) { 38 | return plugins(on, config); 39 | } 40 | } 41 | }); 42 | -------------------------------------------------------------------------------- /sites/modules/@apostrophecms-pro/palette/lib/configs/typography.js: -------------------------------------------------------------------------------- 1 | /** 2 | * For additional information on using and configuring Palette, see: 3 | * https://github.com/apostrophecms/palette 4 | */ 5 | 6 | export default { 7 | add: { 8 | fontSize: { 9 | type: 'select', 10 | label: 'Font Size', 11 | help: 'Base font size for body text', 12 | selector: ':root', 13 | property: '--palette-font-size', 14 | unit: 'px', 15 | choices: [ 16 | { 17 | label: '14px', 18 | value: '14' 19 | }, 20 | { 21 | label: '16px', 22 | value: '16' 23 | }, 24 | { 25 | label: '18px', 26 | value: '18' 27 | } 28 | ], 29 | def: '16' 30 | }, 31 | fontColor: { 32 | type: 'color', 33 | label: 'Font Color', 34 | help: 'Base font color for body text', 35 | selector: ':root', 36 | property: '--palette-font-color', 37 | def: '#333333' 38 | } 39 | }, 40 | group: { 41 | typography: { 42 | label: 'Typography', 43 | fields: [ 44 | 'fontSize', 45 | 'fontColor' 46 | ] 47 | } 48 | } 49 | }; 50 | -------------------------------------------------------------------------------- /sites/modules/theme-demo/ui/src/scss/components/_placeholder.scss: -------------------------------------------------------------------------------- 1 | .placeholder__svg { 2 | position: fixed; 3 | top: 150px; 4 | right: 20px; 5 | transform: scale(-1, 1) translateY(0); 6 | animation: pointer-float 4000ms ease-in-out infinite; 7 | } 8 | 9 | .placeholder__help { 10 | width: 30%; 11 | margin-right: auto; 12 | margin-left: auto; 13 | padding: 50px; 14 | border: 1px solid $color-gray-15; 15 | color: $color-black; 16 | font-size: rem(14); 17 | text-align: center; 18 | border-radius: 10px; 19 | background-color: $color-light-yellow; 20 | } 21 | 22 | .placeholder__button { 23 | display: inline-block; 24 | padding: 5px; 25 | border: 1px solid $color-black; 26 | border-bottom-color: $color-black; 27 | color: $color-black; 28 | font-family: $font-monospace; 29 | font-size: rem(13); 30 | vertical-align: middle; 31 | background-color: $color-white; 32 | border-radius: 6px; 33 | box-shadow: inset 0 -1px 0 $color-black; 34 | } 35 | 36 | @keyframes pointer-float { 37 | 0% { 38 | transform: translateY(0); 39 | } 40 | 41 | 50% { 42 | transform: translateY(-20px); 43 | } 44 | 45 | 100% { 46 | transform: translateY(0); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vim swapfiles 2 | *.swp 3 | .DS_Store 4 | /dashboard/locales 5 | /locales 6 | npm-debug.log 7 | package-lock.json 8 | /data 9 | /dashboard/data 10 | /sites/data 11 | */apos-build 12 | */public/apos-frontend 13 | /dashboard/modules/asset/ui/public 14 | /sites/modules/theme-*/ui/public 15 | */public/modules 16 | */public/uploads 17 | */public/svgs/*.svg 18 | */public/apos-minified 19 | node_modules 20 | # This folder is created on the fly and contains symlinks updated at startup (we'll come up with a Windows solution that actually copies things) 21 | /public/modules 22 | # We don't commit CSS, only LESS 23 | */public/css/*.css 24 | */public/css/*.less 25 | # Don't commit CSS sourcemap files 26 | */public/css/*.map 27 | # Don't commit masters generated on the fly at startup, these import all the rest 28 | /public/css/master-*.less 29 | .jshintrc 30 | /public/js/_site-compiled.js 31 | /public/sitemap.xml 32 | dashboard/modules/assets/public/css/site.css 33 | dashboard/modules/assets/public/js/site.js 34 | sites/modules/theme-*/public/js/site.js 35 | sites/modules/theme-*/public/css/site.css 36 | # Deployed, but not committed 37 | /release-id 38 | # Cypress 39 | /cypress/videos 40 | /cypress/screenshots 41 | /cypress/downloads 42 | -------------------------------------------------------------------------------- /sites/modules/widgets/hero-widget/ui/src/index.scss: -------------------------------------------------------------------------------- 1 | .hero-widget { 2 | --z-index-content: 1; 3 | 4 | position: relative; 5 | display: flex; 6 | align-items: center; 7 | justify-content: center; 8 | margin-bottom: var(--palette-grid-margin); 9 | padding: 20px; 10 | background-size: cover; 11 | background-blend-mode: multiply; 12 | 13 | &.hero-widget--small { 14 | min-height: 20vh; 15 | } 16 | 17 | &.hero-widget--medium { 18 | min-height: 50vh; 19 | } 20 | 21 | &.hero-widget--large { 22 | min-height: 80vh; 23 | } 24 | 25 | p:last-of-type { 26 | margin-bottom: 0; 27 | } 28 | } 29 | 30 | .hero-widget__content { 31 | z-index: var(--z-index-content); 32 | max-width: 620px; 33 | 34 | h1, 35 | h2, 36 | h3, 37 | h4, 38 | h5, 39 | h6 { 40 | margin-bottom: 0; 41 | } 42 | 43 | .button:not(:last-of-type) { 44 | margin-right: 20px; 45 | } 46 | } 47 | 48 | .hero-widget__video, 49 | .hero-widget__screen { 50 | position: absolute; 51 | top: 0; 52 | left: 0; 53 | width: 100%; 54 | height: 100%; 55 | } 56 | 57 | .hero-widget__video { 58 | object-fit: cover; 59 | } 60 | 61 | .hero-widget__screen { 62 | background-color: rgba($color-black, 0.5); 63 | } 64 | 65 | .hero-widget__actions { 66 | margin-top: 20px; 67 | } 68 | -------------------------------------------------------------------------------- /sites/modules/@apostrophecms-pro/palette/lib/configs/color.js: -------------------------------------------------------------------------------- 1 | /** 2 | * For additional information on using and configuring Palette, see: 3 | * https://github.com/apostrophecms/palette 4 | */ 5 | 6 | export default { 7 | add: { 8 | colorPrimary: { 9 | type: 'color', 10 | label: 'Primary Color', 11 | selector: ':root', 12 | property: '--palette-color-primary', 13 | def: '#6236ff' 14 | }, 15 | colorSecondary: { 16 | type: 'color', 17 | label: 'Secondary Color', 18 | selector: ':root', 19 | property: '--palette-color-secondary', 20 | def: '#fe5599' 21 | }, 22 | colorAccent: { 23 | type: 'color', 24 | label: 'Accent Color', 25 | selector: ':root', 26 | property: '--palette-color-accent', 27 | def: '#00cc88' 28 | }, 29 | colorBackground: { 30 | type: 'color', 31 | label: 'Background Color', 32 | help: 'The background color of your website', 33 | selector: ':root', 34 | property: '--palette-color-background' 35 | } 36 | }, 37 | group: { 38 | site: { 39 | label: 'Site Settings', 40 | fields: [ 41 | 'colorPrimary', 42 | 'colorSecondary', 43 | 'colorAccent', 44 | 'colorBackground' 45 | ] 46 | } 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /telemetry.js: -------------------------------------------------------------------------------- 1 | import { NodeSDK } from '@opentelemetry/sdk-node'; 2 | import { resourceFromAttributes } from '@opentelemetry/resources'; 3 | import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'; 4 | import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'; 5 | import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; 6 | import pkg from './package.json' with { type: 'json' }; 7 | 8 | // 1. Add the application metadata (resource) 9 | const resource = resourceFromAttributes({ 10 | [ATTR_SERVICE_NAME]: pkg.name, 11 | [ATTR_SERVICE_VERSION]: pkg.version 12 | }); 13 | 14 | // 2. Initialize the OTLP exporter with correct endpoint 15 | const traceExporter = new OTLPTraceExporter({ 16 | url: 'http://localhost:4318/v1/traces', 17 | headers: {} 18 | }); 19 | 20 | // 3. Initialize the SDK 21 | const sdk = new NodeSDK({ 22 | resource, 23 | traceExporter, 24 | instrumentations: [ getNodeAutoInstrumentations() ] 25 | }); 26 | 27 | // 4. The shutdown handler 28 | const shutdown = async () => { 29 | await sdk 30 | .shutdown() 31 | .then( 32 | () => console.log('OpenTelemetry stopped'), 33 | (err) => console.log('Error shutting down OpenTelemetry', err) 34 | ); 35 | }; 36 | 37 | export default { 38 | sdk, 39 | shutdown 40 | }; 41 | -------------------------------------------------------------------------------- /sites/modules/theme-demo/ui/src/scss/components/_button.scss: -------------------------------------------------------------------------------- 1 | .button { 2 | display: inline-block; 3 | padding: 10px 24px; 4 | color: $color-white; 5 | background: var(--palette-color-primary); 6 | text-decoration: none; 7 | border-radius: 100px; 8 | font-weight: $font-weight-bold; 9 | 10 | &:hover, 11 | &:active { 12 | color: $color-white; 13 | } 14 | 15 | &:hover, 16 | &:focus { 17 | text-decoration: none; 18 | } 19 | 20 | &:hover { 21 | background: color-mix(in srgb, var(--palette-color-primary) 80%, #fff); 22 | } 23 | 24 | &:active { 25 | background: color-mix(in srgb, var(--palette-color-primary) 80%, #000); 26 | } 27 | } 28 | 29 | .color--secondary { 30 | &.button { 31 | background-color: var(--palette-color-secondary); 32 | 33 | &:hover { 34 | background: color-mix(in srgb, var(--palette-color-secondary) 80%, #fff); 35 | } 36 | 37 | &:active { 38 | background: color-mix(in srgb, var(--palette-color-secondary) 80%, #000); 39 | } 40 | } 41 | } 42 | 43 | .color--accent { 44 | &.button { 45 | background-color: var(--palette-color-accent); 46 | 47 | &:hover { 48 | background: color-mix(in srgb, var(--palette-color-accent) 80%, #fff); 49 | } 50 | 51 | &:active { 52 | background: color-mix(in srgb, var(--palette-color-accent) 80%, #000); 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /sites/modules/theme-demo/ui/src/scss/components/_link.scss: -------------------------------------------------------------------------------- 1 | a:not(.button), 2 | .link:not(.button) { 3 | color: var(--palette-color-primary); 4 | text-decoration: none; 5 | 6 | &:hover, 7 | &:focus { 8 | text-decoration: underline; 9 | } 10 | 11 | &:hover { 12 | color: color-mix(in srgb, var(--palette-color-primary) 80%, #fff); 13 | } 14 | 15 | &:active { 16 | color: color-mix(in srgb, var(--palette-color-primary) 80%, #000); 17 | } 18 | 19 | &:visited { 20 | color: var(--palette-color-primary); 21 | } 22 | } 23 | 24 | .color--secondary { 25 | &.link:not(.button) { 26 | color: var(--palette-color-secondary); 27 | 28 | &:hover { 29 | color: color-mix(in srgb, var(--palette-color-secondary) 80%, #fff); 30 | } 31 | 32 | &:active { 33 | color: color-mix(in srgb, var(--palette-color-secondary) 80%, #000); 34 | } 35 | 36 | &:visited { 37 | color: var(--palette-color-secondary); 38 | } 39 | } 40 | } 41 | 42 | .color--accent { 43 | &.link:not(.button) { 44 | color: var(--palette-color-accent); 45 | 46 | &:hover { 47 | color: color-mix(in srgb, var(--palette-color-accent) 80%, #fff); 48 | } 49 | 50 | &:active { 51 | color: color-mix(in srgb, var(--palette-color-accent) 80%, #000); 52 | } 53 | 54 | &:visited { 55 | color: var(--palette-color-accent); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /sites/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Shared Sites configuration 3 | * 4 | * Enabling and configuring a module here will allow the 5 | * module to be used as configured on all sites. 6 | */ 7 | 8 | export default async function (site) { 9 | const config = { 10 | root: import.meta, 11 | // Theme name is globally available as apos.options.theme 12 | theme: site.theme, 13 | nestedModuleSubdirs: true, 14 | modules: { 15 | '@apostrophecms/uploadfs': { 16 | options: { 17 | uploadfs: { 18 | // TODO: Be sure to change 19 | disabledFileKey: 'CHANGEME' 20 | } 21 | } 22 | }, 23 | '@apostrophecms/express': { 24 | options: { 25 | session: { 26 | // TODO: Be sure to change 27 | secret: 'CHANGEME' 28 | } 29 | } 30 | }, 31 | 32 | helpers: {}, 33 | 34 | 'default-page': {}, 35 | 36 | '@apostrophecms-pro/palette': {}, 37 | '@apostrophecms-pro/document-versions': {}, 38 | '@apostrophecms/favicon': {}, 39 | '@apostrophecms/vite': {}, 40 | 41 | websocket: {} 42 | } 43 | }; 44 | 45 | /** 46 | * Allow each theme to modify the configuration object, 47 | * enabling additional modules etc. 48 | */ 49 | const { default: theme } = await import(`./lib/theme-${site.theme}.js`); 50 | theme(site, config); 51 | 52 | return config; 53 | }; 54 | -------------------------------------------------------------------------------- /sites/modules/@apostrophecms-pro/palette/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This is a more advanced setup of Palette where the fields and groups have 3 | * been organized in separate files based on groups. Edit the configs in 4 | * `lib/configs` to if you'd like to change the existing schema or add 5 | * new files in lib/configs to add additional fields and groups. 6 | * The code below should automatically add your fields and groups to Palette 7 | * as long as your config files export an `add` property containing the fields 8 | * and `group` property containing the group definition. 9 | */ 10 | 11 | import path from 'node:path'; 12 | import url from 'node:url'; 13 | 14 | export default { 15 | async fields(self, options) { 16 | const configs = await getConfigs('lib/configs'); 17 | return { 18 | add: filter(configs, 'add'), 19 | group: filter(configs, 'group') 20 | }; 21 | async function getConfigs(folder) { 22 | const dirname = path.dirname(url.fileURLToPath(import.meta.url)); 23 | const files = options.apos.util.glob(path.join(dirname, folder, '**/*.js')); 24 | const configs = []; 25 | for (const file of files) { 26 | const { default: config } = await import(url.pathToFileURL(file)); 27 | configs.push(config); 28 | } 29 | return configs; 30 | } 31 | } 32 | }; 33 | 34 | function filter(configurations, key) { 35 | let items = {}; 36 | 37 | for (const config of Object.keys(configurations)) { 38 | items = { 39 | ...items, 40 | ...configurations[config][key] 41 | }; 42 | }; 43 | 44 | return items; 45 | } 46 | -------------------------------------------------------------------------------- /sites/modules/widgets/card-widget/index.js: -------------------------------------------------------------------------------- 1 | import link from '../../../lib/schema/link.js'; 2 | 3 | const { 4 | linkText, linkType, linkUrl, _linkFile, _linkPage, linkStyle, linkVariant 5 | } = link; 6 | 7 | export default { 8 | extend: '@apostrophecms/widget-type', 9 | options: { 10 | label: 'Card', 11 | icon: 'sign-text-icon' 12 | }, 13 | fields: { 14 | add: { 15 | clickable: { 16 | type: 'boolean', 17 | label: 'Clickable', 18 | help: 'Should clicking on this Card take the user somewhere?', 19 | def: true 20 | }, 21 | _image: { 22 | type: 'relationship', 23 | label: 'Card Image', 24 | withType: '@apostrophecms/image', 25 | max: 1 26 | }, 27 | text: { 28 | type: 'area', 29 | label: 'Card Text', 30 | options: { 31 | widgets: { 32 | '@apostrophecms/rich-text': {} 33 | }, 34 | max: 1 35 | } 36 | }, 37 | actions: { 38 | type: 'array', 39 | label: 'Card Actions', 40 | titleField: 'linkText', 41 | fields: { 42 | add: { 43 | linkText, 44 | linkType, 45 | linkUrl, 46 | _linkFile, 47 | _linkPage, 48 | linkStyle, 49 | linkVariant 50 | } 51 | }, 52 | if: { 53 | clickable: false 54 | } 55 | }, 56 | linkType: { 57 | ...linkType, 58 | if: { 59 | clickable: true 60 | } 61 | }, 62 | linkUrl, 63 | _linkFile, 64 | _linkPage 65 | } 66 | } 67 | }; 68 | -------------------------------------------------------------------------------- /sites/modules/widgets/card-widget/views/widget.html: -------------------------------------------------------------------------------- 1 | {% import "fragments/link.html" as link %} 2 | 3 | {% set widget = data.widget %} 4 | 5 |
6 | {% if widget.clickable %} 7 | {% set path = apos.template.linkPath(widget) %} 8 | 9 | {% endif %} 10 | 11 | {% set img = apos.image.first(widget._image) %} 12 | 13 | {% if img %} 14 | {% set imgSrc = apos.attachment.url(img) %} 15 | {% set imgAlt = widget._image._alt or '' %} 16 | 17 | {{ imgAlt }} 18 | {% endif %} 19 | 20 |
21 | 22 | {% area widget, 'text' %} 23 | 24 | {% if widget.actions.length > 0 %} 25 |
26 | 27 | {% for item in widget.actions %} 28 | {% set path = apos.template.linkPath(item) %} 29 | 30 | {% set style = 'button' if item.linkStyle === 'button' else '' %} 31 | {% set variant = 'color--' + item.linkVariant %} 32 | 33 | {% rendercall link.template({ 34 | path: path, 35 | class: style + ' ' + variant, 36 | attributes: { 37 | target: item.linkTarget[0] 38 | } 39 | }) %} 40 | {{ item.linkText }} 41 | {% endrendercall %} 42 | 43 | {% endfor %} 44 | 45 |
46 | {% endif %} 47 | 48 |
49 | 50 | {% if widget.clickable %} 51 |
52 | {% endif %} 53 |
54 | -------------------------------------------------------------------------------- /sites/modules/@apostrophecms/page/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | options: { 3 | types: [ 4 | { 5 | name: 'default-page', 6 | label: 'Default' 7 | }, 8 | { 9 | name: '@apostrophecms/home-page', 10 | label: 'Home' 11 | } 12 | ] 13 | }, 14 | async init(self) { 15 | self.removeDuplicateParkedPagesMigration(); 16 | }, 17 | methods(self) { 18 | return { 19 | removeDuplicateParkedPagesMigration() { 20 | self.apos.migration.add( 21 | 'remove-duplicate-parked-pages', 22 | async () => { 23 | const duplicateParkedPages = await self.apos.doc.db 24 | .find( 25 | { 26 | parkedId: 'home', 27 | rank: { $ne: 0 }, 28 | level: { $ne: 0 }, 29 | slug: { $ne: '/' } 30 | } 31 | ) 32 | .toArray(); 33 | if (duplicateParkedPages.length === 0) { 34 | return; 35 | } 36 | 37 | for (const duplicate of duplicateParkedPages) { 38 | await self.apos.doc.db.updateOne( 39 | { 40 | _id: duplicate._id 41 | }, 42 | { 43 | $unset: { 44 | parkedId: '', 45 | parked: '' 46 | } 47 | } 48 | ); 49 | } 50 | }); 51 | } 52 | }; 53 | }, 54 | handlers(self, options) { 55 | return { 56 | '@apostrophecms/page:beforeSend': { 57 | setTheme(req) { 58 | req.data.theme = self.apos.options.theme; 59 | } 60 | } 61 | }; 62 | } 63 | }; 64 | -------------------------------------------------------------------------------- /sites/modules/theme-demo/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A Minimally Styled theme that showcases core Apostrophe functionality 3 | */ 4 | 5 | import path from 'node:path'; 6 | import url from 'node:url'; 7 | 8 | const dirname = path.dirname(url.fileURLToPath(import.meta.url)); 9 | const themeDirUrl = url.pathToFileURL(path.resolve(process.cwd(), dirname)); 10 | 11 | export default { 12 | options: { 13 | alias: 'theme' 14 | }, 15 | /** 16 | * Updates the webpack config so we can use SCSS variables and 17 | * utilities from our theme in shared widgets 18 | */ 19 | webpack: { 20 | extensions: { 21 | themeVariables: { 22 | module: { 23 | rules: [ 24 | { 25 | test: /\.s[ac]ss$/, 26 | use: [ 27 | { 28 | loader: 'sass-loader', 29 | options: { 30 | sourceMap: false, 31 | additionalData: ` 32 | @import "${themeDirUrl}/ui/src/scss/settings/_color"; 33 | @import "${themeDirUrl}/ui/src/scss/settings/_font"; 34 | @import "${themeDirUrl}/ui/src/scss/functions/_rem"; 35 | ` 36 | } 37 | } 38 | ] 39 | } 40 | ] 41 | } 42 | } 43 | } 44 | }, 45 | build: { 46 | vite: { 47 | extensions: { 48 | themeVariables: { 49 | css: { 50 | preprocessorOptions: { 51 | scss: { 52 | additionalData: ` 53 | @import "${themeDirUrl}/ui/src/scss/settings/_color"; 54 | @import "${themeDirUrl}/ui/src/scss/settings/_font"; 55 | @import "${themeDirUrl}/ui/src/scss/functions/_rem"; 56 | ` 57 | } 58 | } 59 | } 60 | } 61 | } 62 | } 63 | } 64 | }; 65 | -------------------------------------------------------------------------------- /sites/modules/@apostrophecms/template/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | init(self) { 3 | /** 4 | * Used in Nunjucks templates to determine if an 5 | * HTML attribute is a boolean attribute 6 | */ 7 | self.addFilter({ 8 | isBooleanAttr: self.isBooleanAttr 9 | }); 10 | }, 11 | methods(self) { 12 | return { 13 | isBooleanAttr(attribute) { 14 | const booleanAttributes = [ 15 | 'allowfullscreen', 16 | 'async', 17 | 'autofocus', 18 | 'autoplay', 19 | 'checked', 20 | 'controls', 21 | 'default', 22 | 'defer', 23 | 'disabled', 24 | 'formnovalidate', 25 | 'inert', 26 | 'ismap', 27 | 'itemscope', 28 | 'loop', 29 | 'multiple', 30 | 'muted', 31 | 'nomodule', 32 | 'novalidate', 33 | 'open', 34 | 'playsinline', 35 | 'readonly', 36 | 'required', 37 | 'reversed', 38 | 'selected' 39 | ]; 40 | 41 | return booleanAttributes.includes(attribute); 42 | } 43 | }; 44 | }, 45 | helpers(self, options) { 46 | return { 47 | /** 48 | * The link schema in `/lib/schema/link.js` allows 49 | * Editors to link to a Page, File, or custom url. 50 | * Used in Nunjucks templates to get the appropriate 51 | * field for the link's href based on the link type 52 | * 53 | * Example usage: 54 | * ``` 55 | * apos.template.linkPath(item) 56 | * ``` 57 | */ 58 | 59 | linkPath: (link) => { 60 | if (!link) { 61 | return; 62 | } 63 | 64 | const path = { 65 | page: link?._linkPage[0]?._url, 66 | file: link?._linkFile[0]?._url, 67 | custom: link?.linkUrl 68 | }; 69 | 70 | return path[link.linkType]; 71 | } 72 | }; 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /cypress/tests/site-demo.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | describe('Site Example (theme "demo")', { baseUrl: 'http://site-demo.localhost:3000/' }, () => { 4 | before(() => { 5 | // Required in order to use the fast login method 6 | Cypress.session.clearAllSavedSessions(); 7 | // Reset dashboard database 8 | cy.task('apos:dbReset', 'dashboard'); 9 | // Reset the `site-demo` database 10 | cy.task('apos:dbReset', 'site-demo'); 11 | // Create a user with `admin` role in the `site-demo` database 12 | cy.addUser('admin', 'site-demo'); 13 | }); 14 | 15 | beforeEach(() => { 16 | cy.login('admin', { quick: true }); 17 | // Remove all image tags and default page documents before each test. 18 | cy.aposDeleteDocs({ 19 | type: { $in: [ '@apostrophecms/image-tag', 'default-page' ] }, 20 | profile: 'site-demo' 21 | }); 22 | }); 23 | 24 | it('Visits the site', () => { 25 | cy.visit('/'); 26 | // Not required, here to show that the baseUrl is correct 27 | cy.url().should('eq', 'http://site-demo.localhost:3000/'); 28 | // Showcase a random cy command working as expected 29 | cy.openPagesModal(); 30 | }); 31 | 32 | it('Adds pages and pieces', () => { 33 | // `profile` option is required to specify the site database, because 34 | // `demo-site` is not the default configuration. 35 | cy.addPiece({ 36 | title: 'A tag', 37 | type: '@apostrophecms/image-tag' 38 | }, { profile: 'site-demo' }); 39 | cy.addPage({ 40 | title: 'A page', 41 | slug: '/a-page', 42 | type: 'default-page' 43 | }, { profile: 'site-demo' }); 44 | 45 | cy.visit('/'); 46 | cy.openPagesModal().as('pages'); 47 | cy.get('@pages').findPage('A page'); 48 | cy.get('@pages').findSecondaryButton('Exit').click(); 49 | cy.hasModalCount(0); 50 | 51 | cy.openPieceModal([ 'Media', 'Image Tags' ], 'Manage Image Tags').as('piece'); 52 | cy.get('@piece').findRecord('A tag'); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /cypress/tests/site-default.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | describe('Site Example (theme "default")', () => { 4 | before(() => { 5 | // Required in order to use the fast login method 6 | Cypress.session.clearAllSavedSessions(); 7 | // Reset dashboard database 8 | cy.task('apos:dbReset', 'dashboard'); 9 | // Site with shortName `site-default` is our default configuration, 10 | // so we can omit the `profile` argument. 11 | cy.task('apos:dbReset'/* , 'site-default' */); 12 | // The actual user, created by the command is `cy_admin`. 13 | // The original `admin` user created during the db dump is not used. 14 | // Providing the `site-default` profile to create the user in the correct 15 | // site database is optional, because our default configuration is the 16 | // site 'site-default'. 17 | cy.addUser('admin'/* , 'site-default' */); 18 | }); 19 | 20 | beforeEach(() => { 21 | cy.login('admin', { quick: true }); 22 | // Remove all image tags and default page documents before each test. 23 | cy.aposDeleteDocs({ 24 | type: { $in: [ '@apostrophecms/image-tag', 'default-page' ] } 25 | }); 26 | }); 27 | 28 | it('Visits the site', () => { 29 | cy.visit('/'); 30 | // Not required, here to show that the baseUrl is correct 31 | cy.url().should('eq', 'http://site-default.localhost:3000/'); 32 | // Showcase a random cy command working as expected 33 | cy.openPagesModal(); 34 | }); 35 | 36 | it('Adds pages and pieces', () => { 37 | cy.addPiece({ 38 | title: 'A tag', 39 | type: '@apostrophecms/image-tag' 40 | }); 41 | cy.addPage({ 42 | title: 'A page', 43 | slug: '/a-page', 44 | type: 'default-page' 45 | }); 46 | 47 | cy.visit('/'); 48 | cy.openPagesModal().as('pages'); 49 | cy.get('@pages').findPage('A page'); 50 | cy.get('@pages').findSecondaryButton('Exit').click(); 51 | cy.hasModalCount(0); 52 | 53 | cy.openPieceModal([ 'Media', 'Image Tags' ], 'Manage Image Tags').as('piece'); 54 | cy.get('@piece').findRecord('A tag'); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /sites/modules/widgets/hero-widget/views/widget.html: -------------------------------------------------------------------------------- 1 | {% import 'fragments/link.html' as link %} 2 | 3 | {% set widget = data.widget %} 4 | 5 | {% set img = apos.image.first(widget._image) %} 6 | 7 | {% if img %} 8 | {% set imgUrl = apos.attachment.url(img, { size: 'max' }) %} 9 | {% endif %} 10 | 11 | {% set poster = apos.image.first(widget._videoPoster) %} 12 | 13 | {% if poster %} 14 | {% set posterUrl = apos.attachment.url(poster) %} 15 | {% endif %} 16 | 17 | {% if widget.media === 'image' and widget.backgroundColor %} 18 | {% set background = 'background: center / cover no-repeat url(' + imgUrl + ') ' + widget.backgroundColor %} 19 | {% elif widget.media === 'image' and not widget.backgroundColor %} 20 | {% set background = 'background: center / cover no-repeat url(' + imgUrl + ')' %} 21 | {% elif not widget.media === 'video' and widget.backgroundColor %} 22 | {% set background = 'background-color:' + widget.backgroundColor %} 23 | {% endif %} 24 | 25 |
31 | 32 | {% if widget.media === 'video' %} 33 | 42 |
43 | {% endif %} 44 | 45 |
46 | {% area widget, 'content' %} 47 | 48 | {% if widget.actions.length > 0 %} 49 |
50 | {% for item in widget.actions %} 51 | {% set path = apos.template.linkPath(item) %} 52 | 53 | {% set style = 'button' if item.linkStyle === 'button' else '' %} 54 | {% set variant = 'color--' + item.linkVariant %} 55 | 56 | {% rendercall link.template({ 57 | path: path, 58 | class: style + ' ' + variant, 59 | attributes: { 60 | target: item.linkTarget[0] 61 | } 62 | }) %} 63 | {{ item.linkText }} 64 | {% endrendercall %} 65 | 66 | {% endfor %} 67 |
68 | {% endif %} 69 | 70 |
71 |
72 | -------------------------------------------------------------------------------- /sites/views/layout.html: -------------------------------------------------------------------------------- 1 | {% extends data.outerLayout %} 2 | 3 | {% import 'fragments/link.html' as link %} 4 | 5 | {% block title %} 6 | {% set title = data.piece.title or data.page.title %} 7 | 8 | {{ title }} 9 | 10 | {% if not title %} 11 | {{ 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.') }} 12 | {% endif %} 13 | {% endblock %} 14 | 15 | {% block beforeMain %} 16 |
17 | 18 |
19 | {% set logo = apos.image.first(data.global.logo) %} 20 | 21 | {% if logo %} 22 | 23 | {% else %} 24 | 25 | {% endif %} 26 | 27 | 46 |
47 | 48 |
49 | {% endblock %} 50 | 51 | {% block main %} 52 | {# 53 | Usually, your page templates in the @apostrophecms/pages module will override 54 | this block. It is safe to assume this is where your page-specific content 55 | should go. 56 | #} 57 | {% endblock %} 58 | 59 | {% block afterMain %} 60 |
61 | 70 |
71 | {% endblock %} 72 | -------------------------------------------------------------------------------- /sites/lib/schema/link.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A shared schema field configuration for Links. 3 | * You can use as is in a schema definition or destructure 4 | * when requiring to only use the fields you want 5 | */ 6 | 7 | export default { 8 | linkText: { 9 | label: 'Link Text', 10 | type: 'string', 11 | required: true 12 | }, 13 | linkType: { 14 | label: 'Link Type', 15 | type: 'select', 16 | required: true, 17 | choices: [ 18 | { 19 | label: 'Page', 20 | value: 'page' 21 | }, 22 | { 23 | label: 'File', 24 | value: 'file' 25 | }, 26 | { 27 | label: 'Custom URL', 28 | value: 'custom' 29 | } 30 | ] 31 | }, 32 | _linkPage: { 33 | label: 'Page to link', 34 | type: 'relationship', 35 | withType: '@apostrophecms/page', 36 | max: 1, 37 | builders: { 38 | project: { 39 | title: 1, 40 | _url: 1 41 | } 42 | }, 43 | if: { 44 | linkType: 'page' 45 | }, 46 | required: true 47 | }, 48 | _linkFile: { 49 | label: 'File to link', 50 | type: 'relationship', 51 | withType: '@apostrophecms/file', 52 | max: 1, 53 | if: { 54 | linkType: 'file' 55 | }, 56 | required: true 57 | }, 58 | linkUrl: { 59 | label: 'URL for custom link', 60 | type: 'url', 61 | if: { 62 | linkType: 'custom' 63 | }, 64 | required: true 65 | }, 66 | linkTarget: { 67 | label: 'Will the link open a new browser tab?', 68 | type: 'checkboxes', 69 | choices: [ 70 | { 71 | label: 'Open in new tab', 72 | value: '_blank' 73 | } 74 | ] 75 | }, 76 | linkStyle: { 77 | label: 'Link Style', 78 | type: 'select', 79 | choices: [ 80 | { 81 | label: 'Button', 82 | value: 'button' 83 | }, 84 | { 85 | label: 'Inline', 86 | value: 'inline' 87 | } 88 | ], 89 | def: 'button' 90 | }, 91 | linkVariant: { 92 | label: 'Link Color', 93 | type: 'select', 94 | choices: [ 95 | { 96 | label: 'Primary Color', 97 | value: 'primary' 98 | }, 99 | { 100 | label: 'Secondary Color', 101 | value: 'secondary' 102 | }, 103 | { 104 | label: 'Accent Color', 105 | value: 'accent' 106 | } 107 | ], 108 | def: 'primary' 109 | } 110 | }; 111 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | import multisite from '@apostrophecms-pro/multisite'; 2 | import telemetry from './telemetry.js'; 3 | import sites from './sites/index.js'; 4 | import dashboard from './dashboard/index.js'; 5 | 6 | const { sdk, shutdown } = telemetry; 7 | 8 | go(); 9 | 10 | async function go() { 11 | try { 12 | if (process.env.APOS_OPENTELEMETRY) { 13 | await sdk.start(); 14 | console.log('OpenTelemetry started'); 15 | } 16 | await multisite({ 17 | root: import.meta, 18 | // Default port, for dev 19 | port: 3000, 20 | websocket: true, 21 | // Change this to a hardcoded string when forking to make a new project. 22 | // Just set it to a string which should never change. Ideally should match 23 | // your repo name followed by a `-`, however if you plan to use a 24 | // cheap Atlas cluster (below M10), you must use a unique prefix less 25 | // than 12 characters (before the -). 26 | // Ensure test mode with a prefix of `test-` for CI and local development. 27 | shortNamePrefix: process.env.CI === '1' ? 'test-' : (process.env.APOS_PREFIX || 'a3ab-'), 28 | // Suffix, used only for building hostnames and not affecting 29 | // e.g. database names. For example, if you set this to `-assembly`, 30 | // and your short name is `site`, the hostname for that site would be 31 | // `site-assembly.your-domain.com`, and your dashboard would become available 32 | // at `dashboard-assembly.your-domain.com`. 33 | shortNameSuffix: '', 34 | // Used to separate the locale name from the short name in hostnames. 35 | // For example, if you set this to `-` and your short name is `site`, 36 | // the hostname for the `fr` locale with "Separate Host" enabled, 37 | // would be `fr-site.your-domain.com`. 38 | localeSeparator: '.', 39 | // You may set the dashboard short name to a different value than the default 40 | // 'dashboard'. For example if set to `admin`, the dashboard would be 41 | // available at `https://admin.yourdomain.com`. 42 | dashboardShortName: process.env.APOS_DASHBOARD_SHORTNAME || 'dashboard', 43 | // For development. An environment variable overrides this in staging/production 44 | mongodbUrl: process.env.APOS_MONGODB_URI || 'mongodb://localhost:27017', 45 | sessionSecret: 'CHANGEME', 46 | beforeExit: process.env.APOS_OPENTELEMETRY ? shutdown : null, 47 | sites, 48 | dashboard 49 | }); 50 | } catch (e) { 51 | console.error(e); 52 | process.exit(1); 53 | } 54 | }; 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "starter-kit-assembly-essentials", 3 | "version": "2.0.0", 4 | "description": "A boilerplate for multisite content-management in Apostrophe", 5 | "type": "module", 6 | "private": true, 7 | "scripts": { 8 | "build": "cross-env APOS_UPLOADFS_ASSETS=1 NODE_ENV=production bash -c 'node app @apostrophecms/asset:build --site=dashboard && ./scripts/for-each-theme @apostrophecms/asset:build'", 9 | "//": "because nodemon insists on executing 'start' if it exists, we must distinguish production", 10 | "production-start": "cross-env APOS_UPLOADFS_ASSETS=1 NODE_ENV=production npm run start", 11 | "start": "node app", 12 | "dev": "nodemon", 13 | "test": "eslint . && stylelint 'dashboard/**/*.scss' --allow-empty-input && stylelint 'sites/**/*.scss' --allow-empty-input", 14 | "e2e:dev": "cross-env CI=1 npm run dev", 15 | "e2e:serve": "cross-env NODE_ENV=production CI=1 bash -c 'node app @apostrophecms/asset:build --site=dashboard && ./scripts/for-each-theme @apostrophecms/asset:build && node app'", 16 | "e2e:run": "cypress run", 17 | "e2e:open": "cypress open" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/apostrophecms/starter-kit-assembly-essentials" 22 | }, 23 | "engines": { 24 | "node": ">=18.0.0" 25 | }, 26 | "author": "Apostrophe Technologies", 27 | "license": "UNLICENSED", 28 | "dependencies": { 29 | "@apostrophecms-pro/document-versions": "^2.3.1", 30 | "@apostrophecms-pro/multisite": "^4.3.0", 31 | "@apostrophecms-pro/multisite-dashboard": "^1.4.0", 32 | "@apostrophecms-pro/palette": "^4.3.2", 33 | "@apostrophecms/favicon": "^1.1.2", 34 | "@apostrophecms/vite": "apostrophecms/vite#fix-windows-1", 35 | "@opentelemetry/auto-instrumentations-node": "^0.62.0", 36 | "@opentelemetry/exporter-trace-otlp-http": "^0.203.0", 37 | "@opentelemetry/resources": "^2.0.1", 38 | "@opentelemetry/sdk-node": "^0.203.0", 39 | "@opentelemetry/semantic-conventions": "^1.36.0", 40 | "@tiptap/extension-text-style": "^2.8.0", 41 | "accordion-js": "^3.4.0", 42 | "apostrophe": "^4.23.0", 43 | "cross-env": "^10.1.0", 44 | "glob": "^10.4.5", 45 | "normalize.css": "^8.0.1", 46 | "swiper": "^11.1.3" 47 | }, 48 | "devDependencies": { 49 | "@apostrophecms-pro/cypress-tools": "1.0.0-beta.19", 50 | "autoprefixer": "^10.4.20", 51 | "cypress": "^13.16.0", 52 | "eslint-config-apostrophe": "^6.0.1", 53 | "eslint-plugin-cypress": "^5.1.1", 54 | "nodemon": "^3.1.7", 55 | "stylelint": "^16.6.1", 56 | "stylelint-config-apostrophe": "^4.2.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /sites/modules/widgets/hero-widget/index.js: -------------------------------------------------------------------------------- 1 | import link from '../../../lib/schema/link.js'; 2 | 3 | export default { 4 | extend: '@apostrophecms/widget-type', 5 | options: { 6 | label: 'Hero', 7 | icon: 'sign-text-icon' 8 | }, 9 | fields: { 10 | add: { 11 | content: { 12 | type: 'area', 13 | label: 'Content', 14 | options: { 15 | widgets: { 16 | '@apostrophecms/rich-text': {} 17 | }, 18 | max: 1 19 | } 20 | }, 21 | actions: { 22 | type: 'array', 23 | label: 'Actions', 24 | titleField: 'linkText', 25 | fields: { 26 | add: link 27 | } 28 | }, 29 | media: { 30 | type: 'select', 31 | label: 'Media', 32 | help: 'Use an image or video for the Hero\'s background', 33 | choices: [ 34 | { 35 | label: 'Image', 36 | value: 'image', 37 | def: true 38 | }, 39 | { 40 | label: 'Video', 41 | value: 'video' 42 | } 43 | ] 44 | }, 45 | _image: { 46 | type: 'relationship', 47 | label: 'Hero Image', 48 | withType: '@apostrophecms/image', 49 | if: { 50 | media: 'image' 51 | } 52 | }, 53 | videoUrl: { 54 | type: 'url', 55 | label: 'External Video URL', 56 | help: 'A URL to an externally hosted .mp4', 57 | if: { 58 | media: 'video' 59 | } 60 | }, 61 | _videoPoster: { 62 | type: 'relationship', 63 | label: 'Video Poster', 64 | help: 'This image will appear as the video is loading', 65 | withType: '@apostrophecms/image', 66 | if: { 67 | background: 'video' 68 | } 69 | }, 70 | size: { 71 | type: 'select', 72 | label: 'Size', 73 | choices: [ 74 | { 75 | label: 'Small', 76 | value: 'small' 77 | }, 78 | { 79 | label: 'Medium', 80 | value: 'medium', 81 | def: true 82 | }, 83 | { 84 | label: 'Large', 85 | value: 'large' 86 | } 87 | ] 88 | }, 89 | backgroundColor: { 90 | type: 'color', 91 | label: 'Background Color', 92 | help: 'This option can also be used to set a screen over an image or video by setting the color\'s opacity to a decimal', 93 | options: { 94 | format: 'rgb' 95 | } 96 | } 97 | } 98 | } 99 | }; 100 | -------------------------------------------------------------------------------- /sites/modules/theme-default/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * An empty theme to use as a starting point for custom development 3 | */ 4 | 5 | export default { 6 | options: { 7 | alias: 'theme', 8 | // Silence startup warning about the lack of code since this 9 | // is just an empty starting point for your own work 10 | ignoreNoCodeWarning: true, 11 | // Silence startup warning displayed if this module is 12 | // not activated at all, since only one theme module 13 | // will be activated per site 14 | ignoreUnusedFolderWarning: true 15 | }, 16 | /** 17 | * Updates the webpack config so we can use SCSS variables and 18 | * utilities from our theme in shared widgets. 19 | * 20 | * May remove if you refactor the provided widgets so they don't 21 | * rely on Sass variables 22 | */ 23 | webpack: { 24 | extensions: { 25 | themeVariables: { 26 | module: { 27 | rules: [ 28 | { 29 | test: /\.s[ac]ss$/, 30 | use: [ 31 | { 32 | loader: 'sass-loader', 33 | options: { 34 | sourceMap: false, 35 | additionalData: ` 36 | @use 'sass:math'; 37 | 38 | // The code below is used by the Widgets included in the Starter Kit 39 | // You'll need to keep these so the theme builds or refactor the 40 | // included widgets to remove these variables and functions. 41 | 42 | $color-light-yellow: #ffffd8; 43 | 44 | $color-white: #fff; 45 | $color-gray-05: #eee; 46 | $color-gray-15: #dbdbdb; 47 | $color-gray-80: #2b2b2b; 48 | $color-black: #000; 49 | 50 | $font-monospace: menlo, monaco, consolas, 'Liberation Mono', 'Courier New', monospace; 51 | $font-sans-serif: -apple-system, blinkmacsystemfont, "Segoe UI", roboto, helvetica, arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 52 | 53 | $font-weight-light: 200; 54 | $font-weight-normal: 400; 55 | $font-weight-bold: 700; 56 | 57 | // Converts a px font size to rem unit 58 | // stylelint-disable-next-line at-rule-disallowed-list 59 | @function rem($value) { 60 | @return math.div($value, 16) + rem; 61 | } 62 | ` 63 | } 64 | } 65 | ] 66 | } 67 | ] 68 | } 69 | } 70 | } 71 | }, 72 | build: { 73 | vite: { 74 | extensions: { 75 | themeVariables: { 76 | css: { 77 | preprocessorOptions: { 78 | scss: { 79 | additionalData: ` 80 | @use 'sass:math'; 81 | 82 | // The code below is used by the Widgets included in the Starter Kit 83 | // You'll need to keep these so the theme builds or refactor the 84 | // included widgets to remove these variables and functions. 85 | 86 | $color-light-yellow: #ffffd8; 87 | 88 | $color-white: #fff; 89 | $color-gray-05: #eee; 90 | $color-gray-15: #dbdbdb; 91 | $color-gray-80: #2b2b2b; 92 | $color-black: #000; 93 | 94 | $font-monospace: menlo, monaco, consolas, 'Liberation Mono', 'Courier New', monospace; 95 | $font-sans-serif: -apple-system, blinkmacsystemfont, "Segoe UI", roboto, helvetica, arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 96 | 97 | $font-weight-light: 200; 98 | $font-weight-normal: 400; 99 | $font-weight-bold: 700; 100 | 101 | // Converts a px font size to rem unit 102 | // stylelint-disable-next-line at-rule-disallowed-list 103 | @function rem($value) { 104 | @return math.div($value, 16) + rem; 105 | } 106 | ` 107 | } 108 | } 109 | } 110 | } 111 | } 112 | } 113 | } 114 | }; 115 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Dockerfile for self-hosted Apostrophe Assembly Starter Kit. 2 | # Assumes S3 for media storage and an external mongodb server. 3 | 4 | FROM node:18-bullseye 5 | ENV APOS_MINIFY=1 6 | WORKDIR /app 7 | 8 | # Not required with latest uploadfs 9 | # RUN apt-get -y install imagemagick 10 | 11 | COPY package*.json ./ 12 | 13 | ARG NPMRC 14 | 15 | # So npm install can succeed with private modules 16 | ENV NPMRC=${NPMRC} 17 | 18 | RUN echo $NPMRC > /root/.npmrc 19 | 20 | # Not npm ci because developers don't always agree that 21 | # build tools are not devDependencies 22 | RUN npm install --include=dev 23 | 24 | # Not until after npm install because developers don't 25 | # always agree that build tools are not devDependencies 26 | ENV NODE_ENV=production 27 | 28 | # For mongodb and utilities installed via "m" 29 | ENV PATH="/root/.local/bin:$PATH" 30 | RUN mkdir -p /root/.local/bin 31 | 32 | # Install mongodb command line tools 33 | RUN npm install -g m && echo y | m tools stable 34 | # Temporary: install mongodb itself purely to satisfy the multisite module, 35 | # this instance will not be used in production 36 | RUN echo y | m 5.0 && mkdir -p /root/tmp-mongodb-data 37 | 38 | # See below for comments on each 39 | ARG APOS_PREFIX 40 | ARG ENV 41 | ARG APOS_S3_REGION 42 | ARG APOS_S3_BUCKET 43 | ARG APOS_S3_KEY 44 | ARG APOS_S3_SECRET 45 | ARG APOS_DASHBOARD_HOSTNAME 46 | ARG PLATFORM_BALANCER_API_KEY 47 | ARG CDN 48 | 49 | # Can be removed if you remove the relevant code from 50 | # the boilerplate project, which is intended to pass a hint 51 | # to your load balancer that a site has been added or changed 52 | RUN mkdir -p /opt/cloud && echo $PLATFORM_BALANCER_API_KEY > /opt/cloud/platform-balancer-api-key 53 | 54 | # Prefix for site database names, e.g. "myproject-" 55 | # Dashboard will be "my-project-dashboard" 56 | 57 | ENV APOS_PREFIX=${APOS_PREFIX} 58 | 59 | # dev, staging or prod as appropriate 60 | # Must be "prod" for the "production hostname" field 61 | # in the website dashboard to take effect 62 | ENV ENV=${ENV} 63 | 64 | # e.g. dashboard.myplatformsdomainname.com 65 | ENV APOS_DASHBOARD_HOSTNAME=${APOS_DASHBOARD_HOSTNAME} 66 | 67 | # If not using S3, configure uploadfs for your preferred 68 | # storage using environment variables, or make sure 69 | # sites/uploads and dashboard/uploads are on a shared 70 | # filesystem across all load-balanced instances 71 | ENV APOS_S3_REGION=${APOS_S3_REGION} 72 | ENV APOS_S3_BUCKET=${APOS_S3_BUCKET} 73 | ENV APOS_S3_KEY=${APOS_S3_KEY} 74 | ENV APOS_S3_SECRET=${APOS_S3_SECRET} 75 | 76 | # You can remove this from the boilerplate code if you 77 | # don't plan to implement a similar API in your own 78 | # load balancer for notification that a site was changed 79 | ENV PLATFORM_BALANCER_API_KEY=${PLATFORM_BALANCER_API_KEY} 80 | 81 | # Optional, for cloudflare, cloudfront, etc. 82 | ENV CDN=${CDN} 83 | 84 | # Bring in most of the code late to benefit from caching 85 | COPY . ./ 86 | 87 | # Generate a unique identifier for this particular build 88 | RUN APOS_RELEASE_ID=`cat /dev/random | tr -dc '0-9' | head -c 8` && echo ${APOS_RELEASE_ID} > ./sites/release-id && echo ${APOS_RELEASE_ID} > ./dashboard/release-id 89 | 90 | # Store shared static assets in uploadfs 91 | ENV APOS_UPLOADFS_ASSETS=1 92 | 93 | # Temporary mongod server is deliberately in the background during the build task 94 | RUN bash -c "export MONGODB_URL=mongodb://localhost:27017 && mongod --dbpath=/root/tmp-mongodb-data & ./scripts/wait-for-port 27017 && npm run build" 95 | 96 | # At runtime everything is already baked in 97 | EXPOSE 3000 98 | 99 | # Will fail unless --env is used to specify the true MONGODB_URL to "docker run" 100 | CMD bash -c "npm run production-start" 101 | -------------------------------------------------------------------------------- /sites/modules/widgets/column-widget/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Widget configuration that is shared with all columns 3 | */ 4 | const widgets = { 5 | '@apostrophecms/rich-text': {}, 6 | '@apostrophecms/image': { className: 'image-widget' }, 7 | '@apostrophecms/video': {}, 8 | link: {}, 9 | card: {}, 10 | accordion: {} 11 | }; 12 | 13 | export default { 14 | extend: '@apostrophecms/widget-type', 15 | options: { 16 | label: 'Columns', 17 | alias: 'columnsWidget', 18 | icon: 'view-column-icon' 19 | }, 20 | fields: { 21 | add: { 22 | layout: { 23 | type: 'select', 24 | label: 'Column Layout', 25 | required: true, 26 | choices: [ 27 | { 28 | label: '1 column, 100%', 29 | value: '100' 30 | }, 31 | { 32 | label: '2 columns, 50%', 33 | value: '50' 34 | }, 35 | { 36 | label: '2 columns, 66% / 33%', 37 | value: '66-33' 38 | }, 39 | { 40 | label: '2 columns, 75% / 25%', 41 | value: '75-25' 42 | }, 43 | { 44 | label: '2 columns, 33% / 66%', 45 | value: '33-66' 46 | }, 47 | { 48 | label: '2 columns, 25% / 75%', 49 | value: '25-75' 50 | }, 51 | { 52 | label: '3 columns, 33.3%', 53 | value: '33' 54 | }, 55 | { 56 | label: '4 columns, 25%', 57 | value: '25' 58 | } 59 | ], 60 | def: '100' 61 | }, 62 | column1: { 63 | label: 'Column One', 64 | type: 'area', 65 | contextual: true, 66 | options: { 67 | widgets: { 68 | ...widgets, 69 | slideshow: { 70 | swiper: { 71 | /** 72 | * Override the default Swiper configuration by setting any of the 73 | * available Swiper parameters here. Pagination and Navigation are 74 | * enabled by default. 75 | * https://swiperjs.com/swiper-api#parameters 76 | */ 77 | allowTouchMove: false 78 | } 79 | } 80 | } 81 | }, 82 | if: { 83 | $or: [ 84 | { layout: '100' }, 85 | { layout: '50' }, 86 | { layout: '25-75' }, 87 | { layout: '33-66' }, 88 | { layout: '75-25' }, 89 | { layout: '66-33' }, 90 | { layout: '33' }, 91 | { layout: '25' } 92 | ] 93 | } 94 | }, 95 | column2: { 96 | label: 'Column Two', 97 | type: 'area', 98 | contextual: true, 99 | options: { widgets }, 100 | if: { 101 | $or: [ 102 | { layout: '50' }, 103 | { layout: '25-75' }, 104 | { layout: '33-66' }, 105 | { layout: '75-25' }, 106 | { layout: '66-33' }, 107 | { layout: '33' }, 108 | { layout: '25' } 109 | ] 110 | } 111 | }, 112 | column3: { 113 | label: 'Column Three', 114 | type: 'area', 115 | contextual: true, 116 | options: { widgets }, 117 | if: { 118 | $or: [ 119 | { layout: '33' }, 120 | { layout: '25' } 121 | ] 122 | } 123 | }, 124 | column4: { 125 | label: 'Column Four', 126 | type: 'area', 127 | contextual: true, 128 | options: { widgets }, 129 | if: { 130 | $or: [ 131 | { layout: '25' } 132 | ] 133 | } 134 | }, 135 | backgroundColor: { 136 | type: 'color', 137 | label: 'Background Color' 138 | } 139 | } 140 | }, 141 | helpers(self) { 142 | return { 143 | /** 144 | * A helper to get the number of columns the editor has selected 145 | * Used in the template to generate the columns and areas 146 | */ 147 | getColumns(layout) { 148 | 149 | const columns = { 150 | 1: [ '100' ], 151 | 2: [ '50', '66-33', '75-25', '33-66', '25-75' ], 152 | 3: [ '33' ], 153 | 4: [ '25' ] 154 | }; 155 | 156 | let number = 1; 157 | for (const key in columns) { 158 | if (columns[key].includes(layout)) { 159 | number = key; 160 | } 161 | } 162 | return parseInt(number); 163 | } 164 | }; 165 | } 166 | }; 167 | -------------------------------------------------------------------------------- /self-hosting.md: -------------------------------------------------------------------------------- 1 | # Self-hosting recommendations 2 | 3 | *This page is about self-hosting Apostrophe Assembly in staging and production environments. 4 | For a **development** environment, we recommend running Apostrophe Assembly directly on 5 | MacOS, Linux, or Windows Subsystem for Linux.* 6 | 7 | A sample `Dockerfile` is provided with this project and can be used for self-hosting. 8 | See also the provided `.dockerignore` file. The rest of this document will assume 9 | you are basing your deployment plan on these. 10 | 11 | ## Working with the provided `Dockerfile` 12 | 13 | The provided `Dockerfile` assumes you have obtained an Amazon S3 storage bucket and set up 14 | MongoDB hosting via MongoDB Atlas. The `Dockerfile` is designed to support running as many 15 | instances as you wish on separate servers and load-balancing them via a mechanism of your 16 | choice. 17 | 18 | Typical Docker `build` and `run` commands look like: 19 | 20 | ```bash 21 | # build command 22 | docker build -t a3-assembly-boilerplate . \ 23 | --build-arg="NPMRC=//registry.npmjs.org/:_authToken=YOUR_NPM_TOKEN_GOES_HERE" \ 24 | --build-arg="ENV=prod" --build-arg="APOS_PREFIX=YOUR-PREFIX-GOES-HERE-" \ 25 | --build-arg="DASHBOARD_HOSTNAME=dashboard.YOUR-DOMAIN-NAME-GOES-HERE.com" \ 26 | --build-arg="PLATFORM_BALANCER_API_KEY=YOUR-STRING-GOES-HERE" \ 27 | --build-arg="APOS_S3_REGION=YOURS-GOES-HERE" \ 28 | --build-arg="APOS_S3_BUCKET=YOURS-GOES-HERE" \ 29 | --build-arg="APOS_S3_KEY=YOURS-GOES-HERE" \ 30 | --build-arg="APOS_S3_SECRET=YOURS-GOES-HERE" 31 | 32 | # run command 33 | docker run -it --env MONGODB_URL=YOUR-MONGODB-ATLAS-URL-GOES-HERE a3-assembly-boilerplate 34 | ``` 35 | 36 | To avoid passing the real MongoDB URL to the build task, currently the provided Dockerfile uses a 37 | temporary instance of `mongod` to satisfy a requirement that it be present for the build task. 38 | 39 | An npm token is required to successfully `npm install` the private packages inside the 40 | image during the build. 41 | 42 | S3 credentials are passed to the build so that the static assets can be mirrored to S3, however 43 | at a cost in performance this can be avoided by removing `APOS_UPLOADFS_ASSETS=1` from 44 | the `Dockerfile` and removing the references to these environment variables as well. Note 45 | that you will still need S3 credentials in the `run` command, unless you arrange for 46 | `dashboard/public/uploads` and `sites/public/uploads` to be persistent volumes on a 47 | filesystem shared by all instances. This is slow, so we recommend using S3 or configuring 48 | a different [uploadfs backend](https://github.com/apostrophecms/uploadfs) such as 49 | Azure Blob Storage or Google Cloud Storage. 50 | 51 | If you provide a `PLATFORM_BALANCER_API_KEY`, then your dashboard hostname must 52 | also accept a JSON-encoded `POST` request to `/platform-balancer/refresh` with a single `key` 53 | parameter. You can use that request as a trigger to refresh your list of sites when an admin adds 54 | or edits a site in the dashboard. If you don't want to do this, just don't set the variable. 55 | 56 | ## Understanding our typical deployment process 57 | 58 | In setting up your own self-hosted process, you may find it helpful to better understand 59 | our own process. Toward that end, here is an overview: 60 | 61 | * All of the content that users create while editing is stored in the external MongoDB, 62 | except media which is stored in the external S3 buckets. The application servers are 100% 63 | stateless. This is essential so that you can load-balance application servers without 64 | worrying about any differences. The only way they ever change is when a new image build 65 | is pushed (see below). 66 | * Accordingly, zero code changes are ever made within the running container. 67 | * Static assets, e.g. the js and css bundles loaded on the page and related files, 68 | are pushed to S3 during the asset build (if using APOS_UPLOADFS_ASSETS=1 and the S3 69 | variables) or served directly by Express from the container image (if you don't set 70 | that variable). 71 | * There is a randomly generated release-id, as you've seen in the `Dockerfile, that 72 | uniquely identifies this particular build so it can always find its static assets 73 | in S3 and including that id in `sites/release-id` and `dashboard/release-id` in the 74 | container itself guarantees the running application sees the id that the build task 75 | generated. 76 | * Our normal practice is to set up github webhooks to trigger a container build and 77 | redeployment whenever the staging or production github branches are updated. 78 | * We then use github deploy keys to allow our build worker to git clone the code. 79 | * We then use `docker save` and `docker load` to pipe the new container image from 80 | our build worker to our application servers, stop the old container and start the 81 | new one. You might prefer to use your own container registry. 82 | * If an admin user creates a site in the dashboard and gives it the "short name" 83 | `mysite`, then Apostrophe automatically expects to receive HTTP requests for 84 | `mysite.yourdomainhere.com`, based on your `DASHBOARD_HOSTNAME`, which should be 85 | `dashboard.yourdomainhere.com` unless this pattern has been adjusted (see the 86 | `@apostrophecms-pro/multisite` documentation). 87 | * If that pattern suffices for your needs, you can set up your reverse proxy server 88 | with a DNS wildcard "A" record and a wildcard HTTPS certificate and pass all 89 | traffic to Apostrophe. 90 | * Many of our customers need separate production domain names per site. For this 91 | use case, the reverse proxy server configuratino must be rebuilt dynamically when 92 | sites are added, updated or removed via the dashboard, paying special attention to the 93 | `prodHostname` property of each site piece in the dashboard database that 94 | is not marked `archived: true`. 95 | * Our reverse proxy update software also generates certificates on the fly 96 | using letsencrypt. 97 | 98 | ## A sample query to identify live sites 99 | 100 | With regard to updating reverse proxy server configuration on the fly, the specifics 101 | are up to you, but it is useful to have a way to query Apostrophe for a list of all 102 | live websites in the dashboard. 103 | 104 | First, identify the correct MongoDB database name. If you are setting `APOS_PREFIX` to 105 | `mycompany-`, then the database you need is `mycompany-dashboard`. 106 | 107 | Then, make a query as follows: 108 | 109 | ```javascript 110 | const sites = await docs.find({ 111 | type: 'site', 112 | archived: { 113 | $ne: true 114 | } 115 | ).sort({ _id: 1 }).toArray(); 116 | ``` 117 | 118 | You can then examine the `shortName` and `prodHostname` properties of each site to 119 | set up appropriate routing to your application servers. 120 | 121 | ## Routing for large numbers of sites 122 | 123 | Because Apostrophe can serve many sites from a single process, a single EC2 t2.medium 124 | instance can typically handle about 25 sites before RAM or CPU becomes an issue, depending 125 | greatly of course on the amount of traffic involved. This is good news for lower costs. 126 | 127 | However, if you have 100 sites and use a simple round robin load balancing strategy, you 128 | will have a problem because all 100 sites will soon be consuming RAM on all of your 129 | application servers. 130 | 131 | So when operating at this scale, we recommend generating separate `nginx` configurations 132 | for each site, pointing to just two application server backends each (two per site to provide 133 | high availability). 134 | 135 | The following code snippet is useful in determining which servers to assign based on the 136 | number of application servers available and the `_id` property of an individual site: 137 | 138 | ```javascript 139 | function pickBackends(appServerIps, _id) { 140 | const random = _id.substring(_id.length - 8, _id.length); 141 | const n = parseInt(random, 36); 142 | return [ appServerIps[n % appServerIps.length], appServerIps[(n + 1) % appServerIps.length] ]; 143 | } 144 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Apostrophe Starter Kit Assembly Essentials 2 | >**Important Notice:** 3 | > 4 | >This starter kit requires the`@apostrophecms-pro/multisite` module, which requires an Apostrophe Assembly license. To obtain a license, please visit our [website](https://apostrophecms.com/assembly) to learn more. You can contact our support team for assistance or purchase a subscription directly through [your Apostrophe Workspace](https://app.apostrophecms.com/login). 5 | > 6 | >As an alternative to this starter template, we offer several open-source starter kits that are available without any licensing restrictions. These kits, along with links to their GitHub repositories, are listed on our [website](https://apostrophecms.com/starter-kits). 7 | 8 | 10 | - [Apostrophe Starter Kit Assembly Essentials](#apostrophe-starter-kit-assembly-essentials) 11 | - [Purpose](#purpose) 12 | - [**We recommend installing this project by forking it to your own GitHub account and then cloning it locally. The Apostrophe CLI is not currently intended for multisite projects**](#we-recommend-installing-this-project-by-forking-it-to-your-own-github-account-and-then-cloning-it-locally-the-apostrophe-cli-is-not-currently-intended-for-multisite-projects) 13 | - [First Steps: required before startup](#first-steps-required-before-startup) 14 | - [Setting your shortname prefix](#setting-your-shortname-prefix) 15 | - [Configuring your domains](#configuring-your-domains) 16 | - [Adding a suffix to your subdomains (optional)](#adding-a-suffix-to-your-subdomains-optional) 17 | - [Changing the locale separator of your subdomains (optional)](#changing-the-locale-separator-of-your-subdomains-optional) 18 | - [Setting your Dashboard shortname (optional)](#setting-your-dashboard-shortname-optional) 19 | - [Disabled File Key](#disabled-file-key) 20 | - [Session Secret](#session-secret) 21 | - [Requirements For Development On Your Computer](#requirements-for-development-on-your-computer) 22 | - [Operating System: Mac, Linux, or Virtual Linux](#operating-system-mac-linux-or-virtual-linux) 23 | - [Software Installation Requirements](#software-installation-requirements) 24 | - [`/etc/hosts` File Configuration Requirements](#etchosts-file-configuration-requirements) 25 | - [Starting Up In Development](#starting-up-in-development) 26 | - [Scheduling tasks with Apostrophe Assembly hosting](#scheduling-tasks-with-apostrophe-assembly-hosting) 27 | - [Site Development](#site-development) 28 | - [Where Does My Apostrophe Project Code Go?](#where-does-my-apostrophe-project-code-go) 29 | - [Themes](#themes) 30 | - [Adding a New Theme](#adding-a-new-theme) 31 | - [Custom Module Configuration for Themes](#custom-module-configuration-for-themes) 32 | - [Modern Frontend Assets Without A Custom Build Process](#modern-frontend-assets-without-a-custom-build-process) 33 | - [Example webpack extensions](#example-webpack-extensions) 34 | - [Frontend Assets With Your Own Build Process](#frontend-assets-with-your-own-build-process) 35 | - [Developing For IE11](#developing-for-ie11) 36 | - [Serving Static Files: Fonts and Static Images](#serving-static-files-fonts-and-static-images) 37 | - [Palette Configuration](#palette-configuration) 38 | - [Provided widgets](#provided-widgets) 39 | - [`accordion-widget`](#accordion-widget) 40 | - [`card-widget`](#card-widget) 41 | - [`column-widget`](#column-widget) 42 | - [`hero-widget`](#hero-widget) 43 | - [`link-widget`](#link-widget) 44 | - [`slideshow-widget`](#slideshow-widget) 45 | - [Dashboard Development](#dashboard-development) 46 | - [Allowing dashboard admins to pass configuration to sites](#allowing-dashboard-admins-to-pass-configuration-to-sites) 47 | - [Cypress (end-to-end) Testing](#cypress-end-to-end-testing) 48 | - [Prerequisites](#prerequisites) 49 | - [Running the tests](#running-the-tests) 50 | - [Updating the Cypress configuration DB dumps](#updating-the-cypress-configuration-db-dumps) 51 | - [Integrating Cypress in your existing projects](#integrating-cypress-in-your-existing-projects) 52 | - [Cypress tools](#cypress-tools) 53 | - [Accessing the MongoDB utilities for a specific site](#accessing-the-mongodb-utilities-for-a-specific-site) 54 | - [Hosting](#hosting) 55 | - [Deployment](#deployment) 56 | - [Profiling with OpenTelemetry](#profiling-with-opentelemetry) 57 | - [Self-hosting and the sample Dockerfile](#self-hosting-and-the-sample-dockerfile) 58 | - [Localized domain names](#localized-domain-names) 59 | - [Private locales](#private-locales) 60 | 61 | ## Purpose 62 | The purpose of this repo is to serve as a quick start for multisite-enabled, cloud-hosted projects based on and hosted via Apostrophe Assembly. Technically speaking, it serves as a working example of a project built on the `@apostrophecms-pro/multisite` module. 63 | 64 | It also serves as example code for creating your own custom modules and organizing your files in an ApostropheCMS project. The [section describing the widgets](#provided-widgets) outlines some code practices and features that can be used in your own custom modules. 65 | 66 | This starter kit includes: 67 | 68 | * An example of project-level code for your customer-facing sites. 69 | * An example of project-level code for the dashboard site that manages the rest. 70 | * An example of project-level frontend asset generation via a modern webpack build. 71 | * Best practices for easy hostname configuration in dev, staging and prod environments. 72 | * Support for multiple themes. 73 | 74 | ### **We recommend installing this project by forking it to your own GitHub account and then cloning it locally. The Apostrophe CLI is not currently intended for multisite projects** 75 | 76 | > **📌 Note on Dependency Management** 77 | > 78 | > This starter kit ships with `package-lock.json` in `.gitignore` to avoid merge conflicts during development. 79 | > 80 | > **For production use:** Remove `package-lock.json` from `.gitignore` and commit it to lock your dependencies. This ensures stable, reproducible builds. When you're ready to update dependencies, run `npm update` and commit the updated lock file. 81 | 82 | ## First Steps: required before startup 83 | 84 | ### Setting your shortname prefix 85 | 86 | Before you do anything else, set the fallback value for the `shortnamePrefix` option in `app.js` to a unique string for your project, replacing `a3ab-`. This should match your repo name followed by a `-` character. This should be distinct from any other Assembly projects you have, to ensure their MongoDB databases do not conflict in a dev environment. 87 | 88 | > MongoDB Atlas note: if you are self-hosting and you plan to use a low-end MongoDB Atlas cluster (below M10), you must use a unique prefix less than 12 characters (before the `-`), even if your repo name is longer. This is not an issue with hosting provided by the Apostrophe Assembly team. 89 | 90 | ### Configuring your domains 91 | 92 | After cloning this project, be sure to edit the `domains.js` file in the root directory and update the list to match your actual project's domains, typically for development, staging, and production. The `@apostrophecms-pro/multisite-dashboard` extension's `site` module requires an object with URL strings for the `baseUrlDomains` option, and this file provides those values. While `dev`, `staging`, and `prod` are common domain names, you can use other names, but the first one defined in the object will be considered the development environment. 93 | 94 | If you are doing local development on your own computer, leave the `dev` domain set to `localhost:3000`. For staging and production, the Apostrophe Assembly team will typically preconfigure this for you and you won't need to worry about DNS or certificates. 95 | 96 | If you are rolling your own hosting, the recommended approach is to create a DNS "wildcard" `A` record for a subdomain of your actual domain name, like `*.staging.example.com`, and configure `staging.example.com` as the `staging` value in `domains.js`. You'll also need a wildcard SSL certificate for each of staging and production. 97 | 98 | You will later be able to set a "shortname" for each site and it will automatically work as a subdomain of all three domains. This saves a lot of configuration effort. 99 | 100 | > In the case of production, you will of course also be able to add a final production domain name for *each* site via the user interface. But you will need a "pre-production" hostname for early content creation. That is where `baseUrlDomains` comes into play even for production. 101 | > 102 | > You are not restricted to the environment names `dev`, `staging` and `prod`. However, the first environment configured is assumed to be a local debugging environment for programmers (typically `dev`), and the environment named `prod` is the only one that attempts to serve a site under its `prodHostname`. If you are working with the Apostrophe Assembly team for hosting, ask us for an additional cloud instance for each environment. 103 | 104 | ### Adding a suffix to your subdomains (optional) 105 | 106 | The `shortNameSuffix` configuration option, which defaults to an empty string, allows you to add additional suffix string to every site short name. For example, for a site with short name `cars` and the following configuration: 107 | ```js 108 | multisite({ 109 | // ... 110 | shortNameSuffix: '-assembly', 111 | }); 112 | ``` 113 | The resulting base URL for this site will be `http://cars-assembly.localhost:3000`, `https://cars-assembly.staging.your-domain.com`, etc. 114 | 115 | These options apply only when the hostname is determined in part by the `shortName` field for the site, so if a production hostname is configured, it will be used exactly as given. 116 | 117 | > Note that your dashboard will also be affected, the base URL would become `https://dashboard-assembly.staging.your-domain.com` 118 | 119 | > **Note:** This option is not currently supported by Apostrophe Assembly Hosting, as we apply the naming convention for you when hosting for you. It's there for self-hosted customers with different needs. 120 | 121 | ### Changing the locale separator of your subdomains (optional) 122 | 123 | The `localeSeparator` configuration option, which defaults to `.`, allows you to change how the subdomains for localized sites (if chosen so) will be built. By default a dot separator will be used. For example, if "Separate Host" is enabled for a particular locale, `fr.cars.your-domain.com` will be the URL of a site with the short name `cars` and the `fr` locale. 124 | If you apply the following configuration: 125 | ```js 126 | multisite({ 127 | // ... 128 | localeSeparator: '-', 129 | }); 130 | ``` 131 | The hostname above will become `fr-cars.your-domain.com`. 132 | 133 | This option applies only when the hostname is determined in part by the `shortName` field for the site, so if a production hostname is configured for the locale it will be used exactly as given. 134 | 135 | > **Note:** Your configuration won't be applied immediately on the existing sites. You need to update ("touch") your site records in order to apply the changes. You can do that for all existing sites via the CLI command `node app site:touch --site=dashboard`. If you do not have the `touch` task, update the apostrophe module to the latest 3.x version. 136 | 137 | > **Note:** This option is not currently supported by Apostrophe Assembly Hosting, as we apply the naming convention for you when hosting for you. It's there for self-hosted customers with different needs. 138 | 139 | ### Setting your Dashboard shortname (optional) 140 | 141 | By default, your dashboard will be available on a `dashboard` subdomain - `http://dashboard.localhost:3000`, `https://dashboard.staging.your-domain.com`, etc. You can change that with the configuration option `dashboardShortName` in your `app.js`. For example: 142 | ```js 143 | multisite({ 144 | // ... 145 | dashboardShortName: 'admin', 146 | }); 147 | ``` 148 | With the setting above, the Dashboard application will be available at `http://admin.localhost:3000`, `https://admin.staging.your-domain.com`, etc. 149 | 150 | Note that if `shortNameSuffix` is also set, the two options are combined to arrive at the complete dashboard subdomain. 151 | 152 | > **Note:** This option is not currently supported by Apostrophe Assembly Hosting. Contact us if this is a concern for your project. 153 | 154 | ### Disabled File Key 155 | 156 | In `sites/index.js`, locate `disabledFileKey` and change `CHANGEME` to a random string of your choosing. This is used when disabling access to files in the local backend. 157 | 158 | ### Session Secret 159 | 160 | In `sites/index.js`, locate `secret` and change `CHANGEME` to a random string of your choosing. This is used for login session encryption. 161 | 162 | ## Requirements For Development On Your Computer 163 | 164 | ### Operating System: Mac, Linux, or Virtual Linux 165 | 166 | **Your local development environment must be either MacOS or Linux.** If your development computer runs Windows, we recommend development on Ubuntu Linux in a full virtual Linux machine. This can be through [WSL2 (Windows Subsystem for Linux)](https://learn.microsoft.com/en-us/windows/wsl/install) or via [VirtualBox](https://www.virtualbox.org/). 167 | 168 | ### Software Installation Requirements 169 | 170 | To test-drive the project in development, make sure you have Apostrophe's usual dependencies on your local machine: 171 | 172 | * MongoDB (6.0 or better, we recommend 8.0) 173 | * NodeJS (18.x or better, latest long term support release recommended) 174 | 175 | For more information see the Apostrophe [Development Setup](https://docs.apostrophecms.org/guide/development-setup.html) and [Windows Development](https://docs.apostrophecms.org/cookbook/windows-development.html) documentation. 176 | 177 | ### `/etc/hosts` File Configuration Requirements 178 | 179 | Because this project serves multiple websites, certain hostnames must point directly to your own computer for local testing. 180 | 181 | **If you will only be testing in Chrome at first,** you do not have to edit your hosts file right away. That's because in Chrome, all subdomains of `localhost` resolve to your own computer. 182 | 183 | **If you are running on a Mac,** and your Cypress tests are failing with an error `getaddrinfo ENOTFOUND`, you may need to modify your hosts file by following the instructions below. 184 | 185 | However, in other browsers this is not true and you must add the following lines to `/etc/hosts` before proceeding: 186 | 187 | ``` 188 | 127.0.0.1 dashboard.localhost company1.localhost 189 | ``` 190 | 191 | **You will need a subdomain for each test site you plan to add to the multisite platform.** See the example below, where a site called `company` is added to the platform via the dashboard. You can always add more of these entries later. 192 | 193 | ## Starting Up In Development 194 | 195 | First, clone the starter kit and push it up to your own git repository for ongoing work. 196 | 197 | Then type: 198 | 199 | ``` 200 | npm install 201 | ``` 202 | 203 | After installation, add an admin user to the dashboard site, which manages all other sites: 204 | 205 | ``` 206 | node app @apostrophecms/user:add admin admin --site=dashboard 207 | ``` 208 | 209 | Enter a password when prompted. 210 | 211 | > When running command line tasks in a multisite environment you must always specify which site you are referring to. For the dashboard, use `--site=dashboard`. For other sites, you can use any of their valid hostnames, or `--all-sites` which runs the task on every site except the dashboard. 212 | 213 | Next launch the multisite application: 214 | 215 | ``` 216 | npm run dev 217 | ``` 218 | 219 | When ready, visit: 220 | 221 | ``` 222 | http://dashboard.localhost:3000/login 223 | ``` 224 | 225 | > If you are on a Mac this will work without extra configuration. If you are on Linux you may need to edit `/etc/hosts` and add an entry for `dashboard.localhost`, pointing to 127.0.0.1 just like plain `localhost` does. You'll do this for each site you test locally. 226 | 227 | You can now log into the admin account and view the basic dashboard. 228 | 229 | To create a site, access "Sites" on the admin bar and add a new site. Notice that sites are Apostrophe "pieces" in the dashboard. 230 | 231 | Be sure to give your first site a "shortname" which is distinct from other sites, like `company1`. Also fill out the admin password field for the site. 232 | 233 | After you successfully save the site, you can access: 234 | 235 | ``` 236 | http://company1.localhost:3000/login 237 | ``` 238 | 239 | And log in with the admin account you created for the site. Then make some simple edits to the homepage. 240 | 241 | Now try creating `company2` and `company3`. Notice that while the code is the same, the databases and content are separate. 242 | 243 | > If you access these sites while logged out, you won't see your content edits unless you have used the "Commit" button to make them live. 244 | 245 | ## Scheduling tasks with Apostrophe Assembly hosting 246 | 247 | To schedule tasks much like you would with `cron` in a single-server environment, add a new `tasks` option to `app.js` when configuring `@apostrophecms/multisite`. This option is top-level, it's a peer of the `sites` and `dashboard` options. 248 | 249 | ```javascript 250 | tasks: { 251 | // These tasks are run for all sites, i.e. like the `--all-sites` option 252 | 'all-sites': { 253 | hourly: [ 254 | // Run this task hourly but only on the server that 255 | // happens to grab the lock first 256 | 'products:sync' 257 | ], 258 | daily: [ ... also supported, same syntax ] 259 | }, 260 | // These tasks are run for the dashboard site, i.e. like `--site=dashboard` 261 | dashboard: { 262 | hourly: [ 263 | 'some-module-name:some-task-name' 264 | ], 265 | daily: [ ... also supported, same syntax ] 266 | } 267 | } 268 | ``` 269 | 270 | Note that the individual tasks are configured as strings. These strings start with the Apostrophe task name, like `product:sync`, and can optionally also include additional parameters to the task exactly as they would if you invoked it directly at the command line. You should **not** include `node app` in these strings. 271 | 272 | Then, to test your hourly tasks in a local environment: 273 | 274 | ```javascript 275 | node app tasks --frequency=daily 276 | ``` 277 | 278 | > ⚠️ VERY IMPORTANT NOTE: this will intentionally **not** run the job more than once in an hour, even if you try to test it twice in an hour. That's normal. This is a guard so that tasks scheduled on more than one of our workers actually run just once as intended. 279 | 280 | If you need to skip that check for testing purposes, you can clear the `aposTaskLog` mongodb collection in your dashboard database. If your `shortName` is `companyname`, then your dashboard database name is `companyname-dashboard`. 281 | 282 | ## Site Development 283 | 284 | Right now we have a bare-bones example. Let's look at where to put our code to customize the experience. 285 | 286 | ### Where Does My Apostrophe Project Code Go? 287 | 288 | > If you are not already familiar with single-site Apostrophe development, we strongly recommend that you [read the ApostropheCMS documentation](https://docs.apostrophecms.org/) as a starting point. 289 | 290 | In a typical single-site Apostrophe project, modules are configured in `app.js`. In a multisite project, you'll find that `app.js` is instead reserved for top-level configuration that applies to all sites. 291 | 292 | The code you're used to seeing in `app.js` can instead be found in `sites/index.js`. And, the code you're used to seeing in `modules` can be found in `sites/modules`. 293 | 294 | In all other respects, development is just like normal ApostropheCMS single-site development. Feel free to add page templates and modules. You can `npm install` modules like `@apostrophecms/blog` and configure them in a normal way; just do it in `sites/index.js` rather than `app.js`. 295 | 296 | If you have already started a single-site project, you can move your modules directly from `modules` to `sites/modules`, and move the `modules` section of your `app.js` file to the corresponding section of `sites/index.js`. However, take note of the existing settings we provide and merge accordingly. 297 | 298 | > **If you are hosting your project with us, or using tools provided by us, you should remove any legacy app.js or module code that configures UploadFS cloud storage or mongodb database hosts.** Such settings are handled automatically and the configuration is set behind the scenes by `@apostrophecms-pro/multisite` and the provided logic in the starter kit. 299 | 300 | ### Themes 301 | 302 | Apostrophe Assembly and the multisite module are designed to accommodate hundreds of websites, or more, running on a single codebase. But, you may need some differences in appearance and behavior that go beyond what the palette editor can provide. For that you can create multiple themes. Each site is set via the dashboard UI to use a single theme and will typically stay with that theme throughout its lifetime. 303 | 304 | You might not need more than one theme. If that's the case, just build out the `default` theme to suit your needs, and remove the `demo` theme from `themes.js`. You can also remove the `sites/modules/theme-demo` module and `sites/lib/theme-demo.js`. 305 | 306 | #### Adding a New Theme 307 | 308 | To configure your list of themes, edit `themes.js`. Right now it looks like: 309 | 310 | ```javascript 311 | export default [ 312 | { 313 | value: 'default', 314 | label: 'Default' 315 | }, 316 | { 317 | value: 'demo', 318 | label: 'Demo' 319 | } 320 | ]; 321 | ``` 322 | 323 | You can add additional themes as needed. Your `value` should be a shortname like `default` or `arts`. The `value` must not be changed later. 324 | 325 | #### Custom Module Configuration for Themes 326 | 327 | If your theme is named `default`, then you must have a `sites/lib/theme-default.js` file, like this: 328 | 329 | ```javascript 330 | export default function(site, config) { 331 | config.modules = { 332 | ...config.modules, 333 | 'theme-default': {} 334 | }; 335 | }; 336 | ``` 337 | 338 | The `config` object already contains what was configured in `sites/index.js`. Here we can modify the configuration by adding extra modules only for this theme, or changing the configuration of a module specifically for this theme. 339 | 340 | In this case we add one custom module, `theme-default`, when the default theme is active. **It is a best practice to push your theme's frontend assets to Apostrophe in a module like this,** named after the theme. If your themes share any assets, then they should be imported into the appropriate `.js` or `.scss` master file by each theme. 341 | 342 | #### Modern Frontend Assets Without A Custom Build Process 343 | 344 | Beginning with the 1.1.0 release, there is no need for Webpack for simpler cases. Specifically, you can follow our documentation and place your modern JavaScript code in the `ui/src/index.js` file of any module, or use `import` statements in that file to import it there. As noted in our documentation, it is **important for `ui/src/index.js` to export a function as its default export.** This function will be invoked to initialize your module at a safe time when `apos.http`, `apos.util`, etc. are already available. 345 | 346 | You may also place Sass SCSS code in the `ui/src/index.scss` file of any module, and use `import` statements in that file to bring in more Sass SCSS code. 347 | 348 | To include theme-specific code, place it in the `ui/src/index.scss` or `ui/src/index.js` file of the appropriate theme module. The provided example theme modules are `theme-default` and `theme-alternate`. 349 | 350 | For example: 351 | - The default theme's SASS stylesheet entrypoint is located at `sites/modules/theme-default/ui/src/index.scss` 352 | - The default theme's JavaScript browser-side entry point is located at: `sites/modules/theme-default/ui/src/index.js` 353 | 354 | #### Example webpack extensions 355 | 356 | The `theme-default` and `theme-demo` modules modify the base webpack build using the [`webpack` property](https://docs.apostrophecms.org/guide/webpack.html#extending-webpack-configuration) to incorporate SCSS variables for colors and fonts. This is included to demonstrate how to set up centralized theme management with global variables in one place. They also both add a function for converting font sizes from `px` to `rem`. While this is a useful function that is used in several of the `theme-default` stylesheets, it primarily serves to illustrate how SCSS functions can be added to your project. A similar approach would be used to add in any SCSS mixins that subsequent stylesheets utilize. 357 | 358 | The two theme modules accomplish this extension in slightly different ways. The `theme-default` extension adds all the variables and the function into a template literal block within the `additionalData` property. If you continue to use the `theme-default` module in your project and want to use the included project-level widgets, you need to keep and potentially edit this template literal block since the styling of the widgets depends on them. 359 | 360 | The `theme-demo` module includes the variables and function by importing files from the `sites/modules/theme-demo/ui/src/scss/settings/` folder. Note that these files also need to be imported into the `sites/modules/theme-demo/ui/src/index.scss` file. This is necessary for the main webpack build to include them. If your project includes additional [SASS "partials"](https://sass-lang.com/guide/#partials) files that other stylesheets access through `@use` you will need to add them to both the `index.scss` file and in the extended webpack configuration. Again, the project-level widgets included in this starter-kit depend on the styling included in these files. 361 | 362 | The `theme-default` module depends on only the `sites/layout.html` file to provide markup for the `@apostrophecms/home-page` page type. In contrast, the `views` folder of the `theme-demo` module has two markup files that provide additional HTML markup. The main `welcome.html` file contains a conditional block for displaying different content based on whether there is a user is logged in or not. It has a second conditional block for displaying markup from the `placeholder.html` file if no content has been added to the page. The Nunjucks template in the `sites/modules/@apostophecms/home-page/views/page.html` file conditionally adds this markup if `demo` is the selected theme. You can choose to maintain this structure and modify the `welcome.html` file, or change the `modules/@apostrophecms/home-page/views/page.html` to contain your own markup. 363 | 364 | #### Frontend Assets With Your Own Build Process 365 | 366 | Beginning with the 1.1.0 release, a sample webpack build is not included as standard equipment, as `ui/src` suffices for most needs. However, if you need to use webpack or another custom build process, the solution is to configure the output of your build process to be a `ui/public/something.js` file in any module in your Apostrophe project. As above you can create a build that is included in only one theme by writing its output to the `ui/src` subdirectory of that theme module. 367 | 368 | #### Developing For IE11 369 | 370 | With Microsoft ending Internet Explorer 11 support in 2022, we no longer enable IE11 support by default. However you can enable IE11 support by setting the `es5: true` option to the `@apostrophecms/asset` module. This will create a compatibility build of your `ui/src` JavaScript. Please note that editing is never supported in IE11. See the Apostrophe documentation for more information. 371 | 372 | #### Serving Static Files: Fonts and Static Images 373 | 374 | If you need to serve static files, you can do this much as you would in standalone A3 development. 375 | 376 | The folder `sites/public` maps to `/` in the URL space of a site. For instance, `sites/public/fonts/myfile.ttf` maps to `/fonts/myfile.ttf`. For assets like favicons and fonts, you can add `link` tags to the `standardHead` block already present in `sites/modules/@apostrophecms/template/views/outerLayout.html`. 377 | 378 | ### Palette Configuration 379 | 380 | The `@apostrophecms-pro/palette` module requires a Pro or Assembly subscription and allows styles to be edited visually on the site. It is configured in `sites/modules/@apostrophecms-pro/palette/index.js`. There you can specify the selectors, CSS properties, and field types to be used to manipulate color, font size, font family and other aspects of the site as a whole. 381 | 382 | For complete information and a sample configuration, see the [@apostrophecms-pro/palette module documentation](https://npmjs.org/package/@apostrophecms-pro/palette). *You will need to be logged into an npm account that has been granted access, such as the one you used to npm install this project.* 383 | 384 | > Note that like all other changes, palette changes do not take place for logged-out users until the user clicks "Publish." 385 | 386 | ## Provided widgets 387 | There are six basic widget modules located in the `sites/modules/widgets` folder of this starter kit. This supplements the core `rich-text`, `image`, `video`, and `html` widgets. They can be altered to fit the design and functionality of your project or act as a blueprint to build your own custom widgets. Both the `hero` and `column` widgets have been added to the `main` area of the `@apostrophecms/home-page`. The remainder of the basic widgets have been added to the areas of the `column` widget as described below. 388 | 389 | If you look at the `sites/index.js` file you won't see these widget modules in the `modules` object. Instead, they are being registered using the `nestedModuleSubdirs` property. Setting this property to `true` will cause Apostrophe to register all the modules listed in the `modules.js` file of any subfolder in the project-level `sites/modules` folder. You can choose to organize any custom modules, such as grouping all of your piece-types, to keep your `modules` folder and the `index.js` file less cluttered. Note that if you choose to move any of the provided widgets out of the current folder you will need to add them to the `sites/index.js` file and remove them from the `sites/modules/widgets/modules.js` file. If you choose to keep this structure, any custom widgets you add to the folder need to be listed in the `modules.js` file. 390 | 391 | All the styling for the supplied widgets, except for the partials added in the custom webpack extensions added in the theme modules, is located in the `ui/src/index.scss` file of each module. You can choose to maintain this structure, move the styling to another project-level module like a `sites/modules/asset/ui/src/` folder, or organize them in a different project-specific manner. Note that for them to be included in the standard webpack build, they need to be imported into a `/ui/src/index.scss` file. 392 | 393 | ### `accordion-widget` 394 | The `accordion-widget` implements an accordion element powered by the [`accordion-js` npm package](https://www.npmjs.com/package/accordion-js). You can read about additional configuration options in the documentation of that package. The module consists of a main `index.js` file with the content schema fields, plus a `views` folder that contains a `widget.html` file with the Nunjucks markup for the accordion. 395 | 396 | Finally, there is the `ui/src` folder that contains the `index.scss` stylesheet and the `index.js` file that contains the JavaScript that is delivered to the frontend and powers the accordion using a [widget player](https://docs.apostrophecms.org/guide/custom-widgets.html#client-side-javascript-for-widgets). Any custom widgets that require client-side code should be structured in this same way. Data is passed from the schema fields to the browser for use in the player script by adding it to a data attributes in the template. 397 | 398 | ### `card-widget` 399 | The `card-widget` creates a simple card with optional image and text. The card can be made directly clickable, or can have links and buttons added. The schema fields for these elements are provided by the `lib/schema/link.js` file, which serves as a model for implementing reusable parts of widgets. These same schema fields are reused in the `hero` and `link` widgets and can be used in your custom project widgets. The markup for the links is imported into the `card-widget` template from the `sites/views/fragments/link.html` file using the [`rendercall` helper](https://docs.apostrophecms.org/guide/fragments.html#inserting-markup-with-rendercall). This is present in a simpler form in the `links-widget`. Again, all your custom modules (not just widgets) can utilize fragments to replicate similar areas of markup in this same way. 400 | 401 | ### `column-widget` 402 | The `column-widget` implements one method of adding a user-selected number of columns to a page. It uses a select field and conditional fields that restrict the number of columns based on the value of the select. Each column has an area with widgets for the `link`, `card`, and `accordion` basic widgets, plus the core `rich-text`, `image`, and `video` widgets. These are added through a shared configuration object that defines the available widgets for each column. The first column additionally adds the basic `slideshow` widget. 403 | 404 | The widget also provides a `helper(self)` customization function that is used in the Nunjucks template. Depending on the value of the select field it returns the correct number of columns. The `helper(self)` functions can be used in your custom modules to provide computed values from data passed back from the markup. 405 | 406 | ### `hero-widget` 407 | The `hero-widget` implements a hero element with image or color background, text and links. As stated above, this module reuses the `links.js` helper file. It also demonstrates how to use `relationship` schema fields to add an image or video for the background. 408 | 409 | ### `link-widget` 410 | This simple widget adds either a button or inline-link. As described for the `card-widget`, It utilizes the `lib/schema/link.js` helper file and the `sites/views/fragments/link.html` fragment. Within the widget template there is a `rendercall` that passes data from the widget schema fields to the fragment. 411 | 412 | ### `slideshow-widget` 413 | The `slideshow-widget`, much like the `accordion-widget`, utilizes client-side JavaScript. For this widget the `ui/src/index.js` is adding the [`swiper.js` package](https://swiperjs.com/) to the player. 414 | 415 | ## Dashboard Development 416 | 417 | **The dashboard site has one job: managing the other sites.** As such you don't need to worry about making this site a pretty experience for the general public, because they won't have access to it. However you may want to dress up this experience and add extra functionality for your own customer admin team (the people who add and remove sites from the platform). 418 | 419 | This starter kit has the `@apostrophecms-pro/multisite-dashboard` extension installed. This converts the dashboard from sites being presented as individual cards to a scrollable list. Each site now has a link for login to the site, as well as navigation to the home-page. This extension also creates a search box that makes finding sites easier. Finally, this extension also adds a template tab to the site creation modal. When creating or editing a site you can select to make it a template by clicking on "Template" control in the "Basics" tab. This will still be an active site, but it will be moved to the template tab. Sites in the template tab can be duplicated by selection that option in the context menu to the far right. 420 | 421 | The dashboard site can be extended much like the regular sites. Dashboard development is very similar to regular site development, except that modules live in `dashboard/modules`, what normally resides in `app.js` lives in `dashboard/index.js`, and so on. 422 | 423 | The most important module is the `site` module. The `site` module is a piece type, with a piece to represent each site that your dashboard admins choose to create. This module is registered through the `@apostrophecms-pro/multisite-dashboard` extension and can be extended at the project level by creating a `dashboard/modules/@apostrophecms-pro/site` folder and placing your code there. This is the [standard method](https://docs.apostrophecms.org/guide/modules.html) for extending any package at project level. 424 | 425 | The `site` schema field values get passed to the individual sites in the `site` object. This is what is used to set the theme configuration in the `sites/index.js` file. The starter kit is also adding the value of the `theme` schema field to the `apos.options` object. 426 | 427 | ```javascript 428 | // sites/index.js 429 | export default function (site) { 430 | const config = { 431 | root: import.meta, 432 | // Theme name is globally available as apos.options.theme 433 | theme: site.theme, 434 | // ... 435 | }; 436 | 437 | return config; 438 | }; 439 | ``` 440 | 441 | If you have additional values being passed from the `site` piece schema that you want to make available to your modules you have several choices. The value can be added in the modules config options in the `sites/index.js` file. 442 | 443 | ```javascript 444 | // sites/index.js 445 | export default function (site) { 446 | const config = { 447 | root: import.meta, 448 | // Theme name is globally available as apos.options.theme 449 | theme: site.theme, 450 | nestedModuleSubdirs: true, 451 | modules: { 452 | 'commerce-page': { 453 | options: { 454 | apiKey: site.apiKey, 455 | } 456 | }, 457 | // ... 458 | } 459 | }; 460 | 461 | return config; 462 | }; 463 | ``` 464 | You can also elect to add them to the `apos.options` object, as is shown above example for the `site.theme`. This can then be accessed in any module function with access to `self` using `self.apos.options.`. If you need that value in your templates you can use the [`templateData` module option](https://docs.apostrophecms.org/reference/module-api/module-options.html#templatedata). 465 | ### Allowing dashboard admins to pass configuration to sites 466 | 467 | You can add custom schema fields to `sites` and those fields are available on the `site` object passed to `sites/index.js`, and so they can be passed on as part of the configuration of modules. 468 | 469 | However, there is one important restriction: you **must not decide to completely enable or disable a module that pushes assets on any basis other than the theme name.** This is because Apostrophe builds only one asset bundle per theme. 470 | 471 | **"Should I add a field to the `site` piece in the dashboard, or just add it to `@apostrophecms/global` for sites?"** Good question! Here's a checklist for you: 472 | 473 | * **If single-site admins who cannot edit the dashboard should be able to edit it,** you should put it in `sites/modules/@apostrophecms/global`. 474 | * **If only dashboard admins who create and remove sites should be able to make this decision,** it belongs in `dashboard/modules/site/index.js`. You can then pass it on as module configuration in `sites/lib/index.js`. 475 | 476 | ## Cypress (end-to-end) Testing 477 | 478 | Cypress is configured as the default tool to run end-to-end tests on the multisite platform. The tests are located in the `cypress/test` folder. Read below to learn more about the prerequisites, how to run the tests, how to update your DB dump when needed, and how to integrate Cypress in your existing projects. 479 | 480 | You do not have to use the Cypress test capabilities at all just to get started with your project. They are there for those who want to add end-to-end testing. 481 | 482 | The pre-configured experience includes a DB dump of the "dashboard" site, containing a site per theme ("default" and "demo"). By default, the tests will run against the "default" site theme. For testing the "demo" site theme and the "dashboard", your tests should override the `baseUrl` at the test file level and utilize the `profile` options on the relevant commands. See the `cypress/test` folder for examples. 483 | 484 | You can reconfigure the DB dump to include more or different sites and/or change the defaults. Read below to learn more. 485 | 486 | The multisite application will use `test-` prefixed databases to avoid losing local development data when running the tests. This is achieved by setting the `CI` environment variable to `1` when running the tests (see the `e2e:*` scripts in `package.json`). 487 | 488 | ### Prerequisites 489 | 490 | Install [MongoDB Tools](https://docs.mongodb.com/database-tools/installation/installation-linux/#installation). You should validate that the `mongodump` and `mongorestore` commands are available in your terminal. 491 | 492 | Ensure your [`/etc/hosts` file](#etchosts-file-configuration-requirements) is properly configured. 493 | 494 | ### Running the tests 495 | 496 | You'll find example tests in the `cypress/test` folder. To run the tests: 497 | 498 | ```bash 499 | # Start the multisite platform in production mode 500 | npm run e2e:serve 501 | # OR start the multisite platform in development mode (if you want to see the changes in real-time) 502 | npm run e2e:dev 503 | # In a new terminal window, run the tests in headless mode 504 | npm run e2e:run 505 | # OR run the tests in interactive mode 506 | npm run e2e:open 507 | ``` 508 | 509 | ### Updating the Cypress configuration DB dumps 510 | 511 | 1. Remove all `test-` prefixed databases from your local MongoDB database. 512 | 2. Add `admin` user to the dashboard site: `CI=1 node app @apostrophecms/user:add admin admin --site=dashboard`. 513 | 3. Run `npm run e2e:dev` to start the multisite platform in development test mode. 514 | 4. Open `http://dashboard.localhost:3000` in your browser, login with user `admin` and configure the sites you want for testing. 515 | 5. In a new terminal window, run `CI=1 node app site:cypress-config --site=dashboard`. If you want to change the default configuration to be another site (it's the first in the list by default), you can pass the site shortname as an argument: `CI=1 node app site:cypress-config site-demo --site=dashboard`. 516 | 6. Copy the content of the terminal output between the `# cypress.config.js` and `# END cypress.config.js` comments to the `cypress.config.js` file, replacing the existing content. Feel free to update the configuration options to match your needs (e.g., `viewportWidth`, `viewportHeight`, `apiKey` etc.). In case you change `apiKey` value, you should update the respective value in `dashboard/modules/@apostrophecms/express/index.js` and `sites/modules/@apostrophecms/express/index.js` files. Note that the output for the configuration will be in ESM (ECMAScript Modules) syntax. If you are integrating Cypres in your existing project and you're still using CommonJS syntax, you should convert the `import` and `export` statements to CommonJS syntax (`require()` and `module.exports` respectively). 517 | 7. Copy and execute the content of the terminal output between the `# DB dump commands` and `# END DB dump commands` comments. 518 | 519 | > NOTE: the script assumes that your admin API Key is named `cypressAPIKey`. If you are using a different name, you should update the `cypress.config.js` file accordingly. 520 | 521 | ### Integrating Cypress in your existing projects 522 | 523 | This section is for those who want to add Cypress testing to an existing project that was not created from a recent version of this starter kit and does not already contain the following updates. 524 | 525 | > Note: All code snippets, including the Cypress config generator, are ESM (ECMAScript Modules) syntax. If you are still using CommonJS syntax in your project, you should convert the code snippets accordingly. 526 | 527 | 1. Ensure that your project is fully configured, following the instructions in this documentation. This includes any port changes, theme configurations, and your [`/etc/hosts` file](#etchosts-file-configuration-requirements). 528 | 2. Follow the [Pre-requisites](#prerequisites) instructions to ensure that you have the necessary tools installed. 529 | 3. Install the dependencies: 530 | 531 | ```bash 532 | npm install -D cypress @apostrophecms-pro/cypress-tools eslint-plugin-cypress 533 | ``` 534 | 4. Copy all `e2e:*` scripts from the `package.json` file in this project to your project's `package.json` file. 535 | 5. Copy the `cypress` folder from this project to your project's root folder. 536 | 6. Add to your project's `.gitignore` file: 537 | 538 | ```bash 539 | # Cypress 540 | /cypress/videos 541 | /cypress/screenshots 542 | /cypress/downloads 543 | ``` 544 | 7. Modify your project's `.eslintrc` file: 545 | 546 | ```json 547 | { 548 | "extends": [ 549 | "apostrophe", 550 | "plugin:cypress/recommended" 551 | ] 552 | } 553 | ``` 554 | 8. Modify your `shortNamePrefix` project configuration in `app.js`, replacing `yourExistingPrefix-` with your actual prefix: 555 | 556 | ```javascript 557 | await multisite({ 558 | // ... 559 | shortNamePrefix: process.env.CI === '1' ? 'test-' : (process.env.APOS_PREFIX || 'yourExistingPrefix-'), 560 | // ... 561 | }); 562 | ``` 563 | 9. Add a task to your `dashboard/modules/site/index.js` file (create it if it doesn't exist): 564 | 565 | ```javascript 566 | export default { 567 | tasks(self) { 568 | return { 569 | ...(process.env.CI === '1' && { 570 | 'cypress-config': { 571 | usage: 'List Cypress configuration and CLI commands for creating DB dumps.\n' + 572 | '\nUsage: node app site:cypress-config [siteShortName]', 573 | async task(argv) { 574 | const task = await import( 575 | '@apostrophecms-pro/cypress-tools/apos/assembly-config.js' 576 | ); 577 | try { 578 | const result = await task.default(self.apos, argv); 579 | console.log(result); 580 | } catch (e) { 581 | console.error(e.message); 582 | return 1; 583 | } 584 | } 585 | } 586 | }), 587 | // ... your project's tasks if any 588 | }; 589 | } 590 | }; 591 | ``` 592 | 10. Add API Key to your `dashboard/modules/@apostrophecms/express/index.js` file (create it if it doesn't exist): 593 | 594 | ```javascript 595 | export default { 596 | options: { 597 | apiKeys: process.env.CI === '1' 598 | ? { 599 | cypressAPIKey: { 600 | role: 'admin' 601 | } 602 | } 603 | : {} 604 | } 605 | }; 606 | ``` 607 | > NOTE: If you are using a different API Key name, you should update the `cypress.config.js` file accordingly. 608 | 11. Add API Key to your `sites/modules/@apostrophecms/express/index.js` file (create it if it doesn't exist): 609 | 610 | ```javascript 611 | export default { 612 | options: { 613 | apiKeys: process.env.CI === '1' 614 | ? { 615 | cypressAPIKey: { 616 | role: 'admin' 617 | } 618 | } 619 | : {} 620 | } 621 | }; 622 | ``` 623 | > NOTE: If you are using a different API Key name, you should update the `cypress.config.js` file accordingly. 624 | 12. In `sites/modules/@apostrophecms/asset/index.js` ensure that HMR is not running in Cypress test mode. 625 | 626 | ```javascript 627 | // sites/modules/@apostrophecms/asset/index.js 628 | export default { 629 | options: { 630 | // ... 631 | hmr: process.env.CI === '1' ? false : 'public' 632 | }, 633 | // ... 634 | }; 635 | ``` 636 | 13. Follow the steps in the [Updating the Cypress configuration DB dumps](#updating-the-cypress-configuration-db-dumps) section to create a `cypress.config.js` file and update your DB dumps. 637 | 14. Modify the example tests in the `cypress/test` folder to match your configured `profiles` and default configurations. 638 | 639 | ### Cypress tools 640 | 641 | The [`@apostrophecms-pro/cypress-tools`](https://github.com/apostrophecms/cypress-tools) package provides a set of tools (custom Cypress commands and tasks) to help you manage your Cypress tests. A full list of available commands and tasks can be found in the package's [API Reference](https://github.com/apostrophecms/cypress-tools/blob/main/API.md). 642 | 643 | ## Accessing the MongoDB utilities for a specific site 644 | 645 | The database name for a site is the prefix, followed by the `_id` of the site piece. However this is awkward to look up on your own, so we have provided utility tasks to access the MongoDB utilities: 646 | 647 | ``` 648 | # Mongo shell for the dashboard site 649 | node app mongo:mongo --site=dashboard 650 | # Mongo shell for an individual site; use its hostname 651 | # in the appropriate environment 652 | node app mongo:mongo --site=test1.localhost 653 | # mongodump 654 | node app mongo:mongodump --site=test1.localhost 655 | # mongorestore, with the --drop option to prevent 656 | # doubled content 657 | node app mongo:mongorestore --site=test1.localhost -- --drop 658 | ``` 659 | 660 | Note the use of `--` by itself as an end marker for the options to Apostrophe, allowing the `--drop` option to be passed on to `mongodump`. 661 | 662 | ## Hosting 663 | 664 | Hosting for staging and production clouds is typically provided by the Apostrophe Assembly team. 665 | 666 | Self-hosted arrangements can also be made. For more information contact the Apostrophe Assembly team. 667 | 668 | ## Deployment 669 | 670 | If we are hosting Apostrophe Assembly for you, then you can deploy updates to your staging cloud by pushing to your `staging` git branch, and deploy updates to your production cloud by pushing to your `production` git branch. You will receive notifications in our shared Slack channel, including links to access the deployment progress logs. 671 | 672 | Apostrophe will complete asset builds for each theme, as well as running any necessary new database migrations for each site, before switching to the newly deployed version of the code. 673 | 674 | ## Profiling with OpenTelemetry 675 | 676 | ApostropheCMS supports profiling with OpenTelemetry. There is an [article in the documentation](https://v3.docs.apostrophecms.org/cookbook/opentelemetry.html) covering the use of OpenTelemetry in general. Launching Apostrophe Assembly with OpenTelemetry support is slightly different. However for your convenience, `app.js` and `telemetry.js` are already set up appropriately in this project. 677 | 678 | To launch in your local development environment with OpenTelemetry logging to Jaeger, first [launch Jaeger according to the instructions in our documentation](https://v3.docs.apostrophecms.org/cookbook/opentelemetry.html). Then start your Apostrophe Assembly project like this: 679 | 680 | ``` 681 | APOS_OPENTELEMETRY=1 npm run dev 682 | ``` 683 | 684 | This provides a great deal of visibility into where the time is going when Apostrophe responds to a request. Note that separate hosts can be distinguished via the `http.host` tag attached to each request in Jaeger. 685 | 686 | Using OpenTelemetry in a staging environment provided by the Apostrophe team is possible. This involves modifying the provided `telemetry.js` file to log to a hosted backend such as [New Relic](https://docs.newrelic.com/docs/more-integrations/open-source-telemetry-integrations/opentelemetry/opentelemetry-introduction/) using an appropriate Open Telemetry exporter module. `process.env.ENV` can be used to distinguish between `dev` or no setting (usually local development), `staging` and `prod` when decidig whether to enable an OpenTelemetry backend. 687 | 688 | We do not recommend enabling OpenTelemetry in production, at least not permanently, because of the performance impact of the techniques OpenTelemetry uses to obtain the necessary visibility into async calls. 689 | 690 | ## Self-hosting and the sample Dockerfile 691 | 692 | A sample `Dockerfile` is provided with this project and can be used for self-hosting. See also the provided `.dockerignore` file. 693 | 694 | Typical `build` and `run` commands look like: 695 | 696 | ```bash 697 | # build command 698 | docker build -t apostrophe-assembly . \ 699 | --build-arg="NPMRC=//registry.npmjs.org/:_authToken=YOUR_NPM_TOKEN_GOES_HERE" \ 700 | --build-arg="ENV=prod" --build-arg="APOS_PREFIX=YOUR-PREFIX-GOES-HERE-" \ 701 | --build-arg="DASHBOARD_HOSTNAME=dashboard.YOUR-DOMAIN-NAME-GOES-HERE.com" \ 702 | --build-arg="PLATFORM_BALANCER_API_KEY=YOUR-STRING-GOES-HERE" \ 703 | --build-arg="APOS_S3_REGION=YOURS-GOES-HERE" \ 704 | --build-arg="APOS_S3_BUCKET=YOURS-GOES-HERE" \ 705 | --build-arg="APOS_S3_KEY=YOURS-GOES-HERE" \ 706 | --build-arg="APOS_S3_SECRET=YOURS-GOES-HERE" 707 | 708 | # run command 709 | docker run -it --env MONGODB_URL=YOUR-MONGODB-ATLAS-URL-GOES-HERE apostrophe-assembly 710 | ``` 711 | 712 | To avoid passing the real MongoDB URL to the build task, currently the provided Dockerfile uses a 713 | temporary instance of `mongod` to satisfy a requirement that it be present for the build task. 714 | 715 | An npm token is required to successfully `npm install` the private packages inside the 716 | image during the build. 717 | 718 | S3 credentials are passed to the build so that the static assets can be mirrored to S3, however 719 | at a cost in performance this can be avoided by removing `APOS_UPLOADFS_ASSETS=1` from 720 | the `Dockerfile` and removing the references to these environment variables as well. Note 721 | that you will still need S3 credentials in the `run` command, unless you arrange for 722 | `dashboard/public/uploads` and `sites/public/uploads` to be persistent volumes on a 723 | filesystem shared by all instances. This is slow, so we recommend using S3 or configuring 724 | a different [uploadfs backend](https://github.com/apostrophecms/uploadfs) such as 725 | Azure Blob Storage or Google Cloud Storage. 726 | 727 | ## Localized domain names 728 | 729 | Dashboard administrators can define the locales for each site from the `locales` tab of the site editor modal. This is turned on by default with the `localizedSites` option of the `site` module set to `true`. 730 | 731 | You can add as many locales as you want via the `locales` tab, and for each of them you can give it a name, label, prefix, choose if you want a separate host, and if so, set a separate production hostname. 732 | 733 | If the separate host is set to `true`, the locale will be used as a subdomain of the domain name 734 | in addition to the separate production hostname if that field has been filled out and DNS has been configured for it. 735 | There is now also `stagingSubdomain` to allow a free choice of staging subdomain name, 736 | for those who want to test the effects of `separateProductionHostname` being set the same for any group of sites in advance. 737 | 738 | Let's say we have a French locale with these options: 739 | 740 | | Fields | Values | 741 | | ---------------------------- | -------------------- | 742 | | Label | `French` | 743 | | Prefix | | 744 | | Separate Host | `true` | 745 | | Separate Production Hostname | `my-french-site.com` | 746 | 747 | 748 | And our site piece `shortName` is set to `site`. 749 | 750 | In this case, if the environment variable `ENV` is set to `staging`, we will have `fr.site.staging.com` as the hostname. 751 | If we are in production, so `ENV` is set to `prod`, we will have `fr.site.production.com` and `my-french-site.com` (only in production) as hostnames. 752 | 753 | If we set a prefix, such as `/fr`, then only URLs in which the path part begins with `/fr` will display content from that locale. This way some locales can share the same `separateProductionHostname` being differentiated by the prefix. 754 | 755 | If `separateHost` is set to `false` and `prefix` is `/fr`, we simply use the latter to differentiate locales: `site.localhost:3000/fr`, `site.staging.com/fr`, `site.production.com/fr`. 756 | 757 | Note that you can have only one locale with no prefix _and_ no separate host, that would be the default one. 758 | 759 | ## Private locales 760 | 761 | You can make a locale `private`, meaning that this locale is only visible for logged in users. 762 | 763 | There is a new `boolean` field with the label `Private Locale` for each configured locale in your dashboard. 764 | 765 | When adding the option `localizedSites` to the `site` module of your project, instead of `true` you can pass an object and specify the option `privateByDefault`. 766 | If this sub-option is set to `true`, every new locale created will have its `private` property set to `true` by default, otherwise they will be public by default. 767 | 768 | ```javascript 769 | // in dashboard/index.js 770 | import themes from '../themes.js'; 771 | import baseUrlDomains from '../domains.js'; 772 | 773 | export default { 774 | root: import.meta, 775 | privateDashboards: true, 776 | modules: { 777 | // other dashboard modules 778 | '@apostrophecms-pro/multisite-dashboard': {}, 779 | site: { 780 | options: { 781 | themes, 782 | baseUrlDomains, 783 | localizedSites: { 784 | privateByDefault: true 785 | } 786 | } 787 | }, 788 | 'site-page': {}, 789 | } 790 | }; 791 | 792 | ``` 793 | 794 | The `private` option will be editable from the dashboard when editing your site locales. 795 | --------------------------------------------------------------------------------