diff --git a/.dockerignore b/.dockerignore index 1a10672..b3dba55 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,7 @@ +**/.git +.github/ node_modules/ .env.local +*.local.json Dockerfile* README.md diff --git a/.env.example b/.env.example index e15db32..e69de29 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +0,0 @@ -VUE_APP_API_BASE=https://localhost:7262/api/ -VUE_APP_PRIMARY_COLOR='#8af' -VUE_APP_PRIMARY_FOREGROUND_COLOR='#000' -VUE_APP_NAME='My Wiki' -VUE_APP_USE_AUTH=false diff --git a/.github/scripts/docker-image-test.sh b/.github/scripts/docker-image-test.sh new file mode 100644 index 0000000..2ac908e --- /dev/null +++ b/.github/scripts/docker-image-test.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +# exit on error +set -e + +# copy webapp settings +cp public/webapp-settings.example.json webapp-settings.local.json + +# start server +docker-compose up -d + +# test if the server is running and serves files +requested_settings=$(curl http://localhost:5000/webapp-settings.json) + +# compare served file with actual file (a 404 or 50x error - or a timeout - would fail here) +actual_settings=$(cat webapp-settings.local.json) +if [ "$actual_settings" != "$requested_settings" ]; then + echo "Could not verify server functionality. Received settings:" + echo "$requested_settings" + exit 1 +fi + +echo "Received settings:" +echo "$requested_settings" + +# test if homepage gets served without errors +requested_homepage=$(curl http://localhost:5000/) +echo "Received homepage:" +echo "$requested_homepage" + +echo "Server seems to respond correctly." + +# shutdown server +docker-compose down + +# cleanup +rm webapp-settings.local.json + +exit 0 diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 0000000..ec8705b --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,41 @@ +name: Docker Image CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + name: Build Docker Image + + runs-on: ubuntu-latest + + permissions: + packages: write + + steps: + - uses: actions/checkout@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 + if: github.event_name != 'pull_request' + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Test docker-compose.yml + run: chmod +x ./.github/scripts/docker-image-test.sh && ./.github/scripts/docker-image-test.sh + shell: bash + - name: Build and push image + uses: docker/build-push-action@v3 + if: github.event_name != 'pull_request' + with: + context: . + platforms: linux/amd64 + push: true + tags: | + ghcr.io/${{ github.repository }}:latest + ghcr.io/${{ github.repository }}:${{ github.sha }} diff --git a/.gitignore b/.gitignore index 0058338..1f0836b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ node_modules .env.local .env.*.local appsettings.local.json +public/webapp-settings.json # Log files npm-debug.log* diff --git a/Dockerfile b/Dockerfile index cf9721c..1781c0f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,15 +2,12 @@ FROM node:lts-alpine as build WORKDIR /app -ARG VUE_APP_API_BASE -ENV VUE_APP_API_BASE $VUE_APP_API_BASE - # add node binaries to path ENV PATH /app/node_modules/.bin:$PATH # install dependencies and build tools COPY package*.json ./ -RUN npm install --silent +RUN npm ci # build app COPY . . diff --git a/README.md b/README.md index 4e28c23..ee71276 100644 --- a/README.md +++ b/README.md @@ -3,36 +3,73 @@ [![Node.js CI](https://github.com/trenz-gmbh/trenz-docs/actions/workflows/node.js.yml/badge.svg)](https://github.com/trenz-gmbh/trenz-docs/actions/workflows/node.js.yml) ## Project setup + ``` npm install ``` ### Compiles and hot-reloads for development + ``` npm run serve ``` ### Compiles and minifies for production + ``` npm run build ``` ### Lints and fixes files + ``` npm run lint ``` -### Customize configuration -See [Configuration Reference](https://cli.vuejs.org/config/). - ## Deployment To deploy a trenz-docs wiki, follow these steps: -1. clone this repository to the target server: - ```bash - git clone https://github.com/trenz-gmbh/trenz-docs my-wiki - cd my-wiki +1. create a `docker-compose.yml` or copy the one from this repository and replace the ports: + ```docker-compose.yml + version: '3.4' + + services: + frontend: + image: ghcr.io/trenz-gmbh/trenz-docs:latest + ports: + - ":80" + restart: unless-stopped + depends_on: + - api + volumes: + - ./webapp-settings.local.json:/usr/share/nginx/html/webapp-settings.json + api: + image: ghcr.io/trenz-gmbh/trenz-docs-api:latest + environment: + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Production} + - Meilisearch__Url=http://meilisearch:7700 + - Meilisearch__ApiKey=${MEILISEARCH_API_KEY:-masterKey} + ports: + - ":80" + restart: unless-stopped + depends_on: + - meilisearch + volumes: + - ./appsettings.local.json:/app/appsettings.local.json + meilisearch: + image: getmeili/meilisearch:latest + ports: + - "7700:7700" + environment: + - MEILI_MASTER_KEY=${MEILISEARCH_API_KEY:-masterKey} + - MEILI_NO_ANALYTICS=true + restart: unless-stopped + volumes: + - meili_data:/data + + volumes: + meili_data: ``` 2. add a `appsettings.local.json` file to specify which repositories to use as sources: @@ -50,11 +87,19 @@ To deploy a trenz-docs wiki, follow these steps: } ``` -3. optionally: add a `.env` (or `.env.local`) file: - ```env - VUE_APP_API_BASE=https://localhost:7262/api/ - VUE_APP_PRIMARY_COLOR='#8af' - VUE_APP_PRIMARY_FOREGROUND_COLOR='#000' +3. add a `webapp-settings.local.json` and add your customer-facing api endpoint: + ```json + { + "name": "", + "theme": { + "primary": "80,120,200", + "primary-foreground": "255,255,255" + }, + "api": { + "baseUrl": "https:///api/" + }, + "useAuth": false + } ``` 4. run: @@ -62,4 +107,7 @@ To deploy a trenz-docs wiki, follow these steps: docker-compose up -d ``` -5. access your new wiki at [localhost:5050](http://localhost:5050/) +5. access your new wiki hosted at: + ```bash + http://localhost: + ``` diff --git a/docker-compose.yml b/docker-compose.yml index ebc5bc8..05e400e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,13 +5,13 @@ services: build: context: . target: final - args: - - VUE_APP_API_BASE=http://localhost:5001/api/ ports: - "5000:80" restart: unless-stopped depends_on: - api + volumes: + - ./webapp-settings.local.json:/usr/share/nginx/html/webapp-settings.json api: image: ghcr.io/trenz-gmbh/trenz-docs-api:latest environment: diff --git a/public/webapp-settings.example.json b/public/webapp-settings.example.json new file mode 100644 index 0000000..f6798f0 --- /dev/null +++ b/public/webapp-settings.example.json @@ -0,0 +1,11 @@ +{ + "name": "My Docs", + "theme": { + "primary": "80,120,200", + "primary-foreground": "255,255,255" + }, + "api": { + "baseUrl": "https://localhost:7262/api/" + }, + "useAuth": false +} diff --git a/src/App.vue b/src/App.vue index 544d4d6..2a2e842 100644 --- a/src/App.vue +++ b/src/App.vue @@ -128,13 +128,14 @@ import {defineComponent} from 'vue' import NavTreeNode from "@/components/NavTreeNode.vue"; import {VTextField} from "vuetify/components"; import TrenzDocsLogo from "@/components/TrenzDocsLogo.vue"; -import ApiClient from "@/api/ApiClient"; import {mapGetters} from "vuex"; import * as api from "@/api"; export default defineComponent({ name: 'App', + inject: ['$settings'], + components: { TrenzDocsLogo, NavTreeNode, @@ -151,6 +152,14 @@ export default defineComponent({ }); }, + mounted() { + let allThemed = document.querySelectorAll('.v-theme--light, .v-theme--dark'); + allThemed.forEach(el => { + el.style.setProperty('--v-theme-primary', this.$settings.theme.primary); + el.style.setProperty('--v-theme-on-primary', this.$settings.theme["primary-foreground"]); + }) + }, + unmounted() { window.removeEventListener('keydown', this.handleKeyDown) }, @@ -255,11 +264,11 @@ export default defineComponent({ }, loginUrl() { - return ApiClient.getBaseUrl() + "auth/transfer?returnUrl=" + encodeURI(window.location.origin + this.$route.fullPath); + return this.$settings.api.baseUrl + "auth/transfer?returnUrl=" + encodeURI(window.location.origin + this.$route.fullPath); }, logoutUrl() { - return ApiClient.getBaseUrl() + "auth/signout?returnUrl=" + encodeURI(window.location.origin + this.$route.fullPath); + return this.$settings.api.baseUrl + "auth/signout?returnUrl=" + encodeURI(window.location.origin + this.$route.fullPath); }, }, }) diff --git a/src/WebappSettings.ts b/src/WebappSettings.ts new file mode 100644 index 0000000..a3a3c9c --- /dev/null +++ b/src/WebappSettings.ts @@ -0,0 +1,11 @@ +export interface WebappSettings { + name: string; + theme: { + primary: string; + 'primary-foreground': string; + } + api: { + baseUrl: string; + } + useAuth: boolean; +} diff --git a/src/main.ts b/src/main.ts index a6c1c27..3aa396b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,25 +3,15 @@ import App from './App.vue' import router from './router' import store from './store' import vuetify from './plugins/vuetify' -import {loadFonts} from './plugins/webfontloader' -import ApiClient from "@/api/ApiClient"; - -if (process.env.VUE_APP_API_BASE) { - ApiClient.setBaseUrl(process.env.VUE_APP_API_BASE); -} else { - alert('Please set the VUE_APP_API_BASE environment variable (e.g. VUE_APP_API_BASE=https://localhost:7262/api)'); - - throw new Error('VUE_APP_API_BASE is not set'); -} - -if (process.env.VUE_APP_USE_AUTH && process.env.VUE_APP_USE_AUTH === 'true') { - ApiClient.useAuth = true; -} +import { loadFonts } from './plugins/webfontloader' loadFonts() -createApp(App) - .use(router) - .use(store) - .use(vuetify) - .mount('#app') +store.dispatch('loadWebAppSettings').then(settings => { + createApp(App) + .provide('$settings', settings) + .use(router) + .use(store) + .use(vuetify) + .mount('#app') +}) diff --git a/src/plugins/vuetify.ts b/src/plugins/vuetify.ts index 5cae0b7..c080071 100644 --- a/src/plugins/vuetify.ts +++ b/src/plugins/vuetify.ts @@ -17,17 +17,19 @@ export default createVuetify({ theme: { defaultTheme: 'light', themes: { + // The primary and on-primary values get overridden by the values in public/webapp-settings.json at runtime. + // They may be visible until the settings got loaded and applied. light: { colors: { - primary: process.env.VUE_APP_PRIMARY_COLOR ?? '#ddd', - 'on-primary': process.env.VUE_APP_PRIMARY_FOREGROUND_COLOR ?? '#000', + primary: '#ddd', + 'on-primary': '#000', } } as ThemeDefinition, dark: { dark: true, colors: { - primary: process.env.VUE_APP_PRIMARY_COLOR ?? '#222', - 'on-primary': process.env.VUE_APP_PRIMARY_FOREGROUND_COLOR ?? '#fff', + primary: '#222', + 'on-primary': '#fff', } } as ThemeDefinition, }, diff --git a/src/router/index.ts b/src/router/index.ts index 8bb8e34..248c17f 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -1,6 +1,7 @@ import {createRouter, createWebHistory, RouteRecordRaw} from 'vue-router' import ContentView from "@/views/ContentView.vue"; import NotFoundView from "@/views/NotFoundView.vue"; +import store from "@/store"; const routes: Array = [ { @@ -35,17 +36,18 @@ const routes: Array = [ ] const router = createRouter({ - history: createWebHistory(process.env.BASE_URL), + history: createWebHistory(), routes, }) router.beforeEach((to, from, next) => { + const appName = store.state.settings?.name ?? 'Loading...' if (to.meta.title) { - document.title = to.meta.title + ' \u2022 ' + process.env.VUE_APP_NAME; + document.title = to.meta.title + ' \u2022 ' + appName; } else if (to.params.location) { - document.title = (to.params.location as string).split('/').pop() + ' \u2022 ' + process.env.VUE_APP_NAME; + document.title = (to.params.location as string).split('/').pop() + ' \u2022 ' + appName; } else { - document.title = process.env.VUE_APP_NAME; + document.title = appName; } if (Object.hasOwnProperty.call(to.query, 'error')) { diff --git a/src/shims-vuex.d.ts b/src/shims-vuex.d.ts index 9eb7538..ae2168b 100644 --- a/src/shims-vuex.d.ts +++ b/src/shims-vuex.d.ts @@ -4,5 +4,6 @@ import {State} from "@/store/State"; declare module '@vue/runtime-core' { interface ComponentCustomProperties { $store: Store + $settings: WebappSettings } } diff --git a/src/store/State.ts b/src/store/State.ts index 9580d08..8ae7c5c 100644 --- a/src/store/State.ts +++ b/src/store/State.ts @@ -2,8 +2,10 @@ import {IndexedFile} from "@/models/IndexedFile"; import {NavTree} from "@/models/NavTree"; import {SearchResult} from "@/models/SearchResult"; import IndexStats from "@/models/IndexStats"; +import {WebappSettings} from "@/WebappSettings"; export interface State { + settings: WebappSettings | null, navTree: NavTree; searchQuery: string; searchResults: Array; diff --git a/src/store/index.ts b/src/store/index.ts index 696b0f2..6f4bf26 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -7,6 +7,7 @@ import * as api from "@/api"; import ApiClient, {ApiError} from "@/api/ApiClient"; import IndexStats from "@/models/IndexStats"; import {NavNode} from "@/models/NavNode"; +import {WebappSettings} from "@/WebappSettings"; function replaceApiHost(content: string): string { return content.replaceAll("%API_HOST%", ApiClient.getBaseUrl()?.slice(0, -1) ?? "/api"); @@ -14,6 +15,7 @@ function replaceApiHost(content: string): string { export default createStore({ state: { + settings: null, navTree: {root: {}, containsUnauthorizedChildren: false}, searchQuery: '', searchResults: [], @@ -64,12 +66,38 @@ export default createStore({ setStats(state: State, stats: IndexStats | null) { state.stats = stats; }, + + setSettings(state: State, settings: WebappSettings | null) { + state.settings = settings; + } }, actions: { async loadNavTree({commit}) { commit('setNavTree', await api.documents.navTree()); }, + async loadWebAppSettings({commit}): Promise { + ApiClient.setBaseUrl(window.location.origin) + const settings: WebappSettings = await ApiClient.getJson('webapp-settings.json'); + + const baseUrl = settings.api.baseUrl; + if (typeof baseUrl === 'undefined') { + alert('Please add a webapp-settings.json file to the content root.') + + throw new Error('API base url is not set'); + } else { + ApiClient.setBaseUrl(baseUrl); + } + + if (settings.useAuth) { + ApiClient.useAuth = true; + } + + commit('setSettings', settings); + + return settings; + }, + async search({commit}, query: string) { // update stats async, don't wait for it api.search.stats().then(stats => commit('setStats', stats)); diff --git a/vue.config.js b/vue.config.js index 998a143..f201651 100644 --- a/vue.config.js +++ b/vue.config.js @@ -31,4 +31,11 @@ module.exports = defineConfig({ allowedHosts: 'all', server: serverOpts, }, + + chainWebpack: config => { + config.plugin('copy').tap(options => { + options[0].patterns[0].globOptions.ignore.push('**/webapp-settings.example.json') + return options + }) + } })