├── .prettierignore ├── .eslintignore ├── .lintstagedrc.js ├── .simple-git-hooks.js ├── src ├── utils │ ├── color.ts │ ├── index.ts │ ├── constant.ts │ ├── time.ts │ ├── apollo.ts │ ├── dom.ts │ └── cookie.ts ├── styles │ ├── _post-variables.scss │ ├── _pre-variables.scss │ └── _bootstrap.scss ├── plugins │ ├── constants.ts │ ├── index.ts │ ├── filters.ts │ ├── http.ts │ ├── translator.ts │ ├── apollo.ts │ ├── title.ts │ └── translate.ts ├── tsconfig.json ├── types │ ├── dom.ts │ ├── enum.ts │ ├── index.ts │ ├── store.ts │ ├── env.ts │ ├── global.d.ts │ ├── server.ts │ ├── share.ts │ └── shim.d.ts ├── app.ts ├── components │ ├── HiProgress.vue │ └── HiLoading.vue ├── router │ └── index.ts ├── views │ ├── Categories.vue │ ├── Article.vue │ ├── About.vue │ ├── Archives.vue │ ├── Home.vue │ ├── Pulse.vue │ └── App.vue ├── index.pug ├── store │ └── index.ts ├── entry-client.ts ├── queries.gql └── entry-server.ts ├── public ├── favicon.ico ├── logo-120.png ├── logo-192.png ├── logo-30.png ├── logo-384.png ├── logo-48.png ├── logo-512.png ├── logo-60.png └── manifest.json ├── .gitignore ├── types ├── purgecss-whitelister.d.ts ├── shim.d.ts ├── packtracker__webpack-plugin.d.ts └── google-translate-api.d.ts ├── .editorconfig ├── .yarn └── plugins │ └── plugin-prepare-lifecycle.cjs ├── .postcssrc.js ├── vercel.json ├── codechecks.yml ├── tsconfig.json ├── server ├── router │ ├── dev.ts │ ├── translate.ts │ └── index.ts ├── template.pug ├── dev.ts └── index.ts ├── .prettierrc.js ├── .yarnrc.yml ├── .env.build ├── .eslintrc ├── env.js ├── .github └── workflows │ ├── nodejs.yml │ └── codeql.yml ├── README.md ├── LICENSE └── package.json /.prettierignore: -------------------------------------------------------------------------------- 1 | .yarn 2 | dist 3 | node_modules 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | !/.*.js 2 | .yarn 3 | dist 4 | src/types/schema.ts 5 | -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@1stg/lint-staged/tsc') 2 | -------------------------------------------------------------------------------- /.simple-git-hooks.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@1stg/simple-git-hooks') 2 | -------------------------------------------------------------------------------- /src/utils/color.ts: -------------------------------------------------------------------------------- 1 | export { default as invertColor } from 'invert-color' 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JounQin/blog/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo-120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JounQin/blog/HEAD/public/logo-120.png -------------------------------------------------------------------------------- /public/logo-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JounQin/blog/HEAD/public/logo-192.png -------------------------------------------------------------------------------- /public/logo-30.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JounQin/blog/HEAD/public/logo-30.png -------------------------------------------------------------------------------- /public/logo-384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JounQin/blog/HEAD/public/logo-384.png -------------------------------------------------------------------------------- /public/logo-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JounQin/blog/HEAD/public/logo-48.png -------------------------------------------------------------------------------- /public/logo-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JounQin/blog/HEAD/public/logo-512.png -------------------------------------------------------------------------------- /public/logo-60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JounQin/blog/HEAD/public/logo-60.png -------------------------------------------------------------------------------- /src/styles/_post-variables.scss: -------------------------------------------------------------------------------- 1 | $grid-breakpoints-md: map-get($grid-breakpoints, 'md') !default; 2 | -------------------------------------------------------------------------------- /src/styles/_pre-variables.scss: -------------------------------------------------------------------------------- 1 | $link-color: #555 !default; 2 | $link-hover-decoration: none !default; 3 | -------------------------------------------------------------------------------- /src/plugins/constants.ts: -------------------------------------------------------------------------------- 1 | export const SERVER_PREFIX = __SERVER__ 2 | ? `http://localhost:${process.env.PORT || DEFAULT_PORT}/` 3 | : '/' 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .*cache 2 | *.log 3 | dist 4 | node_modules 5 | sync 6 | .env.js 7 | .env.local 8 | .yarn/* 9 | !.yarn/plugins 10 | !.yarn/releases 11 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "module": "esnext" 5 | }, 6 | "include": ["**/*.ts", "**/*.vue"] 7 | } 8 | -------------------------------------------------------------------------------- /src/plugins/index.ts: -------------------------------------------------------------------------------- 1 | import './filters' 2 | import './http' 3 | import './title' 4 | import './translator' 5 | 6 | export * from './apollo' 7 | export * from './translate' 8 | -------------------------------------------------------------------------------- /src/plugins/filters.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | import { dateFormat, timeAgo } from 'utils' 4 | 5 | Vue.filter('dateFormat', dateFormat) 6 | Vue.filter('timeAgo', timeAgo) 7 | -------------------------------------------------------------------------------- /src/types/dom.ts: -------------------------------------------------------------------------------- 1 | export interface ScrollContext { 2 | startTime?: number 3 | duration?: number 4 | startX?: number 5 | startY?: number 6 | x?: number 7 | y?: number 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './apollo' 2 | export * from './color' 3 | export * from './constant' 4 | export * from './cookie' 5 | export * from './dom' 6 | export * from './time' 7 | -------------------------------------------------------------------------------- /src/types/enum.ts: -------------------------------------------------------------------------------- 1 | export enum Locale { 2 | EN = 'en', 3 | ZH = 'zh', 4 | } 5 | 6 | export enum OwnerType { 7 | user = 'user', 8 | organization = 'organization', 9 | } 10 | -------------------------------------------------------------------------------- /types/purgecss-whitelister.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'purgecss-whitelister' { 2 | function purgecssWhitelister(paths?: string[] | string): string[] 3 | 4 | export = purgecssWhitelister 5 | } 6 | -------------------------------------------------------------------------------- /types/shim.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | import fetch from 'node-fetch' 3 | 4 | namespace NodeJS { 5 | interface Global { 6 | fetch: typeof fetch 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root=true 2 | 3 | [*] 4 | indent_style=space 5 | indent_size=2 6 | tab_width=2 7 | end_of_line=lf 8 | charset=utf-8 9 | trim_trailing_whitespace=true 10 | insert_final_newline=true 11 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './dom' 2 | export * from './enum' 3 | export * from './env' 4 | export * from './schema' 5 | export * from './server' 6 | export * from './share' 7 | export * from './store' 8 | -------------------------------------------------------------------------------- /.yarn/plugins/plugin-prepare-lifecycle.cjs: -------------------------------------------------------------------------------- 1 | module.exports={name:"plugin-prepare-lifecycle",factory:e=>({hooks:{afterAllInstalled(r){if(!r.topLevelWorkspace.manifest.scripts.get("prepare"))return;e("@yarnpkg/shell").execute("yarn prepare")}}})}; 2 | -------------------------------------------------------------------------------- /src/types/store.ts: -------------------------------------------------------------------------------- 1 | import { Env } from './env' 2 | import { Organization, User } from './schema' 3 | 4 | export type Owner = Organization & User 5 | 6 | export interface RootState { 7 | progress: number 8 | user: User 9 | envs: Env 10 | } 11 | -------------------------------------------------------------------------------- /.postcssrc.js: -------------------------------------------------------------------------------- 1 | const config = require('@1stg/postcss-config')() 2 | 3 | config.plugins.push( 4 | require('postcss-pxtorem', { 5 | rootValue: 14, 6 | propList: ['*'], 7 | selectorBlackList: ['html'], 8 | minPixelValue: 2, 9 | }), 10 | ) 11 | 12 | module.exports = config 13 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blog", 3 | "version": 2, 4 | "alias": [ 5 | "blog.1stg.me" 6 | ], 7 | "github": { 8 | "silent": true 9 | }, 10 | "rewrites": [ 11 | { 12 | "source": "/(.*)", 13 | "destination": "https://blog-1stg.herokuapp.com/$1" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /codechecks.yml: -------------------------------------------------------------------------------- 1 | checks: 2 | - name: build-size-watcher 3 | options: 4 | files: 5 | - path: 'dist/**/*.*' 6 | - name: typecov 7 | options: 8 | atLeast: 97 9 | ignoreCatch: true 10 | ignoreFiles: 11 | - 'src/types/schema.ts' 12 | - '*.d.ts' 13 | strict: true 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@1stg/tsconfig/app.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "esModuleInterop": true, 6 | "module": "commonjs", 7 | "paths": { 8 | "*": ["src/*"] 9 | }, 10 | "strictNullChecks": false 11 | }, 12 | "include": ["**/*.ts", "**/*.vue"] 13 | } 14 | -------------------------------------------------------------------------------- /src/types/env.ts: -------------------------------------------------------------------------------- 1 | export interface Env { 2 | GITHUB_REPOSITORY_OWNER: string 3 | GITHUB_REPOSITORY_OWNER_TYPE: 'organization' | 'user' 4 | GITHUB_REPOSITORY_NAME: string 5 | GITHUB_EXCLUDED_LABELS: string[] 6 | GITHUB_CLIENT_ID: string 7 | GITHUB_OAUTH_CALLBACK: string 8 | GITHUB_EXCLUDED_REPOSITORY_OWNERS: string[] 9 | } 10 | -------------------------------------------------------------------------------- /src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | declare var __DEV__: boolean 2 | declare var __SERVER__: boolean 3 | declare var DEFAULT_PORT: number 4 | 5 | declare module '*.gql' { 6 | import { DocumentNode } from 'graphql' 7 | 8 | const value: { 9 | [key: string]: DocumentNode 10 | } 11 | 12 | export = value 13 | } 14 | 15 | declare module '*.vue' { 16 | export { default } from 'vue' 17 | } 18 | -------------------------------------------------------------------------------- /server/router/dev.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'node:child_process' 2 | 3 | import { serverPort } from '../../build/config' 4 | 5 | import startRouter from '.' 6 | 7 | exec( 8 | `kill -9 $(lsof -i:${serverPort + 1} -t) 2> /dev/null`, 9 | (_code, _stdout, stderr) => { 10 | if (stderr) { 11 | console.error(stderr) 12 | return 13 | } 14 | 15 | startRouter() 16 | }, 17 | ) 18 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | const config = require('@1stg/prettier-config/vue') 4 | 5 | /** 6 | * @type {import('prettier').Config} 7 | */ 8 | module.exports = { 9 | ...config, 10 | overrides: [ 11 | ...config.overrides, 12 | { 13 | files: '.env.*', 14 | excludeFiles: ['*.js'], 15 | options: { 16 | parser: 'sh', 17 | }, 18 | }, 19 | ], 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/constant.ts: -------------------------------------------------------------------------------- 1 | import { Locale } from 'types' 2 | 3 | export const INFINITY_DATE = 'Fri, 31 Dec 9999 23:59:59 GMT' 4 | 5 | export const LOCALE_COOKIE = 'LOCALE_COOKIE' 6 | 7 | export const TITLE = '1stG Blog' 8 | 9 | const { EN, ZH } = Locale 10 | 11 | export const TOGGLE_LOCALE = { 12 | [EN]: ZH, 13 | [ZH]: EN, 14 | } 15 | 16 | export const DEFAULT_LOCALE = EN 17 | 18 | export const LOCALES = [EN, ZH] 19 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | compressionLevel: mixed 2 | 3 | nodeLinker: node-modules 4 | 5 | plugins: 6 | - checksum: 37b2361b1502b2054e6779788c0e9bdd6a90ce49852a8cad2feda79b0614ec94f06fb6e78951f5f95429c610d7934dd077caa47413a0227378a102c55161616d 7 | path: .yarn/plugins/plugin-prepare-lifecycle.cjs 8 | spec: 'https://github.com/un-es/yarn-plugin-prepare-lifecycle/releases/download/v0.0.1/index.js' 9 | 10 | yarnPath: .yarn/releases/yarn-4.0.2.cjs 11 | -------------------------------------------------------------------------------- /.env.build: -------------------------------------------------------------------------------- 1 | NODE_ENV=production 2 | 3 | # Available ENVS: 4 | 5 | # APP_KEYS 6 | 7 | # GITHUB_TOKEN 8 | 9 | # GITHUB_REPOSITORY_OWNER 10 | # GITHUB_REPOSITORY_OWNER_TYPE 11 | # GITHUB_REPOSITORY_NAME 12 | 13 | # GITHUB_EXCLUDED_LABELS 14 | # GITHUB_EXCLUDED_REPOSITORY_OWNERS 15 | 16 | # GITHUB_CLIENT_ID 17 | # GITHUB_CLIENT_SECRET 18 | # GITHUB_OAUTH_CALLBACK 19 | 20 | # GOOGLE_TRANSLATE_ENABLED 21 | 22 | # TENCENT_TRANSLATE_API_SECRET_ID 23 | # TENCENT_TRANSLATE_API_SECRET_KEY 24 | 25 | # TRY_TENCENT_ON_GOOGLE_FAILED 26 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": "@1stg", 4 | "rules": { 5 | "@typescript-eslint/no-unnecessary-condition": "off", 6 | "markup/markup": "off", 7 | "unicorn/prefer-node-protocol": "off" 8 | }, 9 | "overrides": [ 10 | { 11 | "files": "packtracker__webpack-plugin.d.ts", 12 | "rules": { 13 | "unicorn/filename-case": "off" 14 | } 15 | }, 16 | { 17 | "files": "*.d.ts", 18 | "rules": { 19 | "no-var": "off" 20 | } 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /src/plugins/http.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import Vue from 'vue' 3 | 4 | import { SERVER_PREFIX } from './constants' 5 | 6 | import { ServerContext } from 'types' 7 | 8 | axios.defaults.baseURL = SERVER_PREFIX + 'api' 9 | 10 | Object.defineProperty( 11 | Vue.prototype, 12 | '$http', 13 | __SERVER__ 14 | ? { 15 | configurable: __DEV__, 16 | get(this: Vue) { 17 | return (this.$ssrContext as ServerContext).axios 18 | }, 19 | } 20 | : { value: axios, writable: __DEV__ }, 21 | ) 22 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | import { createApollo } from 'plugins' 4 | import createRouter from 'router' 5 | import createStore from 'store' 6 | import { invertColor } from 'utils' 7 | import App from 'views/App.vue' 8 | 9 | Object.defineProperty(Vue.prototype, '$utils', { 10 | value: { 11 | invertColor, 12 | }, 13 | writable: __DEV__, 14 | }) 15 | 16 | export default () => { 17 | const router = createRouter() 18 | const store = createStore() 19 | 20 | const app = new Vue({ 21 | router, 22 | store, 23 | render: h => h(App), 24 | }) 25 | 26 | return { app, createApollo, router, store } 27 | } 28 | -------------------------------------------------------------------------------- /src/types/server.ts: -------------------------------------------------------------------------------- 1 | import { AxiosInstance } from 'axios' 2 | import { Context } from 'koa' 3 | import { Translator } from 'vue-translator' 4 | 5 | import { Translate } from 'plugins' 6 | import { Apollo, Locale } from 'types' 7 | 8 | export interface ServerContext { 9 | ctx: Context 10 | apollo: Apollo 11 | axios: AxiosInstance 12 | locale: Locale 13 | script: string 14 | state: object 15 | title: string 16 | translator: Translator 17 | translate: Translate 18 | } 19 | 20 | export interface SetCookie { 21 | name: string 22 | value: string 23 | path?: string 24 | expires?: string 25 | httponly?: boolean 26 | } 27 | -------------------------------------------------------------------------------- /src/types/share.ts: -------------------------------------------------------------------------------- 1 | import { NormalizedCacheObject } from 'apollo-cache-inmemory' 2 | import { ApolloClient } from 'apollo-client' 3 | import { AxiosInstance } from 'axios' 4 | import { Route } from 'vue-router' 5 | import { Store } from 'vuex' 6 | 7 | import { RootState } from './store' 8 | 9 | import { Translate } from 'plugins' 10 | 11 | export type Apollo = ApolloClient 12 | 13 | export interface AsyncData { 14 | apollo?: Apollo 15 | axios?: AxiosInstance 16 | store?: Store 17 | route?: Route 18 | translate?: Translate 19 | } 20 | 21 | export type AsyncDataFn = (params: AsyncData) => T 22 | -------------------------------------------------------------------------------- /types/packtracker__webpack-plugin.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@packtracker/webpack-plugin' { 2 | import { Plugin, Stats } from 'webpack' 3 | 4 | namespace PacktrackerWebpackPlugin { 5 | interface Options { 6 | project_token: string 7 | author?: string 8 | branch?: string 9 | commit?: string 10 | committed_at?: string 11 | excludeAssets?: Stats.StatsExcludeFilter 12 | fail_build?: boolean 13 | message?: string 14 | prior_commit?: string 15 | upload?: boolean 16 | } 17 | } 18 | 19 | class PacktrackerWebpackPlugin extends Plugin { 20 | constructor(options: PacktrackerWebpackPlugin.Options) 21 | } 22 | 23 | export = PacktrackerWebpackPlugin 24 | } 25 | -------------------------------------------------------------------------------- /types/google-translate-api.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'google-translate-api' { 2 | namespace GoggleTranslateAPI {} 3 | 4 | interface GoggleTranslateApiOptions { 5 | from?: string 6 | to?: string 7 | raw?: boolean 8 | } 9 | 10 | interface GoggleTranslateApiResponse { 11 | text: string 12 | from: { 13 | language: { 14 | didYouMean: boolean 15 | iso: string 16 | } 17 | text: { 18 | autoCorrected: boolean 19 | didYouMean: boolean 20 | value: string 21 | } 22 | } 23 | raw: boolean 24 | } 25 | 26 | function GoggleTranslateAPI( 27 | content: string, 28 | options: GoggleTranslateApiOptions, 29 | ): Promise 30 | 31 | export = GoggleTranslateAPI 32 | } 33 | -------------------------------------------------------------------------------- /src/plugins/translator.ts: -------------------------------------------------------------------------------- 1 | import { merge } from 'lodash' 2 | import Vue from 'vue' 3 | import VueTranslator from 'vue-translator' 4 | 5 | import { Locale } from 'types' 6 | import { DEFAULT_LOCALE, LOCALE_COOKIE, TOGGLE_LOCALE, getCookie } from 'utils' 7 | 8 | Vue.use(VueTranslator, { 9 | defaultLocale: DEFAULT_LOCALE, 10 | locale: (!__SERVER__ && (getCookie(LOCALE_COOKIE) as Locale)) || undefined, 11 | merge, 12 | translations: { 13 | en: { 14 | translating: 'Translating', 15 | ellipsis: '...', 16 | }, 17 | zh: { 18 | translating: '翻译中', 19 | ellipsis: '……', 20 | }, 21 | }, 22 | }) 23 | 24 | const { translator } = Vue 25 | 26 | translator.toggleLocale = () => { 27 | translator.locale = TOGGLE_LOCALE[translator.locale as Locale] 28 | } 29 | -------------------------------------------------------------------------------- /src/components/HiProgress.vue: -------------------------------------------------------------------------------- 1 | 4 | 16 | 31 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "1stG Blog", 3 | "short_name": "1stG", 4 | "icons": [ 5 | { 6 | "src": "/logo-48.png", 7 | "sizes": "48x48", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/logo-120.png", 12 | "sizes": "120x120", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "/logo-192.png", 17 | "sizes": "192x192", 18 | "type": "image/png" 19 | }, 20 | { 21 | "src": "/logo-384.png", 22 | "sizes": "384x384", 23 | "type": "image/png" 24 | }, 25 | { 26 | "src": "/logo-512.png", 27 | "sizes": "512x512", 28 | "type": "image/png" 29 | } 30 | ], 31 | "start_url": "/", 32 | "background_color": "#fff", 33 | "display": "standalone", 34 | "theme_color": "#6c757d" 35 | } 36 | -------------------------------------------------------------------------------- /src/components/HiLoading.vue: -------------------------------------------------------------------------------- 1 | 7 | 16 | 39 | -------------------------------------------------------------------------------- /env.js: -------------------------------------------------------------------------------- 1 | const fs = require('node:fs') 2 | 3 | const { parse } = require('dotenv') 4 | 5 | const LOCAL_ENV = '.env.local' 6 | 7 | let localEnv = {} 8 | 9 | if (fs.existsSync(LOCAL_ENV)) { 10 | localEnv = parse(fs.readFileSync(LOCAL_ENV)) 11 | } 12 | 13 | module.exports = { 14 | DEBUG: '1stg:*', 15 | PARSER_NO_WATCH: true, 16 | TS_NODE_FILES: true, 17 | GITHUB_REPOSITORY_NAME: 'blog', 18 | GITHUB_REPOSITORY_OWNER: 'JounQin', 19 | GITHUB_REPOSITORY_OWNER_TYPE: 'user', 20 | GITHUB_EXCLUDED_LABELS: [ 21 | 'dependencies', 22 | 'feature', 23 | 'flag', 24 | 'greenkeeper', 25 | 'PR: draft', 26 | 'PR: merged', 27 | 'PR: partially-approved', 28 | 'PR: reviewed-approved', 29 | 'PR: reviewed-changes-requested', 30 | 'PR: unreviewed', 31 | 'security', 32 | ].join(','), 33 | ...localEnv, 34 | NODE_ENV: process.env.NODE_ENV || localEnv.NODE_ENV, 35 | } 36 | -------------------------------------------------------------------------------- /src/utils/time.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-duplicates 2 | import { format, formatDistance, parseISO } from 'date-fns' 3 | // eslint-disable-next-line import/no-duplicates 4 | import { enUS, zhCN } from 'date-fns/locale' 5 | 6 | import { Locale } from 'types' 7 | 8 | export type DateType = Date | number | string 9 | 10 | export const dateFormat = (date: DateType, f = 'yyyy-MM-dd') => 11 | format(typeof date === 'string' ? parseISO(date) : date, f) 12 | 13 | const locales = { 14 | [Locale.EN]: enUS, 15 | [Locale.ZH]: zhCN, 16 | } 17 | 18 | export const timeAgo = (date: DateType, locale: Locale = Locale.EN) => 19 | formatDistance(typeof date === 'string' ? parseISO(date) : date, Date.now(), { 20 | locale: locales[locale], 21 | }) 22 | 23 | export const now = 24 | typeof performance === 'undefined' || !performance.now 25 | ? Date.now 26 | : performance.now.bind(performance) 27 | -------------------------------------------------------------------------------- /src/plugins/apollo.ts: -------------------------------------------------------------------------------- 1 | import { InMemoryCache } from 'apollo-cache-inmemory' 2 | import ApolloClient from 'apollo-client' 3 | import { createHttpLink } from 'apollo-link-http' 4 | import Vue from 'vue' 5 | 6 | import { SERVER_PREFIX } from './constants' 7 | 8 | import { ServerContext } from 'types' 9 | 10 | export const createApollo = () => 11 | new ApolloClient({ 12 | link: createHttpLink({ 13 | uri: SERVER_PREFIX + 'graphql', 14 | }), 15 | cache: new InMemoryCache(), 16 | ssrMode: __SERVER__, 17 | }) 18 | 19 | export const apollo = __SERVER__ ? null : createApollo() 20 | 21 | Object.defineProperty( 22 | Vue.prototype, 23 | '$apollo', 24 | __SERVER__ 25 | ? { 26 | configurable: __DEV__, 27 | get(this: Vue) { 28 | return (this.$ssrContext as ServerContext).apollo 29 | }, 30 | } 31 | : { 32 | value: apollo, 33 | writable: __DEV__, 34 | }, 35 | ) 36 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | default: 9 | strategy: 10 | matrix: 11 | node: 12 | - 18 13 | - 20 14 | os: 15 | - macOS-latest 16 | - ubuntu-latest 17 | runs-on: ${{ matrix.os }} 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version: ${{ matrix.node }} 24 | cache: yarn 25 | 26 | - name: Enable Corepack 27 | run: corepack enable 28 | 29 | - name: Install Dependencies 30 | run: yarn --immutable 31 | env: 32 | CI: true 33 | 34 | - name: Lint, Build 35 | run: | 36 | yarn lint 37 | yarn typecov 38 | yarn build 39 | env: 40 | CI: true 41 | EFF_NO_LINK_RULES: true 42 | PARSER_NO_WATCH: true 43 | -------------------------------------------------------------------------------- /src/plugins/title.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | import { ServerContext } from 'types' 4 | import { TITLE } from 'utils' 5 | 6 | const setTitle = (vm: Vue) => { 7 | let { title } = vm.$options 8 | title = typeof title === 'function' ? title.call(vm, vm) : title 9 | if (title) { 10 | const target = __SERVER__ ? (vm.$ssrContext as ServerContext) : document 11 | target.title = `${TITLE} | ${title}` 12 | } 13 | } 14 | 15 | Vue.mixin( 16 | __SERVER__ 17 | ? { 18 | created(this: Vue) { 19 | setTitle(this) 20 | }, 21 | } 22 | : { 23 | watch: { 24 | '$t.locale'(this: Vue) { 25 | setTitle(this) 26 | }, 27 | '$tt.loading'(this: Vue, loading: boolean) { 28 | if (!loading) { 29 | setTitle(this) 30 | this.$forceUpdate() 31 | } 32 | }, 33 | }, 34 | mounted(this: Vue) { 35 | setTitle(this) 36 | }, 37 | }, 38 | ) 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # blog 2 | 3 | [![GitHub Actions](https://github.com/JounQin/blog/workflows/Node%20CI/badge.svg)](https://github.com/JounQin/blog/actions?query=workflow%3A%22Node+CI%22) 4 | [![Codacy Grade](https://img.shields.io/codacy/grade/16adf18f305e454db18b5ddb3d63cf20)](https://www.codacy.com/app/JounQin/blog) 5 | [![type-coverage](https://img.shields.io/badge/dynamic/json.svg?label=type-coverage&prefix=%E2%89%A5&suffix=%&query=$.typeCoverage.atLeast&uri=https%3A%2F%2Fraw.githubusercontent.com%2FJounQin%2Fblog%2Fmaster%2Fpackage.json)](https://github.com/plantain-00/type-coverage) 6 | 7 | [![Conventional Commits](https://img.shields.io/badge/conventional%20commits-1.0.0-yellow.svg)](https://conventionalcommits.org) 8 | [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) 9 | [![Code Style: Prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) 10 | 11 | a Blog system built on GitHub GraphQL GraphQL API with Vue SSR 12 | -------------------------------------------------------------------------------- /src/types/shim.d.ts: -------------------------------------------------------------------------------- 1 | import { NormalizedCacheObject } from 'apollo-cache-inmemory' 2 | import { AxiosInstance } from 'axios' 3 | import Vue, { ComponentOptions } from 'vue' 4 | import { Translator } from 'vue-translator' 5 | 6 | import { Translate, TranslateCacheData } from 'plugins' 7 | import { Apollo, AsyncDataFn, RootState } from 'types' 8 | 9 | declare global { 10 | interface Window { 11 | __APOLLO_CACHE__: NormalizedCacheObject 12 | __STORE_STATE__: RootState 13 | __TRANSLATE_CACHE__: TranslateCacheData 14 | } 15 | } 16 | 17 | declare module 'vue/types/options' { 18 | interface ComponentOptions { 19 | asyncData?: AsyncDataFn 20 | title?: string | ((vm: V) => string) 21 | } 22 | } 23 | 24 | declare module 'vue/types/vue' { 25 | interface Vue { 26 | $apollo: Apollo 27 | $http: AxiosInstance 28 | $t: Translator 29 | $tt: Translate 30 | } 31 | } 32 | 33 | declare module 'vue-translator/lib/translator' { 34 | export interface Translator { 35 | toggleLocale?(): void 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: CodeQL 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | schedule: 11 | - cron: 39 11 * * 1 12 | 13 | jobs: 14 | analyze: 15 | name: Analyze 16 | runs-on: ubuntu-latest 17 | permissions: 18 | actions: read 19 | contents: read 20 | security-events: write 21 | 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | language: 26 | - javascript 27 | 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v4 31 | 32 | - name: Initialize CodeQL 33 | uses: github/codeql-action/init@v2 34 | with: 35 | languages: ${{ matrix.language }} 36 | queries: +security-and-quality 37 | 38 | - name: Autobuild 39 | uses: github/codeql-action/autobuild@v2 40 | 41 | - name: Perform CodeQL Analysis 42 | uses: github/codeql-action/analyze@v2 43 | with: 44 | category: '/language:${{ matrix.language }}' 45 | -------------------------------------------------------------------------------- /src/styles/_bootstrap.scss: -------------------------------------------------------------------------------- 1 | // scss-docs-start import-stack 2 | // Configuration 3 | @import 'functions'; 4 | @import 'variables'; 5 | @import 'variables-dark'; 6 | @import 'maps'; 7 | @import 'mixins'; 8 | @import 'utilities'; 9 | 10 | // Layout & components 11 | @import 'root'; 12 | @import 'reboot'; 13 | @import 'type'; 14 | @import 'images'; 15 | @import 'containers'; 16 | @import 'grid'; 17 | @import 'tables'; 18 | @import 'forms'; 19 | @import 'buttons'; 20 | @import 'transitions'; 21 | @import 'dropdown'; 22 | @import 'button-group'; 23 | @import 'nav'; 24 | @import 'navbar'; 25 | @import 'card'; 26 | @import 'accordion'; 27 | @import 'breadcrumb'; 28 | @import 'pagination'; 29 | @import 'badge'; 30 | @import 'alert'; 31 | @import 'progress'; 32 | @import 'list-group'; 33 | @import 'close'; 34 | @import 'toasts'; 35 | @import 'modal'; 36 | @import 'tooltip'; 37 | @import 'popover'; 38 | @import 'carousel'; 39 | @import 'spinners'; 40 | @import 'offcanvas'; 41 | @import 'placeholders'; 42 | 43 | // Helpers 44 | @import 'helpers'; 45 | 46 | // Utilities 47 | @import 'utilities/api'; 48 | // scss-docs-end import-stack 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-present JounQin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/utils/apollo.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | import { ActionContext, Store } from 'vuex' 3 | 4 | import { Apollo, Repository, RootState } from 'types' 5 | 6 | export const getCategoriesQueryOptions = ( 7 | store: ActionContext | Store, 8 | ) => ({ 9 | query: gql` 10 | query categories($name: String!, $owner: String!) { 11 | repository(name: $name, owner: $owner) { 12 | labels(first: 100) { 13 | nodes { 14 | color 15 | id 16 | name 17 | } 18 | } 19 | } 20 | } 21 | `, 22 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access 23 | variables: store.getters.REPOSITORY, 24 | }) 25 | 26 | export const getDefaultLabels = ({ 27 | apollo, 28 | store, 29 | }: { 30 | apollo: Apollo 31 | store: Store 32 | }) => 33 | apollo 34 | .readQuery<{ 35 | repository: Repository 36 | }>(getCategoriesQueryOptions(store)) 37 | .repository.labels.nodes.filter( 38 | label => !store.state.envs.GITHUB_EXCLUDED_LABELS.includes(label.name), 39 | ) 40 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { Component } from 'vue-property-decorator' 3 | import VueRouter from 'vue-router' 4 | 5 | Vue.use(VueRouter) 6 | 7 | Component.registerHooks([ 8 | 'beforeRouteEnter', 9 | 'beforeRouteLeave', 10 | 'beforeRouteUpdate', 11 | ]) 12 | 13 | export default () => 14 | new VueRouter({ 15 | mode: 'history', 16 | fallback: false, 17 | scrollBehavior: () => ({ 18 | x: 0, 19 | y: 0, 20 | }), 21 | routes: [ 22 | { 23 | path: '/', 24 | component: () => import('views/Home.vue'), 25 | }, 26 | { 27 | path: '/article/:number', 28 | component: () => import('views/Article.vue'), 29 | }, 30 | { 31 | path: '/categories', 32 | component: () => import('views/Categories.vue'), 33 | }, 34 | { 35 | path: '/pulse', 36 | component: () => import('views/Pulse.vue'), 37 | }, 38 | { 39 | path: '/about', 40 | component: () => import('views/About.vue'), 41 | }, 42 | { 43 | path: '/archives', 44 | component: () => import('views/Archives.vue'), 45 | }, 46 | ], 47 | }) 48 | -------------------------------------------------------------------------------- /src/views/Categories.vue: -------------------------------------------------------------------------------- 1 | 14 | 42 | -------------------------------------------------------------------------------- /src/utils/dom.ts: -------------------------------------------------------------------------------- 1 | import { now } from './time' 2 | 3 | import { ScrollContext } from 'types' 4 | 5 | const scrollSmoothNativeSupported = 6 | typeof document !== 'undefined' && 7 | 'scrollBehavior' in document.documentElement.style 8 | 9 | const scroll = (context: ScrollContext, el: Element | Window) => { 10 | const { startX, startY, x = 0, y = 0, startTime, duration } = context 11 | 12 | let elapsed = (now() - startTime) / duration 13 | elapsed = elapsed > 1 ? 1 : elapsed 14 | const left = startX + (x - startX) * elapsed 15 | const top = startY + (y - startY) * elapsed 16 | 17 | el.scrollTo({ top, left }) 18 | 19 | if (x !== left || y !== top) { 20 | requestAnimationFrame(() => scroll(context, el)) 21 | } 22 | } 23 | 24 | export const scrollTo = ( 25 | context: ScrollContext, 26 | el: Element | Window = window, 27 | ) => { 28 | if (scrollSmoothNativeSupported) { 29 | return el.scrollTo({ 30 | left: context.x, 31 | top: context.y, 32 | behavior: 'smooth', 33 | }) 34 | } 35 | 36 | context.startX = context.startX || window.scrollX 37 | context.startY = context.startY || window.scrollY 38 | context.startTime = context.startTime || now() 39 | 40 | context.duration = context.duration || 500 41 | 42 | scroll(context, el) 43 | } 44 | -------------------------------------------------------------------------------- /src/index.pug: -------------------------------------------------------------------------------- 1 | html(lang='zh-cmn-Hans-CN') 2 | head 3 | title 1sG Blog | a Blog system built on GitHub GraphQL API with Vue SSR 4 | meta(charset='UTF-8') 5 | meta(http-equiv='X-UA-Compatible', content='IE=edge,chrome=1') 6 | meta(name='mobile-web-app-capable', content='yes') 7 | meta( 8 | name='viewport', 9 | content='width=device-width, initial-scale=1, shrink-to-fit=no' 10 | ) 11 | meta(name='theme-color', content='#6c757d') 12 | link(rel='apple-touch-icon', sizes='120x120', href='/logo-120.png') 13 | link(rel='shortcut icon', sizes='48x48', href='/logo-48.png') 14 | link(rel='manifest', href='/manifest.json') 15 | style. 16 | #skip a { 17 | position: absolute; 18 | left: -10000px; 19 | top: auto; 20 | width: 1px; 21 | height: 1 px; 22 | overflow: hidden; 23 | } 24 | #skip a:focus { 25 | position: static; 26 | width: auto; 27 | height: auto; 28 | } 29 | body 30 | #skip 31 | a(href='#app') skip to content 32 | #app 33 | script. 34 | document.addEventListener('gesturestart', function (e) { 35 | e.preventDefault() 36 | }) 37 | if process.env.NODE_ENV !== 'development' 38 | script(src='https://cdn.polyfill.io/v2/polyfill.min.js') 39 | -------------------------------------------------------------------------------- /server/template.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html(lang='{{ locale }}') 3 | head 4 | title {{ title }} 5 | meta(charset='UTF-8') 6 | meta(http-equiv='X-UA-Compatible', content='IE=edge,chrome=1') 7 | meta(name='mobile-web-app-capable', content='yes') 8 | meta( 9 | name='viewport', 10 | content='width=device-width,initial-scale=1,shrink-to-fit=no' 11 | ) 12 | meta(name='theme-color', content='#6c757d') 13 | link(rel='apple-touch-icon', sizes='120x120', href='/logo-120.png') 14 | link(rel='shortcut icon', sizes='48x48', href='/logo-48.png') 15 | link(rel='manifest', href='/manifest.json') 16 | //- prettier-ignore 17 | | {{{ renderResourceHints() }}} 18 | //- prettier-ignore 19 | | {{{ renderStyles() }}} 20 | base(target='_blank') 21 | style. 22 | #skip a { 23 | position: absolute; 24 | left: -10000px; 25 | top: auto; 26 | width: 1px; 27 | height: 1 px; 28 | overflow: hidden; 29 | } 30 | #skip a:focus { 31 | position: static; 32 | width: auto; 33 | height: auto; 34 | } 35 | body 36 | #skip 37 | a(href='#app') skip to content 38 | //vue-ssr-outlet 39 | script. 40 | document.addEventListener('gesturestart', function (e) { 41 | e.preventDefault() 42 | }) 43 | //- prettier-ignore 44 | | {{{ script }}} 45 | if process.env.NODE_ENV !== 'development' 46 | script(src='https://cdn.polyfill.io/v2/polyfill.min.js') 47 | //- prettier-ignore 48 | | {{{ renderScripts() }}} 49 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { AxiosInstance } from 'axios' 2 | import Vue from 'vue' 3 | import Vuex, { Action, Getter, Mutation } from 'vuex' 4 | 5 | import { Apollo, Env, RootState, User } from 'types' 6 | import { getCategoriesQueryOptions } from 'utils' 7 | 8 | Vue.use(Vuex) 9 | 10 | const getters: { 11 | [key: string]: Getter 12 | } = { 13 | REPOSITORY: state => ({ 14 | name: state.envs.GITHUB_REPOSITORY_NAME, 15 | owner: state.envs.GITHUB_REPOSITORY_OWNER, 16 | }), 17 | LOGIN: state => ({ 18 | login: state.envs.GITHUB_REPOSITORY_OWNER, 19 | }), 20 | } 21 | 22 | const actions: { 23 | [key: string]: Action 24 | } = { 25 | async fetchInfo( 26 | store, 27 | { 28 | apollo, 29 | axios, 30 | }: { 31 | apollo: Apollo 32 | axios: AxiosInstance 33 | }, 34 | ) { 35 | const { 36 | data: { user, envs }, 37 | } = await axios.get<{ 38 | user: User 39 | envs: Env 40 | }>('/fetchInfo') 41 | 42 | store.commit('SET_USER', user) 43 | store.commit('SET_ENVS', envs) 44 | 45 | await apollo.query({ ...getCategoriesQueryOptions(store) }) 46 | }, 47 | } 48 | 49 | const mutations: { 50 | [key: string]: Mutation 51 | } = { 52 | // eslint-disable-next-line sonar/function-name 53 | SET_PROGRESS(state, progress: number) { 54 | state.progress = progress 55 | }, 56 | // eslint-disable-next-line sonar/function-name 57 | SET_USER(state, user: User) { 58 | state.user = user 59 | }, 60 | // eslint-disable-next-line sonar/function-name 61 | SET_ENVS(state, envs: Env) { 62 | state.envs = envs 63 | }, 64 | } 65 | 66 | export default () => 67 | new Vuex.Store({ 68 | state: { 69 | progress: 0, 70 | user: null, 71 | envs: null, 72 | }, 73 | getters, 74 | actions, 75 | mutations, 76 | }) 77 | -------------------------------------------------------------------------------- /src/utils/cookie.ts: -------------------------------------------------------------------------------- 1 | import { INFINITY_DATE } from './constant' 2 | 3 | import { SetCookie } from 'types' 4 | 5 | export const getCookie = (name: string) => 6 | decodeURIComponent( 7 | document.cookie.replace( 8 | new RegExp( 9 | '(?:(?:^|.*;)\\s*' + 10 | encodeURIComponent(name).replaceAll(/[*+.-]/g, '\\$&') + 11 | '\\s*\\=\\s*([^;]*).*$)|^.*$', 12 | ), 13 | '$1', 14 | ), 15 | ) || null 16 | 17 | export const setCookie = ( 18 | name: string, 19 | value: string, 20 | end?: Date | number | string, 21 | path?: string, 22 | domain?: string, 23 | secure?: boolean, 24 | ) => { 25 | if (!name || /^(?:expires|max-age|path|domain|secure)$/i.test(name)) { 26 | return false 27 | } 28 | let sExpires = '' 29 | if (end) { 30 | switch (end.constructor) { 31 | case Number: { 32 | sExpires = 33 | end === Number.POSITIVE_INFINITY 34 | ? `; expires=${INFINITY_DATE}` 35 | : '; max-age=' + (end as string) 36 | break 37 | } 38 | case String: { 39 | sExpires = '; expires=' + (end as string) 40 | break 41 | } 42 | case Date: { 43 | sExpires = '; expires=' + (end as Date).toUTCString() 44 | break 45 | } 46 | } 47 | } 48 | // eslint-disable-next-line unicorn/no-document-cookie 49 | document.cookie = 50 | encodeURIComponent(name) + 51 | '=' + 52 | encodeURIComponent(value == null ? '' : value) + 53 | sExpires + 54 | (domain ? '; domain=' + domain : '') + 55 | (path ? '; path=' + path : '') + 56 | (secure ? '; secure' : '') 57 | return true 58 | } 59 | 60 | export const parseSetCookies = (setCookies: string[] | string) => { 61 | if (!Array.isArray(setCookies)) { 62 | setCookies = [setCookies] 63 | } 64 | return setCookies.reduce((result, cookies) => { 65 | if (!cookies) { 66 | return result 67 | } 68 | const [item, ...rests] = cookies.split(/; */) 69 | const cookie = item.split('=') 70 | const setCookieItem = { 71 | name: cookie[0], 72 | value: cookie[1], 73 | } 74 | for (const rest of rests) { 75 | const [key, value] = rest.split('=') 76 | setCookieItem[key as keyof typeof setCookieItem] = 77 | value == null ? 'true' : value 78 | } 79 | result.push(setCookieItem) 80 | return result 81 | }, []) 82 | } 83 | -------------------------------------------------------------------------------- /server/dev.ts: -------------------------------------------------------------------------------- 1 | import _debug from 'debug' 2 | import koaWebpack from 'koa-webpack' 3 | import MFS from 'memory-fs' 4 | import webpack, { Stats } from 'webpack' 5 | 6 | import { resolve } from '../build/config' 7 | import clientConfig from '../build/vue-client' 8 | import serverConfig from '../build/vue-server' 9 | 10 | const debug = _debug('1stg:server:dev') 11 | 12 | export default (cb: (...args: unknown[]) => void) => { 13 | let _resolve: (value?: unknown) => void 14 | let clientManifest: unknown 15 | let bundle: unknown 16 | let fs: MFS 17 | 18 | // eslint-disable-next-line promise/param-names 19 | const readyPromise = new Promise(r => { 20 | _resolve = r 21 | }) 22 | 23 | const ready = (...args: unknown[]) => { 24 | _resolve() 25 | // eslint-disable-next-line n/no-callback-literal 26 | cb(...args) 27 | } 28 | 29 | const clientCompiler = webpack(clientConfig) 30 | 31 | const webpackMiddlewarePromise = koaWebpack({ 32 | compiler: clientCompiler, 33 | }) 34 | 35 | // eslint-disable-next-line sonar/deprecation 36 | clientCompiler.plugin('done', (stats: Stats) => { 37 | const statsOutput = stats.toJson() 38 | // eslint-disable-next-line unicorn/no-array-for-each 39 | statsOutput.errors.forEach(debug) 40 | // eslint-disable-next-line unicorn/no-array-for-each 41 | statsOutput.warnings.forEach(debug) 42 | 43 | if (statsOutput.errors.length > 0) { 44 | return 45 | } 46 | 47 | // eslint-disable-next-line @typescript-eslint/no-floating-promises 48 | webpackMiddlewarePromise.then(webpackMiddleware => { 49 | fs = webpackMiddleware.devMiddleware.fileSystem 50 | clientManifest = JSON.parse( 51 | fs.readFileSync(resolve('dist/vue-ssr-client-manifest.json')) as string, 52 | ) 53 | 54 | if (bundle) { 55 | ready({ bundle, clientManifest, fs }) 56 | } 57 | }) 58 | }) 59 | 60 | const mfs = new MFS() 61 | const serverCompiler = webpack(serverConfig) 62 | serverCompiler.outputFileSystem = mfs 63 | 64 | serverCompiler.watch({}, (err, stats) => { 65 | if (err) { 66 | throw err 67 | } 68 | 69 | if (stats.hasErrors()) { 70 | return 71 | } 72 | 73 | bundle = JSON.parse( 74 | mfs.readFileSync(resolve('dist/vue-ssr-server-bundle.json')) as string, 75 | ) 76 | 77 | if (clientManifest) { 78 | ready({ bundle, clientManifest, fs }) 79 | } 80 | }) 81 | 82 | return { readyPromise, webpackMiddlewarePromise } 83 | } 84 | -------------------------------------------------------------------------------- /src/entry-client.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import Vue from 'vue' 3 | 4 | import createApp from 'app' 5 | import { apollo, translate } from 'plugins' 6 | import { AsyncDataFn, Locale } from 'types' 7 | import { LOCALE_COOKIE, setCookie } from 'utils' 8 | 9 | const { app, router, store } = createApp() 10 | 11 | app.$watch('$t.locale', (curr: Locale) => { 12 | setCookie(LOCALE_COOKIE, curr, Number.POSITIVE_INFINITY, '/') 13 | }) 14 | 15 | app.$watch('$tt.loading', (curr: boolean) => { 16 | if (curr) { 17 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 18 | Vue.nextTick(translate.cache.prefetch) 19 | } 20 | }) 21 | 22 | apollo.cache.restore(window.__APOLLO_CACHE__) 23 | store.replaceState(window.__STORE_STATE__) 24 | 25 | if (!__DEV__) { 26 | delete window.__APOLLO_CACHE__ 27 | delete window.__STORE_STATE__ 28 | delete window.__TRANSLATE_CACHE__ 29 | } 30 | 31 | const SET_PROGRESS = 'SET_PROGRESS' 32 | 33 | router.onReady(() => { 34 | router.beforeResolve(async (to, from, next) => { 35 | const matched = router.getMatchedComponents(to) 36 | const prevMatched = router.getMatchedComponents(from) 37 | 38 | if (!prevMatched) { 39 | return next() 40 | } 41 | 42 | let diffed = false 43 | 44 | const activated = matched.filter( 45 | (comp, index) => diffed || (diffed = prevMatched[index] !== comp), 46 | ) 47 | 48 | // eslint-disable-next-line @typescript-eslint/no-magic-numbers 49 | store.commit(SET_PROGRESS, 70) 50 | 51 | if (activated.length > 0) { 52 | await Promise.all( 53 | activated.map( 54 | // @ts-expect-error 55 | ({ 56 | options, 57 | asyncData = options?.asyncData, 58 | }: { 59 | options?: { 60 | asyncData?: AsyncDataFn 61 | } 62 | asyncData?: AsyncDataFn 63 | }) => asyncData?.({ apollo, axios, route: to, store, translate }), 64 | ), 65 | ) 66 | await translate.cache.prefetch() 67 | } 68 | 69 | next() 70 | 71 | store.commit(SET_PROGRESS, 100) 72 | 73 | setTimeout(() => { 74 | store.commit(SET_PROGRESS, 0) 75 | }, 500) 76 | }) 77 | 78 | app.$mount('#app') 79 | }) 80 | 81 | if (module.hot) { 82 | module.hot.accept() 83 | } 84 | 85 | if ( 86 | !__DEV__ && 87 | (location.protocol === 'https:' || 88 | ['127.0.0.1', 'localhost'].includes(location.hostname)) && 89 | navigator.serviceWorker 90 | ) { 91 | // eslint-disable-next-line @typescript-eslint/no-floating-promises 92 | navigator.serviceWorker.register('/service-worker.js') 93 | } 94 | -------------------------------------------------------------------------------- /src/views/Article.vue: -------------------------------------------------------------------------------- 1 | 45 | 97 | -------------------------------------------------------------------------------- /server/router/translate.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto' 2 | 3 | import axios from 'axios' 4 | import _debug from 'debug' 5 | import googleTranslateAPI from 'google-translate-api' 6 | import { Middleware } from 'koa' 7 | import qs from 'qs' 8 | 9 | import { Locale } from 'types' 10 | import { LOCALE_COOKIE, TOGGLE_LOCALE } from 'utils' 11 | 12 | const debug = _debug('1stg:translate') 13 | 14 | interface TranslateParams { 15 | source: Locale 16 | text: string 17 | } 18 | 19 | const getTranslatePrams = (params: TranslateParams) => ({ 20 | app_id: process.env.TENCENT_AI_API_APP_ID, 21 | time_stamp: Math.ceil(Date.now() / 1000), 22 | nonce_str: Math.ceil(Math.random() * Number.MAX_SAFE_INTEGER), 23 | ...params, 24 | target: TOGGLE_LOCALE[params.source], 25 | }) 26 | 27 | const alphabeticalSort = (a: string, b: string) => (a > b ? 1 : a < b ? -1 : 0) 28 | 29 | const GoogleTranslateLocales: { 30 | [key: string]: string 31 | } = { 32 | zh: 'zh-cn', 33 | en: 'en', 34 | } 35 | 36 | const translate: Middleware = async ctx => { 37 | const { Source, SourceText } = ctx.query as Record 38 | 39 | if (!SourceText) { 40 | return 41 | } 42 | 43 | if (process.env.GOOGLE_TRANSLATE_ENABLED) { 44 | try { 45 | const translated = await googleTranslateAPI(SourceText, { 46 | from: GoogleTranslateLocales[Source], 47 | to: TOGGLE_LOCALE[Source as Locale], 48 | }) 49 | 50 | ctx.body = { 51 | text: translated.text 52 | .replaceAll(/([^<>]+)<\/\w+> Code>/g, '$1') 53 | .replaceAll(/<\/g> -([^<>]+)>/g, '') 54 | .replaceAll(/<\/ ([^<>]+)>/g, ''), 55 | } 56 | return 57 | } catch (e) { 58 | if (process.env.TRY_TENCENT_ON_GOOGLE_FAILED) { 59 | debug('Google translate failed, try Tencent translate service') 60 | } else { 61 | return ctx.throw(e) 62 | } 63 | } 64 | } 65 | 66 | const translateParams = getTranslatePrams({ 67 | source: (Source || ctx.cookies.get(LOCALE_COOKIE)) as Locale, 68 | text: SourceText, 69 | }) 70 | 71 | const { 72 | data: { 73 | data: { target_text }, 74 | msg, 75 | ret, 76 | }, 77 | } = await axios.get<{ 78 | data?: { 79 | target_text: string 80 | } 81 | msg?: string 82 | ret: number 83 | }>('https://api.ai.qq.com/fcgi-bin/nlp/nlp_texttranslate', { 84 | params: Object.assign(translateParams, { 85 | sign: crypto 86 | .createHash('md5') 87 | .update( 88 | qs.stringify(translateParams, { 89 | sort: alphabeticalSort, 90 | }) + 91 | '&app_key=' + 92 | process.env.TENCENT_AI_API_APP_KEY, 93 | 'utf8', 94 | ) 95 | .digest('hex') 96 | .toUpperCase(), 97 | }), 98 | }) 99 | 100 | if (ret !== 0) { 101 | return ctx.throw(msg) 102 | } 103 | 104 | ctx.body = { 105 | text: target_text.replaceAll(/<([^<>]+)>/g, (_matched, $1: string) => { 106 | $1 = $1.toLowerCase().trim() 107 | $1 = $1.startsWith('/') 108 | ? $1.replaceAll(' ', '') 109 | : $1 110 | // eslint-disable-next-line regexp/optimal-quantifier-concatenation 111 | .replaceAll(/([_a-z-]+)= ?" ?([^"<>]+) ?"?/g, '$1="$2"') 112 | .replaceAll(/"+/g, '"') 113 | return '<' + $1 + '>' 114 | }), 115 | } 116 | } 117 | 118 | export default translate 119 | -------------------------------------------------------------------------------- /src/views/About.vue: -------------------------------------------------------------------------------- 1 | 41 | 110 | 146 | -------------------------------------------------------------------------------- /server/router/index.ts: -------------------------------------------------------------------------------- 1 | import { InMemoryCache } from 'apollo-cache-inmemory' 2 | import ApolloClient from 'apollo-client' 3 | import { createHttpLink } from 'apollo-link-http' 4 | import axios from 'axios' 5 | import _debug from 'debug' 6 | import gql from 'graphql-tag' 7 | import Koa, { DefaultState, DefaultContext, Middleware } from 'koa' 8 | import bodyParser from 'koa-bodyparser' 9 | import compose from 'koa-compose' 10 | import Router from 'koa-router' 11 | import session from 'koa-session' 12 | import fetch from 'node-fetch' 13 | import { v4 as uuid } from 'uuid' 14 | 15 | import { serverHost, serverPort } from '../../build/config' 16 | 17 | import translate from './translate' 18 | 19 | import { User } from 'types' 20 | 21 | // @ts-expect-error 22 | global.fetch = fetch 23 | 24 | const debug = _debug('1stg:server:router') 25 | 26 | const router = new Router({ 27 | prefix: '/api', 28 | }) 29 | 30 | const STR_ENV_KEYS = [ 31 | 'GITHUB_REPOSITORY_OWNER', 32 | 'GITHUB_REPOSITORY_OWNER_TYPE', 33 | 'GITHUB_REPOSITORY_NAME', 34 | 'GITHUB_CLIENT_ID', 35 | 'GITHUB_OAUTH_CALLBACK', 36 | ] 37 | 38 | const STR_ARR_ENV_KEYS = [ 39 | 'GITHUB_EXCLUDED_LABELS', 40 | 'GITHUB_EXCLUDED_REPOSITORY_OWNERS', 41 | ] 42 | 43 | const ENV_KEYS = [...STR_ENV_KEYS, ...STR_ARR_ENV_KEYS] 44 | 45 | router 46 | .get('/fetchInfo', ctx => { 47 | const user = ctx.session.user as { 48 | uuid: string 49 | } 50 | let sessionID: string 51 | 52 | if (!user) { 53 | sessionID = uuid() 54 | ctx.session.uuid = sessionID 55 | } 56 | 57 | ctx.body = { 58 | user: user || { 59 | uuid: sessionID, 60 | }, 61 | envs: ENV_KEYS.reduce((envs, key) => { 62 | let value: string[] | string = process.env[key] 63 | 64 | if (STR_ARR_ENV_KEYS.includes(key)) { 65 | value = value ? value.split(',') : [] 66 | } 67 | 68 | return Object.assign(envs, { 69 | [key]: value, 70 | }) 71 | }, {}), 72 | } 73 | }) 74 | .get('/oauth', async ctx => { 75 | const { code, path, state } = ctx.query as Record 76 | 77 | if (!state || state !== ctx.session.uuid) { 78 | return ctx.throw('invalid oauth redirect') 79 | } 80 | 81 | const { data } = await axios.post<{ 82 | access_token?: string 83 | error?: unknown 84 | }>( 85 | 'https://github.com/login/oauth/access_token', 86 | { 87 | client_id: process.env.GITHUB_CLIENT_ID, 88 | client_secret: process.env.GITHUB_CLIENT_SECRET, 89 | code, 90 | state, 91 | }, 92 | { 93 | headers: { 94 | Accept: 'application/json', 95 | }, 96 | }, 97 | ) 98 | 99 | if (data.error) { 100 | return ctx.throw(data) 101 | } 102 | 103 | const token = data.access_token 104 | 105 | ctx.session.token = token 106 | 107 | const apollo = new ApolloClient({ 108 | link: createHttpLink({ 109 | uri: 'https://api.github.com/graphql', 110 | headers: { 111 | Authorization: `bearer ${token}`, 112 | }, 113 | }), 114 | cache: new InMemoryCache(), 115 | }) 116 | 117 | const { data: user } = await apollo.query<{ viewer: User }>({ 118 | query: gql` 119 | query { 120 | viewer { 121 | avatarUrl 122 | id 123 | login 124 | name 125 | url 126 | websiteUrl 127 | } 128 | } 129 | `, 130 | }) 131 | 132 | ctx.session.user = user.viewer 133 | 134 | ctx.redirect(`${path.replaceAll(' ', '%2B')}`) 135 | }) 136 | .get('/translate', translate) 137 | 138 | export default (app?: Koa) => { 139 | const provided = !!app 140 | 141 | const middlewares = [ 142 | bodyParser(), 143 | router.routes(), 144 | router.allowedMethods(), 145 | ] as Middleware[] 146 | 147 | if (!app) { 148 | app = new Koa() 149 | app.keys = app.keys = (process.env.APP_KEYS || '').split(',') 150 | middlewares.unshift(session({}, app)) 151 | } 152 | 153 | if (provided) { 154 | return middlewares 155 | } 156 | 157 | app.use(compose(middlewares)) 158 | 159 | app.listen(serverPort + 1, serverHost, () => { 160 | debug('Router server is now running at %s:%s', serverHost, serverPort + 1) 161 | }) 162 | } 163 | -------------------------------------------------------------------------------- /src/queries.gql: -------------------------------------------------------------------------------- 1 | query allArchives { 2 | issues { 3 | createdAt 4 | id 5 | number 6 | title 7 | } 8 | } 9 | 10 | query archives( 11 | $name: String! 12 | $owner: String! 13 | $after: String 14 | $labels: [String!] 15 | ) { 16 | repository(name: $name, owner: $owner) { 17 | issues( 18 | after: $after 19 | first: 100 20 | orderBy: { direction: DESC, field: CREATED_AT } 21 | states: OPEN 22 | labels: $labels 23 | ) { 24 | nodes { 25 | createdAt 26 | id 27 | number 28 | title 29 | } 30 | pageInfo { 31 | endCursor 32 | hasNextPage 33 | } 34 | } 35 | } 36 | } 37 | 38 | query article($name: String!, $owner: String!, $number: Int!) { 39 | repository(name: $name, owner: $owner) { 40 | issue(number: $number) { 41 | bodyHTML 42 | createdAt 43 | title 44 | url 45 | labels(first: 5) { 46 | nodes { 47 | color 48 | id 49 | name 50 | } 51 | } 52 | comments(first: 25) { 53 | nodes { 54 | author { 55 | avatarUrl 56 | login 57 | url 58 | } 59 | createdAt 60 | bodyHTML 61 | url 62 | } 63 | } 64 | } 65 | } 66 | } 67 | 68 | query articles( 69 | $name: String! 70 | $owner: String! 71 | $first: Int 72 | $last: Int 73 | $before: String 74 | $after: String 75 | $labels: [String!] 76 | ) { 77 | repository(name: $name, owner: $owner) { 78 | issues( 79 | first: $first 80 | last: $last 81 | before: $before 82 | after: $after 83 | orderBy: { direction: DESC, field: CREATED_AT } 84 | states: OPEN 85 | labels: $labels 86 | ) { 87 | nodes { 88 | createdAt 89 | id 90 | number 91 | title 92 | labels(first: 5) { 93 | nodes { 94 | color 95 | id 96 | name 97 | } 98 | } 99 | } 100 | pageInfo { 101 | endCursor 102 | hasNextPage 103 | hasPreviousPage 104 | startCursor 105 | } 106 | } 107 | } 108 | } 109 | 110 | query search( 111 | $first: Int 112 | $last: Int 113 | $before: String 114 | $after: String 115 | $search: String! 116 | ) { 117 | search( 118 | first: $first 119 | last: $last 120 | before: $before 121 | after: $after 122 | query: $search 123 | type: ISSUE 124 | ) { 125 | nodes { 126 | ... on Issue { 127 | createdAt 128 | id 129 | number 130 | title 131 | labels(first: 5) { 132 | nodes { 133 | color 134 | id 135 | name 136 | } 137 | } 138 | } 139 | } 140 | pageInfo { 141 | endCursor 142 | hasNextPage 143 | hasPreviousPage 144 | startCursor 145 | } 146 | } 147 | } 148 | 149 | query pullRequests($login: String!, $after: String) { 150 | user(login: $login) { 151 | id 152 | pullRequests( 153 | after: $after 154 | first: 100 155 | states: [MERGED, OPEN] 156 | orderBy: { direction: DESC, field: CREATED_AT } 157 | ) { 158 | nodes { 159 | ... on PullRequest { 160 | createdAt 161 | id 162 | mergedAt 163 | state 164 | title 165 | url 166 | repository { 167 | nameWithOwner 168 | url 169 | owner { 170 | login 171 | } 172 | } 173 | } 174 | } 175 | pageInfo { 176 | endCursor 177 | hasNextPage 178 | } 179 | } 180 | } 181 | } 182 | 183 | query issues($login: String!, $after: String) { 184 | user(login: $login) { 185 | id 186 | issues( 187 | after: $after 188 | first: 100 189 | orderBy: { direction: DESC, field: CREATED_AT } 190 | ) { 191 | nodes { 192 | ... on Issue { 193 | closedAt 194 | createdAt 195 | id 196 | state 197 | title 198 | url 199 | repository { 200 | nameWithOwner 201 | url 202 | owner { 203 | login 204 | } 205 | } 206 | } 207 | } 208 | pageInfo { 209 | endCursor 210 | hasNextPage 211 | } 212 | } 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/views/Archives.vue: -------------------------------------------------------------------------------- 1 | 17 | 129 | 183 | -------------------------------------------------------------------------------- /src/entry-server.ts: -------------------------------------------------------------------------------- 1 | import _axios, { AxiosRequestHeaders, AxiosResponse } from 'axios' 2 | import { LRUCache } from 'lru-cache' 3 | import serialize from 'serialize-javascript' 4 | import { createTranslator } from 'vue-translator' 5 | 6 | import createApp from 'app' 7 | import { createTranslate } from 'plugins' 8 | import { Apollo, ServerContext, AsyncDataFn } from 'types' 9 | import { DEFAULT_LOCALE, parseSetCookies } from 'utils' 10 | 11 | const SET_COOKIE = 'set-cookie' 12 | 13 | const KOA_SESS_SIG = 'koa:sess.sig' 14 | 15 | const SCRIPT_SUFFIX = __DEV__ 16 | ? '' 17 | : ';(function(){var s;(s=document.currentScript||document.scripts[document.scripts.length-1]).parentNode.removeChild(s);}())' 18 | 19 | const cache = new LRUCache({ 20 | max: 1000, 21 | ttl: 1000 * 60 * 15, 22 | }) 23 | 24 | export default (context: ServerContext) => 25 | // eslint-disable-next-line no-async-promise-executor, @typescript-eslint/no-misused-promises, sonarjs/cognitive-complexity 26 | new Promise(async (resolve, reject) => { 27 | const start: boolean | number = __DEV__ && Date.now() 28 | 29 | const { ctx, locale } = context 30 | 31 | const { app, createApollo, router, store } = createApp() 32 | 33 | const { url, headers } = ctx 34 | const { fullPath } = router.resolve(url).route 35 | 36 | if (fullPath !== url) { 37 | return reject( 38 | Object.assign(new Error('redirect'), { status: 302, url: fullPath }), 39 | ) 40 | } 41 | 42 | const axios = _axios.create({ headers: headers as AxiosRequestHeaders }) 43 | 44 | let apollo = cache.get(url) 45 | 46 | if (!apollo) { 47 | cache.set(url, (apollo = createApollo())) 48 | } 49 | 50 | const translator = createTranslator({ 51 | locale, 52 | defaultLocale: DEFAULT_LOCALE, 53 | }) 54 | 55 | const translate = createTranslate(translator) 56 | 57 | Object.assign(context, { 58 | apollo, 59 | axios, 60 | translate, 61 | translator, 62 | }) 63 | 64 | axios.interceptors.response.use( 65 | response => { 66 | const { headers } = response 67 | 68 | const cookies = headers[SET_COOKIE] 69 | 70 | for (const { 71 | name, 72 | expires, 73 | httponly: httpOnly, 74 | path, 75 | value, 76 | } of parseSetCookies(cookies)) { 77 | if (name !== KOA_SESS_SIG) { 78 | ctx.cookies.set(name, value, { 79 | expires: expires && new Date(expires), 80 | httpOnly, 81 | path, 82 | }) 83 | } 84 | } 85 | 86 | return response 87 | }, 88 | (e: { response: AxiosResponse }) => { 89 | console.error('error:', e) 90 | if (e.response) { 91 | ctx.set(e.response.headers) 92 | } 93 | reject(e.response || e) 94 | }, 95 | ) 96 | 97 | await store.dispatch('fetchInfo', { apollo, axios }) 98 | 99 | // eslint-disable-next-line @typescript-eslint/no-floating-promises 100 | router.push(url) 101 | 102 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 103 | router.onReady(async () => { 104 | const matched = router.getMatchedComponents() 105 | 106 | if (matched.length === 0) { 107 | console.error('no matched components') 108 | return reject(Object.assign(new Error('not found'), { status: 404 })) 109 | } 110 | 111 | const { currentRoute: route } = router 112 | 113 | if (route.fullPath !== url) { 114 | return reject( 115 | Object.assign(new Error('redirect'), { 116 | status: 302, 117 | url: route.fullPath, 118 | }), 119 | ) 120 | } 121 | 122 | try { 123 | await Promise.all( 124 | matched.map( 125 | // @ts-expect-error 126 | ({ 127 | options, 128 | asyncData = options?.asyncData, 129 | }: { 130 | options?: { 131 | asyncData?: AsyncDataFn 132 | } 133 | asyncData?: AsyncDataFn 134 | }) => asyncData?.({ apollo, axios, route, store, translate }), 135 | ), 136 | ) 137 | await translate.cache.prefetch() 138 | } catch (e) { 139 | const err = e as { 140 | response?: AxiosResponse 141 | } 142 | return reject(err.response ? err.response.data : e) 143 | } 144 | 145 | if (__DEV__) { 146 | console.info(`data pre-fetch: ${Date.now() - start}ms`) 147 | } 148 | 149 | context.script = `` 159 | 160 | resolve(app) 161 | }, reject) 162 | }) 163 | -------------------------------------------------------------------------------- /src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 36 | 194 | 204 | -------------------------------------------------------------------------------- /src/plugins/translate.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import Vue from 'vue' 3 | import { Translations, Translator } from 'vue-translator' 4 | 5 | import { Locale, ServerContext } from 'types' 6 | import { DEFAULT_LOCALE, LOCALES } from 'utils' 7 | 8 | enum Placeholder { 9 | TITLE = 'title', 10 | CONTENT = 'content', 11 | } 12 | 13 | const titlePlaceholder = (locale: string) => ({ 14 | locale, 15 | value: `[${locale}]`, 16 | }) 17 | 18 | const contentPlaceholder = (locale: string) => ({ 19 | locale, 20 | value: `

