├── .browserslistrc ├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── LICENSE ├── README.md ├── babel.config.js ├── demo └── vue_seo_friendly_demo.gif ├── package-lock.json ├── package.json ├── public ├── _redirects ├── favicon.ico ├── img │ └── icons │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── android-chrome-maskable-192x192.png │ │ ├── android-chrome-maskable-512x512.png │ │ ├── apple-touch-icon-120x120.png │ │ ├── apple-touch-icon-152x152.png │ │ ├── apple-touch-icon-180x180.png │ │ ├── apple-touch-icon-60x60.png │ │ ├── apple-touch-icon-76x76.png │ │ ├── apple-touch-icon.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── msapplication-icon-144x144.png │ │ ├── mstile-150x150.png │ │ └── safari-pinned-tab.svg ├── index.html ├── manifest.json ├── robots.txt └── sitemap.xml ├── sitemap-generator.js ├── src ├── App.vue ├── assets │ └── style │ │ ├── base │ │ ├── generic.scss │ │ ├── transitions.scss │ │ └── variables.scss │ │ ├── components │ │ ├── footer.scss │ │ ├── home.scss │ │ └── navbar.scss │ │ └── main.scss ├── components │ ├── Alert.vue │ ├── BackToTop.vue │ ├── Footer.vue │ ├── NavBar.vue │ ├── ToggleTheme.vue │ └── index.ts ├── composables │ ├── index.ts │ ├── useMetaRoute.ts │ └── useSharedTheme.ts ├── config │ ├── fa.config.ts │ ├── features.config.ts │ ├── index.ts │ ├── packages.config.ts │ ├── vue-gtag.config.ts │ ├── vue-meta.config.ts │ └── vue-scrollto.config.ts ├── directives │ ├── index.ts │ └── v-click-outside.ts ├── icons │ ├── VueSEO.vue │ └── index.ts ├── main.ts ├── registerServiceWorker.ts ├── router │ └── index.ts ├── shims-vue.d.ts ├── utils │ ├── createSharedComposable.ts │ └── index.ts └── views │ ├── About.vue │ ├── Home.vue │ ├── NotFound.vue │ └── index.ts ├── tsconfig.json └── vue.config.js /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | extends: [ 7 | "plugin:vue/vue3-essential", 8 | "eslint:recommended", 9 | "@vue/typescript/recommended", 10 | ], 11 | parserOptions: { 12 | ecmaVersion: 2020, 13 | }, 14 | rules: { 15 | "vue/multi-word-component-names": "off", 16 | "@typescript-eslint/no-explicit-any": 0, 17 | "no-console": process.env.NODE_ENV === "production" ? "warn" : "off", 18 | "no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off", 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # Editor directories and files 4 | .idea 5 | .vs/ 6 | .vscode/ 7 | *.suo 8 | *.ntvs* 9 | *.njsproj 10 | *.sln 11 | *.sw? 12 | 13 | # dependencies 14 | /node_modules 15 | 16 | # testing 17 | /coverage 18 | 19 | # production 20 | lib 21 | dist 22 | /build 23 | 24 | # local env files 25 | .env.local 26 | .env.*.local 27 | 28 | # Log files 29 | npm-debug.log* 30 | yarn-debug.log* 31 | yarn-error.log* 32 | pnpm-debug.log* 33 | *.log 34 | 35 | # misc 36 | .DS_Store 37 | package-lock.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Matt Areddia 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue-seo-friendly-spa-template 2 | Built using Vue 3.0. 3 | 4 | Vue.js PWA/SPA template configured for SEO (initially scaffolded with vue-cli). You can find the React version here: [react-seo-friendly-spa-template](https://github.com/based-ghost/react-seo-friendly-spa-template). 5 | 6 | Features: 7 | - TypeScript 8 | - Custom `BackToTop.vue` component that uses [`vue-scrollto`](https://github.com/rigor789/vue-scrollto) 9 | - Custom `ToggleTheme.vue` component that handles light/dark theme transitions 10 | - Google analytics management with [`vue-gtag-next`](https://github.com/MatteoGabriele/vue-gtag-next) 11 | - Route meta tag management with [`vue-meta`](https://github.com/nuxt/vue-meta/tree/next) 12 | - Configured to serve prerendered static HTML with [`prerender-spa-plugin`](https://github.com/chrisvfritz/prerender-spa-plugin) 13 | 14 | ## Demo 15 | 16 | ![demo](./demo/vue_seo_friendly_demo.gif) 17 | 18 | ## General Overview 19 | This template reflects some of the setup I went through when experimenting with the creation of my own static front-end personal site that was to be hosted on Netlify (using GitHub as a repository/pipeline). You can find that experiment live [here](https://basedghostdevelopment.com). After playing around with this process I figured I'd build a higher-level abstraction of that project for quick re-use in the future. 20 | 21 | ## Technology Stack Overview 22 | 23 | ### vue-cli 24 | 25 | initial scaffolding 26 | 27 | ### vue-meta 28 | 29 | [`vue-meta`](https://github.com/nuxt/vue-meta/tree/next) - plugin that allows you to manage your app's meta information, much like [`react-helmet`](https://github.com/nfl/react-helmet) does for React. However, instead of setting your data as props passed to a proprietary component, you simply export it as part of your component's data using the metaInfo property. 30 | 31 | I have meta data configured to be handled via a simple, reusable compostion (`@/composables/useMetaRoute.ts`) - simply import and execute this composable function in the `setup` function of your component and it will attempt to resolve any meta data definitions you configure for that route: 32 | 33 | `useMetaRoute.ts` 34 | ```typescript 35 | import { useRoute } from 'vue-router'; 36 | import { useMeta, type MetaSourceProxy } from 'vue-meta'; 37 | 38 | export default function useMetaRoute(): MetaSourceProxy { 39 | const route = useRoute(); 40 | const { title, description } = route?.meta ?? {}; 41 | const url = window?.location.href || 'unknown'; 42 | 43 | const { meta } = useMeta({ 44 | title, 45 | description, 46 | link: { 47 | rel: 'canonical', 48 | href: url 49 | }, 50 | og: { 51 | url, 52 | title, 53 | description 54 | } 55 | }); 56 | 57 | return meta; 58 | } 59 | ``` 60 | 61 | `About.vue` 62 | ```typescript 63 | 69 | ``` 70 | 71 | ### vue-gtag-next 72 | 73 | [`vue-gtag-next`](https://github.com/MatteoGabriele/vue-gtag-next) - The global site tag (gtag.js) is a JavaScript tagging framework and API that allows you to send event data to Google Analytics, Google Ads, and Google Marketing Platform. 74 | 75 | Inititial plugin configuration found in `config/vue-gtag.config.ts` and then hooked up in the setup function of the application's root component (`App.vue`). 76 | 77 | `vue-gtag.config.ts` 78 | ```typescript 79 | import type { Options } from 'vue-gtag-next'; 80 | 81 | const isEnabled = true; 82 | const isProduction = process.env.NODE_ENV === 'production'; 83 | const useDebugger = isEnabled && !isProduction; 84 | 85 | export const VUE_GTAG_OPTIONS: Options = { 86 | isEnabled, 87 | useDebugger, 88 | property: { 89 | id: 'UA-000000-01', 90 | params: { 91 | send_page_view: false, 92 | } 93 | } 94 | }; 95 | ``` 96 | 97 | `App.vue` 98 | ```typescript 99 | 127 | ``` 128 | 129 | ### prerender-spa-plugin 130 | 131 | [`prerender-spa-plugin`](https://github.com/chrisvfritz/prerender-spa-plugin) - Prerenders static HTML in a single-page application. This is a more straightforward substitue for SSR (Server Side Rendering) and the primary benefit is SEO. 132 | 133 | Configured in the app as follows: 134 | 135 | `vue.config.js` 136 | ```javascript 137 | const path = require("path"); 138 | const cheerio = require("cheerio"); 139 | const PrerenderSPAPlugin = require("prerender-spa-plugin-next"); 140 | const PuppeteerRenderer = require("@prerenderer/renderer-puppeteer"); 141 | 142 | module.exports = { 143 | lintOnSave: false, 144 | 145 | // define port 146 | devServer: { 147 | port: "3000", 148 | hot: true, 149 | }, 150 | 151 | configureWebpack: (config) => { 152 | if (process.env.NODE_ENV !== "production") { 153 | return {}; 154 | } 155 | 156 | return { 157 | performance: { 158 | hints: false, 159 | }, 160 | plugins: [ 161 | // https://github.com/chrisvfritz/prerender-spa-plugin 162 | new PrerenderSPAPlugin({ 163 | staticDir: config.output.path, 164 | routes: ["/", "/about"], 165 | renderer: PuppeteerRenderer, 166 | postProcess(context) { 167 | if (context.route === "/404") { 168 | context.outputPath = path.join(config.output.path, "/404.html"); 169 | } 170 | 171 | // Add 'data-server-rendered' attribute so app knows to hydrate with any changes 172 | const $ = cheerio.load(context.html); 173 | $("#app").attr("data-server-rendered", "true"); 174 | context.html = $.html(); 175 | 176 | return context; 177 | }, 178 | }), 179 | ], 180 | }; 181 | } 182 | }; 183 | ``` 184 | 185 | Remainder of the configuration takes place in `vue.config.js` file where the plugin is added and configured. In the `postProcess` callback I am editing the prerendered content using `cheerio` so you can load the raw prerendered html string into a usable document and modify it using JQuery-like syntax, rather than parsing a long string and calling `.replace()`. 186 | 187 | Note: I found that dynamically adding the `data-server-rendered='true'` attribute in the `postProcess` (rather than hard-coding in the index.html file) seems to work well - this lets the client know that this nodes contents was served as prerendered content and to hydrate the HTML with updates, rather than re-render/replace. 188 | 189 | ## Scripts 190 | 191 | ## Project setup 192 | ``` 193 | npm install 194 | ``` 195 | 196 | ### Compiles and hot-reloads for development 197 | ``` 198 | npm run serve 199 | ``` 200 | 201 | ### Compiles and minifies for production 202 | ``` 203 | npm run build 204 | ``` 205 | 206 | ### Lints and fixes files 207 | ``` 208 | npm run lint 209 | ``` 210 | 211 | - Run the linter (configured in the tslint.json file found in the root of this project) 212 | 213 | ### Generate sitemap.xml file 214 | ``` 215 | npm run sitemap 216 | ``` 217 | 218 | - This command will execute code in the sitemap-generator.js. Using the sitemapUrl parameter defined in that file (should reflect your registered domain name) a sitemap.xml is generated and persisted under the 'public' folder - this file is referenced in the robots.txt file. This uses the `sitemap-generator` package. 219 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ["@vue/cli-plugin-babel/preset"], 3 | compact: true 4 | }; 5 | -------------------------------------------------------------------------------- /demo/vue_seo_friendly_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/based-ghost/vue-seo-friendly-spa-template/7d8b5418a96710db4e5fb71c4b89154dd8703ff6/demo/vue_seo_friendly_demo.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-seo-friendly-spa-template", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build --modern", 8 | "lint": "vue-cli-service lint", 9 | "sitemap": "node sitemap-generator" 10 | }, 11 | "dependencies": { 12 | "@fortawesome/fontawesome-svg-core": "^6.3.0", 13 | "@fortawesome/free-brands-svg-icons": "^6.3.0", 14 | "@fortawesome/free-solid-svg-icons": "^6.3.0", 15 | "@fortawesome/vue-fontawesome": "^3.0.3", 16 | "bulma": "^0.9.4", 17 | "cheerio": "^1.0.0-rc.12", 18 | "core-js": "^3.29.1", 19 | "register-service-worker": "^1.7.2", 20 | "vue": "^3.2.47", 21 | "vue-gtag-next": "^1.14.0", 22 | "vue-meta": "^3.0.0-alpha.7", 23 | "vue-router": "^4.1.6", 24 | "vue-scrollto": "^2.20.0" 25 | }, 26 | "devDependencies": { 27 | "@typescript-eslint/eslint-plugin": "^5.56.0", 28 | "@typescript-eslint/parser": "^5.56.0", 29 | "@vue/cli-plugin-babel": "~5.0.8", 30 | "@vue/cli-plugin-eslint": "~5.0.8", 31 | "@vue/cli-plugin-pwa": "~5.0.8", 32 | "@vue/cli-plugin-router": "~5.0.8", 33 | "@vue/cli-plugin-typescript": "~5.0.8", 34 | "@vue/cli-service": "~5.0.8", 35 | "@vue/eslint-config-prettier": "^7.1.0", 36 | "@vue/eslint-config-typescript": "^11.0.2", 37 | "@vue/server-renderer": "^3.2.47", 38 | "eslint": "^8.36.0", 39 | "eslint-plugin-prettier": "^4.2.1", 40 | "eslint-plugin-vue": "^9.10.0", 41 | "prerender-spa-plugin-next": "^4.2.3", 42 | "prettier": "^2.8.7", 43 | "sass": "^1.60.0", 44 | "sass-loader": "^13.2.1", 45 | "sitemap-generator": "^8.5.1", 46 | "typescript": "^5.0.2", 47 | "vue-loader": "^17.0.1" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | # https://www.netlify.com/docs/redirects/ 2 | /404 /404.html 404 3 | /* /index.html 200 -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/based-ghost/vue-seo-friendly-spa-template/7d8b5418a96710db4e5fb71c4b89154dd8703ff6/public/favicon.ico -------------------------------------------------------------------------------- /public/img/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/based-ghost/vue-seo-friendly-spa-template/7d8b5418a96710db4e5fb71c4b89154dd8703ff6/public/img/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/img/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/based-ghost/vue-seo-friendly-spa-template/7d8b5418a96710db4e5fb71c4b89154dd8703ff6/public/img/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/img/icons/android-chrome-maskable-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/based-ghost/vue-seo-friendly-spa-template/7d8b5418a96710db4e5fb71c4b89154dd8703ff6/public/img/icons/android-chrome-maskable-192x192.png -------------------------------------------------------------------------------- /public/img/icons/android-chrome-maskable-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/based-ghost/vue-seo-friendly-spa-template/7d8b5418a96710db4e5fb71c4b89154dd8703ff6/public/img/icons/android-chrome-maskable-512x512.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/based-ghost/vue-seo-friendly-spa-template/7d8b5418a96710db4e5fb71c4b89154dd8703ff6/public/img/icons/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/based-ghost/vue-seo-friendly-spa-template/7d8b5418a96710db4e5fb71c4b89154dd8703ff6/public/img/icons/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/based-ghost/vue-seo-friendly-spa-template/7d8b5418a96710db4e5fb71c4b89154dd8703ff6/public/img/icons/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/based-ghost/vue-seo-friendly-spa-template/7d8b5418a96710db4e5fb71c4b89154dd8703ff6/public/img/icons/apple-touch-icon-60x60.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/based-ghost/vue-seo-friendly-spa-template/7d8b5418a96710db4e5fb71c4b89154dd8703ff6/public/img/icons/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/based-ghost/vue-seo-friendly-spa-template/7d8b5418a96710db4e5fb71c4b89154dd8703ff6/public/img/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /public/img/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/based-ghost/vue-seo-friendly-spa-template/7d8b5418a96710db4e5fb71c4b89154dd8703ff6/public/img/icons/favicon-16x16.png -------------------------------------------------------------------------------- /public/img/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/based-ghost/vue-seo-friendly-spa-template/7d8b5418a96710db4e5fb71c4b89154dd8703ff6/public/img/icons/favicon-32x32.png -------------------------------------------------------------------------------- /public/img/icons/msapplication-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/based-ghost/vue-seo-friendly-spa-template/7d8b5418a96710db4e5fb71c4b89154dd8703ff6/public/img/icons/msapplication-icon-144x144.png -------------------------------------------------------------------------------- /public/img/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/based-ghost/vue-seo-friendly-spa-template/7d8b5418a96710db4e5fb71c4b89154dd8703ff6/public/img/icons/mstile-150x150.png -------------------------------------------------------------------------------- /public/img/icons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 148 | 149 | 150 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "VueSeoFriendlySpaTemplate", 3 | "short_name": "VueSeoSpa", 4 | "icons": [ 5 | { 6 | "src": "/img/icons/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/img/icons/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "/img/icons/android-chrome-maskable-192x192.png", 17 | "sizes": "196x196", 18 | "type": "image/png", 19 | "purpose": "any maskable" 20 | }, 21 | { 22 | "src": "/img/icons/android-chrome-maskable-512x512.png", 23 | "sizes": "512x512", 24 | "type": "image/png", 25 | "purpose": "any maskable" 26 | } 27 | ], 28 | "start_url": ".", 29 | "display": "standalone", 30 | "background_color": "#ffffff", 31 | "theme_color": "#67dea9" 32 | } 33 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | 4 | Sitemap: https://www.vueseofriendlyspatemplate.com/sitemap.xml -------------------------------------------------------------------------------- /public/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://www.vueseofriendlyspatemplate.com/ 5 | 2019-03-07 6 | 7 | 8 | https://www.vueseofriendlyspatemplate.com/about/ 9 | 2019-03-07 10 | 11 | -------------------------------------------------------------------------------- /sitemap-generator.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const SitemapGenerator = require("sitemap-generator"); 3 | const SITEMAP_URL = "https://www.vueseofriendlyspatemplate.com/"; 4 | 5 | const generator = SitemapGenerator(SITEMAP_URL, { 6 | lastMod: true, 7 | stripQuerystring: true, 8 | filepath: `${__dirname}/public/sitemap.xml` 9 | }); 10 | 11 | // Register event listener for 'done' 12 | generator.on("done", () => { 13 | console.log(`sitemap.xml successfully created for URL: ${SITEMAP_URL}\n`); 14 | }); 15 | 16 | // Register event listener for 'error' 17 | // ErrorMessage has shape => { code: 404, message: 'Not found.', url: 'http://example.com/foo' } 18 | generator.on("error", err => { 19 | console.error(`${JSON.stringify(err)}\n`); 20 | }); 21 | 22 | generator.start(); 23 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | -------------------------------------------------------------------------------- /src/assets/style/base/generic.scss: -------------------------------------------------------------------------------- 1 | @use 'variables' as *; 2 | 3 | body { 4 | will-change: background-color; 5 | background-color: $theme-primary-body-bg-color; 6 | transition: background-color 0.2s ease-out; 7 | 8 | &.secondary-theme { 9 | background-color: $theme-secondary-body-bg-color; 10 | } 11 | } 12 | 13 | svg { 14 | max-width: 100%; 15 | } 16 | 17 | .title { 18 | font-weight: 700; 19 | color: $color-hero-is-dark; 20 | 21 | @include renderTabletDevice { 22 | font-size: 1.75em; 23 | } 24 | 25 | @include renderMobileDevice { 26 | font-size: 1.5em; 27 | } 28 | } 29 | 30 | code { 31 | font-size: 87.5%; 32 | border-radius: 3px; 33 | background-color: $theme-secondary-code-bg-color; 34 | color: $color-green-highlight; 35 | padding: 0.25rem 0.5rem; 36 | } 37 | 38 | .view-wrapper { 39 | min-height: 875px; 40 | padding-top: $navbar-height; 41 | } 42 | 43 | .vue-svg { 44 | width: 100% !important; 45 | height: 100% !important; 46 | } 47 | 48 | .is-132x132 { 49 | width: 132px; 50 | height: 132px; 51 | } 52 | 53 | .subtitle { 54 | font-size: 1.6em; 55 | letter-spacing: 0.01em; 56 | color: $color-hero-is-dark; 57 | 58 | @include renderTabletDevice { 59 | font-size: 1.35em; 60 | } 61 | 62 | @include renderMobileDevice { 63 | font-size: 1.125em; 64 | } 65 | } 66 | 67 | .is-horizontal-center { 68 | align-items: center; 69 | justify-content: center; 70 | } 71 | 72 | .notification-tile { 73 | margin: 0 auto; 74 | padding: 15rem 0 20rem !important; 75 | 76 | .notification { 77 | color: #fff; 78 | text-align: center; 79 | padding: 1.75rem .25rem; 80 | box-shadow: 0 2px 15px 0 rgba(18,16,19,.2); 81 | 82 | &.is-primary { 83 | background-color: #3eaf7c; 84 | } 85 | 86 | &.is-danger { 87 | background-color: #E93E60; 88 | } 89 | 90 | .title { 91 | font-size: 2em; 92 | margin-left: 0.85rem; 93 | } 94 | 95 | .subtitle { 96 | font-size: 1.6em; 97 | margin-top: 0.5rem; 98 | } 99 | 100 | @include renderMobileDevice { 101 | svg { 102 | vertical-align: -0.225em; 103 | } 104 | 105 | .title { 106 | font-size: 1.5em; 107 | } 108 | 109 | .subtitle { 110 | font-size: 1.05em; 111 | } 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/assets/style/base/transitions.scss: -------------------------------------------------------------------------------- 1 | .fade-enter-active, 2 | .fade-leave-active { 3 | transition: opacity 0.25s ease; 4 | } 5 | 6 | .fade-enter, 7 | .fade-leave-to { 8 | opacity: 0; 9 | } 10 | 11 | .rubber-band-enter-active { 12 | animation: rubberBand_animation 1s; 13 | } 14 | 15 | @keyframes rubberBand_animation { 16 | from { 17 | transform: scale3d(1, 1, 1); 18 | } 30% { 19 | transform: scale3d(1.25, 0.75, 1); 20 | } 40% { 21 | transform: scale3d(0.75, 1.25, 1); 22 | } 50% { 23 | transform: scale3d(1.15, 0.85, 1); 24 | } 65% { 25 | transform: scale3d(0.95, 1.05, 1); 26 | } 75% { 27 | transform: scale3d(1.05, 0.95, 1); 28 | } to { 29 | transform: scale3d(1, 1, 1); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/assets/style/base/variables.scss: -------------------------------------------------------------------------------- 1 | $color-hero-is-dark: #282c34; 2 | $color-nav-bar: #20232a; 3 | $color-green-highlight: #67dea9; 4 | $navbar-height: 66px; 5 | 6 | $theme-primary-body-bg-color: white; 7 | $theme-secondary-body-bg-color: #353941; 8 | $theme-secondary-code-bg-color: $color-nav-bar; 9 | 10 | @mixin removeNavBarPadding { 11 | @media all and (max-width: 1099px) { 12 | @content; 13 | } 14 | } 15 | 16 | @mixin reduceNavBarPadding { 17 | @media all and (max-width: 1472px) and (min-width: 1100px) { 18 | @content; 19 | } 20 | } 21 | 22 | @mixin renderMobileDevice { 23 | @media all and (max-width: 449px) { 24 | @content; 25 | } 26 | } 27 | 28 | @mixin renderTabletDevice { 29 | @media all and (max-width: 769px) and (min-width: 450px) { 30 | @content; 31 | } 32 | } 33 | 34 | @mixin renderTabletNavView { 35 | @media all and (max-width: 950px) and (min-width: 600px) { 36 | @content; 37 | } 38 | } 39 | 40 | @mixin renderMobileNavView { 41 | @media all and (max-width: 599px) { 42 | @content; 43 | } 44 | } 45 | 46 | @mixin centerStackedColumnContent { 47 | @media all and (max-width: 769px) { 48 | @content; 49 | } 50 | } -------------------------------------------------------------------------------- /src/assets/style/components/footer.scss: -------------------------------------------------------------------------------- 1 | @use '../base/variables' as *; 2 | 3 | .footer { 4 | color: #fff; 5 | background-color: $color-nav-bar; 6 | padding: 3rem 1.5rem; 7 | font-size: 1.15rem; 8 | width: 100%; 9 | margin: auto; 10 | 11 | @include centerStackedColumnContent { 12 | font-size: 1rem; 13 | } 14 | 15 | .buttons { 16 | margin-bottom: 0; 17 | 18 | > .button { 19 | margin-bottom: 0; 20 | margin-right: 0 !important; 21 | color: #fff; 22 | font-size: 1.4rem; 23 | background-color: transparent; 24 | border-color: transparent; 25 | padding-left: 0.5em; 26 | padding-right: 0.5em; 27 | transition: color 0.2s ease-out; 28 | 29 | &:hover { 30 | color: $color-green-highlight; 31 | } 32 | 33 | &:first-child { 34 | margin-left: auto !important; 35 | } 36 | 37 | &:last-child { 38 | margin-right: auto !important; 39 | } 40 | 41 | .icon { 42 | align-items: baseline; 43 | } 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /src/assets/style/components/home.scss: -------------------------------------------------------------------------------- 1 | @use '../base/variables' as *; 2 | 3 | .hero.is-dark { 4 | background-color: $color-hero-is-dark; 5 | 6 | .hero-body { 7 | padding: 1.25rem 1.5rem 2.5rem; 8 | 9 | svg { 10 | color: $color-green-highlight; 11 | } 12 | 13 | hr { 14 | background-color: rgba(255,255,255,0.13); 15 | height: 1.5px; 16 | margin: 0 auto 1.55rem auto; 17 | width: 65%; 18 | } 19 | 20 | .feature { 21 | font-size: 1.05em; 22 | line-height: 1.25; 23 | 24 | &:not(:last-child) { 25 | margin-bottom: 0.65rem; 26 | } 27 | 28 | svg { 29 | font-size: 90%; 30 | margin-right: 5px; 31 | } 32 | 33 | @include centerStackedColumnContent { 34 | line-height: 1.6; 35 | } 36 | } 37 | } 38 | 39 | .title { 40 | color: #fff; 41 | font-size: 2.8em; 42 | margin: auto auto 1.25rem; 43 | font-family: Segoe Script; 44 | 45 | @include renderMobileDevice { 46 | font-size: 1.8em; 47 | } 48 | } 49 | } 50 | 51 | .dashboard-content { 52 | padding: 1.5rem 0 2rem; 53 | transition: color 0.2s ease-out; 54 | 55 | &.secondary-theme { 56 | color: $theme-primary-body-bg-color; 57 | 58 | .title { 59 | color: $theme-primary-body-bg-color !important; 60 | } 61 | 62 | code { 63 | background-color: $theme-secondary-code-bg-color; 64 | color: $color-green-highlight; 65 | } 66 | 67 | hr { 68 | background-color: rgba(255, 255, 255, 0.13); 69 | } 70 | } 71 | 72 | code { 73 | transition: color 0.2s ease-out, background-color 0.2s ease-out; 74 | background-color: #ebedf0; 75 | color: #242424; 76 | } 77 | 78 | hr { 79 | transition: background-color 0.2s ease-out; 80 | background-color: hsla(0,100%,0%,.11); 81 | margin: 0.75rem 0; 82 | height: 1.5px; 83 | } 84 | 85 | > .columns { 86 | > .column { 87 | padding: 1.85rem 1.25rem !important; 88 | 89 | > .title { 90 | color: #6d6d6d; 91 | text-align: center; 92 | font-size: 1.825rem; 93 | transition: color 0.2s ease-out; 94 | } 95 | } 96 | 97 | @media all and (min-width: 769px) { 98 | > hr { 99 | display: none; 100 | } 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/assets/style/components/navbar.scss: -------------------------------------------------------------------------------- 1 | @use '../base/variables' as *; 2 | 3 | .navbar { 4 | width: 100%; 5 | position: fixed; 6 | z-index: 30; 7 | height: $navbar-height; 8 | background-color: $color-nav-bar; 9 | padding-left: 11rem; 10 | padding-right: 11rem; 11 | box-shadow: 0 4px 6px 0 rgb(0 0 0 / 5%); 12 | 13 | @include reduceNavBarPadding { 14 | padding-left: 7rem; 15 | padding-right: 7rem; 16 | } 17 | 18 | @include removeNavBarPadding { 19 | padding-left: 1rem; 20 | padding-right: 1rem; 21 | } 22 | 23 | @include renderMobileDevice { 24 | padding-right: 0rem; 25 | } 26 | 27 | .navbar-wrapper { 28 | width: 100%; 29 | margin: auto; 30 | display: flex; 31 | height: 100%; 32 | 33 | 34 | .brand-wrapper { 35 | display: flex; 36 | height: 100%; 37 | align-items: center; 38 | width: 46%; 39 | margin: auto; 40 | 41 | @include renderMobileNavView { 42 | margin-right: 0.25rem; 43 | } 44 | } 45 | 46 | .navbar-routes { 47 | line-height: 1.2; 48 | font-size: 1.2rem; 49 | height: 100%; 50 | display: flex; 51 | justify-content: flex-end; 52 | margin: auto; 53 | width: 54%; 54 | align-items: center; 55 | 56 | > .seperator { 57 | width: 1.5px; 58 | margin: .95rem 1.25rem; 59 | align-self: stretch; 60 | box-sizing: border-box; 61 | background-color: rgba(255,255,255,0.13); 62 | } 63 | 64 | > .navbar-item { 65 | user-select: none; 66 | color: white; 67 | letter-spacing: 0.01em; 68 | background-color: transparent; 69 | border-top: 2px solid transparent; 70 | border-bottom: 2px solid transparent; 71 | transition: color 0.2s ease-out, border-bottom-color 0.2s ease-out; 72 | display: flex; 73 | overflow-x: auto; 74 | overflow-y: hidden; 75 | align-items: center; 76 | height: 100%; 77 | padding: 0.5rem 0.75rem; 78 | position: relative; 79 | flex-grow: 0; 80 | flex-shrink: 0; 81 | 82 | &:hover { 83 | color: $color-green-highlight; 84 | background-color: transparent; 85 | } 86 | 87 | &.is-active { 88 | background-color: transparent; 89 | color: $color-green-highlight!important; 90 | border-bottom-color: $color-green-highlight !important; 91 | } 92 | 93 | &:nth-child(2) { 94 | margin-left: 1.25rem; 95 | } 96 | 97 | svg { 98 | font-size: 0.75em; 99 | margin: .063rem 0 0 .55rem; 100 | color: rgba(255,255,255,.5); 101 | transition: color 0.2s ease-out; 102 | } 103 | 104 | @include renderMobileNavView { 105 | font-size: 0.95rem; 106 | border-top-width: 4px; 107 | padding: 0.75rem 0.2rem 0.75rem 0.2rem; 108 | 109 | &:nth-child(2) { 110 | margin-left: 0.5rem; 111 | } 112 | 113 | &.is-active { 114 | border-bottom-color: $color-green-highlight !important; 115 | } 116 | } 117 | } 118 | 119 | > .navbar-theme-toggle { 120 | display: flex; 121 | line-height: 1.25; 122 | margin: 0 0 0 1.25rem; 123 | } 124 | } 125 | } 126 | } -------------------------------------------------------------------------------- /src/assets/style/main.scss: -------------------------------------------------------------------------------- 1 | // overrides + custom variables/mixins 2 | @use 'base/variables'; 3 | 4 | // node_module imports 5 | @use 'bulma/bulma'; 6 | 7 | // custom modules 8 | @use 'base/generic'; 9 | @use 'base/transitions'; 10 | @use 'components/home'; 11 | @use 'components/navbar'; 12 | @use 'components/footer'; -------------------------------------------------------------------------------- /src/components/Alert.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | -------------------------------------------------------------------------------- /src/components/BackToTop.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 34 | 35 | -------------------------------------------------------------------------------- /src/components/Footer.vue: -------------------------------------------------------------------------------- 1 |  -------------------------------------------------------------------------------- /src/components/NavBar.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 53 | -------------------------------------------------------------------------------- /src/components/ToggleTheme.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 38 | 39 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Alert } from './Alert.vue'; 2 | export { default as Navbar } from './Navbar.vue'; 3 | export { default as AppFooter } from './Footer.vue'; 4 | export { default as BackToTop } from './BackToTop.vue'; -------------------------------------------------------------------------------- /src/composables/index.ts: -------------------------------------------------------------------------------- 1 | export { useSharedTheme, Theme } from './useSharedTheme'; 2 | export { default as useMetaRoute } from './useMetaRoute'; 3 | -------------------------------------------------------------------------------- /src/composables/useMetaRoute.ts: -------------------------------------------------------------------------------- 1 | import { useRoute } from 'vue-router'; 2 | import { useMeta, type MetaSourceProxy } from 'vue-meta'; 3 | 4 | export default function useMetaRoute(): MetaSourceProxy { 5 | const route = useRoute(); 6 | const { title, description } = route?.meta ?? {}; 7 | const url = window?.location.href || 'unknown'; 8 | 9 | const { meta } = useMeta({ 10 | title, 11 | description, 12 | link: { 13 | rel: 'canonical', 14 | href: url 15 | }, 16 | og: { 17 | url, 18 | title, 19 | description 20 | } 21 | }); 22 | 23 | return meta; 24 | } -------------------------------------------------------------------------------- /src/composables/useSharedTheme.ts: -------------------------------------------------------------------------------- 1 | import { ref, computed, unref } from 'vue'; 2 | import { createSharedComposable } from '@/utils'; 3 | 4 | export const Theme = { 5 | PRIMARY: 'primary', 6 | SECONDARY: 'secondary' 7 | } as const; 8 | 9 | export type Theme = typeof Theme[keyof typeof Theme]; 10 | 11 | function useTheme() { 12 | const theme = ref(Theme.PRIMARY); 13 | const themeCls = computed(() => `${unref(theme)}-theme`); 14 | 15 | const setTheme = (val: Theme) => { 16 | theme.value = val; 17 | }; 18 | 19 | return { 20 | theme, 21 | setTheme, 22 | themeCls 23 | }; 24 | } 25 | 26 | export const useSharedTheme = createSharedComposable(useTheme); -------------------------------------------------------------------------------- /src/config/fa.config.ts: -------------------------------------------------------------------------------- 1 | import { library } from '@fortawesome/fontawesome-svg-core'; 2 | 3 | import { 4 | faSun, 5 | faMoon, 6 | faCheck, 7 | faInfoCircle, 8 | faAngleDoubleUp, 9 | faExternalLinkAlt, 10 | faExclamationCircle 11 | } from '@fortawesome/free-solid-svg-icons'; 12 | 13 | import { 14 | faEtsy, 15 | faVuejs, 16 | faGithub, 17 | faTwitter 18 | } from '@fortawesome/free-brands-svg-icons'; 19 | 20 | library.add( 21 | faSun, 22 | faMoon, 23 | faEtsy, 24 | faVuejs, 25 | faCheck, 26 | faGithub, 27 | faTwitter, 28 | faInfoCircle, 29 | faAngleDoubleUp, 30 | faExternalLinkAlt, 31 | faExclamationCircle 32 | ); 33 | -------------------------------------------------------------------------------- /src/config/features.config.ts: -------------------------------------------------------------------------------- 1 | export type Feature = Readonly<{ 2 | description: string; 3 | package_name?: string; 4 | }>; 5 | 6 | export const FEATURES: Feature[] = [ 7 | { 8 | description: 'UI styled with Bulma + SASS + Font Awesome 5 (svg-core)' 9 | }, 10 | { 11 | description: 'Configured as a (PWA) Progressive Web App' 12 | }, 13 | { 14 | description: 'Meta tags dynamically handled per route using', 15 | package_name: 'vue-meta' 16 | }, 17 | { 18 | description: 'Global Site Tag plugin already configured', 19 | package_name: 'vue-gtag-next' 20 | }, 21 | { 22 | description: 'Configured to serve prerendered html using', 23 | package_name: 'prerender-spa-plugin' 24 | } 25 | ]; -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | export * from './features.config'; 2 | export * from './vue-meta.config'; 3 | export * from './vue-gtag.config'; 4 | export * from './vue-scrollto.config'; 5 | export * from './packages.config'; 6 | 7 | export const LOREM_IPSUM_TEXT = ` 8 | Lorem ipsum dolor sit amet, alia appareat usu id, has legere facilis in. 9 | Nam inani malorum epicuri id, illud eleifend reformidans nec cu. Stet meis 10 | rebum quo an, ad recusabo praesent reprimique duo, ne delectus expetendis 11 | philosophia nam. Mel lorem recusabo ex, vim congue facilisis eu, id vix 12 | oblique mentitum. Vide aeterno duo ei. Qui ne urbanitas conceptam deseruisse, 13 | commune philosophia eos no. Id ullum reprimique qui, vix ei malorum assueverit 14 | contentiones. Nec facilis dignissim efficiantur ad, tantas tempor nam in. Per 15 | feugait atomorum ut. Novum appareat ei usu, an usu omnium concludaturque. Et nam 16 | latine mentitum, impedit explicari ullamcorper ut est, vis ipsum viderer ei. Porro 17 | essent eu per, ut tantas dissentias vim. Dicant regione argumentum vis id, adipisci 18 | accusata postulant at vix. Adipisci vituperata ea duo, eu summo detracto mei, et 19 | per option periculis. Eos laudem vivendo ex. 20 | `; -------------------------------------------------------------------------------- /src/config/packages.config.ts: -------------------------------------------------------------------------------- 1 | export type Package = Readonly<{ 2 | package_name: string; 3 | description_1: string; 4 | description_2: string; 5 | }>; 6 | 7 | export const PACKAGES: Package[] = [ 8 | { 9 | package_name: 'vue-gtag-next', 10 | description_1: `the global site tag (gtag.js) is a JavaScript tagging framework and API that allows you to send event data to Google Analytics, Google Ads, and Google Marketing Platform.`, 11 | description_2: `For general gtag.js documentation, read the gtag.js developer guide provided by Google.`, 12 | }, 13 | { 14 | package_name: 'vue-meta', 15 | description_1: `is a Vue plugin that allows you to manage your app's meta information, much like react- helmet does for React.However, instead of setting your data as props passed to a proprietary component, you simply export it as part of your component's data using the metaInfo property.`, 16 | description_2: `These properties, when set on a deeply nested component, will cleverly overwrite their parent components' metaInfo, thereby enabling custom info for each top-level view as well as coupling meta info directly to deeply nested subcomponents for more maintainable code.`, 17 | }, 18 | { 19 | package_name: 'prerender-spa-plugin', 20 | description_1: `'s goal is to provide a simple prerendering solution that is easily extensible and usable for any site or single-page-app built with webpack.`, 21 | description_2: `Prerendering differs from (SSR) Server Side Rendering. You can get almost all the advantages of it (without the disadvantages) by using prerendering. Prerendering is basically firing up a headless browser, loading your app's routes, and saving the results to a static HTML file. You can then serve it with whatever static-file-serving solution you were using previously. It just works with HTML5 navigation and the likes.`, 22 | }, 23 | ]; -------------------------------------------------------------------------------- /src/config/vue-gtag.config.ts: -------------------------------------------------------------------------------- 1 | import type { Options } from 'vue-gtag-next'; 2 | 3 | const isEnabled = true; 4 | const isProduction = process.env.NODE_ENV === 'production'; 5 | const useDebugger = isEnabled && !isProduction; 6 | 7 | export const VUE_GTAG_OPTIONS: Options = { 8 | isEnabled, 9 | useDebugger, 10 | property: { 11 | id: 'UA-000000-01', 12 | params: { 13 | send_page_view: false, 14 | } 15 | } 16 | }; -------------------------------------------------------------------------------- /src/config/vue-meta.config.ts: -------------------------------------------------------------------------------- 1 | import { createMetaManager } from 'vue-meta'; 2 | 3 | export const META_MANAGER = createMetaManager(); -------------------------------------------------------------------------------- /src/config/vue-scrollto.config.ts: -------------------------------------------------------------------------------- 1 | import type { ScrollOptions } from 'vue-scrollto'; 2 | 3 | export const VUE_SCROLLTO_OPTIONS: ScrollOptions = { 4 | duration: 500, 5 | container: 'body', 6 | easing: 'ease-out' 7 | }; -------------------------------------------------------------------------------- /src/directives/index.ts: -------------------------------------------------------------------------------- 1 | export { default as vClickOutside } from './v-click-outside'; -------------------------------------------------------------------------------- /src/directives/v-click-outside.ts: -------------------------------------------------------------------------------- 1 | import type { Directive } from 'vue'; 2 | 3 | const vClickOutside: Directive = { 4 | mounted(el, binding, vNode) { 5 | const { value: callbackFn } = binding; 6 | 7 | if (typeof callbackFn !== 'function') { 8 | const compName = vNode.component; 9 | const warnMsg = 10 | `[v-click-outside]: provided expression '${callbackFn}' is not a function, but has to be ${ 11 | compName ? `- Found in component '${compName}` : '' 12 | }`.trim(); 13 | console.warn(warnMsg); 14 | return; 15 | } 16 | 17 | el.clickOutsideEvent = function (e: Event) { 18 | e.stopPropagation(); 19 | if (el !== e.target && !el.contains(e.target)) { 20 | callbackFn(e); 21 | } 22 | } 23 | 24 | document.addEventListener('click', el.clickOutsideEvent); 25 | }, 26 | unmounted(el) { 27 | if (el.clickOutsideEvent) { 28 | document.removeEventListener('click', el.clickOutsideEvent); 29 | } 30 | } 31 | }; 32 | 33 | export default vClickOutside; -------------------------------------------------------------------------------- /src/icons/VueSEO.vue: -------------------------------------------------------------------------------- 1 | 19 | -------------------------------------------------------------------------------- /src/icons/index.ts: -------------------------------------------------------------------------------- 1 | export { default as VueSEO } from './VueSEO.vue'; -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue'; 2 | import App from '@/App.vue'; 3 | 4 | import '@/registerServiceWorker'; 5 | import '@/assets/style/main.scss'; 6 | import '@/config/fa.config'; 7 | 8 | import router from '@/router'; 9 | import VueGtag from 'vue-gtag-next'; 10 | import VueScrollTo from 'vue-scrollto'; 11 | import { vClickOutside } from '@/directives'; 12 | import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; 13 | import { META_MANAGER, VUE_GTAG_OPTIONS, VUE_SCROLLTO_OPTIONS } from '@/config'; 14 | 15 | const app = createApp(App) 16 | .use(router) 17 | .use(META_MANAGER) 18 | .use(VueGtag, VUE_GTAG_OPTIONS) 19 | .use(VueScrollTo, VUE_SCROLLTO_OPTIONS) 20 | .directive('click-outside', vClickOutside) 21 | .component('font-awesome-icon', FontAwesomeIcon); 22 | 23 | app.mount('#app'); -------------------------------------------------------------------------------- /src/registerServiceWorker.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | import { register } from 'register-service-worker'; 4 | 5 | if (process.env.NODE_ENV === 'production') { 6 | register(`${process.env.BASE_URL}service-worker.js`, { 7 | ready() { 8 | console.log( 9 | 'App is being served from cache by a service worker.\n' + 10 | 'For more details, visit https://goo.gl/AFskqB' 11 | ); 12 | }, 13 | registered() { 14 | console.log('Service worker has been registered.'); 15 | }, 16 | cached() { 17 | console.log('Content has been cached for offline use.'); 18 | }, 19 | updatefound() { 20 | console.log('New content is downloading.'); 21 | }, 22 | updated() { 23 | console.log('New content is available; please refresh.'); 24 | }, 25 | offline() { 26 | console.log('No internet connection found. App is running in offline mode.'); 27 | }, 28 | error(error) { 29 | console.error('Error during service worker registration:', error); 30 | } 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { About, Home, NotFound } from '@/views'; 2 | import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'; 3 | 4 | const DESC_SUFFIX = 'description - length <= 160 chars.'; 5 | 6 | const routes: RouteRecordRaw[] = [ 7 | { 8 | path: '/', 9 | name: 'Home', 10 | component: Home, 11 | meta: { 12 | transition: 'fade', 13 | title: 'Home', 14 | description: `Home ${DESC_SUFFIX}` 15 | }, 16 | }, 17 | { 18 | path: '/about', 19 | name: 'About', 20 | component: About, 21 | meta: { 22 | transition: 'fade', 23 | title: 'About', 24 | description: `About ${DESC_SUFFIX}` 25 | }, 26 | }, 27 | { 28 | path: '/:pathMatch(.*)*', 29 | name: 'NotFound', 30 | component: NotFound 31 | } 32 | ]; 33 | 34 | function scrollBehavior() { 35 | return new Promise((resolve) => { 36 | setTimeout(() => { 37 | resolve({ left: 0, top: 0 }); 38 | }, 250); 39 | }); 40 | } 41 | 42 | // Create new instance of vue-router 43 | const router = createRouter({ 44 | routes, 45 | scrollBehavior, 46 | linkExactActiveClass: 'is-active', 47 | history: createWebHistory(process.env.BASE_URL) 48 | }); 49 | 50 | export default router; -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | declare module '*.vue' { 3 | import type { DefineComponent } from 'vue'; 4 | const component: DefineComponent<{}, {}, any>; 5 | export default component; 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/createSharedComposable.ts: -------------------------------------------------------------------------------- 1 | import { onScopeDispose, effectScope, type EffectScope } from 'vue'; 2 | 3 | /** 4 | * Make a composable function usable with multiple Vue instances. 5 | */ 6 | export function createSharedComposable any)>(composable: Fn): Fn { 7 | let subscribers = 0; 8 | let state: ReturnType | undefined; 9 | let scope: EffectScope | undefined; 10 | 11 | const dispose = () => { 12 | if (scope && --subscribers <= 0) { 13 | scope.stop(); 14 | state = scope = undefined; 15 | } 16 | }; 17 | 18 | return ((...args) => { 19 | subscribers++; 20 | if (!state) { 21 | scope = effectScope(true); 22 | state = scope.run(() => composable(...args)); 23 | } 24 | onScopeDispose(dispose); 25 | 26 | return state; 27 | }); 28 | } -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { createSharedComposable } from './createSharedComposable'; -------------------------------------------------------------------------------- /src/views/About.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | -------------------------------------------------------------------------------- /src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | -------------------------------------------------------------------------------- /src/views/NotFound.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | -------------------------------------------------------------------------------- /src/views/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Home } from './Home.vue'; 2 | export { default as About } from './About.vue'; 3 | export { default as NotFound } from './NotFound.vue'; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "noImplicitAny": false, 6 | "noImplicitThis": true, 7 | "jsx": "preserve", 8 | "moduleResolution": "node", 9 | "ignoreDeprecations": "5.0", 10 | "skipLibCheck": true, 11 | "importHelpers": true, 12 | "esModuleInterop": true, 13 | "preserveValueImports": true, 14 | "allowSyntheticDefaultImports": true, 15 | "sourceMap": true, 16 | "baseUrl": ".", 17 | "types": [ 18 | "webpack-env" 19 | ], 20 | "paths": { 21 | "@/*": [ 22 | "src/*" 23 | ] 24 | }, 25 | "lib": [ 26 | "ESNext", 27 | "dom", 28 | "dom.iterable" 29 | ] 30 | }, 31 | "include": [ 32 | "src/**/*.ts", 33 | "src/**/*.tsx", 34 | "src/**/*.vue", 35 | "tests/**/*.ts", 36 | "tests/**/*.tsx" 37 | ], 38 | "exclude": [ 39 | "node_modules" 40 | ] 41 | } -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const path = require("path"); 3 | const cheerio = require("cheerio"); 4 | const { defineConfig } = require("@vue/cli-service"); 5 | const PrerenderSPAPlugin = require("prerender-spa-plugin-next"); 6 | const PuppeteerRenderer = require("@prerenderer/renderer-puppeteer"); 7 | 8 | module.exports = defineConfig({ 9 | lintOnSave: false, 10 | 11 | // define port 12 | devServer: { 13 | // proxy: 'http://160.153.250.157:33000', // option A 14 | // host: 'http://localhost', // option B 15 | port: "3000", // option C - recommended 16 | hot: true, 17 | }, 18 | 19 | // https://github.com/vuejs/core/tree/main/packages/vue#bundler-build-feature-flags 20 | // Setting compiler flag __VUE_OPTIONS_API__ to false reduces bundle size 21 | chainWebpack: (config) => { 22 | config 23 | .plugin("feature-flags") 24 | .tap((args) => { 25 | const disable = JSON.stringify(false); 26 | args[0].__VUE_OPTIONS_API__ = disable; 27 | args[0].__VUE_PROD_DEVTOOLS__ = disable; 28 | 29 | return args; 30 | }); 31 | }, 32 | 33 | // https://cli.vuejs.org/guide/webpack.html 34 | configureWebpack: (config) => { 35 | if (process.env.NODE_ENV !== "production") { 36 | return {}; 37 | } 38 | 39 | return { 40 | performance: { 41 | hints: false, 42 | }, 43 | plugins: [ 44 | // https://github.com/chrisvfritz/prerender-spa-plugin 45 | new PrerenderSPAPlugin({ 46 | staticDir: config.output.path, 47 | routes: ["/", "/about"], 48 | renderer: new PuppeteerRenderer(), 49 | postProcess(context) { 50 | if (context.route === "/404") { 51 | context.outputPath = path.join(config.output.path, "/404.html"); 52 | } 53 | 54 | // Add 'data-server-rendered' attribute so app knows to hydrate with any changes 55 | const $ = cheerio.load(context.html); 56 | $("#app").attr("data-server-rendered", "true"); 57 | context.html = $.html(); 58 | 59 | return context; 60 | }, 61 | }), 62 | ], 63 | }; 64 | }, 65 | 66 | // https://github.com/vuejs/vue-cli/tree/dev/packages/@vue/cli-plugin-pwa 67 | pwa: { 68 | name: "VueSeoFriendlySpaTemplate", 69 | themeColor: "#67dea9", 70 | msTileColor: "#ffffff", 71 | workboxPluginMode: "GenerateSW", 72 | workboxOptions: { 73 | skipWaiting: true, 74 | clientsClaim: true, 75 | cacheId: "VueSeoSpa", 76 | exclude: [/_redirects/], 77 | navigateFallback: "/index.html", 78 | navigateFallbackAllowlist: [/^((?!\/404).)*$/], 79 | }, 80 | }, 81 | }); 82 | --------------------------------------------------------------------------------