├── app ├── public │ ├── robots.txt │ ├── me.png │ ├── favicon.ico │ ├── social │ │ ├── home.png │ │ ├── how-vite-works.png │ │ ├── scale-your-vue-components.png │ │ └── vue-automatic-component-imports.png │ ├── icon.svg │ └── sitemap.xml ├── resources │ ├── me.png │ ├── app-chunk.png │ ├── brand-demo.png │ ├── nuxt-start.png │ ├── subscribers.png │ ├── vendor-chunk.png │ ├── forum-example.png │ ├── network-requests.png │ └── newsletter-example.png ├── subscribe │ └── index.md ├── .vitepress │ ├── types.ts │ ├── theme │ │ ├── NotFound.vue │ │ ├── styles │ │ │ ├── layout.css │ │ │ ├── custom-blocks.css │ │ │ ├── vars.css │ │ │ ├── main.scss │ │ │ └── code.css │ │ ├── components │ │ │ ├── NavBar.vue │ │ │ ├── Posts.vue │ │ │ ├── NavBarLink.vue │ │ │ ├── PostTags.vue │ │ │ ├── NavBarLinks.vue │ │ │ ├── CardPost.vue │ │ │ └── Newsletter.vue │ │ ├── index.ts │ │ ├── utils.ts │ │ └── Layout.vue │ ├── config.ts │ └── posts.ts ├── components.d.ts ├── vite.config.ts ├── index.md ├── windi.config.ts ├── open-blogging │ └── index.md ├── about │ └── index.md ├── contact │ └── index.md ├── auto-imports.d.ts └── blog │ ├── building-unlighthouse │ └── index.md │ ├── scale-your-vue-components │ └── index.md │ ├── vue-automatic-component-imports │ └── index.md │ └── how-the-heck-does-vite-work │ └── index.md ├── .env ├── .gitignore ├── scripts └── deploy.sh ├── tsconfig.json ├── package.json ├── LICENSE.md ├── README.md ├── tools ├── generate-feed.js └── get-posts.js └── .github └── workflows └── deploy.yml /app/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: -------------------------------------------------------------------------------- /app/public/me.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harlan-zw/harlanzw.com-vitepress/HEAD/app/public/me.png -------------------------------------------------------------------------------- /app/resources/me.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harlan-zw/harlanzw.com-vitepress/HEAD/app/resources/me.png -------------------------------------------------------------------------------- /app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harlan-zw/harlanzw.com-vitepress/HEAD/app/public/favicon.ico -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | VITE_PANELBEAR_ID=9EWSXgG8txm 2 | VITE_NEWSLETTER_SUBMIT_URL=https://hooks.zapier.com/hooks/catch/3750603/ocopz6c -------------------------------------------------------------------------------- /app/public/social/home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harlan-zw/harlanzw.com-vitepress/HEAD/app/public/social/home.png -------------------------------------------------------------------------------- /app/resources/app-chunk.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harlan-zw/harlanzw.com-vitepress/HEAD/app/resources/app-chunk.png -------------------------------------------------------------------------------- /app/resources/brand-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harlan-zw/harlanzw.com-vitepress/HEAD/app/resources/brand-demo.png -------------------------------------------------------------------------------- /app/resources/nuxt-start.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harlan-zw/harlanzw.com-vitepress/HEAD/app/resources/nuxt-start.png -------------------------------------------------------------------------------- /app/resources/subscribers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harlan-zw/harlanzw.com-vitepress/HEAD/app/resources/subscribers.png -------------------------------------------------------------------------------- /app/resources/vendor-chunk.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harlan-zw/harlanzw.com-vitepress/HEAD/app/resources/vendor-chunk.png -------------------------------------------------------------------------------- /app/resources/forum-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harlan-zw/harlanzw.com-vitepress/HEAD/app/resources/forum-example.png -------------------------------------------------------------------------------- /app/resources/network-requests.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harlan-zw/harlanzw.com-vitepress/HEAD/app/resources/network-requests.png -------------------------------------------------------------------------------- /app/public/social/how-vite-works.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harlan-zw/harlanzw.com-vitepress/HEAD/app/public/social/how-vite-works.png -------------------------------------------------------------------------------- /app/resources/newsletter-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harlan-zw/harlanzw.com-vitepress/HEAD/app/resources/newsletter-example.png -------------------------------------------------------------------------------- /app/public/social/scale-your-vue-components.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harlan-zw/harlanzw.com-vitepress/HEAD/app/public/social/scale-your-vue-components.png -------------------------------------------------------------------------------- /app/public/social/vue-automatic-component-imports.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harlan-zw/harlanzw.com-vitepress/HEAD/app/public/social/vue-automatic-component-imports.png -------------------------------------------------------------------------------- /app/subscribe/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Subscribe to the newsletter 3 | --- 4 | 8 | 9 | # {{ page.title }} 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/.vitepress/types.ts: -------------------------------------------------------------------------------- 1 | export type Post = { 2 | url: string 3 | title: string 4 | publishDate: string 5 | date: string 6 | excerpt: string 7 | status: string 8 | readMins: number 9 | tags: Array 10 | link?: boolean 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /public/hot 3 | /public/storage 4 | /storage/*.key 5 | /vendor 6 | .idea 7 | /.vscode 8 | /.vagrant 9 | 10 | Homestead.json 11 | Homestead.yaml 12 | npm-debug.log 13 | yarn-error.log 14 | 15 | .*.local 16 | 17 | .phpstorm* 18 | _ide_helper.php 19 | 20 | .nuxt 21 | node_modules 22 | dist 23 | -------------------------------------------------------------------------------- /app/public/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | yarn build 4 | 5 | aws s3 cp app/.vitepress/dist s3://new.harlanzw.com --profile=hzw --recursive --acl=public-read --include="*" --exclude "*.html" --cache-control max-age=31536000,public 6 | aws s3 cp app/.vitepress/dist s3://new.harlanzw.com --profile=hzw --recursive --acl=public-read --exclude="*" --include "*.html" --cache-control max-age=0,no-cache,no-store,must-revalidate 7 | 8 | aws cloudfront create-invalidation --profile=hzw --distribution-id=EMBZGGZKSX2IY --paths '/*' 9 | -------------------------------------------------------------------------------- /app/components.d.ts: -------------------------------------------------------------------------------- 1 | // generated by unplugin-vue-components 2 | // We suggest you to commit this file into source control 3 | // Read more: https://github.com/vuejs/vue-next/pull/3399 4 | import '@vue/runtime-core' 5 | 6 | declare module '@vue/runtime-core' { 7 | export interface GlobalComponents { 8 | RouterLink: typeof import('vue-router')['RouterLink'] 9 | RouterView: typeof import('vue-router')['RouterView'] 10 | SchemaOrgArticle: typeof import('@vueuse/schema-org/components')['SchemaOrgArticle'] 11 | } 12 | } 13 | 14 | export {} 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "module": "ESNext", 5 | "target": "es2017", 6 | "lib": ["DOM", "ESNext"], 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "incremental": false, 10 | "skipLibCheck": true, 11 | "moduleResolution": "node", 12 | "resolveJsonModule": true, 13 | "noUnusedLocals": true, 14 | "strictNullChecks": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "types": [ 17 | "vue", 18 | "vite/client", 19 | "vite-plugin-pages/client", 20 | ], 21 | }, 22 | "exclude": ["dist", "node_modules"] 23 | } 24 | -------------------------------------------------------------------------------- /app/.vitepress/theme/NotFound.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 29 | -------------------------------------------------------------------------------- /app/.vitepress/config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from 'vitepress' 2 | 3 | export default defineConfig({ 4 | title: 'Harlan Wilton', 5 | description: 'Hey 👋 I\'m building Laravel & Vue projects and would like to share my journey with you.', 6 | head: [ 7 | ['link', { rel: 'stylesheet', href: '//fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Dosis:wght@300;400;500;600;700&display=swap' }], 8 | ['link', { rel: 'alternative', type: 'application/rss+xml', href: '/feed.rss', title: 'RSS Feed for harlanzw.com' }] 9 | ], 10 | themeConfig: { 11 | logo: '/icon.svg', 12 | nav: [ 13 | { text: 'Home', link: '/' }, 14 | { text: 'About', link: '/about/' }, 15 | { text: 'Contact', link: '/contact/' } 16 | ], 17 | }, 18 | }) 19 | -------------------------------------------------------------------------------- /app/.vitepress/theme/styles/layout.css: -------------------------------------------------------------------------------- 1 | h1:hover .header-anchor, 2 | h1:focus .header-anchor, 3 | h2:hover .header-anchor, 4 | h2:focus .header-anchor, 5 | h3:hover .header-anchor, 6 | h3:focus .header-anchor, 7 | h4:hover .header-anchor, 8 | h4:focus .header-anchor, 9 | h5:hover .header-anchor, 10 | h5:focus .header-anchor, 11 | h6:hover .header-anchor, 12 | h6:focus .header-anchor { 13 | opacity: 1; 14 | } 15 | 16 | h1, h2, h3, h4, h5 { 17 | position: relative; 18 | } 19 | a.header-anchor { 20 | position: absolute; 21 | left: -50px; 22 | top: 50%; 23 | transform: translateY(-50%); 24 | width: 100%; 25 | font-size: 0.85em !important; 26 | opacity: 0; 27 | transition: all 0.2s ease 0s; 28 | } 29 | 30 | a.header-anchor, 31 | a.header-anchor:hover, 32 | a.header-anchor:focus { 33 | text-decoration: none !important; 34 | } 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "new.harlanzw.com", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "devDependencies": { 7 | "feed": "^4.2.2", 8 | "globby": "^11.0.2", 9 | "gray-matter": "^4.0.2", 10 | "sass": "^1.51.0", 11 | "vite-plugin-windicss": "^1.8.4", 12 | "vitepress": "^0.22.3", 13 | "windicss": "^3.5.1", 14 | "zooming": "^2.1.1" 15 | }, 16 | "scripts": { 17 | "dev": "export NODE_ENV=development; vitepress dev app", 18 | "build": "export NODE_ENV=production; vitepress build app && node ./tools/generate-feed.js", 19 | "serve": "vitepress serve app" 20 | }, 21 | "dependencies": { 22 | "@panelbear/panelbear-js": "^1.2.0", 23 | "@unlighthouse/vite": "^0.3.26", 24 | "@vueuse/head": "^0.7.6", 25 | "@vueuse/schema-org": "^0.5.0", 26 | "@vueuse/schema-org-vite": "^0.5.0", 27 | "lodash": "^4.17.20", 28 | "unplugin-auto-import": "^0.7.1", 29 | "unplugin-icons": "^0.14.3", 30 | "unplugin-vue-components": "^0.19.3", 31 | "vite": "^2.9.8", 32 | "vue": "^3.2.33" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Harlan Wilton 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # harlanzw.com 2 | 3 | ![](https://github.com/loonpwn/harlanzw.com/workflows/Deploy/badge.svg) 4 | 5 | This repo is the project for my personal website. It's built using VitePress (Vite + Vue3) with a custom templat using TailwindCSS. 6 | 7 | Feel free to clone it if you'd like to setup your own personal site. 8 | 9 | Setup 10 | ------------- 11 | 12 | #### **Environment** 13 | 14 | Recommended: 15 | - [node - latest](https://nodejs.org/en/) 16 | - [yarn - latest](https://yarnpkg.com/) 17 | 18 | #### **Instructions** 19 | 20 | 1. Install deps `yarn` 21 | 2. Run app `yarn dev` 22 | 23 | 24 | Usage 25 | ------------- 26 | 27 | #### **Deployment** 28 | 29 | Deployment is only setup for S3+Cloudfront. 30 | 31 | #### GitHub Deployment 32 | 33 | If that is the case, you can go into your Github repo settings and setup the keys: 34 | - `AWS_ACCESS_KEY_ID` 35 | - `AWS_SECRET_ACCESS_KEY` 36 | 37 | On master branch pushes, it will deploy the website. 38 | 39 | Note: The IAM will need permission to the pushing to the s3 bucket and cloudfront cache invalidation. 40 | 41 | 42 | #### Manual Deployment 43 | 44 | If you want to deploy outside of Github repo you can run `./scripts/deploy.sh` 45 | -------------------------------------------------------------------------------- /tools/generate-feed.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const { Feed } = require('feed') 4 | const { getPosts } = require('./get-posts') 5 | 6 | const dist = path.resolve(__dirname, `../app/.vitepress/dist`) 7 | const url = `https://harlanzw.com` 8 | 9 | const feed = new Feed({ 10 | title: 'Laravel & Vue Sydney Developer | Harlan Wilton', 11 | description: 'Hey I'm building Laravel & Vue projects and would like to share my journey with you.', 12 | id: url, 13 | link: url, 14 | language: 'en', 15 | image: 'https://harlanzw.com/social/home.png', 16 | favicon: `${url}/favicon.ico`, 17 | copyright: 18 | 'Copyright (c) 2021-present, Harlan Wilton' 19 | }) 20 | 21 | getPosts().forEach((post) => { 22 | const file = path.join(dist, path.join(post.url, '/index.html')) 23 | const rendered = fs.readFileSync(file, 'utf-8') 24 | 25 | const content = rendered.match( 26 | /([\s\S]*)<\/div>/m 27 | ) 28 | feed.addItem({ 29 | title: post.title, 30 | id: `${url}${post.url}`, 31 | link: `${url}${post.url}`, 32 | description: post.excerpt, 33 | content: content[1], 34 | image: post.image, 35 | author: [ 36 | { 37 | name: 'Harlan Wilton', 38 | link: `https://twitter.com/harlan_zw` 39 | } 40 | ], 41 | date: post.published 42 | }) 43 | }) 44 | 45 | fs.writeFileSync(path.join(dist, 'feed.rss'), feed.rss2()) 46 | -------------------------------------------------------------------------------- /app/.vitepress/theme/styles/custom-blocks.css: -------------------------------------------------------------------------------- 1 | .custom-block.tip, 2 | .custom-block.warning, 3 | .custom-block.danger { 4 | margin: 1rem 0; 5 | border-left: .5rem solid; 6 | padding: .1rem 1.5rem; 7 | } 8 | 9 | .custom-block.tip { 10 | background-color: #f3f5f7; 11 | border-color: #42b983; 12 | } 13 | 14 | .custom-block.warning { 15 | border-color: #e7c000; 16 | color: #6b5900; 17 | background-color: rgba(255, 229, 100, .3); 18 | } 19 | 20 | .custom-block.warning .custom-block-title { 21 | color: #b29400; 22 | } 23 | 24 | .custom-block.warning a { 25 | color: var(--c-text); 26 | } 27 | 28 | .custom-block.danger { 29 | border-color: #c00; 30 | color: #4d0000; 31 | background-color: #ffe6e6; 32 | } 33 | 34 | .custom-block.danger .custom-block-title { 35 | color: #900; 36 | } 37 | 38 | .custom-block.danger a { 39 | color: var(--c-text); 40 | } 41 | 42 | .custom-block.details { 43 | position: relative; 44 | display: block; 45 | border-radius: 2px; 46 | margin: 1.6em 0; 47 | padding: 1.6em; 48 | background-color: #eee; 49 | } 50 | 51 | .custom-block.details h4 { 52 | margin-top: 0; 53 | } 54 | 55 | .custom-block.details figure:last-child, 56 | .custom-block.details p:last-child { 57 | margin-bottom: 0; 58 | padding-bottom: 0; 59 | } 60 | 61 | .custom-block.details summary { 62 | outline: none; 63 | cursor: pointer; 64 | } 65 | 66 | .custom-block-title { 67 | margin-bottom: -.4rem; 68 | font-weight: 600; 69 | } 70 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@master 13 | - name: Configure AWS Credentials 14 | uses: aws-actions/configure-aws-credentials@v1 15 | with: 16 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 17 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 18 | aws-region: ap-southeast-2 19 | 20 | - name: Install pnpm 21 | uses: pnpm/action-setup@v2.0.1 22 | with: 23 | version: 6.15.1 24 | 25 | - name: Use Node.js 16.x 26 | uses: actions/setup-node@v2 27 | with: 28 | node-version: '16.x' 29 | registry-url: https://registry.npmjs.org/ 30 | cache: "pnpm" 31 | 32 | - run: pnpm install 33 | - run: pnpm build 34 | - name: Deploy Non-HTML 35 | run: aws s3 cp app/.vitepress/dist s3://new.harlanzw.com --recursive --acl=public-read --include="*" --exclude "*.html" --cache-control max-age=31536000,public 36 | - name: Deploy HTML 37 | run: aws s3 cp app/.vitepress/dist s3://new.harlanzw.com --recursive --acl=public-read --exclude="*" --include "*.html" --cache-control max-age=3200,no-cache,no-store,must-revalidate 38 | - name: Invalidate Cloudfront 39 | run: aws cloudfront create-invalidation --distribution-id=EMBZGGZKSX2IY --paths '/*' 40 | -------------------------------------------------------------------------------- /app/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import Components from 'unplugin-vue-components/vite' 3 | import WindiCSS from 'vite-plugin-windicss' 4 | import IconsResolver from 'unplugin-icons/resolver' 5 | import Icons from 'unplugin-icons/vite' 6 | import { join } from 'path' 7 | import { SchemaOrgResolver, schemaOrgAutoImports } from '@vueuse/schema-org-vite' 8 | import AutoImport from 'unplugin-auto-import/vite' 9 | 10 | export default defineConfig(async({ command }) => { 11 | const plugins = [] 12 | 13 | return { 14 | plugins: [ 15 | Components({ 16 | include: [/\.vue$/, /\.vue\?vue/, /\.md$/], 17 | dts: true, 18 | resolvers: [ 19 | SchemaOrgResolver(), 20 | IconsResolver(), 21 | ], 22 | }), 23 | AutoImport({ 24 | include: [ 25 | /\.[tj]sx?$/, // .ts, .tsx, .js, .jsx 26 | /\.vue$/, /\.vue\?vue/, // .vue 27 | /\.md$/, // .md 28 | ], 29 | imports: [ 30 | 'vue', 31 | schemaOrgAutoImports, 32 | ], 33 | dts: 'auto-imports.d.ts', 34 | }), 35 | Icons(), 36 | WindiCSS({ 37 | scan: { 38 | dirs: [ 39 | __dirname, 40 | join(__dirname, '.vitepress', 'theme'), 41 | join(__dirname, '.vitepress', 'theme', 'components'), 42 | ], 43 | }, 44 | }), 45 | ...plugins, 46 | ], 47 | 48 | optimizeDeps: { 49 | include: [ 50 | 'vue', 51 | ], 52 | }, 53 | } 54 | }) 55 | -------------------------------------------------------------------------------- /app/.vitepress/theme/components/NavBar.vue: -------------------------------------------------------------------------------- 1 | 17 | 35 | 36 | 37 | 53 | -------------------------------------------------------------------------------- /app/.vitepress/theme/components/Posts.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 27 | 28 | 73 | -------------------------------------------------------------------------------- /app/.vitepress/theme/styles/vars.css: -------------------------------------------------------------------------------- 1 | /** Base Styles */ 2 | :root { 3 | 4 | /** 5 | * Colors 6 | * --------------------------------------------------------------------- */ 7 | 8 | --c-black: #000000; 9 | 10 | --c-divider-light: rgba(60, 60, 67, .12); 11 | --c-divider-dark: rgba(84, 84, 88, .48); 12 | 13 | --c-text-light-1: #2c3e50; 14 | --c-text-light-2: #476582; 15 | 16 | --c-brand: #3eaf7c; 17 | 18 | /** 19 | * Typography 20 | * --------------------------------------------------------------------- */ 21 | 22 | --font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; 23 | --font-family-mono: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; 24 | 25 | /** 26 | * Z Indexes 27 | * --------------------------------------------------------------------- */ 28 | 29 | --z-index-navbar: 1100; 30 | --z-index-sidebar: 1000; 31 | 32 | /** 33 | * Sizes 34 | * --------------------------------------------------------------------- */ 35 | 36 | --header-height: 3.6rem; 37 | } 38 | 39 | /** Fallback Styles */ 40 | :root { 41 | 42 | --c-divider: var(--c-divider-light); 43 | 44 | --c-text: var(--c-text-light-1); 45 | --c-text-light: var(--c-text-light-2); 46 | 47 | --c-bg: var(--c-white); 48 | 49 | --code-line-height: 24px; 50 | --code-font-family: var(--font-family-mono); 51 | --code-font-size: 14px; 52 | --code-inline-bg-color: rgba(27, 31, 35, .05); 53 | --code-bg-color: #282c34; 54 | } 55 | -------------------------------------------------------------------------------- /app/.vitepress/theme/index.ts: -------------------------------------------------------------------------------- 1 | import 'windi.css' 2 | import './styles/main.scss'; 3 | import './styles/vars.css'; 4 | import './styles/layout.css'; 5 | import './styles/code.css'; 6 | import './styles/custom-blocks.css'; 7 | import Layout from './Layout.vue'; 8 | import NotFound from './NotFound.vue'; 9 | import CardPost from './components/CardPost.vue'; 10 | import Posts from './components/Posts.vue' 11 | import Newsletter from './components/Newsletter.vue' 12 | import Zooming from 'zooming' 13 | import * as Panelbear from '@panelbear/panelbear-js' 14 | import DefaultTheme from 'vitepress/theme' 15 | import { installSchemaOrg } from '@vueuse/schema-org-vite/vitepress' 16 | 17 | const theme = DefaultTheme 18 | 19 | theme.Layout = Layout 20 | theme.NotFound = NotFound 21 | theme.enhanceApp = (ctx) => { 22 | const { app } = ctx 23 | app.component('Newsletter', Newsletter) 24 | app.component('CardPost', CardPost) 25 | app.component('Posts', Posts) 26 | 27 | installSchemaOrg(ctx, { 28 | // set to your production domain 29 | canonicalHost: 'https://harlanzw.com', 30 | // change to your default language 31 | defaultLanguage: 'en-AU', 32 | }) 33 | 34 | // if we're in a server context then we exit out here 35 | if (typeof document === 'undefined' || typeof window === 'undefined') { 36 | return 37 | } 38 | 39 | const zooming = new Zooming() 40 | zooming.config({ 41 | scaleBase: 0.75, 42 | bgOpacity: 0, 43 | }) 44 | app.provide('zoom', zooming) 45 | 46 | // analytics 47 | app.provide('analytics', Panelbear) 48 | Panelbear.load('9EWSXgG8txm', { 49 | spaMode: 'history', 50 | autoTrack: true, 51 | debug: false, //import.meta.env.DEV 52 | }) 53 | } 54 | 55 | export default DefaultTheme 56 | -------------------------------------------------------------------------------- /app/.vitepress/theme/styles/main.scss: -------------------------------------------------------------------------------- 1 | a:not(.unstyled):not(.header-anchor) { 2 | position: relative; 3 | overflow: hidden; 4 | 5 | &:before { 6 | position: absolute; 7 | height: 3px; 8 | content: ' '; 9 | display: block; 10 | background-color: rgb(5, 150, 105); 11 | width: 12px; 12 | opacity: 0; 13 | right: 0; 14 | top: 1.4em; 15 | transition: 0.2s; 16 | border-top-right-radius: 3px; 17 | border-bottom-right-radius: 3px; 18 | overflow: hidden; 19 | } 20 | 21 | &:after { 22 | position: absolute; 23 | height: 3px; 24 | content: ' '; 25 | display: block; 26 | background-color: rgb(5, 150, 105); 27 | width: 12px; 28 | opacity: 0; 29 | left: 0; 30 | top: 1.4em; 31 | transition: 0.4s; 32 | border-top-left-radius: 3px; 33 | border-bottom-left-radius: 3px; 34 | overflow: hidden; 35 | } 36 | 37 | &:hover, &.active { 38 | text-decoration: none !important; 39 | &:after { 40 | opacity: 1; 41 | width: 50%; 42 | } 43 | &:before { 44 | opacity: 1; 45 | width: 50%; 46 | } 47 | } 48 | } 49 | 50 | .prose { 51 | pre { 52 | max-height: 85vh; 53 | } 54 | a { 55 | color: rgb(7, 130, 97); 56 | font-weight: normal; 57 | text-decoration: none !important; 58 | } 59 | iframe { 60 | width: 100%; 61 | height: auto; 62 | min-height: 400px; 63 | } 64 | p > code, li > code { 65 | padding: .25rem 0.5rem; 66 | font-size: 1.125rem; 67 | border-radius: .125rem; 68 | background-image: linear-gradient(rgb(174, 239, 221), rgb(240 245 242)); 69 | overflow-wrap: anywhere; 70 | word-break: break-word; 71 | font-weight: normal; 72 | &:before { 73 | display: none; 74 | } 75 | &:after { 76 | display: none; 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /app/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Laravel & Vue Sydney Developer 3 | description: "Harlan Wilton is building Laravel & Vue projects and would like to share his journey with you." 4 | head: 5 | - - meta 6 | - name: description 7 | content: "Harlan Wilton is building Laravel & Vue projects and would like to share his journey with you." 8 | - - meta 9 | - property: "og:type" 10 | content: "website" 11 | - - meta 12 | - property: "og:url" 13 | content: "https://harlanzw.com/" 14 | - - meta 15 | - property: "og:title" 16 | content: "Laravel & Vue Sydney Developer" 17 | - - meta 18 | - property: "og:description" 19 | content: "Harlan Wilton is building Laravel & Vue projects and would like to share his journey with you." 20 | - - meta 21 | - property: "og:image" 22 | content: "https://harlanzw.com/social/home.png" 23 | - - meta 24 | - property: "twitter:card" 25 | content: "summary_large_image" 26 | - - meta 27 | - property: "twitter:url" 28 | content: "https://harlanzw.com/" 29 | - - meta 30 | - property: "twitter:title" 31 | content: "Laravel & Vue Sydney Developer" 32 | - - meta 33 | - property: "twitter:description" 34 | content: "Harlan Wilton is building Laravel & Vue projects and would like to share his journey with you." 35 | - - meta 36 | - property: "twitter:image" 37 | content: "https://harlanzw.com/social/home.png" 38 | --- 39 | 40 |

