├── .husky ├── common.sh ├── .gitignore ├── pre-commit ├── commit-msg └── prepare-commit-msg ├── types └── pako.d.ts ├── api ├── package.json ├── db │ ├── dbConstants.ts │ ├── getTop10Records.ts │ ├── getRecordById.ts │ ├── insertRecord.ts │ └── index.ts ├── types.ts ├── tsconfig.json ├── getLink.ts └── createLink.ts ├── globals.d.ts ├── .prettierrc ├── constants ├── constants.ts └── messages.ts ├── static ├── ads.txt ├── diff.png ├── icon.png ├── dark-favicon.ico ├── light-favicon.ico ├── dark-apple-touch-icon.png ├── light-favicon-16x16.png ├── light-favicon-32x32.png ├── light-apple-touch-icon.png ├── dark-android-chrome-192x192.png ├── dark-android-chrome-512x512.png ├── dark-apple-touch-icon-57x57.png ├── dark-apple-touch-icon-72x72.png ├── dark-apple-touch-icon-76x76.png ├── light-android-chrome-192x192.png ├── light-android-chrome-512x512.png ├── light-apple-touch-icon-57x57.png ├── light-apple-touch-icon-72x72.png ├── light-apple-touch-icon-76x76.png ├── dark-apple-touch-icon-114x114.png ├── dark-apple-touch-icon-120x120.png ├── dark-apple-touch-icon-144x144.png ├── dark-apple-touch-icon-152x152.png ├── dark-apple-touch-icon-180x180.png ├── light-apple-touch-icon-114x114.png ├── light-apple-touch-icon-120x120.png ├── light-apple-touch-icon-144x144.png ├── light-apple-touch-icon-152x152.png ├── light-apple-touch-icon-180x180.png └── manifest.json ├── commitlint.config.js ├── stylelint.config.js ├── .editorconfig ├── .babelrc ├── .github ├── semantic.yml ├── dependabot.yml └── workflows │ ├── release.yml │ └── ci.yml ├── layouts └── main.vue ├── vercel.json ├── nginx └── default.conf ├── components ├── icons │ ├── forward.vue │ ├── back.vue │ ├── bin.vue │ ├── up.vue │ ├── down.vue │ ├── sun.vue │ ├── link.vue │ ├── prettyCode.vue │ ├── copied.vue │ ├── moon.vue │ ├── swap.vue │ ├── nuxt.vue │ ├── tailwind.vue │ ├── diffStyle.vue │ ├── brand.vue │ ├── github.vue │ └── sponsor.vue ├── buttons │ ├── skipToNav.vue │ ├── toggleInSync.vue │ ├── prevDiff.vue │ ├── nextDiff.vue │ ├── swapDiffContent.vue │ ├── diffStyle.vue │ ├── copyLink.vue │ ├── stickyCopy.vue │ └── toggleInlineDiffView.vue ├── inlineDiff.vue ├── footer.vue ├── v2 │ ├── footer.vue │ ├── navbar.vue │ └── diffActionBar.vue ├── Toast.vue ├── singleDiff.vue ├── navbar.vue └── diffActionBar.vue ├── docker-compose.yml ├── store ├── README.md ├── scrollInSync.ts ├── inlineDiffView.ts ├── theme.ts ├── data.ts └── toast.ts ├── Dockerfile ├── .eslintrc.js ├── helpers ├── isSkipTutorialCookie.ts ├── isDarkModeCookie.ts ├── isSkipCopyE2ELinkTutorial.ts ├── isSkipScrollInSyncTutorial.ts ├── isSkipCopyLinkShortcutTutorial.ts ├── isSkipSubmitKbdShortcutTutorial.ts ├── isSkipBackButtonPersistsDataTutorial.ts ├── decrypt.ts ├── encrypt.ts ├── types.ts ├── utils.ts └── driverjsTutorials.ts ├── app.html ├── jest.config.js ├── .vscode └── launch.json ├── tsconfig.json ├── styles └── global.scss ├── LICENSE ├── .dockerignore ├── .gitignore ├── plugins └── cookie-injector.client.ts ├── tailwind.config.js ├── package.json ├── pages ├── v1 │ └── diff.vue ├── index.vue ├── diff.vue └── v2 │ ├── diff.vue │ └── index.vue ├── README.md └── nuxt.config.js /.husky/common.sh: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /types/pako.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'pako' 2 | -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module" 3 | } 4 | -------------------------------------------------------------------------------- /globals.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'diff-match-patch' 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /api/db/dbConstants.ts: -------------------------------------------------------------------------------- 1 | export const DB_SCHEMA = process.env.DB_SCHEMA 2 | -------------------------------------------------------------------------------- /constants/constants.ts: -------------------------------------------------------------------------------- 1 | export const SIMPLE_DIFF_CHARACTER_LIMIT = 10000; 2 | -------------------------------------------------------------------------------- /static/ads.txt: -------------------------------------------------------------------------------- 1 | google.com, pub-4467877923505914, DIRECT, f08c47fec0942fa0 2 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["@commitlint/config-conventional"], 3 | }; 4 | -------------------------------------------------------------------------------- /static/diff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technikhil314/offline-diff-viewer/HEAD/static/diff.png -------------------------------------------------------------------------------- /static/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technikhil314/offline-diff-viewer/HEAD/static/icon.png -------------------------------------------------------------------------------- /static/dark-favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technikhil314/offline-diff-viewer/HEAD/static/dark-favicon.ico -------------------------------------------------------------------------------- /static/light-favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technikhil314/offline-diff-viewer/HEAD/static/light-favicon.ico -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | . "$(dirname "$0")/common.sh" 4 | 5 | npx lint-staged 6 | -------------------------------------------------------------------------------- /static/dark-apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technikhil314/offline-diff-viewer/HEAD/static/dark-apple-touch-icon.png -------------------------------------------------------------------------------- /static/light-favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technikhil314/offline-diff-viewer/HEAD/static/light-favicon-16x16.png -------------------------------------------------------------------------------- /static/light-favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technikhil314/offline-diff-viewer/HEAD/static/light-favicon-32x32.png -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | . "$(dirname "$0")/common.sh" 4 | 5 | npx commitlint --edit $1 6 | -------------------------------------------------------------------------------- /static/light-apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technikhil314/offline-diff-viewer/HEAD/static/light-apple-touch-icon.png -------------------------------------------------------------------------------- /static/dark-android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technikhil314/offline-diff-viewer/HEAD/static/dark-android-chrome-192x192.png -------------------------------------------------------------------------------- /static/dark-android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technikhil314/offline-diff-viewer/HEAD/static/dark-android-chrome-512x512.png -------------------------------------------------------------------------------- /static/dark-apple-touch-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technikhil314/offline-diff-viewer/HEAD/static/dark-apple-touch-icon-57x57.png -------------------------------------------------------------------------------- /static/dark-apple-touch-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technikhil314/offline-diff-viewer/HEAD/static/dark-apple-touch-icon-72x72.png -------------------------------------------------------------------------------- /static/dark-apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technikhil314/offline-diff-viewer/HEAD/static/dark-apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /static/light-android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technikhil314/offline-diff-viewer/HEAD/static/light-android-chrome-192x192.png -------------------------------------------------------------------------------- /static/light-android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technikhil314/offline-diff-viewer/HEAD/static/light-android-chrome-512x512.png -------------------------------------------------------------------------------- /static/light-apple-touch-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technikhil314/offline-diff-viewer/HEAD/static/light-apple-touch-icon-57x57.png -------------------------------------------------------------------------------- /static/light-apple-touch-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technikhil314/offline-diff-viewer/HEAD/static/light-apple-touch-icon-72x72.png -------------------------------------------------------------------------------- /static/light-apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technikhil314/offline-diff-viewer/HEAD/static/light-apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /static/dark-apple-touch-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technikhil314/offline-diff-viewer/HEAD/static/dark-apple-touch-icon-114x114.png -------------------------------------------------------------------------------- /static/dark-apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technikhil314/offline-diff-viewer/HEAD/static/dark-apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /static/dark-apple-touch-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technikhil314/offline-diff-viewer/HEAD/static/dark-apple-touch-icon-144x144.png -------------------------------------------------------------------------------- /static/dark-apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technikhil314/offline-diff-viewer/HEAD/static/dark-apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /static/dark-apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technikhil314/offline-diff-viewer/HEAD/static/dark-apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /static/light-apple-touch-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technikhil314/offline-diff-viewer/HEAD/static/light-apple-touch-icon-114x114.png -------------------------------------------------------------------------------- /static/light-apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technikhil314/offline-diff-viewer/HEAD/static/light-apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /static/light-apple-touch-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technikhil314/offline-diff-viewer/HEAD/static/light-apple-touch-icon-144x144.png -------------------------------------------------------------------------------- /static/light-apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technikhil314/offline-diff-viewer/HEAD/static/light-apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /static/light-apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technikhil314/offline-diff-viewer/HEAD/static/light-apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /api/types.ts: -------------------------------------------------------------------------------- 1 | export interface DBRecord { 2 | data: string 3 | id: string 4 | creationTimestamp: string 5 | } 6 | 7 | export type DBInsertRecord = Omit 8 | -------------------------------------------------------------------------------- /stylelint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'stylelint-config-standard', 4 | 'stylelint-config-prettier' 5 | ], 6 | // add your custom config here 7 | // https://stylelint.io/user-guide/configuration 8 | rules: {} 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "test": { 4 | "presets": [ 5 | [ 6 | "@babel/preset-env", 7 | { 8 | "targets": { 9 | "node": "current" 10 | } 11 | } 12 | ] 13 | ] 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.github/semantic.yml: -------------------------------------------------------------------------------- 1 | # Always validate the PR title AND all the commits 2 | titleAndCommits: true 3 | # Allows use of Merge commits (eg on github: "Merge branch 'master' into feature/ride-unicorns") 4 | # this is only relevant when using commitsOnly: true (or titleAndCommits: true) 5 | allowMergeCommits: true 6 | -------------------------------------------------------------------------------- /layouts/main.vue: -------------------------------------------------------------------------------- 1 | 8 | 15 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "redirects": [ 4 | { 5 | "source": "/", 6 | "destination": "/v2", 7 | "permanent": false 8 | } 9 | ], 10 | "functions": { 11 | "api/createLink.ts": { 12 | "maxDuration": 50 13 | }, 14 | "api/getLink.ts": { 15 | "maxDuration": 50 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.husky/prepare-commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | if [ "$2" = "message" ]; then 5 | echo "Skipping prepare-commit-msg hook due to message." 6 | exit 0 7 | fi 8 | 9 | if [ "$2" = "commit" ]; then 10 | echo "Skipping prepare-commit-msg hook due to amend." 11 | exit 0 12 | fi 13 | 14 | exec 2 | 9 | 10 | 11 | 12 | 16 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | offline-diff-viewer: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | container_name: offline-diff-viewer 7 | ports: 8 | - "3000:80" 9 | security_opt: 10 | - no-new-privileges:true 11 | volumes: 12 | - /var/log/nginx 13 | restart: unless-stopped 14 | environment: 15 | - NODE_ENV=production 16 | - NODE_OPTIONS=--openssl-legacy-provider 17 | -------------------------------------------------------------------------------- /store/README.md: -------------------------------------------------------------------------------- 1 | # STORE 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your Vuex Store files. 6 | Vuex Store option is implemented in the Nuxt.js framework. 7 | 8 | Creating a file in this directory automatically activates the option in the framework. 9 | 10 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/vuex-store). 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18 AS build-stage 2 | 3 | WORKDIR /app 4 | 5 | COPY package*.json ./ 6 | 7 | RUN npm ci 8 | 9 | COPY . . 10 | 11 | ENV NODE_OPTIONS=--openssl-legacy-provider 12 | RUN npm run generate 13 | 14 | FROM nginx:1.27.0-alpine-slim AS production-stage 15 | 16 | COPY --from=build-stage /app/dist /usr/share/nginx/html 17 | 18 | COPY nginx/default.conf /etc/nginx/conf.d/default.conf 19 | 20 | EXPOSE 80 21 | 22 | CMD ["nginx", "-g", "daemon off;"] 23 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | node: true 6 | }, 7 | extends: [ 8 | '@nuxtjs/eslint-config-typescript', 9 | 'plugin:nuxt/recommended', 10 | 'prettier' 11 | ], 12 | plugins: [ 13 | ], 14 | // add your custom rules here 15 | rules: { 16 | "no-console": [ 17 | "error", 18 | { 19 | allow: [ 20 | "error" 21 | ] 22 | } 23 | ] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /store/scrollInSync.ts: -------------------------------------------------------------------------------- 1 | import { GetterTree, MutationTree } from 'vuex/types' 2 | 3 | export const state = () => ({ 4 | isEnabled: true, 5 | }) 6 | 7 | export type isScrollInSync = ReturnType 8 | 9 | export const getters: GetterTree = { 10 | isEnabled: (state) => state.isEnabled, 11 | } 12 | 13 | export const mutations: MutationTree = { 14 | toggle(state) { 15 | state.isEnabled = !state.isEnabled 16 | }, 17 | } 18 | -------------------------------------------------------------------------------- /components/icons/back.vue: -------------------------------------------------------------------------------- 1 | 17 | 21 | -------------------------------------------------------------------------------- /store/inlineDiffView.ts: -------------------------------------------------------------------------------- 1 | import { GetterTree, MutationTree } from 'vuex/types' 2 | 3 | export const state = () => ({ 4 | isEnabled: false, 5 | }) 6 | 7 | export type isInlineDiffView = ReturnType 8 | 9 | export const getters: GetterTree = { 10 | isEnabled: (state) => state.isEnabled, 11 | } 12 | 13 | export const mutations: MutationTree = { 14 | toggle(state) { 15 | state.isEnabled = !state.isEnabled 16 | }, 17 | } 18 | -------------------------------------------------------------------------------- /components/icons/bin.vue: -------------------------------------------------------------------------------- 1 | 15 | 19 | -------------------------------------------------------------------------------- /helpers/isSkipTutorialCookie.ts: -------------------------------------------------------------------------------- 1 | export default function () { 2 | const cookies = document.cookie.split(';') 3 | const cookieMap: { 4 | isSkipTutorial?: string 5 | } = { 6 | isSkipTutorial: 'false', 7 | } 8 | cookies.forEach((element) => { 9 | const [name, val] = element.split('=') 10 | const trimmedName = name.trim() 11 | if (trimmedName === 'isSkipTutorial') { 12 | cookieMap[trimmedName] = val 13 | } 14 | }) 15 | return cookieMap.isSkipTutorial === 'true' 16 | } 17 | -------------------------------------------------------------------------------- /api/db/getTop10Records.ts: -------------------------------------------------------------------------------- 1 | import { DB_SCHEMA } from './dbConstants.js' 2 | import { getPool } from './index.js' 3 | 4 | export async function getTop10Records() { 5 | const client = await getPool().connect() 6 | try { 7 | const res = await client.query( 8 | `SELECT data, id, "creationTimestamp" FROM "${DB_SCHEMA}".e2e_data limit 10;` 9 | ) 10 | return res.rows 11 | } catch (error) { 12 | console.error(error) 13 | return null 14 | } finally { 15 | await client.release() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /components/icons/up.vue: -------------------------------------------------------------------------------- 1 | 18 | 22 | -------------------------------------------------------------------------------- /components/icons/down.vue: -------------------------------------------------------------------------------- 1 | 18 | 22 | -------------------------------------------------------------------------------- /store/theme.ts: -------------------------------------------------------------------------------- 1 | import { GetterTree, MutationTree } from 'vuex/types' 2 | 3 | export const state = () => ({ 4 | darkMode: false 5 | }) 6 | 7 | export type themeStore = ReturnType 8 | 9 | export const getters: GetterTree = { 10 | isEnabled: (state) => state.darkMode, 11 | } 12 | 13 | export const mutations: MutationTree = { 14 | toggle(state) { 15 | state.darkMode = !state.darkMode 16 | }, 17 | set(state, value) { 18 | state.darkMode = value 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /api/db/getRecordById.ts: -------------------------------------------------------------------------------- 1 | import { DB_SCHEMA } from './dbConstants.js' 2 | import { getPool } from './index.js' 3 | 4 | export async function getRecordById(id: string) { 5 | const client = await getPool().connect() 6 | try { 7 | const res = await client.query( 8 | `SELECT data, "creationTimestamp" FROM "${DB_SCHEMA}".e2e_data WHERE id = $1;`, 9 | [id] 10 | ) 11 | return res.rows 12 | } catch (error) { 13 | console.error(error) 14 | return null 15 | } finally { 16 | await client.release() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /components/icons/sun.vue: -------------------------------------------------------------------------------- 1 | 17 | 21 | -------------------------------------------------------------------------------- /helpers/isDarkModeCookie.ts: -------------------------------------------------------------------------------- 1 | export default function () { 2 | const cookies = document.cookie.split(';') 3 | const cookieMap: { 4 | darkMode?: string 5 | } = {} 6 | cookies.forEach((element) => { 7 | const [name, val] = element.split('=') 8 | const trimmedName = name.trim() 9 | if (trimmedName === 'darkMode') { 10 | cookieMap[trimmedName] = val 11 | } 12 | }) 13 | if (cookieMap.darkMode) { 14 | return cookieMap.darkMode === 'true' 15 | } 16 | return window.matchMedia('(prefers-color-scheme: dark)').matches 17 | } 18 | -------------------------------------------------------------------------------- /helpers/isSkipCopyE2ELinkTutorial.ts: -------------------------------------------------------------------------------- 1 | export default function () { 2 | const cookies = document.cookie.split(';') 3 | const cookieMap: { 4 | isSkipCopyE2ELinkTutorial?: string 5 | } = { 6 | isSkipCopyE2ELinkTutorial: 'false', 7 | } 8 | cookies.forEach((element) => { 9 | const [name, val] = element.split('=') 10 | const trimmedName = name.trim() 11 | if (trimmedName === 'isSkipCopyE2ELinkTutorial') { 12 | cookieMap[trimmedName] = val 13 | } 14 | }) 15 | return cookieMap.isSkipCopyE2ELinkTutorial === 'true' 16 | } 17 | -------------------------------------------------------------------------------- /api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "lib": [ 7 | "ESNext", 8 | "ESNext.AsyncIterable", 9 | ], 10 | "esModuleInterop": true, 11 | "allowJs": true, 12 | "sourceMap": true, 13 | "strict": true, 14 | "noEmit": true, 15 | "experimentalDecorators": true, 16 | "baseUrl": ".", 17 | "types": [ 18 | "@types/node", 19 | ] 20 | }, 21 | "exclude": [ 22 | "node_modules", 23 | "dist" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /helpers/isSkipScrollInSyncTutorial.ts: -------------------------------------------------------------------------------- 1 | export default function () { 2 | const cookies = document.cookie.split(';') 3 | const cookieMap: { 4 | isSkipScrollInSyncTutorial?: string 5 | } = { 6 | isSkipScrollInSyncTutorial: 'false', 7 | } 8 | cookies.forEach((element) => { 9 | const [name, val] = element.split('=') 10 | const trimmedName = name.trim() 11 | if (trimmedName === 'isSkipScrollInSyncTutorial') { 12 | cookieMap[trimmedName] = val 13 | } 14 | }) 15 | return cookieMap.isSkipScrollInSyncTutorial === 'true' 16 | } 17 | -------------------------------------------------------------------------------- /helpers/isSkipCopyLinkShortcutTutorial.ts: -------------------------------------------------------------------------------- 1 | export default function () { 2 | const cookies = document.cookie.split(';') 3 | const cookieMap: { 4 | isSkipCopyLinkShortcutTutorial?: string 5 | } = { 6 | isSkipCopyLinkShortcutTutorial: 'false', 7 | } 8 | cookies.forEach((element) => { 9 | const [name, val] = element.split('=') 10 | const trimmedName = name.trim() 11 | if (trimmedName === 'isSkipCopyLinkShortcutTutorial') { 12 | cookieMap[trimmedName] = val 13 | } 14 | }) 15 | return cookieMap.isSkipCopyLinkShortcutTutorial === 'true' 16 | } 17 | -------------------------------------------------------------------------------- /api/db/insertRecord.ts: -------------------------------------------------------------------------------- 1 | import { DBInsertRecord } from '../types.js' 2 | import { DB_SCHEMA } from './dbConstants.js' 3 | import { getPool } from './index.js' 4 | 5 | export async function insertRecord({ data, id }: DBInsertRecord) { 6 | const client = await getPool().connect() 7 | try { 8 | await client.query( 9 | `INSERT INTO "${DB_SCHEMA}".e2e_data(data, id) VALUES ($1, $2);`, 10 | [data, id] 11 | ) 12 | return true 13 | } catch (error) { 14 | console.error(error) 15 | return false 16 | } finally { 17 | await client.release() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /helpers/isSkipSubmitKbdShortcutTutorial.ts: -------------------------------------------------------------------------------- 1 | export default function () { 2 | const cookies = document.cookie.split(';') 3 | const cookieMap: { 4 | isSkipSubmitKbdShortcutTutorial?: string 5 | } = { 6 | isSkipSubmitKbdShortcutTutorial: 'false', 7 | } 8 | cookies.forEach((element) => { 9 | const [name, val] = element.split('=') 10 | const trimmedName = name.trim() 11 | if (trimmedName === 'isSkipSubmitKbdShortcutTutorial') { 12 | cookieMap[trimmedName] = val 13 | } 14 | }) 15 | return cookieMap.isSkipSubmitKbdShortcutTutorial === 'true' 16 | } 17 | -------------------------------------------------------------------------------- /app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 16 | {{ HEAD }} 17 | 18 | 19 | 20 | {{ APP }} 21 | 22 | 23 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleNameMapper: { 3 | '^@/(.*)$': '/$1', 4 | '^~/(.*)$': '/$1', 5 | '^vue$': 'vue/dist/vue.common.js' 6 | }, 7 | moduleFileExtensions: [ 8 | 'ts', 9 | 'js', 10 | 'vue', 11 | 'json' 12 | ], 13 | transform: { 14 | "^.+\\.ts$": "ts-jest", 15 | '^.+\\.js$': 'babel-jest', 16 | '.*\\.(vue)$': 'vue-jest' 17 | }, 18 | collectCoverage: true, 19 | collectCoverageFrom: [ 20 | '/components/**/*.vue', 21 | '/pages/**/*.vue' 22 | ], 23 | testEnvironment: 'jsdom' 24 | } 25 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch via NPM", 9 | "request": "launch", 10 | "runtimeArgs": [ 11 | "run", 12 | "dev" 13 | ], 14 | "runtimeExecutable": "npm", 15 | "skipFiles": [ 16 | "/**" 17 | ], 18 | "type": "node" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /components/icons/link.vue: -------------------------------------------------------------------------------- 1 | 17 | 21 | -------------------------------------------------------------------------------- /components/icons/prettyCode.vue: -------------------------------------------------------------------------------- 1 | 14 | 25 | -------------------------------------------------------------------------------- /components/icons/copied.vue: -------------------------------------------------------------------------------- 1 | 17 | 21 | -------------------------------------------------------------------------------- /helpers/isSkipBackButtonPersistsDataTutorial.ts: -------------------------------------------------------------------------------- 1 | export default function () { 2 | const cookies = document.cookie.split(';') 3 | const cookieMap: { 4 | isSkipBackButtonPersistsDataTutorial?: string 5 | } = { 6 | isSkipBackButtonPersistsDataTutorial: 'false', 7 | } 8 | cookies.forEach((element) => { 9 | const [name, val] = element.split('=') 10 | const trimmedName = name.trim() 11 | if (trimmedName === 'isSkipBackButtonPersistsDataTutorial') { 12 | cookieMap[trimmedName] = val 13 | } 14 | }) 15 | return cookieMap.isSkipBackButtonPersistsDataTutorial === 'true' 16 | } 17 | -------------------------------------------------------------------------------- /components/icons/moon.vue: -------------------------------------------------------------------------------- 1 | 17 | 21 | -------------------------------------------------------------------------------- /store/data.ts: -------------------------------------------------------------------------------- 1 | import { GetterTree, MutationTree } from 'vuex/types' 2 | 3 | export const state = () => ({ 4 | lhs: '', 5 | rhs: '', 6 | lhsLabel: 'Original Text', 7 | rhsLabel: 'Changed Text', 8 | }) 9 | 10 | export type DataState = ReturnType 11 | 12 | export const getters: GetterTree = { 13 | get: (state) => ({ 14 | ...state, 15 | }), 16 | } 17 | 18 | export const mutations: MutationTree = { 19 | set(state, { lhs, rhs, rhsLabel, lhsLabel }: DataState) { 20 | state.lhs = lhs 21 | state.rhs = rhs 22 | state.lhsLabel = lhsLabel 23 | state.rhsLabel = rhsLabel 24 | }, 25 | } 26 | -------------------------------------------------------------------------------- /components/buttons/skipToNav.vue: -------------------------------------------------------------------------------- 1 | 9 | 19 | 20 | 30 | -------------------------------------------------------------------------------- /static/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Diff viewer", 3 | "short_name": "Diff viewer", 4 | "description": "A text diff viewer that is privacy focused, secure, sharable and simple", 5 | "icons": [ 6 | { 7 | "src": "/dark-android-chrome-192x192.png", 8 | "sizes": "192x192", 9 | "type": "image/png" 10 | }, 11 | { 12 | "src": "/dark-android-chrome-512x512.png", 13 | "sizes": "512x512", 14 | "type": "image/png" 15 | } 16 | ], 17 | "start_url": "/", 18 | "url_handlers": [ 19 | { 20 | "origin": "https://diffviewer.vercel.app/" 21 | } 22 | ], 23 | "theme_color": "#2563EB", 24 | "background_color": "#2563EB", 25 | "display": "standalone" 26 | } 27 | -------------------------------------------------------------------------------- /api/getLink.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express' 2 | import { getRecordById } from './db/getRecordById.js' 3 | import { getPool } from './db/index.js' 4 | import { DBRecord } from './types.js' 5 | 6 | export const config = { runtime: 'nodejs' } 7 | 8 | export default async function handler(req: Request<{}, {}, {}, Pick>, res: Response) { 9 | try { 10 | const records = await getRecordById(req.query.id) 11 | res.json(records) 12 | } catch (error: unknown) { 13 | if (error instanceof Error) { 14 | console.error(error) 15 | res.status(500).json({ message: error.message || 'Internal server error' }) 16 | } 17 | } 18 | } 19 | 20 | process.on('SIGTERM', () => { 21 | getPool().end() 22 | }) 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "lib": [ 7 | "ESNext", 8 | "ESNext.AsyncIterable", 9 | "DOM" 10 | ], 11 | "esModuleInterop": true, 12 | "allowJs": true, 13 | "sourceMap": true, 14 | "strict": true, 15 | "noEmit": true, 16 | "experimentalDecorators": true, 17 | "baseUrl": ".", 18 | "paths": { 19 | "~/*": [ 20 | "./*" 21 | ], 22 | "@/*": [ 23 | "./*" 24 | ] 25 | }, 26 | "types": [ 27 | "@nuxt/types", 28 | "@types/node", 29 | "./global.d.ts" 30 | ] 31 | }, 32 | "exclude": [ 33 | "node_modules", 34 | ".nuxt", 35 | "dist" 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Fetch and update latest `npm` packages 4 | - package-ecosystem: npm 5 | directory: '/' 6 | schedule: 7 | interval: daily 8 | time: '00:00' 9 | open-pull-requests-limit: 10 10 | reviewers: 11 | - technikhil314 12 | assignees: 13 | - technikhil314 14 | commit-message: 15 | prefix: fix 16 | prefix-development: chore 17 | include: scope 18 | # Fetch and update latest `github-actions` pkgs 19 | - package-ecosystem: github-actions 20 | directory: '/' 21 | schedule: 22 | interval: daily 23 | time: '00:00' 24 | open-pull-requests-limit: 10 25 | reviewers: 26 | - technikhil314 27 | assignees: 28 | - technikhil314 29 | commit-message: 30 | prefix: fix 31 | prefix-development: chore 32 | include: scope 33 | -------------------------------------------------------------------------------- /api/db/index.ts: -------------------------------------------------------------------------------- 1 | import { Pool } from 'pg' 2 | 3 | let pool: Pool | null = null 4 | 5 | export function getPool() { 6 | if (!process.env.DB_USER || !process.env.DB_HOST || !process.env.DB_NAME || !process.env.DB_PASSWORD) { 7 | throw new Error('Missing database environment variables') 8 | } 9 | if (!pool) { 10 | // if ssl is true then port number is not needed 11 | pool = new Pool({ 12 | user: process.env.DB_USER, 13 | host: process.env.DB_HOST, 14 | database: process.env.DB_NAME, 15 | password: process.env.DB_PASSWORD, 16 | max: 5, // Maximum number of connections in the pool 17 | idleTimeoutMillis: 30000, // Close idle connections after 30 seconds 18 | connectionTimeoutMillis: 2000, // How long to wait for a connection from the pool 19 | ssl: true, 20 | }) 21 | } 22 | return pool 23 | } 24 | -------------------------------------------------------------------------------- /components/icons/swap.vue: -------------------------------------------------------------------------------- 1 | 20 | 24 | -------------------------------------------------------------------------------- /helpers/decrypt.ts: -------------------------------------------------------------------------------- 1 | import { base64ToArrayBuffer } from './utils' 2 | 3 | export async function getDepryctionKey(key: string) { 4 | const decryptionKey = await window.crypto.subtle.importKey( 5 | 'jwk', 6 | { 7 | k: key, 8 | alg: 'A128GCM', 9 | ext: true, 10 | key_ops: ['encrypt', 'decrypt'], 11 | kty: 'oct', 12 | }, 13 | { name: 'AES-GCM', length: 128 }, 14 | false, // extractable 15 | ['decrypt'] 16 | ) 17 | return decryptionKey 18 | } 19 | 20 | export async function getDecryptedText(data: string, key: CryptoKey) { 21 | const decryptedContentBuffer = await window.crypto.subtle.decrypt( 22 | { name: 'AES-GCM', iv: new Uint8Array(12) }, 23 | key, 24 | base64ToArrayBuffer(data) 25 | ) 26 | const plainText = new window.TextDecoder().decode(new Uint8Array(decryptedContentBuffer)) 27 | return plainText 28 | } 29 | -------------------------------------------------------------------------------- /components/icons/nuxt.vue: -------------------------------------------------------------------------------- 1 | 20 | 24 | -------------------------------------------------------------------------------- /components/inlineDiff.vue: -------------------------------------------------------------------------------- 1 | 4 | 38 | -------------------------------------------------------------------------------- /api/createLink.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express' 2 | import { getPool } from './db/index.js' 3 | import { insertRecord } from './db/insertRecord.js' 4 | import { DBInsertRecord } from './types.js' 5 | export const config = { runtime: 'nodejs' } 6 | 7 | export default async function handler(req: Request<{}, {}, DBInsertRecord>, res: Response) { 8 | const body = req.body 9 | try { 10 | const result = await insertRecord({ 11 | data: body.data, 12 | id: body.id, 13 | }) 14 | if (result) { 15 | res.status(200).json({ 16 | success: true, 17 | }) 18 | } else { 19 | throw new Error('Failed to insert record') 20 | } 21 | } catch (error: unknown) { 22 | if (error instanceof Error) { 23 | console.error(error) 24 | res.status(500).json({ message: error.message || 'Internal server error' }) 25 | } 26 | } 27 | } 28 | 29 | process.on('SIGTERM', () => { 30 | getPool().end() 31 | }) 32 | -------------------------------------------------------------------------------- /components/buttons/toggleInSync.vue: -------------------------------------------------------------------------------- 1 | 21 | 36 | -------------------------------------------------------------------------------- /components/buttons/prevDiff.vue: -------------------------------------------------------------------------------- 1 | 16 | 29 | -------------------------------------------------------------------------------- /components/buttons/nextDiff.vue: -------------------------------------------------------------------------------- 1 | 16 | 29 | -------------------------------------------------------------------------------- /components/buttons/swapDiffContent.vue: -------------------------------------------------------------------------------- 1 | 16 | 29 | -------------------------------------------------------------------------------- /helpers/encrypt.ts: -------------------------------------------------------------------------------- 1 | import { arrayBufferToBase64 } from "./utils"; 2 | 3 | export async function getEncryptionKey(): Promise { 4 | const symmetricEncryptionKey = await window.crypto.subtle.generateKey( 5 | { name: 'AES-GCM', length: 128 }, 6 | true, // extractable 7 | ['encrypt', 'decrypt'] 8 | ); 9 | return symmetricEncryptionKey; 10 | } 11 | 12 | export async function getEncryptedData(content: string, key: CryptoKey): Promise { 13 | const encryptedContentBuffer = await window.crypto.subtle.encrypt( 14 | { name: 'AES-GCM', iv: new Uint8Array(12) /* don't reuse key! */ }, 15 | key, 16 | new TextEncoder().encode(content) 17 | ); 18 | const ecryptedText = arrayBufferToBase64(encryptedContentBuffer); 19 | return ecryptedText; 20 | } 21 | 22 | export async function getExtractedEncryptionKey(key: CryptoKey): Promise { 23 | const extractedEncryptionJWK = (await window.crypto.subtle.exportKey('jwk', key)); 24 | return extractedEncryptionJWK.k as string; 25 | } 26 | -------------------------------------------------------------------------------- /constants/messages.ts: -------------------------------------------------------------------------------- 1 | export const E2E_LINK_GENERATION_ERROR = 2 | 'Failed to generate E2E Link. Please try again later.' 3 | 4 | export const E2E_LINK_GENERATION_SUCCESS = 'E2E encrypted diff link copied to your clipboard' 5 | 6 | export const E2E_DATA_LOADING_INFO = 'Loading your end to end encrypted diff...' 7 | 8 | export const E2E_DATA_DECRYPTING_INFO = 'Decrypting your diff on your browser...' 9 | 10 | export const E2E_DATA_FINALIZING_INFO = 'Diffing your data...' 11 | 12 | export const E2E_DATA_FETCH_ERROR = 13 | "We couldn't fetch your diff. Please try again later." 14 | 15 | export const E2E_DATA_DECRYPTION_ERROR = 16 | "We couldn't decrypt your diff. Please check the link and try again." 17 | 18 | export const E2E_DATA_NO_LONGER_AVAILABLE_ERROR = 19 | 'Looks like the link has expired. Please check the link or generate a new one.' 20 | 21 | export const LINK_COPY_SUCCESS = 'Link copied to your clipboard' 22 | 23 | export const DIFF_USER_BLANK_SIDE_ERROR = 'Please enter some data on both sides to compare' 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - master 7 | 8 | jobs: 9 | changelog: 10 | runs-on: ubuntu-latest 11 | name: create release on tag 12 | 13 | steps: 14 | - uses: actions/checkout@master 15 | 16 | # This action generates changelog which then the release action consumes 17 | - name: Conventional Changelog Action 18 | id: changelog 19 | uses: TriPSs/conventional-changelog-action@v3 20 | with: 21 | github-token: ${{ secrets.github_token }} 22 | skip-commit: 'true' 23 | 24 | - name: Create Release 25 | uses: actions/create-release@v1 26 | if: ${{ steps.changelog.outputs.skipped == 'false' }} 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.github_token }} 29 | with: 30 | tag_name: ${{ steps.changelog.outputs.tag }} 31 | release_name: ${{ steps.changelog.outputs.tag }} 32 | body: ${{ steps.changelog.outputs.clean_changelog }} 33 | -------------------------------------------------------------------------------- /components/footer.vue: -------------------------------------------------------------------------------- 1 | 29 | 37 | -------------------------------------------------------------------------------- /components/v2/footer.vue: -------------------------------------------------------------------------------- 1 | 29 | 37 | -------------------------------------------------------------------------------- /components/Toast.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 41 | -------------------------------------------------------------------------------- /components/icons/tailwind.vue: -------------------------------------------------------------------------------- 1 | 23 | 27 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | pull_request: 9 | branches: 10 | - main 11 | - master 12 | 13 | jobs: 14 | ci: 15 | runs-on: ${{ matrix.os }} 16 | 17 | strategy: 18 | matrix: 19 | os: [ubuntu-latest] 20 | node: [20] 21 | 22 | steps: 23 | - name: Checkout 🛎 24 | uses: actions/checkout@master 25 | 26 | - name: Setup node env 🏗 27 | uses: actions/setup-node@v3 28 | with: 29 | node-version: ${{ matrix.node }} 30 | check-latest: true 31 | 32 | - name: Cache node_modules 📦 33 | uses: actions/cache@v3 34 | with: 35 | path: ~/.npm 36 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 37 | restore-keys: | 38 | ${{ runner.os }}-node- 39 | 40 | - name: Install dependencies 👨🏻‍💻 41 | run: npm ci --prefer-offline --no-audit 42 | 43 | - name: Run linter 👀 44 | run: npm run lint 45 | 46 | - name: Run tests 🧪 47 | run: npm run test 48 | -------------------------------------------------------------------------------- /helpers/types.ts: -------------------------------------------------------------------------------- 1 | export interface Cookies { 2 | isDarkMode: boolean 3 | isSkipTutorial: boolean 4 | isSkipScrollInSyncTutorial: boolean 5 | isSkipBackButtonPersistsDataTutorial: boolean 6 | isSkipSubmitKbdShortcutTutorial: boolean 7 | isSkipCopyLinkShortcutTutorial: boolean 8 | isSkipCopyE2ELinkTutorial: boolean 9 | } 10 | 11 | export interface Tutorial { 12 | element: string 13 | popover: { 14 | title: string 15 | description: string 16 | } 17 | } 18 | 19 | export interface TutorialMetadata { 20 | tutorial: Tutorial[] 21 | cookieName: keyof Cookies 22 | } 23 | 24 | export type TutorialsMetadata = Record 25 | 26 | export interface DiffActionBarData { 27 | comparator: HTMLElement | null 28 | comparer: HTMLElement | null 29 | copied: Boolean | null 30 | e2eLink: string | null 31 | treeWalker: TreeWalker | null 32 | } 33 | 34 | export interface DiffData { 35 | lhs: any[] 36 | rhs: any[] 37 | rhsLabel: string 38 | lhsLabel: string 39 | monacoDiffEditor: any 40 | diffNavigator: any 41 | } 42 | 43 | export interface v2DiffData { 44 | lhs: string 45 | rhs: string 46 | rhsLabel: string 47 | lhsLabel: string 48 | monacoDiffEditor: any 49 | diffNavigator: any 50 | isSideBySideDiff: boolean 51 | e2eDataStatusText: string 52 | } 53 | -------------------------------------------------------------------------------- /store/toast.ts: -------------------------------------------------------------------------------- 1 | import { GetterTree, MutationTree, Store } from 'vuex/types' 2 | 3 | export const state = () => ({ 4 | show: false, 5 | content: '', 6 | theme: 'info', 7 | iconHTML: '', 8 | autoDismiss: true, 9 | timeout: 5000, 10 | }) 11 | let timeoutId: NodeJS.Timeout 12 | 13 | export type ToastState = ReturnType 14 | 15 | export const getters: GetterTree = { 16 | show: (state) => state.show, 17 | content: (state) => state.content, 18 | theme: (state) => state.theme, 19 | iconHTML: (state) => state.iconHTML, 20 | autoDismiss: (state) => state.autoDismiss, 21 | timeout: (state) => state.timeout, 22 | } 23 | 24 | export const mutations: MutationTree = { 25 | show(state, { show, content, iconHTML, theme }: ToastState) { 26 | if (!show) { 27 | state.show = show 28 | return 29 | } 30 | const isAlreadyShowing = state.show 31 | state.content = content 32 | state.theme = theme || 'info' 33 | state.iconHTML = iconHTML 34 | state.show = !!content && show 35 | if (isAlreadyShowing && state.show && timeoutId) { 36 | clearTimeout(timeoutId) 37 | } 38 | if (state.show) { 39 | timeoutId = setTimeout(() => { 40 | const store = this as unknown as Store 41 | store.commit('toast/show', { show: false }) 42 | }, 5000) 43 | } 44 | }, 45 | } 46 | -------------------------------------------------------------------------------- /components/buttons/diffStyle.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 54 | -------------------------------------------------------------------------------- /styles/global.scss: -------------------------------------------------------------------------------- 1 | @import 'driver.js/dist/driver.min.css'; 2 | 3 | /* Basic layout */ 4 | :root { 5 | color-scheme: dark light; 6 | } 7 | 8 | html, 9 | body, 10 | #__nuxt, 11 | #__layout, 12 | .page-root { 13 | @apply min-h-[100vh]; 14 | } 15 | 16 | html, 17 | body, 18 | #__nuxt, 19 | #__layout, 20 | .page-root, 21 | main { 22 | @apply bg-gray-50 dark:bg-gray-900; 23 | scroll-padding-block: 140px; 24 | scroll-behavior: smooth; 25 | } 26 | 27 | body { 28 | font-family: 'Open Sans', sans-serif; 29 | } 30 | 31 | .page-root { 32 | @apply flow-root w-full; 33 | min-height: 100vh; 34 | } 35 | 36 | .page-contents { 37 | @apply grid; 38 | grid-template-rows: 70px 1fr; 39 | } 40 | 41 | main { 42 | @apply flex flex-wrap xl:container; 43 | margin-top: 2rem; 44 | } 45 | 46 | kbd { 47 | @apply bg-gray-100 dark:bg-gray-800 border bottom-1 dark:border-gray-600 border-gray-300 shadow rounded-sm text-gray-700 dark:text-gray-50 inline-block; 48 | font-size: 0.85em; 49 | font-weight: 700; 50 | line-height: 1; 51 | padding: 2px 4px; 52 | white-space: nowrap; 53 | } 54 | 55 | /* custom scrollbar */ 56 | ::-webkit-scrollbar { 57 | width: 10px; 58 | } 59 | ::-webkit-scrollbar-track { 60 | @apply bg-gray-50 dark:bg-gray-700; 61 | } 62 | ::-webkit-scrollbar-thumb { 63 | @apply bg-gray-500 dark:bg-gray-500; 64 | } 65 | ::-webkit-scrollbar-thumb:hover { 66 | @apply bg-gray-400 dark:bg-gray-400; 67 | } 68 | 69 | ::selection { 70 | @apply bg-gray-800 text-gray-300 dark:bg-gray-200 dark:text-gray-800; 71 | } 72 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 Nikhil Mehta. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | 3. All advertising materials mentioning features or use of this software must display the following acknowledgement: 10 | This product includes software developed by Nikhil Mehta. 11 | 12 | 4. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 13 | 14 | THIS SOFTWARE IS PROVIDED BY COPYRIGHT HOLDER "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL COPYRIGHT HOLDER BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 15 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | /logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # TypeScript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | # parcel-bundler cache (https://parceljs.org/) 63 | .cache 64 | 65 | # next.js build output 66 | .next 67 | 68 | # nuxt.js build output 69 | .nuxt 70 | 71 | # Nuxt generate 72 | dist 73 | 74 | # vuepress build output 75 | .vuepress/dist 76 | 77 | # Serverless directories 78 | .serverless 79 | 80 | # IDE / Editor 81 | .idea 82 | 83 | # Service worker 84 | sw.* 85 | 86 | # macOS 87 | .DS_Store 88 | 89 | # Vim swap files 90 | *.swp 91 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | /logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # TypeScript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | # parcel-bundler cache (https://parceljs.org/) 63 | .cache 64 | 65 | # next.js build output 66 | .next 67 | 68 | # nuxt.js build output 69 | .nuxt 70 | 71 | # Nuxt generate 72 | dist 73 | 74 | # vuepress build output 75 | .vuepress/dist 76 | 77 | # Serverless directories 78 | .serverless 79 | 80 | # IDE / Editor 81 | .idea 82 | 83 | # Service worker 84 | sw.* 85 | 86 | # macOS 87 | .DS_Store 88 | 89 | # Vim swap files 90 | *.swp 91 | 92 | # Vercel cli 93 | .vercel 94 | .vercel_cache 95 | -------------------------------------------------------------------------------- /components/icons/diffStyle.vue: -------------------------------------------------------------------------------- 1 | 16 | 27 | -------------------------------------------------------------------------------- /plugins/cookie-injector.client.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from '@nuxt/types' 2 | import isDarkModeCookie from '~/helpers/isDarkModeCookie' 3 | import isSkipTutorialCookie from '~/helpers/isSkipTutorialCookie' 4 | import isSkipScrollInSyncTutorial from '~/helpers/isSkipScrollInSyncTutorial' 5 | import isSkipBackButtonPersistsDataTutorial from '~/helpers/isSkipBackButtonPersistsDataTutorial' 6 | import isSkipSubmitKbdShortcutTutorial from '~/helpers/isSkipSubmitKbdShortcutTutorial' 7 | import { Cookies } from '~/helpers/types' 8 | import isSkipCopyLinkShortcutTutorial from '~/helpers/isSkipCopyLinkShortcutTutorial' 9 | import isSkipCopyE2ELinkTutorial from '~/helpers/isSkipCopyE2ELinkTutorial' 10 | 11 | declare module 'vue/types/vue' { 12 | interface Vue { 13 | $cookies: Cookies 14 | } 15 | } 16 | 17 | declare module '@nuxt/types' { 18 | // nuxtContext.app.$isDarkModeinside asyncData, fetch, plugins, middleware, nuxtServerInit 19 | interface NuxtAppOptions { 20 | $cookies: Cookies 21 | } 22 | // nuxtContext.isDarkMode$ 23 | interface Context { 24 | $cookies: Cookies 25 | } 26 | } 27 | 28 | declare module 'vuex/types/index' { 29 | // this.$isDarkModeinside Vuex stores 30 | interface Store { 31 | $cookies: Cookies | S 32 | } 33 | } 34 | 35 | const cookieInjectorPlugin: Plugin = (_context, inject) => { 36 | inject('cookies', { 37 | isDarkMode: isDarkModeCookie(), 38 | isSkipTutorial: isSkipTutorialCookie(), 39 | isSkipScrollInSyncTutorial: isSkipScrollInSyncTutorial(), 40 | isSkipBackButtonPersistsDataTutorial: 41 | isSkipBackButtonPersistsDataTutorial(), 42 | isSkipSubmitKbdShortcutTutorial: isSkipSubmitKbdShortcutTutorial(), 43 | isSkipCopyLinkShortcutTutorial: isSkipCopyLinkShortcutTutorial(), 44 | isSkipCopyE2ELinkTutorial: isSkipCopyE2ELinkTutorial(), 45 | }) 46 | } 47 | 48 | export default cookieInjectorPlugin 49 | -------------------------------------------------------------------------------- /components/buttons/copyLink.vue: -------------------------------------------------------------------------------- 1 | 40 | 58 | -------------------------------------------------------------------------------- /components/icons/brand.vue: -------------------------------------------------------------------------------- 1 | 21 | 25 | 30 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | mode: 'jit', 3 | darkMode: 'class', 4 | variants: { 5 | extend: { 6 | boxShadow: ['dark'], 7 | }, 8 | }, 9 | theme: { 10 | extend: { 11 | keyframes: { 12 | 'linear-bounce': { 13 | '0%, 100%': { 14 | transform: 'translateX(25%)', 15 | 'animation-timing-function': 'cubic-bezier(0.8, 0, 1, 1)', 16 | }, 17 | '50%': { 18 | transform: 'translateX(0)', 19 | 'animation-timing-function': 'cubic-bezier(0, 0, 0.2, 1)', 20 | }, 21 | }, 22 | wiggle: { 23 | '0%, 100%': { transform: 'rotate(-10deg)' }, 24 | '50%': { transform: 'rotate(10deg)' }, 25 | }, 26 | }, 27 | }, 28 | maxHeight: { 29 | 'screen--nav': 'calc(100vh - 7rem)', 30 | }, 31 | container: { 32 | center: true, 33 | }, 34 | animation: { 35 | 'linear-bounce': 'linear-bounce 1s linear infinite', 36 | wiggle: 'wiggle 1s linear infinite', 37 | bounce: 'bounce 1s linear infinite', 38 | }, 39 | zIndex: { 40 | 1: 1, 41 | 2: 2, 42 | 10: 10, 43 | 11: 11, 44 | }, 45 | minHeight: { 46 | 80: '20rem', 47 | }, 48 | translate: { 49 | '-screen': '-100vh', 50 | }, 51 | boxShadow: { 52 | DEFAULT: 53 | '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)', 54 | md: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)', 55 | lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)', 56 | xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)', 57 | '2xl': '0 25px 50px -12px rgba(0, 0, 0, 0.25)', 58 | '3xl': '0 35px 60px -15px rgba(0, 0, 0, 0.3)', 59 | inner: 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.06)', 60 | dark: '0 5px 10px -3px rgba(255, 255, 255, 0.1), 0 4px 6px -2px rgba(255, 255, 255, 0.05)', 61 | none: 'none', 62 | }, 63 | }, 64 | plugins: [require('@tailwindcss/forms')], 65 | } 66 | -------------------------------------------------------------------------------- /components/buttons/stickyCopy.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 82 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "offline-diff-viewer", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "clean": "rimraf ./dist", 7 | "dev": "npm run clean && nuxt", 8 | "build": "npm run clean && nuxt build", 9 | "start": "nuxt start", 10 | "generate": "npm run clean && nuxt generate", 11 | "lint:jsts": "eslint --ext \".ts,.js,.vue\" --ignore-path .gitignore .", 12 | "lint:style": "stylelint \"**/*.{vue,css}\" --ignore-path .gitignore", 13 | "prepare": "npx husky install", 14 | "lint": "npm run lint:jsts -- --fix && npm run lint:style", 15 | "test": "jest --passWithNoTests" 16 | }, 17 | "engines": { 18 | "node": "22.x" 19 | }, 20 | "lint-staged": { 21 | "*.{js,vue,ts}": "eslint --fix", 22 | "*.{css,vue}": "stylelint --fix" 23 | }, 24 | "dependencies": { 25 | "@monaco-editor/loader": "^1.4.0", 26 | "@nuxtjs/pwa": "^3.3.5", 27 | "@nuxtjs/sitemap": "^2.4.0", 28 | "@tailwindcss/forms": "^0.5.7", 29 | "core-js": "^3.15.1", 30 | "diff": "^5.0.0", 31 | "diff-match-patch": "^1.0.5", 32 | "driver.js": "^0.9.8", 33 | "monaco-editor": "^0.43.0", 34 | "monaco-editor-webpack-plugin": "^7.1.0", 35 | "monaco-languageclient": "^7.1.0", 36 | "nuxt": "^2.15.7", 37 | "pako": "^2.0.4", 38 | "sass": "^1.43.4" 39 | }, 40 | "devDependencies": { 41 | "@babel/eslint-parser": "^7.14.7", 42 | "@commitlint/cli": "^12.1.4", 43 | "@commitlint/config-conventional": "^12.1.4", 44 | "@nuxt/types": "^2.15.7", 45 | "@nuxt/typescript-build": "^2.1.0", 46 | "@nuxtjs/eslint-config-typescript": "^6.0.1", 47 | "@nuxtjs/eslint-module": "^3.0.2", 48 | "@nuxtjs/stylelint-module": "^4.0.0", 49 | "@nuxtjs/tailwindcss": "^4.2.0", 50 | "@types/express": "^5.0.3", 51 | "@types/pg": "^8.15.5", 52 | "@vue/test-utils": "^1.2.1", 53 | "babel-core": "7.0.0-bridge.0", 54 | "babel-jest": "^27.0.5", 55 | "commitizen": "^4.3.0", 56 | "cz-conventional-changelog": "^3.3.0", 57 | "eslint": "^7.29.0", 58 | "eslint-config-prettier": "^8.3.0", 59 | "eslint-plugin-nuxt": "^2.0.0", 60 | "eslint-plugin-vue": "^7.12.1", 61 | "husky": "^6.0.0", 62 | "jest": "^27.0.5", 63 | "lint-staged": "^10.5.4", 64 | "pg": "^8.16.3", 65 | "postcss": "^8.3.5", 66 | "rimraf": "^3.0.2", 67 | "stylelint": "^13.13.1", 68 | "stylelint-config-prettier": "^8.0.2", 69 | "stylelint-config-standard": "^22.0.0", 70 | "ts-jest": "^27.0.3", 71 | "vercel": "^47.0.4", 72 | "vue-jest": "^3.0.4" 73 | }, 74 | "config": { 75 | "commitizen": { 76 | "path": "./node_modules/cz-conventional-changelog" 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /components/singleDiff.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 68 | 69 | 103 | -------------------------------------------------------------------------------- /helpers/utils.ts: -------------------------------------------------------------------------------- 1 | import { Store } from 'vuex' 2 | import { ToastState } from '~/store/toast' 3 | 4 | export function doUrlSafeBase64(decoded: string) { 5 | return decoded.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') 6 | } 7 | 8 | export function undoUrlSafeBase64(_encoded: string) { 9 | let encoded = _encoded.replace(/-/g, '+').replace(/_/g, '/') 10 | while (encoded.length % 4) encoded += '=' 11 | return encoded 12 | } 13 | 14 | export function urlEncode(unencoded: string): string { 15 | const encoded = globalThis.btoa(unencoded) 16 | return doUrlSafeBase64(encoded) 17 | } 18 | 19 | export function urlDecode(_encoded: string): string { 20 | const encoded = undoUrlSafeBase64(_encoded) 21 | return globalThis.atob(encoded) 22 | } 23 | 24 | export function escapeHtml(unsafe: string) { 25 | return unsafe 26 | .replace(/&/g, '&') 27 | .replace(//g, '>') 29 | .replace(/"/g, '"') 30 | .replace(/'/g, ''') 31 | } 32 | 33 | export function putToClipboard( 34 | textToPut: string, 35 | toastContent: string, 36 | store: Store 37 | ) { 38 | navigator.clipboard.writeText(textToPut) 39 | store.commit('toast/show', { 40 | show: true, 41 | content: toastContent, 42 | iconHTML: ` 43 | 50 | 56 | 57 | `, 58 | theme: 'success', 59 | }) 60 | } 61 | 62 | export function getMonacoEditorDefaultOptions(theme: string) { 63 | return { 64 | language: 'javascript', 65 | theme, 66 | fontSize: parseFloat(getComputedStyle(document.documentElement).fontSize), 67 | scrollBeyondLastLine: false, 68 | scrollBeyondLastColumn: 0, 69 | minimap: { 70 | enabled: false, 71 | }, 72 | contextmenu: false, 73 | } 74 | } 75 | 76 | export function arrayBufferToBase64(buffer: ArrayBuffer) { 77 | let binary = ''; 78 | const bytes = new Uint8Array(buffer); 79 | const len = bytes.byteLength; 80 | for (let i = 0; i < len; i++) { 81 | binary += String.fromCharCode(bytes[i]); 82 | } 83 | return window.btoa(binary); 84 | } 85 | 86 | export function base64ToArrayBuffer(base64: string) { 87 | const binaryString = window.atob(base64); 88 | const len = binaryString.length; 89 | const bytes = new Uint8Array(len); 90 | for (let i = 0; i < len; i++) { 91 | bytes[i] = binaryString.charCodeAt(i) 92 | } 93 | return bytes.buffer; 94 | } 95 | 96 | export function getRandomDiffId() { 97 | const array = new Uint32Array(2); 98 | const randomValues = crypto.getRandomValues(array); 99 | const randomValuesHex = randomValues.map(value => value).join(''); 100 | return `diff-${randomValuesHex}` 101 | } 102 | -------------------------------------------------------------------------------- /pages/v1/diff.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 101 | -------------------------------------------------------------------------------- /components/navbar.vue: -------------------------------------------------------------------------------- 1 | 70 | 71 | 112 | -------------------------------------------------------------------------------- /components/v2/navbar.vue: -------------------------------------------------------------------------------- 1 | 73 | 74 | 120 | -------------------------------------------------------------------------------- /components/buttons/toggleInlineDiffView.vue: -------------------------------------------------------------------------------- 1 | 35 | 50 | -------------------------------------------------------------------------------- /helpers/driverjsTutorials.ts: -------------------------------------------------------------------------------- 1 | import { Cookies, Tutorial, TutorialMetadata, TutorialsMetadata } from './types' 2 | // Need this to keep track of latest value of cookie otherwise users sees same tutorial untill they refresh the page after the cookie is dropped 3 | const _cookies: Partial = {} 4 | const labelsTutorial: Tutorial[] = [ 5 | { 6 | element: '#lhsLabel', 7 | popover: { 8 | title: 'New feature', 9 | description: 'Now you can add custom labels to text blocks', 10 | }, 11 | }, 12 | { 13 | element: '#rhsLabel', 14 | popover: { 15 | title: 'New feature', 16 | description: 'Now you can add custom labels to text blocks', 17 | }, 18 | }, 19 | ] 20 | 21 | const submitShortcutTutorial: Tutorial[] = [ 22 | { 23 | element: '#submitButton', 24 | popover: { 25 | title: 'New keyboard shortcut', 26 | description: 27 | 'Now you can press Cmd + Enter/Ctrl + Enter to go to compare screen.', 28 | }, 29 | }, 30 | ] 31 | 32 | const actionBarTutorial: Tutorial[] = [ 33 | { 34 | element: '#toggleScrollInSyncSection', 35 | popover: { 36 | title: 'Scroll In Sync', 37 | description: 'Now you can choose to scroll both sides in sync.', 38 | }, 39 | }, 40 | { 41 | element: '#nextDiffSection', 42 | popover: { 43 | title: 'Travel through diff hunks', 44 | description: 'Now you can move between next and previous diff hunks.', 45 | }, 46 | }, 47 | { 48 | element: '#prevDiffSection', 49 | popover: { 50 | title: 'Travel through diff hunks', 51 | description: 'Now you can move between next and previous diff hunks.', 52 | }, 53 | }, 54 | ] 55 | 56 | const backButtonTutorial: Tutorial[] = [ 57 | { 58 | element: '#backToDataLink', 59 | popover: { 60 | title: 'Go back to edit screen', 61 | description: 62 | 'Now your data persists between the page navigation.
PS: Works only if you have clicked on "Compare" button', 63 | }, 64 | }, 65 | ] 66 | 67 | const copyLinkShortcutTutorial: Tutorial[] = [ 68 | { 69 | element: '#copyLinkButton', 70 | popover: { 71 | title: 'Copy link with ease', 72 | description: 73 | 'Now you can press Cmd+C/Ctrl+C any time on this screen to copy link to this diff.', 74 | }, 75 | }, 76 | ] 77 | 78 | const CopyE2ELinkTutorial: Tutorial[] = [ 79 | { 80 | element: '#copyLinkButton', 81 | popover: { 82 | title: 'Copy E2E link', 83 | description: 'Now links for large data comparison are end-to-end encrytpted automatically. Read more about it on github repo.', 84 | }, 85 | }, 86 | ] 87 | 88 | const diffV1Tutorials: TutorialMetadata[] = [ 89 | { 90 | tutorial: actionBarTutorial, 91 | cookieName: 'isSkipScrollInSyncTutorial', 92 | }, 93 | { 94 | tutorial: backButtonTutorial, 95 | cookieName: 'isSkipBackButtonPersistsDataTutorial', 96 | }, 97 | { 98 | tutorial: copyLinkShortcutTutorial, 99 | cookieName: 'isSkipCopyLinkShortcutTutorial', 100 | }, 101 | { 102 | tutorial: CopyE2ELinkTutorial, 103 | cookieName: 'isSkipCopyE2ELinkTutorial', 104 | } 105 | ] 106 | 107 | const diffV2Tutorials: TutorialMetadata[] = [ 108 | { 109 | tutorial: CopyE2ELinkTutorial, 110 | cookieName: 'isSkipCopyE2ELinkTutorial', 111 | }, 112 | { 113 | tutorial: backButtonTutorial, 114 | cookieName: 'isSkipBackButtonPersistsDataTutorial', 115 | }, 116 | { 117 | tutorial: copyLinkShortcutTutorial, 118 | cookieName: 'isSkipCopyLinkShortcutTutorial', 119 | }, 120 | ] 121 | 122 | const comparePageV1Tutorials: TutorialMetadata[] = [ 123 | { 124 | tutorial: labelsTutorial, 125 | cookieName: 'isSkipTutorial', 126 | }, 127 | { 128 | tutorial: submitShortcutTutorial, 129 | cookieName: 'isSkipSubmitKbdShortcutTutorial', 130 | }, 131 | ] 132 | 133 | const comparePageV2Tutorials: TutorialMetadata[] = [ 134 | { 135 | tutorial: labelsTutorial, 136 | cookieName: 'isSkipTutorial', 137 | }, 138 | ] 139 | 140 | const tutorialsMetadata: TutorialsMetadata = { 141 | '/v1/diff': diffV1Tutorials, 142 | '/v2/diff': diffV2Tutorials, 143 | '/': comparePageV1Tutorials, 144 | '/v2': comparePageV2Tutorials, 145 | } 146 | 147 | export default async function showTutorials( 148 | cookies: Cookies, 149 | route: string, 150 | isDarkMode: boolean 151 | ) { 152 | const { default: Driver } = await import('driver.js') 153 | const possibleTutorialsToShow: TutorialMetadata[] = tutorialsMetadata[route] 154 | let finalTutorial: Tutorial[] = [] 155 | const cookiesToSet: TutorialMetadata[] = [] 156 | const driver = new Driver({ 157 | closeBtnText: 'Skip', 158 | className: 'dark:filter dark:invert', 159 | stageBackground: isDarkMode ? 'hsl(221deg 50% 90% / 0.5)' : '#ffffff', 160 | onReset: () => { 161 | cookiesToSet.forEach((x: TutorialMetadata) => { 162 | _cookies[x.cookieName] = true 163 | document.cookie = `${x.cookieName}=true; max-age=31536000; path=/;` 164 | }) 165 | }, 166 | }) 167 | possibleTutorialsToShow.forEach((x) => { 168 | if (!cookies[x.cookieName] && !_cookies[x.cookieName]) { 169 | finalTutorial = finalTutorial.concat(x.tutorial) 170 | cookiesToSet.push(x) 171 | } 172 | }) 173 | if (finalTutorial.length) { 174 | driver.defineSteps(finalTutorial) 175 | driver.start() 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /components/diffActionBar.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 181 | 186 | -------------------------------------------------------------------------------- /pages/index.vue: -------------------------------------------------------------------------------- 1 | 73 | 74 | 166 | 167 | 177 | -------------------------------------------------------------------------------- /components/icons/github.vue: -------------------------------------------------------------------------------- 1 | 40 | 44 | -------------------------------------------------------------------------------- /components/v2/diffActionBar.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 203 | 208 | -------------------------------------------------------------------------------- /components/icons/sponsor.vue: -------------------------------------------------------------------------------- 1 | 51 | 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # offline-diff-viewer 2 | 3 | A diff viewer that gives you sharable diff view links but does not store your data. (This takes inspiration from typescript playground how it stores your code in url itself) but the for very large data we will be doing end to end encryption just like `excalidraw` so you can still have sharable links without worrying if you should store your enterprise data or not. 4 | 5 | # Data Privacy and security 6 | 7 | ## :bangbang: No data sent to server 8 | 9 | - You can take a look at the source code itself. 10 | - All your data is kept as hash fragment in URL which never makes its way to server. Totally avoiding man in middle and XSS attacks to steal your data or any data breach. 11 | - The data always stays in your URL and browser and never makes its way on the wire. Thats the main motive behind developing this tool. 12 | - More about reasoning, why and how can be found in [Motivation](#motivation) section below. 13 | 14 | ## End to End Encryption 15 | 16 | - If the calculated diff data is larger 10000 characters then your data is first encrypted on browser and the encrypted data is stored on server. 17 | - The key to decrypt the data is added as hash fragment in the url, also each diff view is encrypted with different key 18 | - Every diff view is assigned cryptographically strong unique identifier making it impossible to guess diff view identifiers for hackers 19 | - The data is always decrypted in browser cause the hash fragement is never sent to server by browsers, so server has no way to decrypt the data 20 | - even if someone gets hold of your data using man in middle attack they cannot decrypt it as decryption key is available only in browser. 21 | 22 | # Formats currently supported 23 | 24 | 1. Any texual format (JSON, HTML, Plain text, JS, CSS and any text based file content) 25 | 26 | # Upcoming support 27 | 28 | 1. Images 29 | 2. Audio wave format 30 | 31 | ## Reason for building yet another diff viewer tool 32 | 33 | I realise we are missing a diff viewer that is 34 | 35 | - Privacy focused 36 | - Simple to use 37 | - And most importantly does not store any data on server to give sharable diff urls 38 | 39 | ## Motivation 40 | 41 | I realise as a developer community we are missing on a diff viewer tool that does not store your data on their server to give you links to diff view to share with your teams. 42 | There can be serious implications of storing your enterprise data on some server that you don’t know anything about. 43 | 44 | Also current diff tools lack one major ability that is to compare any two text blocks. Many diff viewers out there target specific text types like JSON etc which is not what we want most of the time. 45 | 46 | Also due to lack of such tool, if you want to see the the diff again you have to do the following 47 | 48 | 1. Ask/get the data from someone/somewhere 49 | 2. Search for a diff viewer tool online 50 | 3. Copy paste the data sources to the tool 51 | 4. Compare and share findings 52 | 53 | This is still a mechnical part that can be easily automated. But most such tools out there right now store your data to give you sharable diff URLs which was a concerning thing at least for me for security reasons. 54 | 55 | The simplest solution in my opinion will be 56 | 57 | 1. Get the data just once 58 | 2. Search for diff tool online just once 59 | 3. Copy your data to the tool just once 60 | 4. Compare and get a sharable url that stores your data in link itself (Like typescript playground stores your code in link) 61 | 62 | In the chase of one such tool I ended up creating one as I did not find any that satisfied my requirements. 63 | This is open source and has very easy user interface. Here is the link to the tool https://diffviewer.vercel.app/ 64 | It has following benefits 65 | 66 | 1. Since the tool does not always store your data on its server there is no server required in the tool 67 | 2. The tool is blazing fast 68 | 3. Most importantly the link can be shared with anyone without security concerns(Unless you share link itself over some insecure network) 69 | 4. As the link contains data whomever you share link with can get data too 70 | 5. Also note that the data is put with hash in url so server can not read the data or encryption key 71 | 6. For very large data comparison take a look at [End to End Encryption](#end-to-end-encryption) section above 72 | 73 | [Here is sample e2e encrypted link](https://diffviewer.vercel.app/v2/diff?id=permanent-42812281783313231307#d9_okhxt7vhCLB_kXgkKVA) 74 | 75 | [Here is a link to sample diff view](https://diffviewer.vercel.app/v2/diff#H4sIAAAAAAAAA6VZTW_bOBD9K4Eve-kS1octqyfmo02w7bbZpmgPm0VBSbTMmCK9FGXXKPrfdyjXRYz1jAvrkMT2kyM_cOa9N-NvI71oRy9Hfz-ai4tv4dfFxeNIVY-jlxfRi_3zuXKt_2JEI8Prj6Nr67yR28fRzyu0OLjgrZx7fXCBbITSO7DUO3TMn8RKmJVtPXtaPbu2lqaSbnfxa3ijls9AtfoiqsrJtt1dECdjFqURy6csTqLHUbju-4sjdGKUzoNwAudyq3y7EM1xLm29QyPeWc1K27DCncskYUnKspTFY4JFgrJ421V2rUqLM3m3lcdZaLOVMV85IzftRjkZiCAs_qQ5RFHGojEcBfyk1GmkKI93wi-EEVLjRB5WaqlM3R5nY9ofcMLD-9j8XC75jGUTKC6glKUElwneKJ3WSuJE3hi5lh5pk2UPprzoWmXgU22kXJ5_MrOIRdOMxTGLcqrApiiZN8JLB58Ep_On8F6KslTHGS2bPT7horCdZ408t1ki6PcogUKLUhaRHZOhhG6ktlTn3_x2DdokjpOpqjKAU76RRUscy2kmaRJOJYfGn1BVNkN5fBBNS6nxnTTOIlXmFj2YcbkSrLbrM1tlBn0yBiGewGkkGcEiR1lcSdERpVW-kcJUx0kUTbkM6IyXRvoh6jWDQwAdhl7JE4JFNEZpvNcKutYTXQJE9XEetgAo5xtr_UI2clBZxWnOpgy6Pk5JJrjLv6qkwVlcrp0A4zuomGdUpNjjgrfeOjXfDmsSKLBsBgUGJ5NTfHCbh9SitKb6HdStRsS4CljBSwsmqTWTVYdQue0fz3WnKoLPZMKynAGhbEaRwd3-k3K10opw-zsJWo0U2nrRgyWvpS2VV1BqaAI71TNjaPlxsMgETodkg3v--0JUSiyInnGqqjUqALbY4xWP4uR3J2uoNtYtz-SUhQQDlTaBP1SKiXDrv9RzJyuSUXsQ1p7REUUPSr618C7lhwSyCRg-yFqUxOCVZOfgzn_jupKKMfd26-GWiFGuduicf3VSDBKBEF4g7kOlxUlMUcE9_952RLC8l8IdBJxnPFarHqx5q8WgEwFhjpMcUsupfsEd_xaEDGdxbRvobu-RGFaXe3wBfmkGncg4Dh0CyRIiDBVeItz3L423RhFO89kutHRIdYnNDlUDrT8O6YVNQoQhh0jc-D_BDYwn8_GlM5B6EUkWPfjEwfqFBynzqhx0NHnOwmgMNpPsA8Cj-Wf0AiLfGRO_Vg2lZWD0Bptjih065q3tTFVq21VD9CxjWcSmkGsSqnnwGPBKK2GsI8cYV2IDPzwMYMT9RkHKc0P1bJaFgMZmk7Nm_qv-c6FqtrUeOZRiFbCYy85ZCP4SSzMntQymMDDL4P0xFTNx6790CgIVcRofnWxAtLExWfg9nvBCW9sU0tWDTiUCbYZ5ZpKd0DTc-x-6Jbkds2v5ZJHtWKt3aMpLrcplIcySmQPyz9lcqf0zgg8LhwSlRh0Qbv6fVBnyO5Gbr-TcYXTWRQ9OIMp0y26ooEUxDGgpI20T9_5rUSpyHXMJtVMJXWOLS7HHp_wrjAODB5ocIgBI9PSsqf8dVEeIh8R2Sco1lsrMsgczDiG1UgOMM0_6PQwMNFS4xO3_TjSUht3BzYDocRKLxQ6d8bkoZWHtgP1YlI_BKCEjh8xPhks8AoQo0ycrosY-i6XUSIGJTQ_mfCsW1g4qr2D9YYsxCwngvPn_AYZETQ1lN8qXi6JzBlGyao8LrkylZG1ritTlSSmDeAYCkMAJJWTYxM3_D0Ho8jU8FebgEzyj81T-gAvu5YAEA0NlNA1b2LDAJAsN9_17ZcqO6PxLv5XIbLkSASu5EWsyvZxgARksisPSDwqMJIE7f78tJhYYH2wrN0IjK4zG_YArvlZGWEeNlqfJTAKXlJEhhpj2byFTEt9XeCeMqBcC6ZS6_XmB5GHebyQdLX9l6B8HHWMJZS7EyN9PM0vC9P_q5FYqh1TZ-t8f8PxXdhgn5SwFMsEpwTTJ1E8M_g8gQZLaynaulpiSFT1Y8wpe2obXye3SLwR_NgVGccRm1DcXxALgtXXw7_yBVv1_Y64cjNbHKc2bctnDC-4778MW0AumsJx5ouBCBoB0Bn2UnrkFeBBOUqPzlWhbhUhBW_Sg-qXV324_C-VJOg0kgVm6W5qRXy4R-4BrLRxhNrdwPyyclXUPPvFWylYIIv-f_hoA9Dmd9SvAeHy4B9CL9i0kWj16OXrvVA0iqi8-yq9-tyLYQ9cgSrWsdsj3_wBPobtqMCAAAA) 76 | 77 | ## TODO/Upcoming features 78 | 79 | Please check all To dos and upcoming things [here](https://github.com/technikhil314/offline-diff-viewer/projects/1) 80 | 81 | ## Build Setup 82 | 83 | ```bash 84 | # install dependencies 85 | $ npm install 86 | 87 | # serve with hot reload at localhost:3000 88 | # Note that the command below will serve only via nuxt server and 89 | # wont run vercel functions used for e2e encryption link generation 90 | $ npm run dev 91 | 92 | # if you want to run vercel function during development 93 | # then first create a vercel project from this repo by logging in on vercel.com 94 | # then run following command and follow the instructions on terminal 95 | $ npx vercel dev 96 | 97 | # build for production and launch server 98 | $ npm run build 99 | $ npm run start 100 | 101 | # generate static project 102 | $ npm run generate 103 | ``` 104 | 105 | ## Self Host 106 | 107 | This guide provides detailed instructions on how to self-host the offline-diff-viewer application using Docker and Docker Compose. Self-hosting allows you to run the application on your own server, providing you with full control over its environment and configuration. 108 | 109 | ### Building and Running the Docker Container 110 | 111 | 1. Build the Docker Image 112 | 113 | ```bash 114 | $ docker build -t offline-diff-viewer . 115 | ``` 116 | 117 | 2. Run the Docker Container via docker run command 118 | 119 | ```bash 120 | $ docker run -d \ 121 | --name offline-diff-viewer \ 122 | -p 3000:80 \ 123 | --security-opt no-new-privileges:true \ 124 | -v /var/log/nginx:/var/log/nginx \ 125 | --restart unless-stopped \ 126 | -e NODE_ENV=production \ 127 | -e NODE_OPTIONS=--openssl-legacy-provider \ 128 | offline-diff-viewer 129 | ``` 130 | 131 | ### Running the Container with Docker Compose 132 | 133 | ```bash 134 | $ docker compose up -d --build 135 | ``` 136 | -------------------------------------------------------------------------------- /pages/diff.vue: -------------------------------------------------------------------------------- 1 | 91 | 92 | 192 | 193 | 234 | -------------------------------------------------------------------------------- /pages/v2/diff.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 241 | 242 | 247 | -------------------------------------------------------------------------------- /pages/v2/index.vue: -------------------------------------------------------------------------------- 1 | 112 | 113 | 248 | 249 | 264 | -------------------------------------------------------------------------------- /nuxt.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import MonacoWebpackPlugin from 'monaco-editor-webpack-plugin' 3 | 4 | const BASE_URL = 'https://diffviewer.vercel.app' 5 | const TITLE_DESCRIPTION = 6 | 'A tool that helps you compare, differentiate, analyze, visualize text online' 7 | const DESCRIPTION = 8 | 'A privacy focused tool and/or utility that allows you to compare/analyze/contrast/differentiate/visualize/analyze pieces of texts' 9 | export default { 10 | ssr: false, 11 | head: { 12 | title: `${TITLE_DESCRIPTION} | Diff Viewer`, 13 | script: [ 14 | { 15 | src: 'https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-4467877923505914', 16 | crossorigin: 'anonymous', 17 | }, 18 | ], 19 | meta: [ 20 | { charset: 'utf-8' }, 21 | { 22 | name: 'keywords', 23 | content: 24 | 'compare text, difference, diff view, diff viewer, diff checker, hamming distance, difference, data privacy, differentiate, differentiator, text differentiator', 25 | }, 26 | { name: 'color-scheme', content: 'dark light' }, 27 | { 28 | name: 'viewport', 29 | content: 'width=750px; initial-scale=1', 30 | }, 31 | { name: 'format-detection', content: 'telephone=no' }, 32 | { name: 'theme-color', content: '#2563EB' }, 33 | { name: 'og:url', property: 'og:url', content: `${BASE_URL}` }, 34 | { 35 | name: 'og:image', 36 | property: 'og:url', 37 | content: `${BASE_URL}/brand-430x495.png`, 38 | }, 39 | { name: 'twitter:title', property: 'og:url', content: DESCRIPTION }, 40 | { name: 'og:title', property: 'og:url', content: DESCRIPTION }, 41 | { name: 'og:type', property: 'og:url', content: 'website' }, 42 | { name: 'description', property: 'og:url', content: DESCRIPTION }, 43 | { name: 'og:description', property: 'og:url', content: DESCRIPTION }, 44 | { name: 'twitter:description', property: 'og:url', content: DESCRIPTION }, 45 | { name: 'twitter:card', property: 'og:url', content: 'summary' }, 46 | { 47 | name: 'twitter:creator', 48 | property: 'og:url', 49 | content: '@technikhil314', 50 | }, 51 | { 52 | name: 'og:image', 53 | property: 'og:url', 54 | content: `${BASE_URL}/128x128.png`, 55 | }, 56 | { 57 | name: 'og:image', 58 | property: 'og:url', 59 | content: `${BASE_URL}/brand-192x192.png`, 60 | }, 61 | { 62 | name: 'og:image', 63 | property: 'og:url', 64 | content: `${BASE_URL}/brand-200x200.png`, 65 | }, 66 | { 67 | name: 'og:image', 68 | property: 'og:url', 69 | content: `${BASE_URL}/brand-512x512.png`, 70 | }, 71 | { 72 | name: 'og:image', 73 | property: 'og:url', 74 | content: `${BASE_URL}/brand-800x800.png`, 75 | }, 76 | { 77 | name: 'image', 78 | property: 'og:url', 79 | content: `${BASE_URL}/brand-1200x600.png`, 80 | }, 81 | { name: 'og:image:alt', property: 'og:url', content: DESCRIPTION }, 82 | { 83 | name: 'twitter:image', 84 | property: 'og:url', 85 | content: `${BASE_URL}/128x128.png`, 86 | }, 87 | { 88 | name: 'google-adsense-account', 89 | content: 'ca-pub-4467877923505914', 90 | }, 91 | ], 92 | link: [ 93 | { rel: 'manifest', href: '/manifest.json' }, 94 | { 95 | rel: 'sitemap', 96 | type: 'application/xml', 97 | title: 'Sitemap', 98 | href: '/sitemap.xml', 99 | }, 100 | { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, 101 | { 102 | rel: 'preconnect', 103 | href: 'https://fonts.gstatic.com', 104 | crossorigin: true, 105 | }, 106 | { 107 | href: 'https://fonts.googleapis.com/css2?family=Open+Sans&display=swap', 108 | rel: 'stylesheet', 109 | }, 110 | { 111 | rel: 'icon', 112 | media: '(prefers-color-scheme: light)', 113 | type: 'image/x-icon', 114 | href: '/dark-favicon.ico', 115 | }, 116 | { 117 | rel: 'shortcut icon', 118 | media: '(prefers-color-scheme: light)', 119 | href: '/dark-favicon.ico', 120 | type: 'image/x-icon', 121 | }, 122 | { 123 | rel: 'apple-touch-icon', 124 | media: '(prefers-color-scheme: light)', 125 | href: '/dark-apple-touch-icon.png', 126 | }, 127 | { 128 | rel: 'apple-touch-icon', 129 | media: '(prefers-color-scheme: light)', 130 | sizes: '57x57', 131 | href: '/dark-apple-touch-icon-57x57.png', 132 | }, 133 | { 134 | rel: 'apple-touch-icon', 135 | media: '(prefers-color-scheme: light)', 136 | sizes: '72x72', 137 | href: '/dark-apple-touch-icon-72x72.png', 138 | }, 139 | { 140 | rel: 'apple-touch-icon', 141 | media: '(prefers-color-scheme: light)', 142 | sizes: '76x76', 143 | href: '/dark-apple-touch-icon-76x76.png', 144 | }, 145 | { 146 | rel: 'apple-touch-icon', 147 | media: '(prefers-color-scheme: light)', 148 | sizes: '114x114', 149 | href: '/dark-apple-touch-icon-114x114.png', 150 | }, 151 | { 152 | rel: 'apple-touch-icon', 153 | media: '(prefers-color-scheme: light)', 154 | sizes: '120x120', 155 | href: '/dark-apple-touch-icon-120x120.png', 156 | }, 157 | { 158 | rel: 'apple-touch-icon', 159 | media: '(prefers-color-scheme: light)', 160 | sizes: '144x144', 161 | href: '/dark-apple-touch-icon-144x144.png', 162 | }, 163 | { 164 | rel: 'apple-touch-icon', 165 | media: '(prefers-color-scheme: light)', 166 | sizes: '152x152', 167 | href: '/dark-apple-touch-icon-152x152.png', 168 | }, 169 | { 170 | rel: 'apple-touch-icon', 171 | media: '(prefers-color-scheme: light)', 172 | sizes: '180x180', 173 | href: '/dark-apple-touch-icon-180x180.png', 174 | }, 175 | { 176 | rel: 'icon', 177 | media: '(prefers-color-scheme: dark)', 178 | type: 'image/x-icon', 179 | href: '/light-favicon.ico', 180 | }, 181 | { 182 | rel: 'shortcut icon', 183 | media: '(prefers-color-scheme: dark)', 184 | href: '/light-favicon.ico', 185 | type: 'image/x-icon', 186 | }, 187 | { 188 | rel: 'apple-touch-icon', 189 | media: '(prefers-color-scheme: dark)', 190 | href: '/light-apple-touch-icon.png', 191 | }, 192 | { 193 | rel: 'apple-touch-icon', 194 | media: '(prefers-color-scheme: dark)', 195 | sizes: '57x57', 196 | href: '/light-apple-touch-icon-57x57.png', 197 | }, 198 | { 199 | rel: 'apple-touch-icon', 200 | media: '(prefers-color-scheme: dark)', 201 | sizes: '72x72', 202 | href: '/light-apple-touch-icon-72x72.png', 203 | }, 204 | { 205 | rel: 'apple-touch-icon', 206 | media: '(prefers-color-scheme: dark)', 207 | sizes: '76x76', 208 | href: '/light-apple-touch-icon-76x76.png', 209 | }, 210 | { 211 | rel: 'apple-touch-icon', 212 | media: '(prefers-color-scheme: dark)', 213 | sizes: '114x114', 214 | href: '/light-apple-touch-icon-114x114.png', 215 | }, 216 | { 217 | rel: 'apple-touch-icon', 218 | media: '(prefers-color-scheme: dark)', 219 | sizes: '120x120', 220 | href: '/light-apple-touch-icon-120x120.png', 221 | }, 222 | { 223 | rel: 'apple-touch-icon', 224 | media: '(prefers-color-scheme: dark)', 225 | sizes: '144x144', 226 | href: '/light-apple-touch-icon-144x144.png', 227 | }, 228 | { 229 | rel: 'apple-touch-icon', 230 | media: '(prefers-color-scheme: dark)', 231 | sizes: '152x152', 232 | href: '/light-apple-touch-icon-152x152.png', 233 | }, 234 | { 235 | rel: 'apple-touch-icon', 236 | media: '(prefers-color-scheme: dark)', 237 | sizes: '180x180', 238 | href: '/light-apple-touch-icon-180x180.png', 239 | }, 240 | ], 241 | }, 242 | 243 | // Global CSS: https://go.nuxtjs.dev/config-css 244 | css: ['~/styles/global.scss'], 245 | 246 | // Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins 247 | plugins: ['~/plugins/cookie-injector.client.ts'], 248 | 249 | // Auto import components: https://go.nuxtjs.dev/config-components 250 | components: true, 251 | 252 | // Modules for dev and build (recommended): https://go.nuxtjs.dev/config-modules 253 | buildModules: [ 254 | // https://go.nuxtjs.dev/typescript 255 | '@nuxt/typescript-build', 256 | // https://go.nuxtjs.dev/stylelint 257 | '@nuxtjs/stylelint-module', 258 | // https://go.nuxtjs.dev/tailwindcss 259 | '@nuxtjs/tailwindcss', 260 | ], 261 | 262 | // Modules: https://go.nuxtjs.dev/config-modules 263 | modules: [ 264 | // https://go.nuxtjs.dev/pwa 265 | '@nuxtjs/pwa', 266 | '@nuxtjs/sitemap', 267 | ], 268 | 269 | // PWA module configuration: https://go.nuxtjs.dev/pwa 270 | pwa: { 271 | manifest: { 272 | lang: 'en', 273 | }, 274 | }, 275 | 276 | // sitemap autogeneration https://github.com/nuxt-community/sitemap-module 277 | sitemap: { 278 | hostname: BASE_URL, 279 | }, 280 | 281 | // Build Configuration: https://go.nuxtjs.dev/config-build 282 | build: { 283 | extractCSS: true, 284 | productionSourceMap: false, 285 | extend(config, { isClient }) { 286 | if (isClient && process.env.NODE_ENV === 'development') { 287 | config.resolve.alias.vscode = path.resolve( 288 | './node_modules/monaco-languageclient/lib/vscode-compatibility' 289 | ) 290 | config.plugins.push( 291 | new MonacoWebpackPlugin({ 292 | languages: ['javascript'], 293 | features: ['coreCommands', 'find'], 294 | }) 295 | ) 296 | config.devtool = 'source-map' 297 | } 298 | }, 299 | }, 300 | } 301 | --------------------------------------------------------------------------------