[${locale}]

`, 21 | }) 22 | 23 | const allPlaceholders = { 24 | [Placeholder.TITLE]: LOCALES.map(titlePlaceholder), 25 | [Placeholder.CONTENT]: LOCALES.map(contentPlaceholder), 26 | } 27 | 28 | const endPlaceholders = { 29 | [Placeholder.TITLE]: titlePlaceholder('_end_').value, 30 | [Placeholder.CONTENT]: contentPlaceholder('end').value, 31 | } 32 | 33 | const ERROR_TIPS: Record = { 34 | zh: '翻译出现错误', 35 | en: 'Translation error occurs', 36 | } 37 | 38 | const getErrorTip = (locale: string, type: boolean) => { 39 | const errorTip = ERROR_TIPS[locale] || ERROR_TIPS.en 40 | return type ? errorTip + ': ' : `

${errorTip}

` 41 | } 42 | 43 | export interface Translate { 44 | (template: string, type?: boolean): string 45 | cache?: TranslateCache 46 | loading?: boolean 47 | } 48 | 49 | export interface TranslateCacheData { 50 | [key: string]: string 51 | } 52 | 53 | export interface TranslateCache { 54 | extract: () => TranslateCacheData 55 | prefetch: () => Promise 56 | } 57 | 58 | /** 59 | * DSL: 60 | * 61 | * title: header [en] title [zh] 标题 [_end_] footer 62 | * 63 | * content: 64 | * ``` 65 | * header 66 | * 67 | * [en] 68 | * 69 | * Wait for update 70 | * 71 | * [zh] 72 | * 73 | * 等待更新 74 | * 75 | * [_end_] 76 | * 77 | * footer 78 | * ``` 79 | */ 80 | export const createTranslate = ( 81 | translator: Translator = Vue.translator, 82 | ): Translate => { 83 | const cacheData: TranslateCacheData = 84 | (!__SERVER__ && window.__TRANSLATE_CACHE__) || {} 85 | const storages: Array> = [] 86 | 87 | // eslint-disable-next-line sonarjs/cognitive-complexity 88 | const instance: Translate = (template, type = true) => { 89 | const placeholder = type ? Placeholder.TITLE : Placeholder.CONTENT 90 | const placeholders = allPlaceholders[placeholder] 91 | 92 | let startIndex: number 93 | 94 | placeholders.some(({ value }) => { 95 | startIndex = template.indexOf(value) 96 | return startIndex !== -1 97 | }) 98 | 99 | if (startIndex === -1) { 100 | return template 101 | } 102 | 103 | const start = template.slice(0, Math.max(0, startIndex)) 104 | 105 | const endIndex = template.indexOf(endPlaceholders[placeholder]) 106 | 107 | const hasEnd = endIndex !== -1 108 | 109 | const end = hasEnd 110 | ? template.slice(endIndex + endPlaceholders[placeholder].length) 111 | : '' 112 | 113 | const main = hasEnd 114 | ? template.slice(startIndex, endIndex) 115 | : template.slice(startIndex) 116 | 117 | const indexes: Array<{ 118 | locale: Locale 119 | placeholder: string 120 | index: number 121 | }> = [] 122 | 123 | for (const item of placeholders) { 124 | const index = main.indexOf(item.value) 125 | if (index !== -1) { 126 | indexes.push({ 127 | locale: item.locale as Locale, 128 | placeholder: item.value, 129 | index, 130 | }) 131 | } 132 | } 133 | 134 | indexes.sort((x, y) => x.index - y.index) 135 | 136 | const translations: Translations = {} 137 | 138 | let firstLocale: string 139 | let firstTranslation: string 140 | 141 | for (const [index, item] of indexes.entries()) { 142 | const itemIndex = item.index + item.placeholder.length 143 | const translation = 144 | index === indexes.length - 1 145 | ? main.slice(itemIndex) 146 | : main.slice(itemIndex, indexes[index + 1].index) 147 | 148 | if (!index) { 149 | firstLocale = item.locale 150 | firstTranslation = translation 151 | } 152 | 153 | translations[item.locale] = translation 154 | } 155 | 156 | const locale = translator.locale as Locale 157 | 158 | let body = (translations[locale] || translations[DEFAULT_LOCALE]) as string 159 | 160 | if (body == null) { 161 | body = cacheData[main] 162 | 163 | if (!body) { 164 | body = cacheData[main] = 165 | translator('translating') + translator('ellipsis') 166 | 167 | instance.loading = true 168 | 169 | const storage = axios 170 | .get<{ text: string }>('/translate', { 171 | params: { 172 | Source: firstLocale, 173 | SourceText: firstTranslation, 174 | }, 175 | }) 176 | .then(({ data: { text } }) => { 177 | cacheData[main] = text 178 | }) 179 | .catch(() => { 180 | cacheData[main] = `${getErrorTip( 181 | firstLocale, 182 | type, 183 | )}${firstTranslation}` 184 | }) 185 | 186 | storages.push(storage) 187 | } 188 | } 189 | 190 | return start + (body || firstTranslation) + end 191 | } 192 | 193 | instance.cache = { 194 | extract: () => cacheData, 195 | prefetch: () => 196 | Promise.all(storages).then(() => { 197 | storages.length = 0 198 | instance.loading = false 199 | }), 200 | } 201 | ;( 202 | Vue.util as { 203 | defineReactive: (obj: object, key: string, val: unknown) => void 204 | warn: (msg: string) => void 205 | } 206 | ).defineReactive(instance, 'loading', false) 207 | 208 | return instance 209 | } 210 | 211 | export const translate = __SERVER__ ? null : createTranslate() 212 | 213 | Object.defineProperty( 214 | Vue.prototype, 215 | '$tt', 216 | __SERVER__ 217 | ? { 218 | configurable: __DEV__, 219 | get(this: Vue) { 220 | return (this.$ssrContext as ServerContext).translate 221 | }, 222 | } 223 | : { 224 | value: translate, 225 | writable: __DEV__, 226 | }, 227 | ) 228 | -------------------------------------------------------------------------------- /src/views/Pulse.vue: -------------------------------------------------------------------------------- 1 | 32 | 225 | 251 | -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | 3 | import acceptLanguage from 'accept-language' 4 | import _debug from 'debug' 5 | import Koa, { Middleware } from 'koa' 6 | import proxy from 'koa-better-http-proxy' 7 | import compose from 'koa-compose' 8 | import compress from 'koa-compress' 9 | import logger from 'koa-logger' 10 | import session from 'koa-session' 11 | import staticCache from 'koa-static-cache' 12 | import { LRUCache } from 'lru-cache' 13 | import { 14 | BundleRenderer, 15 | RenderCache, 16 | createBundleRenderer, 17 | } from 'vue-server-renderer' 18 | 19 | import { 20 | resolve, 21 | runtimeRequire, 22 | serverHost, 23 | serverPort, 24 | } from '../build/config' 25 | 26 | import startRouter from './router' 27 | 28 | import { INFINITY_DATE, LOCALES, LOCALE_COOKIE, TITLE } from 'utils' 29 | 30 | acceptLanguage.languages(LOCALES) 31 | 32 | const ACCEPT_LANGUAGE = 'Accept-Language' 33 | 34 | const REDIRECT = 301 35 | const UNAUTHORIZED = 401 36 | const NOT_FOUND = 404 37 | 38 | const debug = _debug( 39 | `1stg:server${process.env.NODE_ENV === 'development' ? ':core' : ''}`, 40 | ) 41 | 42 | const app = new Koa() 43 | 44 | app.keys = (process.env.APP_KEYS || '').split(',') 45 | 46 | let renderer: BundleRenderer 47 | let ready: Promise 48 | 49 | const template: string = 50 | process.env.NODE_ENV === 'development' 51 | ? // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires 52 | (require('pug') as typeof import('pug')).renderFile( 53 | resolve('server/template.pug'), 54 | { 55 | pretty: true, 56 | }, 57 | ) 58 | : fs.readFileSync(resolve('dist/template.html'), 'utf8') 59 | 60 | // https://github.com/vuejs/vue/blob/dev/packages/vue-server-renderer/README.md#why-use-bundlerenderer 61 | const createRenderer = (bundle: object, options: object) => 62 | createBundleRenderer(bundle, { 63 | ...options, 64 | template, 65 | inject: false, 66 | cache: new LRUCache({ 67 | max: 1000, 68 | ttl: 1000 * 60 * 15, 69 | }) as unknown as RenderCache, 70 | basedir: resolve('dist/static'), 71 | runInNewContext: false, 72 | }) 73 | 74 | const middlewares: Koa.Middleware[] = [ 75 | logger(), 76 | async (ctx, next) => { 77 | const { cookies, method, url } = ctx 78 | 79 | if ( 80 | method !== 'GET' || 81 | url.lastIndexOf('.') > url.lastIndexOf('/') || 82 | !['*/*', 'text/html'].some(mimeType => 83 | ctx.get('Accept').includes(mimeType), 84 | ) || 85 | /^\/(?:api|graphql)(?:$|\/)/.test(url) 86 | ) { 87 | return next() 88 | } 89 | 90 | await ready 91 | 92 | const start = Date.now() 93 | 94 | const originalLocale = cookies.get(LOCALE_COOKIE) 95 | 96 | const locale = 97 | originalLocale || acceptLanguage.get(ctx.get(ACCEPT_LANGUAGE)) 98 | 99 | if (!originalLocale) { 100 | cookies.set(LOCALE_COOKIE, locale, { 101 | httpOnly: false, 102 | path: '/', 103 | expires: new Date(INFINITY_DATE), 104 | }) 105 | } 106 | 107 | const context = { ctx, locale, title: TITLE } 108 | 109 | ctx.respond = false 110 | ctx.status = 200 111 | ctx.set({ 112 | 'Content-Type': 'text/html', 113 | }) 114 | 115 | const { res } = ctx 116 | 117 | const stream = renderer 118 | .renderToStream(context) 119 | .on('error', (e: Error & { status: number; url: string }) => { 120 | switch ((ctx.status = e.status || 500)) { 121 | case REDIRECT: { 122 | ctx.set({ Location: e.url }) 123 | return res.end() 124 | } 125 | case UNAUTHORIZED: { 126 | ctx.redirect(`/login?next=${url}`) 127 | return res.end() 128 | } 129 | case NOT_FOUND: { 130 | return res.end('404 | Page Not Found') 131 | } 132 | default: { 133 | res.end('500 | Internal Server Error') 134 | debug(`error during render : ${url}`) 135 | console.error(e.stack) 136 | } 137 | } 138 | }) 139 | .on('end', () => { 140 | debug(`whole request: ${Date.now() - start}ms`) 141 | }) 142 | 143 | stream.pipe(res) 144 | }, 145 | ] 146 | 147 | const MAX_AGE = 1000 * 3600 * 24 * 365 // one year 148 | 149 | const publicStatic = staticCache('public', { maxAge: MAX_AGE }) 150 | const sessionMiddleware = session({}, app) 151 | 152 | if (process.env.NODE_ENV === 'development') { 153 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 154 | const { 155 | readyPromise, 156 | webpackMiddlewarePromise, 157 | }: { 158 | readyPromise: Promise 159 | webpackMiddlewarePromise: Promise 160 | // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-var-requires 161 | } = require('./dev').default( 162 | ({ 163 | bundle, 164 | clientManifest, 165 | }: { 166 | bundle: object 167 | clientManifest: unknown 168 | }) => { 169 | renderer = createRenderer(bundle, { clientManifest }) 170 | }, 171 | ) 172 | 173 | ready = readyPromise 174 | 175 | middlewares.splice( 176 | 1, 177 | 0, 178 | publicStatic, 179 | sessionMiddleware, 180 | proxy(serverHost, { 181 | port: serverPort + 1, 182 | preserveReqSession: true, 183 | filter: ctx => ctx.url.startsWith('/api/'), 184 | }), 185 | ) 186 | 187 | // eslint-disable-next-line @typescript-eslint/no-floating-promises, unicorn/prefer-top-level-await 188 | webpackMiddlewarePromise.then(webpackMiddleware => app.use(webpackMiddleware)) 189 | } else { 190 | renderer = createRenderer( 191 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 192 | runtimeRequire(resolve('dist/vue-ssr-server-bundle.json')), 193 | { 194 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 195 | clientManifest: runtimeRequire( 196 | resolve('dist/vue-ssr-client-manifest.json'), 197 | ), 198 | }, 199 | ) 200 | 201 | const files: staticCache.Files = {} 202 | 203 | middlewares.splice( 204 | 1, 205 | 0, 206 | compress(), 207 | publicStatic, 208 | staticCache('dist/static', { maxAge: MAX_AGE }, files), 209 | sessionMiddleware, 210 | ...startRouter(app), 211 | ) 212 | 213 | files['/service-worker.js'].maxAge = 0 214 | } 215 | 216 | middlewares.push( 217 | proxy('api.github.com/graphql', { 218 | filter: ctx => ctx.url === '/graphql', 219 | https: true, 220 | proxyReqOptDecorator(req, ctx) { 221 | req.headers.Authorization = `bearer ${ 222 | (ctx.session.token as string) || process.env.GITHUB_TOKEN 223 | }` 224 | return req 225 | }, 226 | }), 227 | ) 228 | 229 | app.use(compose(middlewares)) 230 | 231 | app.listen(serverPort, serverHost, () => { 232 | debug('Server is now running at %s:%s', serverHost, serverPort) 233 | }) 234 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@1stg/blog", 3 | "version": "0.0.0", 4 | "description": "a Blog system built on GitHub GraphQL API with Vue SSR", 5 | "repository": "git@github.com:JounQin/blog.git", 6 | "author": "JounQin ", 7 | "license": "MIT", 8 | "private": true, 9 | "packageManager": "yarn@4.0.2", 10 | "scripts": { 11 | "build": "rimraf dist && mkdirp dist/static && cross-env NODE_ENV=production env-cmd run-p 'build:**'", 12 | "build:server": "webpack --config build/server.ts --color --progress", 13 | "build:template": "ts-node build/template", 14 | "build:vue:client": "webpack --config build/vue-client.ts --color --progress", 15 | "build:vue:server": "webpack --config build/vue-server.ts --color --progress", 16 | "dev": "env-cmd run-p server watch", 17 | "lint": "run-p 'lint:*'", 18 | "lint:es": "cross-env PARSER_NO_WATCH=true eslint . --cache -f friendly", 19 | "lint:style": "stylelint --cache 'src/**/*.{scss,vue}'", 20 | "lint:tsc": "tsc --noEmit", 21 | "prepare": "cpy env.js . --rename=.env.js && simple-git-hooks", 22 | "server": "ts-node -T -r tsconfig-paths/register server", 23 | "server:router": "ts-node -T -r tsconfig-paths/register server/router/dev", 24 | "start": "env-cmd node dist/server", 25 | "typecov": "type-coverage", 26 | "watch": "nodemon" 27 | }, 28 | "dependencies": { 29 | "accept-language": "^3.0.18", 30 | "apollo-cache-inmemory": "^1.6.6", 31 | "apollo-client": "^2.6.10", 32 | "apollo-link-http": "^1.5.17", 33 | "axios": "^0.27.2", 34 | "date-fns": "^2.30.0", 35 | "debug": "^4.3.4", 36 | "dotenv": "^16.3.1", 37 | "env-cmd": "^10.1.0", 38 | "github-markdown-css": "^5.4.0", 39 | "google-translate-api": "^2.3.0", 40 | "graphql": "^16.8.1", 41 | "graphql-tag": "^2.12.6", 42 | "invert-color": "^2.0.0", 43 | "koa": "^2.14.2", 44 | "koa-better-http-proxy": "^0.2.10", 45 | "koa-bodyparser": "^4.4.1", 46 | "koa-compose": "^4.1.0", 47 | "koa-compress": "^5.1.1", 48 | "koa-logger": "^3.2.1", 49 | "koa-router": "^12.0.1", 50 | "koa-session": "^6.4.0", 51 | "koa-static-cache": "^5.1.4", 52 | "lodash": "^4.17.21", 53 | "lru-cache": "^10.0.2", 54 | "memory-fs": "^0.5.0", 55 | "node-fetch": "^2.7.0", 56 | "qs": "^6.11.2", 57 | "serialize-javascript": "^6.0.1", 58 | "uuid": "^9.0.1", 59 | "vue": "^2.7.15", 60 | "vue-class-component": "^7.2.6", 61 | "vue-property-decorator": "^9.1.2", 62 | "vue-router": "^3.6.5", 63 | "vue-server-renderer": "^2.7.15", 64 | "vue-translator": "^0.9.6", 65 | "vuex": "^3.6.2", 66 | "vuex-class": "^0.3.2" 67 | }, 68 | "devDependencies": { 69 | "@1stg/app-config": "^9.0.0", 70 | "@types/debug": "^4.1.12", 71 | "@types/html-minifier": "^4", 72 | "@types/html-webpack-plugin": "^3.2.9", 73 | "@types/koa": "^2.13.11", 74 | "@types/koa-bodyparser": "^4.3.12", 75 | "@types/koa-compose": "^3.2.5", 76 | "@types/koa-compress": "^4.0.6", 77 | "@types/koa-logger": "^3.1.5", 78 | "@types/koa-router": "^7.4.7", 79 | "@types/koa-session": "^6.4.5", 80 | "@types/koa-static-cache": "^5.1.4", 81 | "@types/koa-webpack": "^6.0.8", 82 | "@types/lodash": "^4.14.201", 83 | "@types/lru-cache": "^7.10.10", 84 | "@types/memory-fs": "^0.3.7", 85 | "@types/mini-css-extract-plugin": "^1.2.2", 86 | "@types/node": "^20.9.0", 87 | "@types/node-fetch": "^2.6.9", 88 | "@types/prettier": "^2.7.3", 89 | "@types/pug": "^2.0.9", 90 | "@types/qs": "^6.9.10", 91 | "@types/serialize-javascript": "^5.0.4", 92 | "@types/uuid": "^9.0.7", 93 | "@types/webpack": "^4.41.26", 94 | "@types/webpack-env": "^1.18.4", 95 | "@types/webpack-merge": "^4.1.5", 96 | "@types/webpack-node-externals": "^3.0.4", 97 | "@types/workbox-webpack-plugin": "^6.0.0", 98 | "bootstrap": "^5.3.2", 99 | "commitlint": "^18.4.2", 100 | "cpy-cli": "^5.0.0", 101 | "cross-env": "^7.0.3", 102 | "css-loader": "^5.2.7", 103 | "eslint": "^8.53.0", 104 | "file-loader": "^6.2.0", 105 | "font-awesome": "^4.7.0", 106 | "fork-ts-checker-webpack-plugin": "^6.5.2", 107 | "glob": "^10.3.10", 108 | "google-fonts-webpack-plugin": "^0.4.4", 109 | "html-loader": "^1.3.2", 110 | "html-minifier": "^4.0.0", 111 | "html-webpack-plugin": "^4.5.2", 112 | "koa-webpack": "^6.0.0", 113 | "lint-staged": "^13.3.0", 114 | "lodash-es": "^4.17.21", 115 | "mini-css-extract-plugin": "^1.6.2", 116 | "mkdirp": "^3.0.1", 117 | "nodemon": "^3.0.1", 118 | "npm-run-all": "^4.1.5", 119 | "postcss-loader": "^4.2.0", 120 | "postcss-pxtorem": "^6.0.0", 121 | "prettier": "^2.8.8", 122 | "pug": "^3.0.2", 123 | "pug-plain-loader": "^1.1.0", 124 | "purgecss-webpack-plugin": "^4.1.3", 125 | "purgecss-whitelister": "^2.4.0", 126 | "resolve-url-loader": "^5.0.0", 127 | "rimraf": "^5.0.5", 128 | "sass": "^1.69.5", 129 | "sass-loader": "^10.4.1", 130 | "simple-git-hooks": "^2.9.0", 131 | "style-resources-loader": "^1.5.0", 132 | "stylelint": "^15.11.0", 133 | "ts-loader": "^8.4.0", 134 | "ts-node": "^10.9.1", 135 | "tsconfig-paths": "^4.2.0", 136 | "type-coverage": "^2.27.0", 137 | "typeface-lato": "^1.1.13", 138 | "typescript": "^5.2.2", 139 | "url-loader": "^4.1.1", 140 | "vue-loader": "^15.11.1", 141 | "vue-template-compiler": "^2.7.15", 142 | "webpack": "^4.47.0", 143 | "webpack-cli": "^4.0.0-rc.1", 144 | "webpack-merge": "^4.2.2", 145 | "webpack-node-externals": "^3.0.0", 146 | "workbox-webpack-plugin": "^7.0.0" 147 | }, 148 | "resolutions": { 149 | "prettier": "^2.8.8", 150 | "terser-webpack-plugin": "^4.2.3", 151 | "webpack": "^4.47.0" 152 | }, 153 | "browserslist": [ 154 | "extends @1stg/browserslist-config/modern" 155 | ], 156 | "commitlint": { 157 | "extends": [ 158 | "github>1stG/configs" 159 | ] 160 | }, 161 | "nodemonConfig": { 162 | "exec": "yarn server:router", 163 | "ext": "ts", 164 | "watch": [ 165 | ".env.local", 166 | "server/router" 167 | ] 168 | }, 169 | "remarkConfig": { 170 | "plugins": [ 171 | "@1stg/remark-preset" 172 | ] 173 | }, 174 | "renovate": { 175 | "extends": [ 176 | "@1stg" 177 | ] 178 | }, 179 | "stylelint": { 180 | "extends": [ 181 | "@1stg/stylelint-config", 182 | "@1stg/stylelint-config/scss", 183 | "@1stg/stylelint-config/modules" 184 | ], 185 | "rules": { 186 | "import-notation": "string" 187 | }, 188 | "overrides": [ 189 | { 190 | "files": [ 191 | "*.scss", 192 | "*.vue" 193 | ], 194 | "rules": { 195 | "scss/at-import-no-partial-leading-underscore": null, 196 | "scss/load-no-partial-leading-underscore": true 197 | } 198 | }, 199 | { 200 | "files": [ 201 | "*.vue" 202 | ], 203 | "customSyntax": "postcss-html" 204 | } 205 | ] 206 | }, 207 | "typeCoverage": { 208 | "atLeast": 98.99, 209 | "detail": true, 210 | "ignoreAsAssertion": true, 211 | "ignoreFiles": [ 212 | "src/types/schema.ts", 213 | "*.d.ts" 214 | ], 215 | "ignoreNested": true, 216 | "showRelativePath": true, 217 | "skipCatch": true, 218 | "strict": true, 219 | "update": true 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /src/views/App.vue: -------------------------------------------------------------------------------- 1 | 78 | 237 | 242 | 373 | 480 | --------------------------------------------------------------------------------