├── .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 | 
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 |
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 |
50 |
51 |
52 | {{ `VueSeoFriendlySpa | ${content}` }}
53 |
54 |
55 |
56 |
57 |
61 |
62 |
63 |
64 |
65 |
66 |
--------------------------------------------------------------------------------
/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 |
22 |
23 |
27 |
31 |
32 |
36 |
37 | {{ title }}
38 |
39 |
40 |
41 | {{ subTitle }}
42 |
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/src/components/BackToTop.vue:
--------------------------------------------------------------------------------
1 |
22 |
23 |
24 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/src/components/Footer.vue:
--------------------------------------------------------------------------------
1 |
2 |
36 |
--------------------------------------------------------------------------------
/src/components/NavBar.vue:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
52 |
53 |
--------------------------------------------------------------------------------
/src/components/ToggleTheme.vue:
--------------------------------------------------------------------------------
1 |
19 |
20 |
21 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
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 |
2 |
18 |
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 |
9 |
15 |
--------------------------------------------------------------------------------
/src/views/Home.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
20 |
21 |
22 |
SEO Friendly SPA
23 |
24 |
29 |
30 | {{ description }}
31 |
32 | {{ package_name }}
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
45 |
{{ package_name }}
46 |
47 |
48 | {{ package_name }}
{{ description_1 }}
49 |
50 |
{{ description_2 }}
51 |
52 |
53 |
54 |
55 |
56 |
57 |
lorem ipsum
58 |
{{ LOREM_IPSUM_TEXT }}
59 |
60 |
61 |
62 |
63 |
--------------------------------------------------------------------------------
/src/views/NotFound.vue:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
24 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------