Harlan WiltonHey, I'm Harlan 👋

41 | 42 | 43 | I'm self-employed, working on my personal and professional growth. Actively trying to build new things with Laravel and Vue. Sharing my journey and learnings along the way. 44 | 45 | ## Blog 46 | 47 | -------------------------------------------------------------------------------- /app/public/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | https://harlanzw.com/ 4 | 2020-11-30T05:04:08+00:00 5 | 1.00 6 | 7 | 8 | https://harlanzw.com/about/ 9 | 2020-11-30T05:04:08+00:00 10 | 0.80 11 | 12 | 13 | https://harlanzw.com/open-blogging/ 14 | 2021-01-10T05:04:08+00:00 15 | 0.80 16 | 17 | 18 | https://harlanzw.com/subscribe/ 19 | 2021-01-25T05:04:08+00:00 20 | 0.80 21 | 22 | 23 | https://harlanzw.com/contact/ 24 | 2020-11-30T05:04:08+00:00 25 | 0.80 26 | 27 | 28 | https://harlanzw.com/blog/scale-your-vue-components/ 29 | 2021-01-12T05:04:08+00:00 30 | 0.80 31 | 32 | 33 | https://harlanzw.com/blog/building-unlighthouse/ 34 | 2022-02-28T05:04:08+00:00 35 | 0.80 36 | 37 | 38 | https://harlanzw.com/blog/how-the-heck-does-vite-work/ 39 | 2020-12-22T05:04:08+00:00 40 | 0.80 41 | 42 | 43 | https://harlanzw.com/blog/vue-automatic-component-imports/ 44 | 2020-12-22T05:04:08+00:00 45 | 0.80 46 | 47 | 48 | -------------------------------------------------------------------------------- /app/.vitepress/theme/components/NavBarLink.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 77 | 78 | 83 | -------------------------------------------------------------------------------- /app/.vitepress/posts.ts: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | url: '/blog/building-unlighthouse/', 4 | title: 'Building Unlighthouse: Open-Source Package For Site-wide Google Lighthouse scans', 5 | publishDate: '28 Feb 2022', 6 | date: '2022-02-28', 7 | excerpt: 'Going into detail of what goes into making a modern open-source package.', 8 | status: 'published', 9 | readMins: 6, 10 | tags: ['vue'] 11 | }, 12 | { 13 | url: '/blog/scale-your-vue-components/', 14 | title: 'Scaling Your Vue Components for Mid-Large Size Apps', 15 | publishDate: '12th Jan 2021', 16 | date: '2021-01-12', 17 | excerpt: 'Working on a mid-large size app usually means hundreds of components. How do you make sure these components will scale?', 18 | status: 'published', 19 | readMins: 8, 20 | tags: ['vue'] 21 | }, 22 | { 23 | url: '/blog/vue-automatic-component-imports/', 24 | title: 'Building a Vue Auto Component Importer - A Better Dev Experience', 25 | publishDate: '22nd Dec 2020', 26 | date: '2020-12-22', 27 | excerpt: 'Having component folders \'auto-magically\' imported into your app is the latest craze. How does it work and is it good?', 28 | status: 'published', 29 | readMins: 10, 30 | tags: ['webpack', 'vue'] 31 | }, 32 | { 33 | url: 'https://github.com/loonpwn/vue-cli-plugin-import-components', 34 | link: true, 35 | publishDate: '12th Dec 2020', 36 | date: '2020-12-12', 37 | status: 'published', 38 | title: 'Vue-CLI Plugin: Import Components', 39 | excerpt: 'I created a Vue-CLI plugin to automatically import your components in your Vue CLI app with tree shaking, supporting Vue 2 and 3.', 40 | tags: ['vue', 'github'] 41 | }, 42 | { 43 | url: '/blog/how-the-heck-does-vite-work/', 44 | title: 'How Does Vite Work - A Comparison to Webpack', 45 | publishDate: '1st Dec 2020', 46 | date: '2020-12-01', 47 | excerpt: 'I used Vite to build a new blazing fast blog ⚡, find out what I learnt and why Vite is the next big thing.', 48 | status: 'published', 49 | readMins: 10, 50 | tags: ['webpack', 'vue'] 51 | }, 52 | ] 53 | -------------------------------------------------------------------------------- /app/windi.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'windicss/helpers' 2 | 3 | export default defineConfig({ 4 | plugins: [ 5 | require('windicss/plugin/forms'), 6 | require('windicss/plugin/typography'), 7 | ], 8 | theme: { 9 | extend: { 10 | animation: { 11 | fadeIn: 'fadeIn 200ms ease-in forwards' 12 | }, 13 | keyframes: { 14 | fadeIn: { 15 | '0%': { opacity: 0 }, 16 | '100%': { opacity: 1 } 17 | } 18 | }, 19 | fontFamily: { 20 | sans: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"', 21 | header: 'Dosis, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"', 22 | }, 23 | typography: (theme) => ({ 24 | DEFAULT: { 25 | css: { 26 | a: { 27 | textDecoration: 'none' 28 | }, 29 | h1: { 30 | color: 'rgb(55, 70, 60)', 31 | fontWeight: '600', 32 | fontFamily: theme('fontFamily.header'), 33 | lineHeight: 1.3 34 | }, 35 | h2: { 36 | color: 'rgb(55, 70, 60)', 37 | fontFamily: theme('fontFamily.header'), 38 | }, 39 | h3: { 40 | color: 'rgb(55, 70, 60)', 41 | fontFamily: theme('fontFamily.header'), 42 | }, 43 | h4: { 44 | color: 'rgb(55, 70, 60)', 45 | fontWeight: '600', 46 | fontSize: '1.5rem', 47 | lineHeight: '2rem', 48 | fontFamily: theme('fontFamily.header'), 49 | textDecoration: 'underline' 50 | }, 51 | a: { 52 | textDecoration: 'initial', 53 | }, 54 | blockquote: { 55 | fontWeight: '400', 56 | } 57 | }, 58 | }, 59 | xl: { 60 | css: { 61 | h1: { 62 | lineHeight: 1.3 63 | } 64 | } 65 | } 66 | }), 67 | }, 68 | }, 69 | }) 70 | -------------------------------------------------------------------------------- /app/.vitepress/theme/utils.ts: -------------------------------------------------------------------------------- 1 | import { Post } from '../types' 2 | import allPosts from '../posts' 3 | 4 | export const hashRE = /#.*$/ 5 | export const extRE = /(index)?\.(md|html)$/ 6 | export const outboundRE = /^[a-z]+:/i 7 | 8 | export function isExternal(path: string): boolean { 9 | return outboundRE.test(path) 10 | } 11 | 12 | export function isActive(route, path?: string): boolean { 13 | if (path === undefined) { 14 | return false 15 | } 16 | 17 | const routePath = normalize(route.path) 18 | const pagePath = normalize(path) 19 | 20 | return routePath === pagePath 21 | } 22 | 23 | export function normalize(path: string): string { 24 | return decodeURI(path).replace(hashRE, '').replace(extRE, '') 25 | } 26 | 27 | export function joinUrl(base: string, path: string): string { 28 | const baseEndsWithSlash = base.endsWith('/') 29 | const pathStartsWithSlash = path.startsWith('/') 30 | 31 | if (baseEndsWithSlash && pathStartsWithSlash) { 32 | return base.slice(0, -1) + path 33 | } 34 | 35 | if (!baseEndsWithSlash && !pathStartsWithSlash) { 36 | return `${base}/${path}` 37 | } 38 | 39 | return base + path 40 | } 41 | 42 | /** 43 | * get the path without filename (the last segment). for example, if the given 44 | * path is `/guide/getting-started.html`, this method will return `/guide/`. 45 | * Always with a trailing slash. 46 | */ 47 | export function getPathDirName(path: string): string { 48 | const segments = path.split('/') 49 | 50 | if (segments[segments.length - 1]) { 51 | segments.pop() 52 | } 53 | 54 | return ensureEndingSlash(segments.join('/')) 55 | } 56 | 57 | export function ensureSlash(path: string): string { 58 | return ensureEndingSlash(ensureStartingSlash(path)) 59 | } 60 | 61 | export function ensureStartingSlash(path: string): string { 62 | return /^\//.test(path) ? path : `/${path}` 63 | } 64 | 65 | export function ensureEndingSlash(path: string): string { 66 | return /(\.html|\/)$/.test(path) ? path : `${path}/` 67 | } 68 | 69 | /** 70 | * Remove `.md` or `.html` extention from the given path. It also converts 71 | * `index` to slush. 72 | */ 73 | export function removeExtention(path: string): string { 74 | return path.replace(/(index)?(\.(md|html))?$/, '') || '/' 75 | } 76 | 77 | export function postForPath (path: string) : Post { 78 | path = removeExtention(path) 79 | return allPosts.filter(p => p.url === path)[0] 80 | } 81 | -------------------------------------------------------------------------------- /app/open-blogging/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Open Blogging" 3 | description: "I'm practicing 'Open Blogging'. I'll be sharing all of my code and data, including analytics and newsletter subscriber counts." 4 | head: 5 | - - meta 6 | - name: description 7 | content: "I'm practicing 'Open Blogging'. I'll be sharing all of my code and data, including analytics and newsletter subscriber counts." 8 | - - meta 9 | - property: "og:type" 10 | content: "website" 11 | - - meta 12 | - property: "og:url" 13 | content: "https://harlanzw.com/open-blogging/" 14 | - - meta 15 | - property: "og:title" 16 | content: "Open Blogging" 17 | - - meta 18 | - property: "og:description" 19 | content: "I'm practicing 'Open Blogging'. I'll be sharing all of my code and data, including analytics and newsletter subscriber counts." 20 | - - meta 21 | - property: "og:image" 22 | content: "https://harlanzw.com/social/home.png" 23 | - - meta 24 | - property: "twitter:card" 25 | content: "summary_large_image" 26 | - - meta 27 | - property: "twitter:url" 28 | content: "https://harlanzw.com/open-blogging/" 29 | - - meta 30 | - property: "twitter:title" 31 | content: "Open Blogging" 32 | - - meta 33 | - property: "twitter:description" 34 | content: "I'm practicing 'Open Blogging'. I'll be sharing all of my code and data, including analytics and newsletter subscriber counts." 35 | - - meta 36 | - property: "twitter:image" 37 | content: "https://harlanzw.com/social/home.png" 38 | --- 39 | 40 | # Open Blogging 41 | 42 | I'm practicing "Open Blogging". I'll be sharing all of my code and data, including analytics and newsletter subscriber counts. 43 | 44 | ## Articles 45 | 46 | - 3 Articles published 47 | - 28 minutes 48 | - 7341 words 49 | 50 | ## Code 51 | 52 | ### Blog 53 | 54 | Code is open source under the MIT license. See on Github: [harlanzw.com](https://github.com/loonpwn/harlanzw.com) 55 | 56 | ### Open Source Projects 57 | 58 | - https://github.com/loonpwn/vue-cli-plugin-import-components 59 | 60 | ## Analytics 61 | 62 | Powered by Panelbear. 63 | 64 | [Open Panelbear](https://app.panelbear.com/share/4fDa4dnKGsyhrVn2JdgP48/) 65 | 66 | ## Newsletter 67 | 68 | Powered by EmailOctopus. 69 | 70 | [Open EmailOctopus - Referral Link](https://emailoctopus.com/?urli=Lz6tl) 71 | 72 |
73 | Email Subscribers 74 |
EmailOctopus Subscribers
75 |
76 | -------------------------------------------------------------------------------- /app/.vitepress/theme/components/PostTags.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 33 | -------------------------------------------------------------------------------- /app/.vitepress/theme/components/NavBarLinks.vue: -------------------------------------------------------------------------------- 1 | 34 | 64 | 65 | 66 | 74 | -------------------------------------------------------------------------------- /app/.vitepress/theme/components/CardPost.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 45 | 46 | 91 | -------------------------------------------------------------------------------- /app/about/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: About 3 | description: "My name's Harlan Wilton. I am a full stack developer living and working out of Sydney, Australia." 4 | head: 5 | - - meta 6 | - name: description 7 | content: "My name's Harlan Wilton. I am a full stack developer living and working out of Sydney, Australia." 8 | --- 9 | 19 | 20 | # {{ page.title }} 21 | 22 | Hey! My name is Harlan Wilton. Thank you for checking out my site 😊 23 | 24 | ## Overview 25 | 26 | ### 👨‍💻 Working full time for myself 27 | 28 | Starting December 2020, I have moved to working for myself full time. 29 | 30 | Whilst working professionally full time for the last few years I have felt a consistent hum of burn out. 31 | 32 | There was an underlying tension from not having the time to work on what I wanted to work on. A tension from the stacks 33 | of unanswered emails from my freelancing client that I haven't had the time to answer. Feeling guilty for taking time off 34 | when I needed it. 35 | 36 | I have no idea how long I can last, but at least I now have better balance. The goal is to earn enough money to support 37 | working for myself. 38 | 39 | ### :hammer: Trying to build profitable SaaS projects with Laravel & Vue 40 | 41 | The primary way I want to support myself is through building profitable SaaS projects. Laravel and Vue are the stacks I'm 42 | currently using and plan to continue to use. 43 | 44 | I've been working on a side project for the past 6 months, I haven't made a lot of progress, but I plan to launch the 45 | landing page and share details soon. 46 | 47 | ### :writing_hand: Sharing my journey along the way 48 | 49 | I want to be able to motivate others to take the plunge having work-lifestyle that works for them, the best way I can do 50 | that is by sharing my journey and making it work. 51 | 52 | I'll be posting here, Twitter and IndieHackers for now. 53 | 54 | 55 | ### :money_with_wings: $0 MRR so far 56 | 57 | Being transparent about how my journey is going is important to me. I want people to see what it really takes to move the needle. 58 | I plan to build my projects in public, you'll see my MRR be updated as I go along. 59 | 60 | 61 | ## My Freelance Clients 62 | 63 | - Kintell 64 | - New World Artists 65 | - Massive Monster 66 | - Odyssey Traveller 67 | - Ben Sanford Media 68 | - Simon Bass Landscaping 69 | - Truths Touch 70 | -------------------------------------------------------------------------------- /app/.vitepress/theme/components/Newsletter.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 71 | -------------------------------------------------------------------------------- /app/contact/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Contact Harlan 3 | description: "Hey, this is Harlan Wilton. Please feel free to get in touch with me." 4 | head: 5 | - - meta 6 | - name: description 7 | content: "Hey, this is Harlan Wilton. Please feel free to get in touch with me." 8 | - - meta 9 | - property: "og:type" 10 | content: "website" 11 | - - meta 12 | - property: "og:url" 13 | content: "https://harlanzw.com/contact/" 14 | - - meta 15 | - property: "og:title" 16 | content: "Contact Harlan Wilton" 17 | - - meta 18 | - property: "og:description" 19 | content: "Hey, this is Harlan Wilton. Please feel free to get in touch with me." 20 | - - meta 21 | - property: "og:image" 22 | content: "https://harlanzw.com/social/home.png" 23 | - - meta 24 | - property: "twitter:card" 25 | content: "summary_large_image" 26 | - - meta 27 | - property: "twitter:url" 28 | content: "https://harlanzw.com/contact/" 29 | - - meta 30 | - property: "twitter:title" 31 | content: "Contact Harlan Wilton" 32 | - - meta 33 | - property: "twitter:description" 34 | content: "Hey, this is Harlan Wilton. Please feel free to get in touch with me." 35 | - - meta 36 | - property: "twitter:image" 37 | content: "https://harlanzw.com/social/home.png" 38 | --- 39 | 40 | 50 | 51 | # {{ page.title }} 52 | 53 | Get in touch, let's chat! :) 54 | 55 | ## Email me 56 | 57 | harlan@harlanzw.com 58 | 59 | ## Socials 60 | 61 | 62 | GitHub icon 63 | 64 | 65 | Twitter icon 66 | 67 | 68 | In Australia and work with PHP? You can find me in the [PHP Australia Slack](https://bit.ly/2LNYaTo). 69 | -------------------------------------------------------------------------------- /tools/get-posts.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const matter = require('gray-matter') 4 | const globby = require('globby') 5 | const postMetas = 6 | [ 7 | { 8 | url: '/blog/building-unlighthouse/', 9 | title: 'Building Unlighthouse: Open-Source Package For Site-wide Google Lighthouse scans', 10 | publishDate: '28 Feb 2022', 11 | date: '2022-02-28', 12 | excerpt: 'Going into detail of what goes into making a modern open-source package.', 13 | status: 'published', 14 | readMins: 6, 15 | tags: ['vue'] 16 | }, 17 | { 18 | url: '/blog/scale-your-vue-components/', 19 | title: 'Scaling Your Vue Components for Mid-Large Size Apps', 20 | publishDate: '12th Jan 2021', 21 | date: '2021-01-12', 22 | image: 'https://harlanzw.com/social/scale-your-vue-components.png', 23 | excerpt: 'Working on a mid-large size app usually means hundreds of components. How do you make sure these components will scale?', 24 | status: 'published', 25 | readMins: 8, 26 | tags: ['vue'] 27 | }, 28 | { 29 | url: '/blog/vue-automatic-component-imports/', 30 | title: 'Building a Vue Auto Component Importer - A Better Dev Experience', 31 | publishDate: '22nd Dec 2020', 32 | image: 'https://harlanzw.com/social/vue-automatic-component-imports.png', 33 | date: '2020-12-22', 34 | excerpt: 'Having component folders \'auto-magically\' imported into your app is the latest craze. How does it work and is it good?', 35 | status: 'published', 36 | readMins: 10, 37 | tags: ['webpack', 'vue'] 38 | }, 39 | { 40 | url: 'https://github.com/loonpwn/vue-cli-plugin-import-components', 41 | link: true, 42 | publishDate: '12th Dec 2020', 43 | date: '2020-12-12', 44 | status: 'published', 45 | title: 'Vue-CLI Plugin: Import Components', 46 | excerpt: 'I created a Vue-CLI plugin to automatically import your components in your Vue CLI app with tree shaking, supporting Vue 2 and 3.', 47 | tags: ['vue', 'github'] 48 | }, 49 | { 50 | url: '/blog/how-the-heck-does-vite-work/', 51 | title: 'How Does Vite Work - A Comparison to Webpack', 52 | publishDate: '1st Dec 2020', 53 | image: 'https://harlanzw.com/social/how-vite-works.png', 54 | date: '2020-12-01', 55 | excerpt: 'I used Vite to build a new blazing fast blog ⚡, find out what I learnt and why Vite is the next big thing.', 56 | status: 'published', 57 | readMins: 10, 58 | tags: ['webpack', 'vue'] 59 | }, 60 | ] 61 | 62 | exports.getPosts = function getPosts() { 63 | const cwd = path.resolve(__dirname, '../app/blog') 64 | const posts = globby.sync(['**/*.md'], { cwd }) 65 | console.log('globby posts', posts, cwd) 66 | return posts 67 | .map(file => { 68 | const src = fs.readFileSync(path.join(cwd, file), 'utf-8') 69 | const { content } = matter(src) 70 | 71 | // match file to post definition 72 | const url = '/blog/' + file.replace('index.md', '') 73 | const postMeta = postMetas.find(p => p.url.endsWith(url)) 74 | 75 | return { 76 | title: postMeta?.title, 77 | date: formatDate(postMeta?.date), 78 | ...postMeta, 79 | content 80 | } 81 | }) 82 | .sort((a, b) => b?.date.time - a?.date.time) 83 | } 84 | 85 | function formatDate(date) { 86 | date = new Date(date) 87 | date.setUTCHours(12) 88 | return { 89 | time: +date, 90 | string: date.toLocaleDateString('en-US', { 91 | year: 'numeric', 92 | month: 'long', 93 | day: 'numeric' 94 | }) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /app/.vitepress/theme/Layout.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 62 | 63 | 100 | -------------------------------------------------------------------------------- /app/auto-imports.d.ts: -------------------------------------------------------------------------------- 1 | // Generated by 'unplugin-auto-import' 2 | // We suggest you to commit this file into source control 3 | declare global { 4 | const asReadAction: typeof import('@vueuse/schema-org')['asReadAction'] 5 | const asSearchAction: typeof import('@vueuse/schema-org')['asSearchAction'] 6 | const computed: typeof import('vue')['computed'] 7 | const createApp: typeof import('vue')['createApp'] 8 | const customRef: typeof import('vue')['customRef'] 9 | const defineArticle: typeof import('@vueuse/schema-org')['defineArticle'] 10 | const defineArticlePartial: typeof import('@vueuse/schema-org')['defineArticlePartial'] 11 | const defineAsyncComponent: typeof import('vue')['defineAsyncComponent'] 12 | const defineBreadcrumb: typeof import('@vueuse/schema-org')['defineBreadcrumb'] 13 | const defineBreadcrumbPartial: typeof import('@vueuse/schema-org')['defineBreadcrumbPartial'] 14 | const defineComment: typeof import('@vueuse/schema-org')['defineComment'] 15 | const defineCommentPartial: typeof import('@vueuse/schema-org')['defineCommentPartial'] 16 | const defineComponent: typeof import('vue')['defineComponent'] 17 | const defineHowTo: typeof import('@vueuse/schema-org')['defineHowTo'] 18 | const defineHowToPartial: typeof import('@vueuse/schema-org')['defineHowToPartial'] 19 | const defineImage: typeof import('@vueuse/schema-org')['defineImage'] 20 | const defineImagePartial: typeof import('@vueuse/schema-org')['defineImagePartial'] 21 | const defineLocalBusiness: typeof import('@vueuse/schema-org')['defineLocalBusiness'] 22 | const defineLocalBusinessPartial: typeof import('@vueuse/schema-org')['defineLocalBusinessPartial'] 23 | const defineOrganization: typeof import('@vueuse/schema-org')['defineOrganization'] 24 | const defineOrganizationPartial: typeof import('@vueuse/schema-org')['defineOrganizationPartial'] 25 | const definePerson: typeof import('@vueuse/schema-org')['definePerson'] 26 | const definePersonPartial: typeof import('@vueuse/schema-org')['definePersonPartial'] 27 | const defineProduct: typeof import('@vueuse/schema-org')['defineProduct'] 28 | const defineProductPartial: typeof import('@vueuse/schema-org')['defineProductPartial'] 29 | const defineQuestion: typeof import('@vueuse/schema-org')['defineQuestion'] 30 | const defineQuestionPartial: typeof import('@vueuse/schema-org')['defineQuestionPartial'] 31 | const defineRecipe: typeof import('@vueuse/schema-org')['defineRecipe'] 32 | const defineRecipePartial: typeof import('@vueuse/schema-org')['defineRecipePartial'] 33 | const defineVideo: typeof import('@vueuse/schema-org')['defineVideo'] 34 | const defineVideoPartial: typeof import('@vueuse/schema-org')['defineVideoPartial'] 35 | const defineWebPage: typeof import('@vueuse/schema-org')['defineWebPage'] 36 | const defineWebPagePartial: typeof import('@vueuse/schema-org')['defineWebPagePartial'] 37 | const defineWebSite: typeof import('@vueuse/schema-org')['defineWebSite'] 38 | const defineWebSitePartial: typeof import('@vueuse/schema-org')['defineWebSitePartial'] 39 | const effectScope: typeof import('vue')['effectScope'] 40 | const EffectScope: typeof import('vue')['EffectScope'] 41 | const getCurrentInstance: typeof import('vue')['getCurrentInstance'] 42 | const getCurrentScope: typeof import('vue')['getCurrentScope'] 43 | const h: typeof import('vue')['h'] 44 | const inject: typeof import('vue')['inject'] 45 | const isReadonly: typeof import('vue')['isReadonly'] 46 | const isRef: typeof import('vue')['isRef'] 47 | const markRaw: typeof import('vue')['markRaw'] 48 | const nextTick: typeof import('vue')['nextTick'] 49 | const onActivated: typeof import('vue')['onActivated'] 50 | const onBeforeMount: typeof import('vue')['onBeforeMount'] 51 | const onBeforeUnmount: typeof import('vue')['onBeforeUnmount'] 52 | const onBeforeUpdate: typeof import('vue')['onBeforeUpdate'] 53 | const onDeactivated: typeof import('vue')['onDeactivated'] 54 | const onErrorCaptured: typeof import('vue')['onErrorCaptured'] 55 | const onMounted: typeof import('vue')['onMounted'] 56 | const onRenderTracked: typeof import('vue')['onRenderTracked'] 57 | const onRenderTriggered: typeof import('vue')['onRenderTriggered'] 58 | const onScopeDispose: typeof import('vue')['onScopeDispose'] 59 | const onServerPrefetch: typeof import('vue')['onServerPrefetch'] 60 | const onUnmounted: typeof import('vue')['onUnmounted'] 61 | const onUpdated: typeof import('vue')['onUpdated'] 62 | const provide: typeof import('vue')['provide'] 63 | const reactive: typeof import('vue')['reactive'] 64 | const readonly: typeof import('vue')['readonly'] 65 | const ref: typeof import('vue')['ref'] 66 | const resolveComponent: typeof import('vue')['resolveComponent'] 67 | const shallowReactive: typeof import('vue')['shallowReactive'] 68 | const shallowReadonly: typeof import('vue')['shallowReadonly'] 69 | const shallowRef: typeof import('vue')['shallowRef'] 70 | const toRaw: typeof import('vue')['toRaw'] 71 | const toRef: typeof import('vue')['toRef'] 72 | const toRefs: typeof import('vue')['toRefs'] 73 | const triggerRef: typeof import('vue')['triggerRef'] 74 | const unref: typeof import('vue')['unref'] 75 | const useAttrs: typeof import('vue')['useAttrs'] 76 | const useCssModule: typeof import('vue')['useCssModule'] 77 | const useCssVars: typeof import('vue')['useCssVars'] 78 | const useSchemaOrg: typeof import('@vueuse/schema-org')['useSchemaOrg'] 79 | const useSlots: typeof import('vue')['useSlots'] 80 | const watch: typeof import('vue')['watch'] 81 | const watchEffect: typeof import('vue')['watchEffect'] 82 | } 83 | export {} 84 | -------------------------------------------------------------------------------- /app/.vitepress/theme/styles/code.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | code .token.deleted { 4 | color: #ec5975; 5 | } 6 | 7 | code .token.inserted { 8 | color: var(--c-brand); 9 | } 10 | 11 | div[class*='language-'] { 12 | position: relative; 13 | margin: 1rem -1.5rem; 14 | background-color: var(--code-bg-color); 15 | } 16 | 17 | li > div[class*='language-'] { 18 | border-radius: 6px 0 0 6px; 19 | margin: 1rem -1.5rem 1rem -1.25rem; 20 | } 21 | 22 | @media (min-width: 420px) { 23 | div[class*='language-'] { 24 | margin: 1rem 0; 25 | border-radius: 6px; 26 | } 27 | 28 | li > div[class*='language-'] { 29 | margin: 1rem 0 1rem 0rem; 30 | border-radius: 6px; 31 | } 32 | } 33 | 34 | [class*='language-'] pre, 35 | [class*='language-'] code { 36 | text-align: left; 37 | white-space: pre; 38 | word-spacing: normal; 39 | word-break: normal; 40 | word-wrap: normal; 41 | -moz-tab-size: 4; 42 | -o-tab-size: 4; 43 | tab-size: 4; 44 | -webkit-hyphens: none; 45 | -moz-hyphens: none; 46 | -ms-hyphens: none; 47 | hyphens: none; 48 | } 49 | 50 | [class*='language-'] pre { 51 | position: relative; 52 | z-index: 1; 53 | margin: 0; 54 | padding: 1.25rem 1.5rem; 55 | background: transparent; 56 | overflow-x: auto; 57 | } 58 | 59 | [class*='language-'] code { 60 | padding: 0; 61 | line-height: var(--code-line-height); 62 | font-size: var(--code-font-size); 63 | color: #eee; 64 | } 65 | 66 | /* Line highlighting */ 67 | 68 | .highlight-lines { 69 | position: absolute; 70 | top: 0; 71 | bottom: 0; 72 | left: 0; 73 | padding: 1.25rem 0; 74 | width: 100%; 75 | line-height: var(--code-line-height); 76 | font-family: var(--code-font-family); 77 | font-size: var(--code-font-size); 78 | user-select: none; 79 | } 80 | 81 | .highlight-lines .highlighted { 82 | background-color: rgba(0, 0, 0, .66); 83 | } 84 | 85 | /* Line numbers mode */ 86 | 87 | div[class*='language-'].line-numbers-mode { 88 | padding-left: 3.5rem; 89 | } 90 | 91 | .line-numbers-wrapper { 92 | position: absolute; 93 | top: 0; 94 | bottom: 0; 95 | left: 0; 96 | z-index: 3; 97 | border-right: 1px solid rgba(0, 0, 0, .5); 98 | padding: 1.25rem 0; 99 | width: 3.5rem; 100 | text-align: center; 101 | line-height: var(--code-line-height); 102 | font-family: var(--code-font-family); 103 | font-size: var(--code-font-size); 104 | color: #888; 105 | } 106 | 107 | /* Language marker */ 108 | 109 | [class*='language-']:before { 110 | position: absolute; 111 | top: .6em; 112 | right: 1em; 113 | z-index: 2; 114 | font-size: .8rem; 115 | color: #888; 116 | } 117 | 118 | [class~='language-html']:before, 119 | [class~='language-markup']:before { 120 | content: 'html'; 121 | } 122 | 123 | [class~='language-md']:before, 124 | [class~='language-markdown']:before { 125 | content: 'md'; 126 | } 127 | 128 | [class~='language-css']:before { 129 | content: 'css'; 130 | } 131 | 132 | [class~='language-sass']:before { 133 | content: 'sass'; 134 | } 135 | 136 | [class~='language-scss']:before { 137 | content: 'scss'; 138 | } 139 | 140 | [class~='language-less']:before { 141 | content: 'less'; 142 | } 143 | 144 | [class~='language-stylus']:before { 145 | content: 'styl'; 146 | } 147 | 148 | [class~='language-js']:before, 149 | [class~='language-typescript']:before { 150 | content: 'js'; 151 | } 152 | 153 | [class~='language-ts']:before, 154 | [class~='language-typescript']:before { 155 | content: 'ts'; 156 | } 157 | 158 | [class~='language-json']:before { 159 | content: 'json'; 160 | } 161 | 162 | [class~='language-rb']:before, 163 | [class~='language-ruby']:before { 164 | content: 'rb'; 165 | } 166 | 167 | [class~='language-py']:before, 168 | [class~='language-python']:before { 169 | content: 'py'; 170 | } 171 | 172 | [class~='language-sh']:before, 173 | [class~='language-bash']:before { 174 | content: 'sh'; 175 | } 176 | 177 | [class~='language-php']:before { 178 | content: 'php'; 179 | } 180 | 181 | [class~='language-go']:before { 182 | content: 'go'; 183 | } 184 | 185 | [class~='language-rust']:before { 186 | content: 'rust'; 187 | } 188 | 189 | [class~='language-java']:before { 190 | content: 'java'; 191 | } 192 | 193 | [class~='language-c']:before { 194 | content: 'c'; 195 | } 196 | 197 | [class~='language-yaml']:before { 198 | content: 'yaml'; 199 | } 200 | 201 | [class~='language-dockerfile']:before { 202 | content: 'dockerfile'; 203 | } 204 | 205 | /** 206 | * prism.js tomorrow night eighties for JavaScript, CoffeeScript, CSS and HTML. 207 | * Based on https://github.com/chriskempson/tomorrow-theme 208 | * 209 | * @author Rose Pritchard 210 | */ 211 | .token.comment, 212 | .token.block-comment, 213 | .token.prolog, 214 | .token.doctype, 215 | .token.cdata { 216 | color: #999; 217 | } 218 | 219 | .token.punctuation { 220 | color: #ccc; 221 | } 222 | 223 | .token.tag, 224 | .token.attr-name, 225 | .token.namespace, 226 | .token.deleted { 227 | color: #e2777a; 228 | } 229 | 230 | .token.function-name { 231 | color: #6196cc; 232 | } 233 | 234 | .token.boolean, 235 | .token.number, 236 | .token.function { 237 | color: #f08d49; 238 | } 239 | 240 | .token.property, 241 | .token.class-name, 242 | .token.constant, 243 | .token.symbol { 244 | color: #f8c555; 245 | } 246 | 247 | .token.selector, 248 | .token.important, 249 | .token.atrule, 250 | .token.keyword, 251 | .token.builtin { 252 | color: #cc99cd; 253 | } 254 | 255 | .token.string, 256 | .token.char, 257 | .token.attr-value, 258 | .token.regex, 259 | .token.variable { 260 | color: #7ec699; 261 | } 262 | 263 | .token.operator, 264 | .token.entity, 265 | .token.url { 266 | color: #67cdcc; 267 | } 268 | 269 | .token.important, 270 | .token.bold { 271 | font-weight: bold; 272 | } 273 | 274 | .token.italic { 275 | font-style: italic; 276 | } 277 | 278 | .token.entity { 279 | cursor: help; 280 | } 281 | 282 | .token.inserted { 283 | color: green; 284 | } 285 | -------------------------------------------------------------------------------- /app/blog/building-unlighthouse/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Building Unlighthouse: Open-Source Package For Site-wide Google Lighthouse scans" 3 | description: "Going into detail of what goes into making a modern open-source package." 4 | head: 5 | - - meta 6 | - name: description 7 | content: "Going into detail of what goes into making a modern open-source package." 8 | - - meta 9 | - property: "og:type" 10 | content: "website" 11 | - - meta 12 | - property: "og:url" 13 | content: "https://harlanzw.com/blog/building-unlighthouse/" 14 | - - meta 15 | - property: "og:title" 16 | content: "Building Unlighthouse: Open-Source Package For Site-wide Google Lighthouse scans" 17 | - - meta 18 | - property: "og:description" 19 | content: "Going into detail of what goes into making a modern open-source package." 20 | - - meta 21 | - property: "og:image" 22 | content: "https://next.unlighthouse.dev/og.png" 23 | - - meta 24 | - property: "twitter:card" 25 | content: "summary_large_image" 26 | - - meta 27 | - property: "twitter:url" 28 | content: "https://harlanzw.com/blog/building-unlighthouse/" 29 | - - meta 30 | - property: "twitter:title" 31 | content: "Building Unlighthouse: Open-Source Package For Site-wide Google Lighthouse scans" 32 | - - meta 33 | - property: "twitter:description" 34 | content: "Going into detail of what goes into making a modern open-source package." 35 | - - meta 36 | - property: "twitter:image" 37 | content: "https://next.unlighthouse.dev/og.png" 38 | --- 39 | 40 | 41 | 42 | ## Introduction 43 | 44 | [Unlighthouse](https://github.com/harlan-zw/unlighthouse) is an open-source package to scan your entire site using Google Lighthouse. Featuring a modern UI, minimal config and smart sampling. 45 | 46 | ## The Journey To An Idea 47 | 48 | As a freelancer I keep on top of my clients organic growth with Google Search Console. 49 | 50 | Was a day like any other, looking at one of my clients' dashboard. Seemingly out of nowhere, I saw the trend of page position, clicks and page views in free fall. My clients' income was based on organic traffic, not good. 51 | 52 | ![Trending down Google Search Console](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/n4ajn7qv5iir7kmido0p.png) 53 | 54 | Isolating the reason for the falling page rank wasn't easy. The site had issues, but what was causing the free fall. There was no way to know. 55 | 56 | To diagnose the issue, I used Google Lighthouse. I went through all pages of the site, fixing all reported issues. 57 | 58 | What happened next? Things started turning around. I was able to invert the graph. Organic growth doubled in the next few months. Happy client. 59 | 60 | ![Trending up Google Search Console](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wh6s6kjiy5y8ilv8pzeq.png) 61 | 62 | Now that was out of the way, how could I make it easier to stay on top of the health of the sites I manage? 63 | 64 | ## Starting The Build 65 | 66 | So I know I wanted to build something that would run Google Lighthouse on an entire site with just the home page URL. 67 | 68 | When it came time to put something together, I had a rough idea of the stack. Typescript, Vue, Vite, etc. 69 | 70 | There were also a myriad of nifty packages that were coming out of the [UnJS](https://github.com/unjs) ecosystem that I wanted to play with. 71 | 72 | With that, the package would be known as **Un** (inspired by Unjs) **Lighthouse**. 73 | 74 | ## Unlighthouse Architecture 75 | 76 | The code that what went into building the package. 77 | 78 | ### Vue 3 / Vite client 79 | 80 | The beloved [Vite](https://github.com/vitejs/vite) was to be used to make the development of the client as easy and fast as possible. 81 | 82 | Vue v3 used to make use of the vast collection of utilities available at [VueUse](https://vueuse.org/). 83 | 84 | ### Lighthouse Binary 85 | 86 | Unlighthouse wouldn't be possible if Google hadn't published Lighthouse as its own [NPM binary](https://github.com/GoogleChrome/lighthouse). 87 | 88 | To make Unlighthouse fast, I combined the binary with the package [puppeteer-cluster](https://github.com/thomasdondorf/puppeteer-cluster), which allows for multi-threaded lighthouse scans. 89 | 90 | ### PNPM Monorepo 91 | 92 | [PNPM](https://pnpm.io/) is the new kid on the block of node package managers and has gained a large following quickly, for good reason. It is the most performant package manager and has first class support for monorepos. 93 | 94 | There are many benefits to using a monorepo for a package. My personal favourite is it allows me to easily isolate logic and dependencies for your package, letting you write simpler code. Allowing end users to pull any specific part of your package that they want to use. 95 | 96 | ![Unlighthouse monorepo](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3hfzz5ik3fa9qtzmmmz9.png) 97 | 98 | ### Vitest Testing 99 | 100 | [Vitest](https://vitest.dev/) is also the new kid on the block of testing. It's original aim was to be a testing framework specifically for Vite, but it has ended up being a possible replacement for Jest entirely. 101 | 102 | Vitest makes writing your logic and tests a breeze and I'd recommend checking it out for any project. 103 | 104 | ### [unbuild](https://github.com/unjs/unbuild) 105 | 106 | This package is described as a "A unified javascript build system". 107 | 108 | In reality, it's a minimal config way to build your package code to ESM and CJS. 109 | 110 | One of the amazing features of unbuild is stubbing. This allows you can run source code from your dist folder, meaning it transpiles just-in-time. 111 | 112 | This allows you to completely cut out the build step when you're iterating and testing integrations on your package. 113 | 114 | It's as simple as `unbuild --stub`. 115 | 116 | ```ts 117 | import { defineBuildConfig } from 'unbuild' 118 | 119 | export default defineBuildConfig({ 120 | entries: [ 121 | { input: 'src/index' }, 122 | { input: 'src/process', outDir: 'dist/process', builder: 'mkdist', declaration: false }, 123 | ], 124 | }) 125 | ``` 126 | 127 | ### [unctx](https://github.com/unjs/unctx) 128 | 129 | It's amazing that a simple pattern like composition has evaded Node packages for so long. 130 | 131 | With the introduction of Vue 3, composition became cool. And with that, unctx is composition for your own package. 132 | 133 | unctx allows you define a scope where there's only a single instance of something that is globally accessible. This is incredibly useful for building packages, as you no longer need to be juggling core state. You can build your logic out as composables that interact with the core. 134 | 135 | ```ts 136 | import { createContext } from 'unctx' 137 | 138 | const engineContext = createContext() 139 | 140 | export const useUnlighthouse = engineContext.use as () => UnlighthouseContext 141 | 142 | export const createUnlighthouse = async(userConfig: UserConfig, provider?: Provider) => { 143 | // ... 144 | engineContext.set(ctx, true) 145 | } 146 | ``` 147 | 148 | ### [unrouted](https://github.com/harlan-zw/unrouted) 149 | 150 | I needed an API for the client to communicate with the Node server to fetch the status of the scan and submit re-scans. 151 | 152 | The current JS offerings were a bit lackluster. I wanted something that just worked and had a nice way to use it. 153 | 154 | I ended up building unrouted as a way to solve that. 155 | 156 | ```ts 157 | group('/api', () => { 158 | group('/reports', () => { 159 | post('/rescan', () => { 160 | const { worker } = useUnlighthouse() 161 | 162 | const reports = [...worker.routeReports.values()] 163 | logger.info(`Doing site rescan, clearing ${reports.length} reports.`) 164 | worker.routeReports.clear() 165 | reports.forEach((route) => { 166 | const dir = route.artifactPath 167 | if (fs.existsSync(dir)) 168 | fs.rmSync(dir, { recursive: true }) 169 | }) 170 | worker.queueRoutes(reports.map(report => report.route)) 171 | return true 172 | }) 173 | 174 | post('/:id/rescan', () => { 175 | const report = useReport() 176 | const { worker } = useUnlighthouse() 177 | 178 | if (report) 179 | worker.requeueReport(report) 180 | }) 181 | }) 182 | 183 | get('__launch', () => { 184 | const { file } = useQuery<{ file: string }>() 185 | if (!file) { 186 | setStatusCode(400) 187 | return false 188 | } 189 | const path = file.replace(resolvedConfig.root, '') 190 | const resolved = join(resolvedConfig.root, path) 191 | logger.info(`Launching file in editor: \`${path}\``) 192 | launch(resolved) 193 | }) 194 | 195 | get('ws', req => ws.serve(req)) 196 | 197 | get('reports', () => { 198 | const { worker } = useUnlighthouse() 199 | 200 | return worker.reports().filter(r => r.tasks.inspectHtmlTask === 'completed') 201 | }) 202 | 203 | get('scan-meta', () => createScanMeta()) 204 | }) 205 | ``` 206 | 207 | ### [hookable](https://github.com/unjs/hookable) 208 | 209 | For Nuxt.js users, you might be familiar with the concept of frameworks hooks. A way for you to modify or do something with the internal logic of Nuxt. 210 | 211 | Building a package, I knew that this was a useful feature, not just for end-users, but for me as a way to organise logic. 212 | 213 | Having a core which is hookable means you can avoid baking logic in that may be better suited elsewhere. 214 | 215 | For example, I wanted to make sure that Unlighthouse didn't start for integrations until they visited the page. 216 | 217 | I simply set a hook for it to start only when they visit the client. 218 | 219 | ```ts 220 | hooks.hookOnce('visited-client', () => { 221 | ctx.start() 222 | }) 223 | ``` 224 | 225 | ### [unconfig](https://github.com/antfu/unconfig) 226 | 227 | Unconfig is a universal solution for loading configurations. This let me allow the package to load in a configuration from `unlighthouse.config.ts` or a custom path, with barely any code. 228 | 229 | ```ts 230 | import { loadConfig } from 'unconfig' 231 | 232 | const configDefinition = await loadConfig({ 233 | cwd: userConfig.root, 234 | sources: [ 235 | { 236 | files: [ 237 | 'unlighthouse.config', 238 | // may provide the config file as an argument 239 | ...(userConfig.configFile ? [userConfig.configFile] : []), 240 | ], 241 | // default extensions 242 | extensions: ['ts', 'js'], 243 | }, 244 | ], 245 | }) 246 | if (configDefinition.sources?.[0]) { 247 | configFile = configDefinition.sources[0] 248 | userConfig = defu(configDefinition.config, userConfig) 249 | } 250 | ``` 251 | 252 | ### [ufo](https://github.com/unjs/ufo) 253 | 254 | > URL utils for humans 255 | 256 | Dealing with URLs in Node isn't very nice. For Unlighthouse I needed to deal with many URLS, I needed to make sure they were standardised no matter how they were formed. 257 | 258 | This meant using the ufo package heavily. The slash trimming came in very handy and the origin detection. 259 | 260 | ```ts 261 | export const trimSlashes = (s: string) => withoutLeadingSlash(withoutTrailingSlash(s)) 262 | ``` 263 | 264 | ```ts 265 | const site = new $URL(url).origin 266 | ``` 267 | 268 | ## Putting It Together - Part 2 269 | 270 | Part 2 of this article will be coming soon where I go over some technical feats in putting together the above packages. 271 | 272 | ## Conclusion 273 | 274 | Thanks for reading Part 1. I hope you at least found it interesting or some of the links useful. 275 | 276 | -------------------------------------------------------------------------------- /app/blog/scale-your-vue-components/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Scaling Your Vue Components for Mid-Large Size Apps" 3 | description: "Working on a mid-large size app usually means hundreds of components. How do you make sure these components will scale?" 4 | head: 5 | - - meta 6 | - name: description 7 | content: "Working on a mid-large size app usually means hundreds of components. How do you make sure these components will scale?" 8 | - - meta 9 | - property: "og:type" 10 | content: "website" 11 | - - meta 12 | - property: "og:url" 13 | content: "https://harlanzw.com/blog/scale-your-vue-components/" 14 | - - meta 15 | - property: "og:title" 16 | content: "Scaling Your Vue Components for Mid-Large Size Apps" 17 | - - meta 18 | - property: "og:description" 19 | content: "Working on a mid-large size app usually means hundreds of components. How do you make sure these components will scale?" 20 | - - meta 21 | - property: "og:image" 22 | content: "https://harlanzw.com/social/scale-your-vue-components.png" 23 | - - meta 24 | - property: "twitter:card" 25 | content: "summary_large_image" 26 | - - meta 27 | - property: "twitter:url" 28 | content: "https://harlanzw.com/blog/scale-your-vue-components/" 29 | - - meta 30 | - property: "twitter:title" 31 | content: "Scaling Your Vue Components for Mid-Large Size Apps" 32 | - - meta 33 | - property: "twitter:description" 34 | content: "Working on a mid-large size app usually means hundreds of components. How do you make sure these components will scale?" 35 | - - meta 36 | - property: "twitter:image" 37 | content: "https://harlanzw.com/social/scale-your-vue-components.png" 38 | --- 39 | 40 | 41 | 42 | One of the key pieces in scaling your Vue app is having good component architecture. 43 | 44 | - How are components named? 45 | - What folder hierarchy should you use? 46 | - How is component code scoped? 47 | 48 | The cost of not having clear answers to these simple questions increases as your app grows. 49 | 50 | My previous role was the tech lead at a startup. Growing pains were frequent with pivots, design changes and new features. All pushing our total component count up. 51 | 52 | My below suggestions are what I came up with to solve _our_ scaling issues. Your project will have its own requirements. 53 | 54 | ## 100+ component club 55 | 56 | Let's assume once you hit 100+ components, then you are a mid-size app and you will be feeling your own growing pains. 57 | 58 | Are you in the club? Run the following in your component folder: 59 | 60 | ```shell 61 | # cd app/components 62 | COMPONENTS=$(ls -lR **/*.vue | wc -l) && echo -e "You have ${COMPONENTS} components." 63 | ``` 64 | 65 | ### Problems you may have 66 | 67 | - Difficult to remember which component to use where 68 | - Code is being repeated 69 | - Monolithic components 70 | - New components are being built instead of leveraging existing ones 71 | - Inconsistent emits and props between components with the same functionality 72 | - Technical debt is being ignored because it is too painful 73 | 74 | 75 | ## Solving component scaling with rules 76 | 77 | Good code adheres to a set of rules. You either follow existing rules (syntax and conventions) or create 78 | new ones and make sure others follow them (documentation and code reviews). 79 | 80 | ### Rule 0. Have good dev processes 81 | There is no substitute for a good development process. You need to be following best continuous delivery, documentation and communication practices. 82 | The rest of the rules will not help you if you are not functioning like a well-oiled machine. 83 | 84 | ### Rule 1. Know the style guide 85 | You should be familiar with the official [Vue.js Style Guide](https://v3.vuejs.org/style-guide/). 86 | It gives you clear, concise instructions on what you should and shouldn't do. 87 | You should set up [eslint-plugin-vue](https://eslint.vuejs.org/) with the recommended rules. 88 | 89 | ```js 90 | // .eslintrc.js 91 | module.exports = { 92 | extends: [ 93 | // ... 94 | 'plugin:vue/vue3-recommended', 95 | // 'plugin:vue/recommended' // Use this if you are using Vue.js 2.x. 96 | ], 97 | } 98 | ``` 99 | 100 | 101 | ### Rule 2. Use a component naming convention 102 | 103 | The bane of developers lives: how to name something. 104 | 105 | You can address that by having an easy-to-follow convention on how to name a component. The convention also tells you where to put the component in your folder hierarchy. 106 | 107 |
108 | {prefix}-{namespace}{?-class} 109 |
110 | 111 | #### Prefix 112 | 113 | > Base components (a.k.a. presentational, dumb, or pure components) that apply app-specific styling and conventions should all begin with a specific prefix, such as Base, App, or V. 114 | 115 | A short prefix for _all_ your components is preferable to the above. 116 | 117 | Using a prefix avoids conflicts with HTML tags and third-party components. It also gives you scoped IDE autocompletion and more reusable components. 118 | 119 | Prefixing becomes especially important when working with a component library ([Vuetify](https://vuetifyjs.com/), [VueStrap](https://yuche.github.io/vue-strap/), etc) or third-party components 120 | ([algolia](https://github.com/algolia/vue-instantsearch), [google maps](https://github.com/xkjyeah/vue-google-maps), etc). 121 | 122 | You should use something which relates to your app, for example, I use `h` as the prefix because my site is harlanzw.com. 123 | 124 | ```vue 125 | 132 | ``` 133 | 134 | You can use many prefixes for your components to help you with scoping code. 135 | 136 | ```vue 137 | 151 | ``` 152 | 153 | #### Namespace 154 | 155 | > Child components that are tightly coupled with their parent should include the parent component name as a prefix. 156 | 157 | The style guide recommends starting the component name with the parent component. I've found using a _namespace_ after the prefix instead is more flexible. 158 | 159 | Namespaces avoid conflicts, improve IDE autocompletion and define the scope of the component. 160 | 161 | You should map namespaces to a folder, this way you can group components, making them easier to find and use. 162 | 163 | An example of a namespace is `Field`, for all our field components (text field, textarea, search, etc.). 164 | 165 | ```shell 166 | components/ 167 | |- Field/ # namespace 168 | |--- HFieldText.vue 169 | |--- HFieldTextarea.vue 170 | |--- HFieldSearch.vue 171 | |--- HFieldAutocomplete.vue 172 | |--- HFieldCheckbox.vue 173 | ``` 174 | 175 | You can then create conventions that components in a namespace should follow. For example these components should all have a `:value` prop and `$emit('input', value)`. 176 | 177 | #### Class (optional) 178 | > Component names should start with the highest-level (often most general) words and end with descriptive modifying words. 179 | 180 | The final part of the convention is, in fact, the name of the component. Thinking of it as a class name makes the distinction between the namespace easier. You still want to follow the above style guide rule, our class names should be 181 | general to descriptive. 182 | 183 | The class should be optional. Namespaces can provide a default component to reduce the name of common components. 184 | 185 | Imagine you have a project with a few buttons. Most of the time you want to use the default button, you shouldn't 186 | need to name it `HButtonDefault.vue`. 187 | 188 | ```shell 189 | components/ 190 | |- Button/ # namespace 191 | |--- HButton.vue # The namespaces default component 192 | |--- HButtonCallToAction.vue # A call to action button 193 | |--- HButtonSubmitForm.vue # A button to submit forms 194 | ``` 195 | 196 | Recommendations on naming the class: 197 | - Describe the application function of the component, rather than what it looks like. 198 | - ❌ `HButtonRainbowFlashing.vue` 199 | - ✅ `HButtonCallToAction.vue` 200 | - Choose to be verbose if it adds clarity to the scope. 201 | - ❌ `HProfileUser.vue` 202 | - ✅ `HProfileAuthenticatedUsersCard.vue` 203 | - Prefer full words over abbreviations. From the [style guide](https://v3.vuejs.org/style-guide/#full-word-component-names-strongly-recommended). 204 | 205 | ### Rule 3. Separate component scopes 206 | 207 | Defining scopes for how components behave will guide you in staying DRY. 208 | 209 | There are many ways to set this up. A good starting point is a scope for "shared" (a.k.a. base, presentational or dumb) components and "app" (a.k.a single-instance). 210 | 211 | ```shell 212 | components/ 213 | |- app # Contains application logic 214 | |- shared # Does not contain application logic 215 | ``` 216 | 217 | You could also pull out your "shared" components into their own npm package. 218 | 219 | When creating new components it's natural to couple application logic in. With this setup, you'll think about component scopes more and how code can be re-used. 220 | 221 | 222 | 223 | 224 | #### "Shared" Folder - Base Components 225 | These components are re-usable and include form inputs, buttons, dialogues and modals. They should never contain application logic or state data. 226 | 227 | You should be aiming to build your own "UI kit" from these components. 228 | 229 | Copy-pasting your shared folder into a new project should work out of the box (assuming you handle dependencies). 230 | 231 | #### "App" Folder - App components 232 | 233 | App components do contain application logic and state data. 234 | 235 | If you were to copy+paste an app component into a new project, it should not work. 236 | 237 | ## Example: Newsletter Sign Up 238 | 239 | This exists as two "app" components, they contain logic for validation and posting to an API. They both contain "shared" components. 240 | 241 |
242 | Newsletter component example 243 |
HNewsletterCard.vue
244 |
245 | 246 | ```shell 247 | components/ 248 | # application component scope 249 | |- app/ 250 | |-- Newsletter # namespace 251 | |--- HNewsletterForm.vue # validates and posts data 252 | |--- HNewsletterCard.vue # handles successful form post 253 | # shared component scope 254 | |- shared/ 255 | |-- Alert/ 256 | |--- HAlertSuccess.vue 257 | |-- Button/ 258 | |--- HButton.vue 259 | |-- Card/ 260 | |--- HCard.vue 261 | |-- Form 262 | |--- HForm.vue 263 | |-- Field/ 264 | |--- HFieldEmail.vue 265 | ``` 266 | 267 | ```vue 268 | 278 | ``` 279 | 280 | ```vue 281 | 298 | ``` 299 | 300 | 301 | ## Example: Forum Thread 302 | 303 | Now imagine you want to build a forum thread page. A user can see comments, upvote comments and post their own comment. 304 | 305 |
306 | Laravel.io Forum Example 307 |
Laravel.io Forum Thread
308 |
309 | 310 | Using `F` as our component prefix, let's look at what you need. 311 | 312 | ```shell 313 | components/ 314 | # application component scope 315 | |- app/ 316 | |-- Thread # namespace 317 | |--- FThread.vue # Wraps the entire thread 318 | |--- FThreadPost.vue # A single post / reply 319 | |--- FThreadFormReply.vue # Form to submit a reply 320 | |-- Field/ 321 | |--- FFieldComment.vue # Comment box for posts 322 | |-- Button/ 323 | |--- FButtonUpvote.vue # The thumbs up button 324 | # shared component scope 325 | |- shared/ 326 | |-- Img/ 327 | |--- FImgAvatar.vue # Users photos 328 | |-- Field/ 329 | |--- FFieldWYSIWYG.vue # Comment box for posts 330 | |-- Card/ 331 | |--- FCard.vue # Gives posts a 'card' look 332 | |-- Button/ 333 | |--- FButton.vue # Reply button for the post box 334 | ``` 335 | 336 | ```vue 337 | 346 | ``` 347 | 348 | ```vue 349 | 367 | ``` 368 | 369 | ```vue 370 | 382 | ``` 383 | 384 | ## Extra and optional rules 385 | 386 | ### Use An Automatic Component Importer 387 | 388 | Being tied to import paths once you have a few hundred components is going to slow you down. 389 | 390 | Using an [automatic component imports](/blog/vue-automatic-component-imports/) will clean up your code. You'll be free to tinker with the directory structure of your components in any way you want. 391 | 392 | ### Typescript Components 393 | 394 | The value of types, when you're working with objects is too good to pass up. Will save you hours down the line in developer experience. As a starting point, I'd try and get your shared components using Typescript. 395 | 396 | ```vue 397 | 410 | ``` 411 | 412 | ### Components have "one job" 413 | 414 | > Every component should have one job, any code in the component that isn't achieving that job shouldn't be there. 415 | 416 | You should be thinking when you create a component what it's one core function is. 417 | 418 | You can limit yourself with this mindset but it's worth keeping in mind as you go. 419 | 420 | ### Create component demo pages 421 | 422 | Using a package like [Storybook](https://storybook.js.org/) is a great idea, but it comes with overhead and when you're starting out it can be a bit overkill. 423 | 424 | As a starting point, you can create pages under a `/demo` prefix and throw your components on it. 425 | You want an easy way to find components and classes that are available. 426 | 427 | Here is a rough demo page as an example: [Massive Monster UI Demo](https://massivemonster.co/demo). Keep it as basic as you want. 428 | 429 | Massive Monster Demo Page 430 | 431 | ### Mixins and composables 432 | 433 | This one should be pretty obvious and there are enough articles elsewhere on using these. 434 | 435 | You want to pull out common logic from components and put them in either mixins or composables. 436 | 437 | Check out [vueuse](https://github.com/antfu/vueuse) for some ideas on what that could look like. 438 | 439 | 440 | ## Thanks for reading 441 | 442 | If you like the technical side of Vue and Laravel, I'll be posting regular articles on this site. The best 443 | way to keep up to date is by following me [@harlan_zw](https://twitter.com/harlan_zw) or signing up for the newsletter below. 444 | -------------------------------------------------------------------------------- /app/blog/vue-automatic-component-imports/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Building a Vue Auto Component Importer - A Better Dev Experience" 3 | description: "Components magically being imported into your app is the latest developer experience trend in Vue. Why does it exist and how does it work?" 4 | head: 5 | - - meta 6 | - name: description 7 | content: "Components magically being imported into your app is the latest developer experience trend in Vue. Why does it exist and how does it work?" 8 | - - meta 9 | - property: "og:type" 10 | content: "website" 11 | - - meta 12 | - property: "og:url" 13 | content: "https://harlanzw.com/blog/vue-automatic-component-imports/" 14 | - - meta 15 | - property: "og:title" 16 | content: "Building a Vue Auto Component Importer - A Better Dev Experience" 17 | - - meta 18 | - property: "og:description" 19 | content: "Components magically being imported into your app is the latest developer experience trend in Vue. Why does it exist and how does it work?" 20 | - - meta 21 | - property: "og:image" 22 | content: "https://harlanzw.com/social/vue-automatic-component-imports.png" 23 | - - meta 24 | - property: "twitter:card" 25 | content: "summary_large_image" 26 | - - meta 27 | - property: "twitter:url" 28 | content: "https://harlanzw.com/blog/vue-automatic-component-imports/" 29 | - - meta 30 | - property: "twitter:title" 31 | content: "Building a Vue Auto Component Importer - A Better Dev Experience" 32 | - - meta 33 | - property: "twitter:description" 34 | content: "Components magically being imported into your app is the latest developer experience trend in Vue. Why does it exist and how does it work?" 35 | - - meta 36 | - property: "twitter:image" 37 | content: "https://harlanzw.com/social/vue-automatic-component-imports.png" 38 | --- 39 | 40 | 41 | 42 | 43 | When first learning Vue, you are taught you need to import and add components to `components` in the script block. 44 | 45 | ```vue 46 | 49 | 57 | ``` 58 | 59 | However, there's been a recent trend to "upgrade" the Vue developer experience (DX), having components magically import themselves 60 | at compile-time. 61 | 62 | ```vue 63 | 66 | ``` 67 | 68 | In the wild, you can find auto component imports in most popular Vue frameworks, as part of the core or a plugin. 69 | 70 | - [Nuxt Components](https://github.com/nuxt/components) 71 | - [Vuetify](https://github.com/vuetifyjs/vuetify-loader) 72 | - [Chakra](https://github.com/segunadebayo/chakra-ui) 73 | - [Vue CLI](https://github.com/loonpwn/vue-cli-plugin-import-components) (built by me) 74 | - [Vite](https://github.com/antfu/vite-plugin-components) 75 | 76 | This article will look at: why automatic component imports exist, how you can easily build our own auto component importer using 77 | a Webpack loader and what the performance cost of using them has on your app. 78 | 79 | Finally, we'll look at some other compile-time DX upgrades that are possible. 80 | 81 | 82 | ## Why Automatic Component Imports? 83 | 84 | The _why_ that comes first to my mind, is the developer experience is great. No more confusion or typos on import paths, 85 | refactoring becomes easier and there's less code overall. 86 | 87 | The unintuitive but equally great advantage is found in the problem that this feature first solved. 88 | 89 | The UI framework [Vuetify](https://vuetifyjs.com/) is a huge library of over 80 components, coming in at [99.4KB](https://cdnjs.cloudflare.com/ajax/libs/vuetify/2.3.17/vuetify.js) 90 | for their scripts. As far as I know, they were the first to introduce automatic component imports. 91 | 92 | ### Problem: UI Framework Bloat 93 | 94 | One of the complaints you'll hear about using a UI framework over something simple like [TailwindCSS](https://tailwindcss.com/), 95 | is the bloat it will add to your app. 96 | 97 | This is a valid concern. It's unlikely your application is going to need half the components that a UI framework has to offer. Forcing 98 | browsers to download code that will never run, dead code, is not ideal. 99 | 100 | Additionally, this component bloat can make import paths harder to work with and further scope for issues to pop up. 101 | 102 | So, how do Vuetify and other UI frameworks overcome their inherent bloat? 103 | 104 | ### Solution: webpack Optimisations 105 | 106 | As is the way, webpack is here to magically solve our problems with [tree shaking](https://webpack.js.org/guides/tree-shaking/) and [code splitting](https://webpack.js.org/guides/code-splitting/) optimisations. 107 | 108 | If tree shaking is new to you, you can think of it as an optimisation to remove code that isn't explicitly used. Banishing 109 | 'dead' code to the shadow realm. 110 | 111 | The tree shaking optimisation requires ES2015 module syntax, (i.e `import` and `export`) and a production build. The code can't be compiled 112 | to CommonJS modules (i.e `require`) for it to work. 113 | 114 | So how does all this relate to automatic component imports? 115 | 116 | With Vuetify handling the imports of your components (_[a la carte](https://vuetifyjs.com/en/features/treeshaking/)_ as they call it), they 117 | can ensure webpack optimisations are running out of the box for your app with their component library. 118 | 119 | > The A la carte system enables you to pick and choose which components to import, drastically lowering your build size. 120 | 121 | > This will also make code-splitting more effective, as webpack will only load the components required for that chunk to be displayed. 122 | 123 | 124 | ## Fundamental: How Does webpack Load Vue Files? 125 | 126 | Before we jump into building our own automatic component importer, we'll need to have a basic understanding of how webpack loads Vue files. 127 | 128 | When you request a resource (such as a file) in webpack, it pushes the request through a pipeline of webpack loaders to resolve the output. A webpack loader is a piece of code which will transform a resource from one thing into another, it has an `input` and `output`. 129 | 130 | For example, the [raw-loader](https://v4.webpack.js.org/loaders/raw-loader/) will read a file and give you the string contents. 131 | The `input` is a path to a file in your filesystem, the `output` is the string contents of the file. 132 | 133 | ```js 134 | import txt from 'raw-loader!./hello.txt'; 135 | // txt=HelloWorld 136 | ``` 137 | 138 | The `vue-loader` is the loader for `.vue` files. The loader compiles and bundles your component Single File Component (SFC) into code 139 | that the browser can understand and run. 140 | 141 | 142 | ### Vue Loader in Action 143 | 144 | Let's take a look at an example of input and output from the vue-loader. 145 | 146 | #### Input: App.vue 147 | 148 | This is the default entry file for Vue CLI with Vue 3. 149 | 150 | ```vue 151 | 155 | 156 | 166 | 167 | 177 | ``` 178 | 179 | #### Output: App.vue 180 | 181 | Internally, the loader parses this code using the compiler, getting an SFC descriptor object that is used to create the 182 | final string output of the loader. 183 | 184 | 185 | ```js 186 | import { render } from "./App.vue?vue&type=template&id=7ba5bd90" 187 | import script from "./App.vue?vue&type=script&lang=js" 188 | export * from "./App.vue?vue&type=script&lang=js" 189 | 190 | import "./App.vue?vue&type=style&index=0&id=7ba5bd90&lang=css" 191 | script.render = render 192 | script.__file = "src/App.vue" 193 | 194 | export default script 195 | ``` 196 | 197 | Note: I've removed the Hot Module Reloading (HMR) code for simplicity here. 198 | 199 | The output of the loader isn't that important to understand, just know that the vue-loader has an in and out function. The output 200 | is usually parsed to another loader such as [babel-loader](https://github.com/babel/babel-loader) before being chunked. 201 | 202 | ## Building an Automatic Component Importer 203 | 204 | If you have some spare time, I'd encourage you to join along. You can use [Vue CLI](https://cli.vuejs.org/) with the Vue 3 preset. 205 | 206 | ```shell 207 | vue create auto-component-importer -p __default_vue_3__ 208 | ``` 209 | 210 | To begin, let's remove the manual import from the entry SFC, like so: 211 | 212 | #### New App.vue 213 | 214 | ```vue 215 | 219 | 220 | 225 | 226 | 236 | ``` 237 | 238 | When we load our `App.vue`, the `HelloWorld` doesn't work, as expected. Our goal is to get it to work without touching the Vue code. 239 | 240 | ### Step 1. Modify the webpack Configuration 241 | 242 | We need to make sure the loader we'll be making is going to run after the vue-loader. 243 | 244 | ```js 245 | // ./vue.config.js 246 | module.exports = { 247 | chainWebpack: config => { 248 | config.module 249 | .rules 250 | .get('vue') 251 | .use('components') 252 | .loader(require.resolve('./imports-loader')) 253 | .before('vue-loader') 254 | .end() 255 | } 256 | } 257 | ``` 258 | 259 | If you'd like to see the raw webpack config example, open the below. 260 | 261 |
262 | webpack.config.js example 263 | 264 | ```js 265 | // webpack.config.js 266 | module.exports = { 267 | // ... 268 | module: { 269 | rules: [ 270 | { 271 | test: /\.vue$/, 272 | loader: 'vue-loader' 273 | } 274 | ] 275 | } 276 | } 277 | ``` 278 | 279 | Knowing that webpack loaders are loaded from bottom to top, we would modify the configuration as so: 280 | 281 | ```js 282 | // webpack.config.js 283 | module.exports = { 284 | // ... 285 | module: { 286 | rules: [ 287 | { 288 | test: /\.vue$/, 289 | use: [ 290 | { 291 | loader: require.resolve('./imports-loader'), 292 | }, 293 | { 294 | loader: 'vue-loader', 295 | } 296 | ] 297 | }, 298 | ] 299 | } 300 | } 301 | ``` 302 | ::: tip Hint 303 | Normally a webpack would handle this configuration changing for you. 304 | ::: 305 |
306 | 307 | 308 | 309 | Now we create the loader called `imports-loader.js` in your apps root directory. We're going to make sure we only run it for the virtual SFC module. 310 | 311 | ```js 312 | // imports-loader.js 313 | module.exports = function loader(source) { 314 | // only run for the virtual SFC 315 | if (this.resourceQuery) { 316 | return source 317 | } 318 | console.log(source) 319 | return source; 320 | } 321 | ``` 322 | 323 | The `source` variable is the output of the vue-loader, the [Output: App.vue](#output-app-vue). 324 | 325 | Here we can now change anything about how our components work by modifying the vue-loader output. 326 | 327 | ### Step 2. Dumb Compile-Time Import 328 | 329 | As a proof of concept, let's try to import the `HelloWorld.vue` component so our [New App.vue](#new-app-vue) works. 330 | 331 | At this stage, we can just append the import code on to the `source`. 332 | 333 | ```js 334 | // imports-loader.js 335 | module.exports = function loader (source) { 336 | // only run for the virtual SFC 337 | if (this.resourceQuery) { 338 | return source 339 | } 340 | return source + ` 341 | import HelloWorld from "@/components/HelloWorld.vue" 342 | script.components = Object.assign({ HelloWorld }, script.components) 343 | `; 344 | } 345 | ``` 346 | 347 | Your `App.vue` now knows what the HelloWorld component is and works. Try it yourself. 348 | 349 | Note: This is a _dumb_ solution, as it will be modifying `HelloWorld.vue` to also import itself. 350 | 351 | ### Step 3. Making it smart 352 | 353 | A smarter solution would give us the ability to add components to our component folder and use them straight away without 354 | any imports. 355 | 356 | #### a. Scan components 357 | 358 | The first step in making it smarter is we need to create a map of the components files we want to automatically import. 359 | 360 | We recursively iterate over the components folder and do some mapping. 361 | 362 | ```js 363 | // a. Scan components 364 | const base = './src/components/' 365 | const fileComponents = (await globby('*.vue', { cwd: base })).map(c => { 366 | const name = path.parse(c).name 367 | const shortPath = path.resolve(base).replace(path.resolve('./src'), '@') 368 | return { 369 | name: name, 370 | import: `import ${name} from "${shortPath}/${c}"` 371 | } 372 | }) 373 | //[ { name: 'HelloWorld', import: 'import HelloWorld from "@/components/HelloWorld.vue"' } ] 374 | ``` 375 | 376 | #### b. Find the template tags 377 | 378 | To understand what components are being used, we need to have our new loader to compile the SFC `