7 |
8 |
9 | ## NimbleAB
10 | ## About
11 | Nimble AB is a lightweight NextJS AB testing platform designed to streamline AB testing on NextJS apps that want to take advantage of SSG load times in UX experiments. The platform's goal is to make the developer experience of configuring and implementing AB tests on static sites fast and painless so that businesses can optimize load times on AB tests without sacrificing the dynamicism required for high-impact testing.
12 |
13 | For more info visit our [website](https://nimbleab.io/) or read our [Medium article](https://nimblelabs.medium.com/6b54e84e473)
14 |
15 | ## Tech Stack
16 |
8 |
9 | Configure a variant above. Add a file path name and a weight
10 |
11 |
12 |
13 | Weights represent the percentage of page loads that should show a
14 | variant.
15 |
16 |
17 | The file path selected will save to your local. Make it semantically
18 | relevant to the changes
19 |
20 |
21 | Click edit to open the code editor to make the changes required for your
22 | variant. Save when finished and the updates will write to your repo
23 |
24 |
25 | When finished with a valid experiment your local will have a variants
26 | folder containing all created variants, and a config.json middleware
27 | file that will return variants according to the weights and set a cookie
28 | to give return visitors a consistent experience
29 |
30 |
31 | );
32 | };
33 |
34 | export default TestConfigInstructions;
35 |
--------------------------------------------------------------------------------
/app/src/pages/config/TestingConfig.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import ReactDOM from "react-dom";
3 | import axios from "axios";
4 | import { createClient } from "@supabase/supabase-js";
5 | import VariantRow from "./ConfigureVariantComponents/VariantRow";
6 | import CreateVariant from "./VariantDisplayComponents/EditVariant";
7 | import { PrismaClient } from "@prisma/client";
8 | import { IElectronAPI } from "../../../../renderer";
9 | import { createContext, useEffect } from "react";
10 | import VariantDisplay from "./VariantDisplayComponents/VariantDisplay";
11 | import exp from "constants";
12 | import { v4 as uuidv4 } from "uuid";
13 | import { useLocation } from "react-router-dom";
14 | import { UseSelector } from "react-redux/es/hooks/useSelector";
15 | import ExperimentDropDown from "./Unused/ExperimentDropDown";
16 | import ConfigureVariant from "./ConfigureVariantComponents/ConfigureVariant";
17 | import { useSelector } from "react-redux";
18 | import { RootState } from "../../redux/store";
19 | import TestConfigInstructions from "./TestConfigInstructions";
20 |
21 | interface RowProps {
22 | index: number;
23 | }
24 |
25 | export interface Variant {
26 | filePath: string;
27 | weight: number;
28 | deviceType: string;
29 | }
30 |
31 | interface Experiment {
32 | name: string;
33 | variants: [];
34 | }
35 |
36 | interface ExperimentContextType {
37 | experimentId: any;
38 | experimentPath: string;
39 | repoId: string | number;
40 | directoryPath: string;
41 | experimentName: string;
42 | reload: () => void;
43 | }
44 |
45 | export const experimentContext = createContext({
46 | experimentId: "",
47 | experimentPath: "",
48 | repoId: "",
49 | directoryPath: "",
50 | experimentName: "",
51 | // there is a downstream typescript problem that requires a function to be placed here. Placeholder added. Go easy on us...
52 | reload: () => {
53 | return 1;
54 | },
55 | });
56 |
57 | const TestingConfig: React.FC = () => {
58 | // declare state variables
59 | const [rows, setRows] = useState[]>([]);
60 | const [totalWeight, setTotalWeight] = useState(0);
61 | const [variants, setVariants] = useState([]);
62 | const [experimentObj, updateExperimentObj] = useState({});
63 | const [reset, causeReset] = useState(false);
64 | const [resetFlag, setResetFlag] = useState(false);
65 | // use Redux for the repo path
66 | const repoPath = useSelector(
67 | (state: RootState) => state.experiments.repoPath
68 | );
69 |
70 | const changeHandler = () => {
71 | setResetFlag((prevResetFlag) => !prevResetFlag);
72 | console.log("Reached the change handler");
73 | };
74 | // get state data sent from the home page
75 | const location = useLocation();
76 | const {
77 | experimentName,
78 | experimentPath,
79 | repoId,
80 | experimentId,
81 | directoryPath,
82 | } = location.state;
83 |
84 | // get variants data from the server
85 | const getVariants = async (id: number | string) => {
86 | try {
87 | // async call to the local server
88 | const variantsString = await window.electronAPI.getVariants(experimentId);
89 | // returned as a JSON formatted string; parse out
90 | const rawVariants = JSON.parse(variantsString);
91 | // the server returns an object with an array of length 1 containing an array of objects. This is due Prisma default formatting. Assign the variants variable this array of variant objects
92 | const variants = rawVariants[0].Variants;
93 | // generate the meaningful data we want
94 | const newVariants = variants.map((variant: any) => ({
95 | filePath: variant.filePath,
96 | weight: variant.weights,
97 | deviceType: variant.deviceType, // deprecated ; removal of variant-level references to device type is an opp to address tech debt
98 | }));
99 |
100 | setVariants(newVariants); // Update the variants state
101 | } catch (error) {
102 | console.error("An error occurred:", error);
103 | }
104 | };
105 |
106 | // component functionality: get experiment if exists on user's local
107 | async function getExperimentdata() {
108 | const experimentData = await window.electronAPI.getExperiments();
109 | // if experiment data is falsy, inform the user. This indicates larger breakage
110 | if (!experimentData) {
111 | alert(
112 | "No experiment was found - please contact Nimble Team with bug report"
113 | );
114 | } else {
115 | console.log("Returned the experiment data");
116 | return experimentData;
117 | }
118 | }
119 |
120 | async function main() {
121 | try {
122 | console.log(repoPath + " if there's a file path, redux is working");
123 | const experimentObjectString = await getExperimentdata();
124 |
125 | const experimentObject = JSON.parse(experimentObjectString);
126 | getVariants(experimentId);
127 | } catch (error) {
128 | console.error("An error occurred:", error);
129 | }
130 | }
131 |
132 | useEffect(() => {
133 | main();
134 | }, [resetFlag]);
135 |
136 | // getVariants(experimentId);
137 | //use effect to listen out for updates to variant rows
138 | // rounded-xl w-1/2 h-96 bg-slate-800 text-white p-2 flex flex-col items-center
139 | return (
140 |
95 | Warning - weights must sum to 100 for experiment to be valid
96 |
97 |
98 | ) : (
99 |
100 | )}
101 |
102 | );
103 | };
104 |
105 | export default VariantDisplay;
106 |
--------------------------------------------------------------------------------
/app/src/pages/home/home.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ExperimentCreate from "../../components/experimentCreate";
3 | import ActiveExperiment from "../../components/activeExperiments";
4 | import Instructions from "../../components/instructions";
5 | import { v4 as uuidv4 } from "uuid";
6 |
7 | const Home = (): React.JSX.Element => {
8 | const setUserId = () => {
9 | // check to see if user ID cookie exists, if yes then do nothing, if no then set a new one // check to see if user ID cookie exists, if yes then do nothing, if no then set a new one
10 | const userIdCookie = document.cookie.includes("user_id");
11 | if (!userIdCookie) {
12 | const newUserId = uuidv4();
13 | document.cookie = "user_id=" + newUserId;
14 | }
15 | };
16 | return (
17 | <>
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | >
26 | );
27 | };
28 |
29 | export default Home;
30 |
--------------------------------------------------------------------------------
/app/src/redux/experimentsSlice.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from '@reduxjs/toolkit';
2 |
3 | const initialState = {
4 | experimentId: null,
5 | experimentName: '',
6 | repoPath: '/',
7 | fullFilePath: '',
8 | };
9 | const experimentSlice = createSlice({
10 | name: 'experiment',
11 | initialState,
12 | reducers: {
13 | updateExperimentId: (state, action) => {
14 | state.experimentId = action.payload;
15 | },
16 | updateExperimentName: (state, action) => {
17 | state.experimentName = action.payload;
18 | },
19 | updateRepoPath: (state, action) => {
20 | state.repoPath = action.payload;
21 | },
22 | updateFullFilePath: (state, action) => {
23 | state.fullFilePath = action.payload;
24 | },
25 | },
26 | });
27 |
28 | // // Export actions
29 | export const {
30 | updateExperimentId,
31 | updateExperimentName,
32 | updateRepoPath,
33 | updateFullFilePath,
34 | } = experimentSlice.actions;
35 |
36 | // // Export reducer
37 | export default experimentSlice.reducer;
38 |
--------------------------------------------------------------------------------
/app/src/redux/store.ts:
--------------------------------------------------------------------------------
1 | import { configureStore } from '@reduxjs/toolkit';
2 | import experimentsReducer from './experimentsSlice';
3 |
4 | const store = configureStore({
5 | reducer: {
6 | experiments: experimentsReducer,
7 | },
8 | });
9 |
10 | export default store;
11 |
12 | export type RootState = ReturnType;
13 | export type AppDispatch = typeof store.dispatch;
14 |
--------------------------------------------------------------------------------
/app/templates/middleware.ts:
--------------------------------------------------------------------------------
1 | // importing required modules and types from the 'next/server' package
2 | import { NextRequest, NextResponse, userAgent } from 'next/server';
3 | import { createClient } from '@supabase/supabase-js';
4 | // importing the variants config from the JSON file
5 | import variantsConfig from './nimble.config.json';
6 | import { NextURL } from 'next/dist/server/web/next-url';
7 | import { v4 as uuidv4 } from 'uuid';
8 | import { ChildProcess } from 'child_process';
9 |
10 | // initialize Supabase client - https://supabase.com/docs/reference/javascript/initializing
11 | const supabaseUrl = 'https://tawrifvzyjqcddwuqjyq.supabase.co';
12 | const supabaseKey =
13 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InRhd3JpZnZ6eWpxY2Rkd3VxanlxIiwicm9sZSI6ImFub24iLCJpYXQiOjE2OTI2NTc2MjcsImV4cCI6MjAwODIzMzYyN30.-VekGbd6Iwey0Q32SQA0RxowZtqSlDptBhlt2r-GZBw';
14 | const supabase = createClient(supabaseUrl, supabaseKey);
15 |
16 | //initialize experiment - only input f
17 | // const experiment = variants.filter("exper")
18 |
19 | // defining a type for the variant with properties: id, fileName, and weight
20 |
21 | type Variant = {
22 | id: string;
23 | fileName: string;
24 | weight: number;
25 | // experiment_id: string;
26 | };
27 |
28 | // export const config = {
29 | // matcher: '/blog', //experiment path
30 | // };
31 |
32 | // middleware function that determines which variant to serve based on device type and possibly cookie values
33 | export async function middleware(req: NextRequest) {
34 | // extract the device details from the user agent of the request - https://nextjs.org/docs/messages/middleware-parse-user-agent
35 | const {ua} = userAgent(req);
36 | // console.log(data)
37 | function mobile_check(a){
38 | if(/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a)||/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0,4))) return true;
39 | else return false
40 | };
41 |
42 | // determine the device type, whether it's mobile or desktop
43 | const deviceType = mobile_check(ua) === true ? 'mobile' : 'desktop';
44 |
45 | const url = req.nextUrl;
46 | const currentPath = url.pathname;
47 |
48 | // find the experiment configuration for the current path
49 | const experimentConfig = variantsConfig.find(
50 | (config) => config.experiment_path === currentPath
51 | );
52 |
53 | // if no experiment configuration found for the current path, return the URL without any changes
54 | if (!experimentConfig || experimentConfig.device_type !== deviceType) {
55 | return NextResponse.rewrite(url);
56 | }
57 |
58 | // function to choose a variant based on device type and weights of available variants
59 | function chooseVariant(
60 | deviceType: 'mobile' | 'desktop',
61 | variants: Variant[]
62 | ): Variant {
63 | // calculate the total weight of all variants
64 | let totalWeight = variants.reduce((sum, v) => sum + v.weight, 0);
65 |
66 | // generate a random value within the range of the total weight
67 | let randomValue = Math.random() * totalWeight;
68 |
69 | // loop through variants to find a matching variant based on its weight
70 | for (const variant of variants) {
71 | if (randomValue < variant.weight) {
72 | return variant;
73 | }
74 | randomValue -= variant.weight;
75 | }
76 |
77 | // default to the first variant if no variant is matched
78 | return variants[0];
79 | }
80 |
81 | // // check for existing cookie
82 | // const expVariantID = req.cookies.get('expVariantID')?.value;
83 |
84 | // // choose an experiment and then a variant inside the experiment
85 | // const experiment = variantsConfig.filter(
86 | // (experiments) => experiments.experiment_name === 'test1'
87 | // );
88 |
89 | // const experimentId = experiment[0].experiment_id; //change string based on test name
90 | // // console.log(experimentId);
91 |
92 | const experimentId = experimentConfig.experiment_id;
93 | const expVariantID = req.cookies.get('expVariantID')?.value;
94 |
95 | // prioritize experiment selection via query parameter
96 | // first check if a variant has been selected based on the expVariantID cookie
97 | // if not, then choose a variant based on the device type and the weights of the available variants
98 |
99 | let chosenExperiment: string = expVariantID
100 | ? expVariantID?.split('_')[0]
101 | : experimentId;
102 | // console.log('chosenExperiment :>> ', chosenExperiment);
103 |
104 | async function getVariant(
105 | experimentConfig: any,
106 | varID: string
107 | ): Promise {
108 | // console.log(experiment[0].variants);
109 | // return experiment[0].variants.filter((variant) => variant.id === varID)[0];
110 | return experimentConfig.variants.filter(
111 | (variant: { id: string }) => variant.id === varID
112 | )[0];
113 | }
114 | // if (expVariantID) console.log(getVariant(expVariantID?.split('_')[1]));
115 |
116 | // let chosenVariant: Variant = expVariantID
117 | // ? await getVariant(expVariantID.split('_')[1])
118 | // : chooseVariant(deviceType, experiment[0].variants);
119 |
120 | let chosenVariant: Variant = expVariantID
121 | ? await getVariant(experimentConfig, expVariantID.split('_')[1])
122 | : chooseVariant(deviceType, experimentConfig.variants);
123 |
124 | // console.log('chosenVariant :>> ', chosenVariant);
125 | // asynchronously call the increment RPC function in Supabase without waiting for it to complete
126 | // create a separate static_variants table and static_increment function for the staticConfig (https://supabase.com/dashboard/project/tawrifvzyjqcddwuqjyq/database/functions) per https://www.youtube.com/watch?v=n5j_mrSmpyc
127 |
128 |
129 |
130 | supabase
131 | .rpc('increment', { row_id: chosenVariant.id })
132 | .then(({ data, error }) => {
133 | if (error) {
134 | console.error('Error incrementing variant count:', error);
135 | } else {
136 | console.log(data);
137 | }
138 | });
139 |
140 | // rewrite the request to serve the chosen variant's file
141 |
142 | // const url = req.nextUrl;
143 | // url.pathname = url.pathname.replace(
144 | // '/blog',
145 | // `/blog/${chosenVariant.fileName}`
146 | // );
147 |
148 | url.pathname = url.pathname.replace(
149 | currentPath,
150 | `${currentPath}/variants/${chosenVariant.fileName}`
151 | );
152 |
153 | const res = NextResponse.rewrite(url);
154 |
155 | // if the variant ID doesn't exist in the cookies, set it now for future requests
156 | if (!expVariantID) {
157 | res.cookies.set('expVariantID', `${experimentId}_${chosenVariant.id}`, {
158 | path: '/',
159 | httpOnly: true,
160 | maxAge: 10 * 365 * 24 * 60 * 60, // set the cookie to expire in 10 years
161 | });
162 | }
163 |
164 | // return the response with the rewritten url or any set cookies
165 | return res;
166 | }
167 |
168 |
--------------------------------------------------------------------------------
/app/templates/nimble.config.json:
--------------------------------------------------------------------------------
1 | [
2 |
3 | ]
4 |
--------------------------------------------------------------------------------
/app/templates/staticConfig.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "experiment_path": "/blog",
4 | "experiment_name": "test1",
5 | "experiment_id": "59f734ad-078e-4665-a783-537af9f92bf4",
6 | "device_type": "mobile",
7 | "variants": [
8 | {
9 | "id": "05292789-0f23-4b86-b940-d8845038607e",
10 | "fileName": "1",
11 | "weight": 33
12 | },
13 | {
14 | "id": "82846299-fa14-4bee-a170-134d5e9d77ee",
15 | "fileName": "2",
16 | "weight": 33
17 | },
18 | {
19 | "id": "9d323bda-3ddd-4f8d-9397-0fdee2fb4108",
20 | "fileName": "3",
21 | "weight": 33
22 | }
23 | ]
24 | },
25 | {
26 | "experiment_path": "/home",
27 | "experiment_name": "test2",
28 | "experiment_id": "test2id",
29 | "device_type": "mobile",
30 | "variants": [
31 | {
32 | "id": "1",
33 | "fileName": "variant1.html",
34 | "weight": 50
35 | },
36 | {
37 | "id": "2",
38 | "fileName": "variant2.html",
39 | "weight": 30
40 | },
41 | {
42 | "id": "3",
43 | "fileName": "variant3.html",
44 | "weight": 20
45 | }
46 | ]
47 | }
48 | ]
49 |
--------------------------------------------------------------------------------
/dev-scripts/launchDevServer.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const { exec } = require('child_process');
3 | const logFilePath = './dev-scripts/webpack-dev-server.log';
4 | const errorLogFilePath = './dev-scripts/webpack-dev-server-error.log';
5 | const interval = 100;
6 | const showHint = 600 * 3; // show hint after 3 minutes (60 sec * 3)
7 | let hintCounter = 1;
8 |
9 | // Poll webpack-dev-server.log until the webpack bundle has compiled successfully
10 | const intervalId = setInterval(function () {
11 | try {
12 | if (fs.existsSync(logFilePath)) {
13 | const log = fs.readFileSync(logFilePath, {
14 | encoding: 'utf8',
15 | });
16 |
17 | // "compiled successfully" is the string we need to find
18 | // to know that webpack is done bundling everything and we
19 | // can load our Electron app with no issues. We split up the
20 | // validation because the output contains non-standard characters.
21 | const compiled = log.indexOf('compiled');
22 | if (compiled >= 0 && log.indexOf('successfully', compiled) >= 0) {
23 | console.log(
24 | 'Webpack development server is ready, launching Electron app.'
25 | );
26 | clearInterval(intervalId);
27 |
28 | // Start our electron app
29 | const electronProcess = exec(
30 | 'cross-env NODE_ENV=development electron .'
31 | );
32 | electronProcess.stdout.on('data', function (data) {
33 | process.stdout.write(data);
34 | });
35 | electronProcess.stderr.on('data', function (data) {
36 | process.stdout.write(data);
37 | });
38 | } else if (log.indexOf('Module build failed') >= 0) {
39 | if (fs.existsSync(errorLogFilePath)) {
40 | const errorLog = fs.readFileSync(errorLogFilePath, {
41 | encoding: 'utf8',
42 | });
43 |
44 | console.log(errorLog);
45 | console.log(
46 | `Webpack failed to compile; this error has also been logged to '${errorLogFilePath}'.`
47 | );
48 | clearInterval(intervalId);
49 |
50 | return process.exit(1);
51 | } else {
52 | console.log('Webpack failed to compile, but the error is unknown.');
53 | clearInterval(intervalId);
54 |
55 | return process.exit(1);
56 | }
57 | } else {
58 | hintCounter++;
59 |
60 | // Show hint so user is not waiting/does not know where to
61 | // look for an error if it has been thrown and/or we are stuck
62 | if (hintCounter > showHint) {
63 | console.error(
64 | `Webpack is likely failing for an unknown reason, please check '${errorLogFilePath}' for more details.`
65 | );
66 | clearInterval(intervalId);
67 |
68 | return process.exit(1);
69 | }
70 | }
71 | }
72 | } catch (error) {
73 | // Exit with an error code
74 | console.error('Webpack or electron fatal error' + error);
75 | clearInterval(intervalId);
76 |
77 | return process.exit(1);
78 | }
79 | }, interval);
80 |
--------------------------------------------------------------------------------
/dev-scripts/prepareDevServer.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const { exec } = require('child_process');
3 | const logFilePath = './dev-scripts/webpack-dev-server.log';
4 | const errorLogFilePath = './dev-scripts/webpack-dev-server-error.log';
5 |
6 | console.log(
7 | `Preparing webpack development server. (Logging webpack output to '${logFilePath}')`
8 | );
9 |
10 | // Delete the old webpack-dev-server.log if it is present
11 | try {
12 | fs.unlinkSync(logFilePath);
13 | } catch (error) {
14 | // Existing webpack-dev-server log file may not exist
15 | }
16 |
17 | // Delete the old webpack-dev-server-error.log if it is present
18 | try {
19 | fs.unlinkSync(errorLogFilePath);
20 | } catch (error) {
21 | // Existing webpack-dev-server-error log file may not exist
22 | }
23 |
24 | // Start the webpack development server
25 | exec('npm run dev-server');
26 |
--------------------------------------------------------------------------------
/images/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/NimbleABApp/c04180a1466f6cbe7cba08e044009d0ea6a96cd2/images/icon.png
--------------------------------------------------------------------------------
/middleware.ts:
--------------------------------------------------------------------------------
1 | // importing required modules and types from the 'next/server' package
2 | import { NextRequest, NextResponse, userAgent } from 'next/server';
3 | import { createClient } from '@supabase/supabase-js';
4 | // importing the variants config from the JSON file
5 | import variantsConfig from './nimble.config.json';
6 | import { NextURL } from 'next/dist/server/web/next-url';
7 | import { v4 as uuidv4 } from 'uuid';
8 | import { ChildProcess } from 'child_process';
9 |
10 | // initialize Supabase client - https://supabase.com/docs/reference/javascript/initializing
11 | const supabaseUrl = 'https://tawrifvzyjqcddwuqjyq.supabase.co';
12 | const supabaseKey =
13 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InRhd3JpZnZ6eWpxY2Rkd3VxanlxIiwicm9sZSI6ImFub24iLCJpYXQiOjE2OTI2NTc2MjcsImV4cCI6MjAwODIzMzYyN30.-VekGbd6Iwey0Q32SQA0RxowZtqSlDptBhlt2r-GZBw';
14 | const supabase = createClient(supabaseUrl, supabaseKey);
15 |
16 | //initialize experiment - only input f
17 | // const experiment = variants.filter("exper")
18 |
19 | // defining a type for the variant with properties: id, fileName, and weight
20 |
21 | type Variant = {
22 | id: string;
23 | fileName: string;
24 | weight: number;
25 | // experiment_id: string;
26 | };
27 |
28 | // export const config = {
29 | // matcher: '/blog', //experiment path
30 | // };
31 |
32 | // middleware function that determines which variant to serve based on device type and possibly cookie values
33 | export async function middleware(req: NextRequest) {
34 | // extract the device details from the user agent of the request - https://nextjs.org/docs/messages/middleware-parse-user-agent
35 | const {ua} = userAgent(req);
36 | // console.log(data)
37 | function mobile_check(a){
38 | if(/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a)||/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0,4))) return true;
39 | else return false
40 | };
41 |
42 | // determine the device type, whether it's mobile or desktop
43 | const deviceType = mobile_check(ua) === true ? 'mobile' : 'desktop';
44 |
45 | const url = req.nextUrl;
46 | const currentPath = url.pathname;
47 |
48 | // find the experiment configuration for the current path
49 | const experimentConfig = variantsConfig.find(
50 | (config) => config.experiment_path === currentPath
51 | );
52 |
53 | // if no experiment configuration found for the current path, return the URL without any changes
54 | if (!experimentConfig || experimentConfig.device_type !== deviceType) {
55 | return NextResponse.rewrite(url);
56 | }
57 |
58 | // function to choose a variant based on device type and weights of available variants
59 | function chooseVariant(
60 | deviceType: 'mobile' | 'desktop',
61 | variants: Variant[]
62 | ): Variant {
63 | // calculate the total weight of all variants
64 | let totalWeight = variants.reduce((sum, v) => sum + v.weight, 0);
65 |
66 | // generate a random value within the range of the total weight
67 | let randomValue = Math.random() * totalWeight;
68 |
69 | // loop through variants to find a matching variant based on its weight
70 | for (const variant of variants) {
71 | if (randomValue < variant.weight) {
72 | return variant;
73 | }
74 | randomValue -= variant.weight;
75 | }
76 |
77 | // default to the first variant if no variant is matched
78 | return variants[0];
79 | }
80 |
81 | // // check for existing cookie
82 | // const expVariantID = req.cookies.get('expVariantID')?.value;
83 |
84 | // // choose an experiment and then a variant inside the experiment
85 | // const experiment = variantsConfig.filter(
86 | // (experiments) => experiments.experiment_name === 'test1'
87 | // );
88 |
89 | // const experimentId = experiment[0].experiment_id; //change string based on test name
90 | // // console.log(experimentId);
91 |
92 | const experimentId = experimentConfig.experiment_id;
93 | const expVariantID = req.cookies.get('expVariantID')?.value;
94 |
95 | // prioritize experiment selection via query parameter
96 | // first check if a variant has been selected based on the expVariantID cookie
97 | // if not, then choose a variant based on the device type and the weights of the available variants
98 |
99 | let chosenExperiment: string = expVariantID
100 | ? expVariantID?.split('_')[0]
101 | : experimentId;
102 | // console.log('chosenExperiment :>> ', chosenExperiment);
103 |
104 | async function getVariant(
105 | experimentConfig: any,
106 | varID: string
107 | ): Promise {
108 | // console.log(experiment[0].variants);
109 | // return experiment[0].variants.filter((variant) => variant.id === varID)[0];
110 | return experimentConfig.variants.filter(
111 | (variant: { id: string }) => variant.id === varID
112 | )[0];
113 | }
114 | // if (expVariantID) console.log(getVariant(expVariantID?.split('_')[1]));
115 |
116 | // let chosenVariant: Variant = expVariantID
117 | // ? await getVariant(expVariantID.split('_')[1])
118 | // : chooseVariant(deviceType, experiment[0].variants);
119 |
120 | let chosenVariant: Variant = expVariantID
121 | ? await getVariant(experimentConfig, expVariantID.split('_')[1])
122 | : chooseVariant(deviceType, experimentConfig.variants);
123 |
124 | // console.log('chosenVariant :>> ', chosenVariant);
125 | // asynchronously call the increment RPC function in Supabase without waiting for it to complete
126 | // create a separate static_variants table and static_increment function for the staticConfig (https://supabase.com/dashboard/project/tawrifvzyjqcddwuqjyq/database/functions) per https://www.youtube.com/watch?v=n5j_mrSmpyc
127 |
128 |
129 |
130 | supabase
131 | .rpc('increment', { row_id: chosenVariant.id })
132 | .then(({ data, error }) => {
133 | if (error) {
134 | console.error('Error incrementing variant count:', error);
135 | } else {
136 | console.log(data);
137 | }
138 | });
139 |
140 | // rewrite the request to serve the chosen variant's file
141 |
142 | // const url = req.nextUrl;
143 | // url.pathname = url.pathname.replace(
144 | // '/blog',
145 | // `/blog/${chosenVariant.fileName}`
146 | // );
147 |
148 | url.pathname = url.pathname.replace(
149 | currentPath,
150 | `${currentPath}/variants/${chosenVariant.fileName}`
151 | );
152 |
153 | const res = NextResponse.rewrite(url);
154 |
155 | // if the variant ID doesn't exist in the cookies, set it now for future requests
156 | if (!expVariantID) {
157 | res.cookies.set('expVariantID', `${experimentId}_${chosenVariant.id}`, {
158 | path: '/',
159 | httpOnly: true,
160 | maxAge: 10 * 365 * 24 * 60 * 60, // set the cookie to expire in 10 years
161 | });
162 | }
163 |
164 | // return the response with the rewritten url or any set cookies
165 | return res;
166 | }
167 |
168 |
--------------------------------------------------------------------------------
/nimble.config.json:
--------------------------------------------------------------------------------
1 | [
2 |
3 | ]
4 |
--------------------------------------------------------------------------------
/nimbleStore2.db:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/NimbleABApp/c04180a1466f6cbe7cba08e044009d0ea6a96cd2/nimbleStore2.db
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "NimbleAB",
3 | "description": "An IDE for AB Testing",
4 | "version": "1.0.0",
5 | "main": "app/electron/main.js",
6 | "author": "Team JAZ",
7 | "license": "MIT",
8 | "types": "./index.d.ts",
9 | "build": {
10 | "productName": "NimbleAB",
11 | "appId": "com.Nimble|electron.NimbleAB",
12 | "directories": {
13 | "buildResources": "images"
14 | },
15 | "asar": "true",
16 | "files": [
17 | "app/dist/**/*",
18 | "app/electron/**/*"
19 | ],
20 | "mac": {
21 | "category": "public.app-category.productivity",
22 | "target": {
23 | "target": "default",
24 | "arch": [
25 | "x64",
26 | "arm64"
27 | ]
28 | }
29 | },
30 | "dmg": {
31 | "sign": false,
32 | "background": null,
33 | "backgroundColor": "#FFFFFF",
34 | "window": {
35 | "width": "400",
36 | "height": "300"
37 | },
38 | "contents": [
39 | {
40 | "x": 100,
41 | "y": 100
42 | },
43 | {
44 | "x": 300,
45 | "y": 100,
46 | "type": "link",
47 | "path": "/Applications"
48 | }
49 | ]
50 | },
51 | "win": {
52 | "target": [
53 | {
54 | "target": "nsis",
55 | "arch": [
56 | "x64"
57 | ]
58 | }
59 | ]
60 | },
61 | "linux": {
62 | "target": [
63 | "deb",
64 | "rpm",
65 | "snap",
66 | "AppImage"
67 | ]
68 | },
69 | "extraResources": [
70 | "prisma/nimbleStore2.db",
71 | "node_modules/.prisma/**/*",
72 | "node_modules/@prisma/client/**/*"
73 | ]
74 | },
75 | "scripts": {
76 | "postinstall": "electron-builder install-app-deps",
77 | "audit-app": "npx electronegativity -i ./ -x LimitNavigationGlobalCheck,PermissionRequestHandlerGlobalCheck",
78 | "dev-server": "cross-env NODE_ENV=development webpack serve --config ./webpack.development.js > dev-scripts/webpack-dev-server.log 2> dev-scripts/webpack-dev-server-error.log",
79 | "dev": "concurrently --success first \"node dev-scripts/prepareDevServer.js\" \"node dev-scripts/launchDevServer.js\" -k",
80 | "prod-build": "cross-env NODE_ENV=production npx webpack --mode=production --config ./webpack.production.js",
81 | "prod": "npm run prod-build && electron .",
82 | "pack": "electron-builder --dir",
83 | "dist": "npm run test && npm run prod-build && electron-builder",
84 | "dist-mac": "npm run prod-build && electron-builder --mac",
85 | "dist-linux": "npm run prod-build && electron-builder --linux",
86 | "dist-windows": "npm run prod-build && electron-builder --windows",
87 | "dist-all": "npx prisma generate && npm run prod-build && npx prisma generate && electron-builder install-app-deps && electron-builder --mac --linux --windows",
88 | "test": "mocha"
89 | },
90 | "dependencies": {
91 | "@emotion/react": "^11.11.1",
92 | "@emotion/styled": "^11.11.0",
93 | "@loadable/component": "^5.15.2",
94 | "@monaco-editor/react": "^4.5.2",
95 | "@mui/material": "^5.14.6",
96 | "@prisma/client": "^5.2.0",
97 | "@reduxjs/toolkit": "^1.8.3",
98 | "@supabase/supabase-js": "^2.33.1",
99 | "@types/loadable__component": "^5.13.4",
100 | "@types/react": "^18.2.21",
101 | "@types/react-dom": "^18.0.6",
102 | "@uiw/react-codemirror": "^4.21.12",
103 | "autoprefixer": "^10.4.15",
104 | "axios": "^1.5.0",
105 | "easy-redux-undo": "^1.0.5",
106 | "electron-devtools-installer": "^3.2.0",
107 | "electron-store": "^8.1.0",
108 | "glob": "^10.3.3",
109 | "install": "^0.13.0",
110 | "npm": "^9.8.1",
111 | "postcss": "^8.4.28",
112 | "process": "^0.11.10",
113 | "react": "^18.2.0",
114 | "react-dom": "^18.2.0",
115 | "react-redux": "^8.0.2",
116 | "react-router": "^6.3.0",
117 | "react-router-dom": "^6.3.0",
118 | "redux": "^4.2.0",
119 | "redux-first-history": "^5.1.1",
120 | "reflect-metadata": "^0.1.13",
121 | "sqlite": "^5.0.1",
122 | "sqlite3": "^5.1.6",
123 | "supabase": "^1.88.0",
124 | "tailwindcss": "^3.3.3",
125 | "typeorm": "^0.3.17",
126 | "typescript": "^4.9.5",
127 | "uuid": "^9.0.0"
128 | },
129 | "devDependencies": {
130 | "@babel/core": "^7.18.9",
131 | "@babel/plugin-syntax-dynamic-import": "^7.8.3",
132 | "@babel/plugin-transform-react-jsx": "^7.18.6",
133 | "@babel/preset-env": "^7.18.9",
134 | "@babel/preset-react": "^7.18.6",
135 | "@babel/preset-typescript": "^7.18.6",
136 | "@doyensec/electronegativity": "^1.9.1",
137 | "@types/loadable__component": "^5.13.4",
138 | "@types/node": "^20.5.7",
139 | "@types/react": "^18.2.21",
140 | "@types/react-dom": "^18.0.6",
141 | "@types/uuid": "^9.0.3",
142 | "autoprefixer": "^10.4.15",
143 | "babel-loader": "^8.2.5",
144 | "babel-plugin-module-resolver": "^4.1.0",
145 | "buffer": "^6.0.3",
146 | "clean-webpack-plugin": "^4.0.0",
147 | "concurrently": "^7.3.0",
148 | "cross-env": "^7.0.3",
149 | "crypto-browserify": "^3.12.0",
150 | "csp-html-webpack-plugin": "^5.1.0",
151 | "css-loader": "^6.7.1",
152 | "css-minimizer-webpack-plugin": "^4.0.0",
153 | "daisyui": "^3.6.3",
154 | "electron": "^19.0.10",
155 | "electron-builder": "^23.0.2",
156 | "electron-debug": "^3.2.0",
157 | "html-loader": "^4.1.0",
158 | "html-webpack-plugin": "^5.5.0",
159 | "mini-css-extract-plugin": "^2.6.1",
160 | "mocha": "^10.0.0",
161 | "path-browserify": "^1.0.1",
162 | "postcss": "^8.4.28",
163 | "postcss-cli": "^10.1.0",
164 | "postcss-loader": "^7.3.3",
165 | "postcss-preset-env": "^9.1.1",
166 | "prisma": "^5.2.0",
167 | "spectron": "^19.0.0",
168 | "stream-browserify": "^3.0.0",
169 | "style-loader": "^3.3.3",
170 | "tailwindcss": "^3.3.3",
171 | "typescript": "^4.9.5",
172 | "webpack": "^5.74.0",
173 | "webpack-cli": "^4.10.0",
174 | "webpack-dev-server": "^4.9.3",
175 | "webpack-merge": "^5.8.0"
176 | }
177 | }
178 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | const tailwindcss = require("tailwindcss");
2 | module.exports = {
3 | plugins: [tailwindcss("./tailwind.config.js"), require("autoprefixer")],
4 | };
5 |
--------------------------------------------------------------------------------
/prisma/nimbleStore2.db:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/NimbleABApp/c04180a1466f6cbe7cba08e044009d0ea6a96cd2/prisma/nimbleStore2.db
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | generator client {
2 | provider = "prisma-client-js"
3 | output = "../node_modules/.prisma/client"
4 | previewFeatures = ["napi"]
5 | binaryTargets = ["darwin", "windows", "darwin-arm64"]
6 | }
7 |
8 | datasource db {
9 | provider = "sqlite"
10 | url = "file:nimbleStore2.db"
11 | }
12 |
13 | model Experiments {
14 | id Int @id @default(autoincrement())
15 | Experiment_Name String?
16 | Device_Type String?
17 | Repo_id Int?
18 | experiment_path String?
19 | experiment_uuid String?
20 | Repos Repos? @relation(fields: [Repo_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
21 | Variants Variants[]
22 | }
23 |
24 | model Variants {
25 | id Int @id @default(autoincrement())
26 | filePath String
27 | weights Decimal
28 | Experiment_Id Int
29 | Experiments Experiments @relation(fields: [Experiment_Id], references: [id], onDelete: NoAction, onUpdate: NoAction)
30 | }
31 |
32 | model Repos {
33 | id Int @id @default(autoincrement())
34 | FilePath String
35 | Experiments Experiments[]
36 | }
37 |
--------------------------------------------------------------------------------
/renderer.d.ts:
--------------------------------------------------------------------------------
1 | export interface IElectronAPI {
2 | openFile: () => Promise;
3 | parsePaths: () => Promise;
4 | getExperiments: () => Promise;
5 | createModal: (value: any) => Promise;
6 | addExperiment: (experiment: object) => Promise;
7 | addVariant: (name: object) => Promise;
8 | addRepo: (repo: object) => Promise;
9 | getVariants: (expId: number | string) => Promise;
10 | getRepo: (repoId: any) => Promise;
11 | loadFile: (callback: any) => Promise;
12 | saveFile: (callback: any) => Promise;
13 | closeFile: (value: any) => Promise;
14 | removeVariant: (variant: object) => Promise;
15 | }
16 |
17 | declare global {
18 | interface Window {
19 | electronAPI: IElectronAPI;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: [
4 | './app/src/**/*.{ts,tsx,js,jsx,html}',
5 | './app/dist/*.{html,js}',
6 | './app/src/index.html',
7 | ],
8 | plugins: [require('daisyui')],
9 | };
10 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "module": "umd",
5 | "lib": [
6 | "ES2015",
7 | "ES2016",
8 | "ES2017",
9 | "ES2018",
10 | "ES2019",
11 | "ES2020",
12 | "ESNext",
13 | "dom"
14 | ],
15 | "emitDecoratorMetadata": true,
16 | "experimentalDecorators": true,
17 | "jsx": "react",
18 | "noEmit": true,
19 | "sourceMap": true,
20 | /* Strict Type-Checking Options */
21 | "strict": true,
22 | "noImplicitAny": true,
23 | "strictNullChecks": true,
24 | /* Module Resolution Options */
25 | "moduleResolution": "node",
26 | "forceConsistentCasingInFileNames": true,
27 | "esModuleInterop": true
28 | // "emitDeclarationOnly": true
29 | },
30 | "include": ["app/src"],
31 | "exclude": ["node_modules", "renderer.d.ts"]
32 | }
33 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const { CleanWebpackPlugin } = require("clean-webpack-plugin");
2 | const MiniCssExtractPlugin = require("mini-css-extract-plugin");
3 | const webpack = require("webpack");
4 | const path = require("path");
5 |
6 | module.exports = {
7 | target: "web", // Our app can run without electron
8 | entry: { home: "./app/src/index.js", modal: "./app/src/modal/index.js", loading: "./app/src/loading/index.js"}, // The entry point of our app; these entry points can be named and we can also have multiple if we'd like to split the webpack bundle into smaller files to improve script loading speed between multiple pages of our app
9 | output: {
10 | path: path.resolve(__dirname, "app/dist"), // Where all the output files get dropped after webpack is done with them
11 | filename: "[name].js", // The name of the webpack bundle that's generated
12 | publicPath: "/",
13 | },
14 | resolve: {
15 | fallback: {
16 | crypto: require.resolve("crypto-browserify"),
17 | buffer: require.resolve("buffer/"),
18 | path: require.resolve("path-browserify"),
19 | stream: require.resolve("stream-browserify"),
20 | },
21 | },
22 | module: {
23 | rules: [
24 | {
25 | // loads .html files
26 | test: /\.(html)$/,
27 | include: [path.resolve(__dirname, "app/src")],
28 | use: {
29 | loader: "html-loader",
30 | options: {
31 | sources: {
32 | list: [
33 | {
34 | tag: "img",
35 | attribute: "data-src",
36 | type: "src",
37 | },
38 | ],
39 | },
40 | },
41 | },
42 | },
43 | // loads .js/jsx/tsx files
44 | {
45 | test: /\.[jt]sx?$/,
46 | include: [path.resolve(__dirname, "app/src")],
47 | loader: "babel-loader",
48 | resolve: {
49 | extensions: [".js", ".jsx", ".ts", ".tsx", ".json"],
50 | },
51 | },
52 | // loads .css files
53 | {
54 | test: /\.css$/,
55 | include: [
56 | path.resolve(__dirname, "app/src/"),
57 | // webpackPaths.srcRendererPath,
58 | path.resolve(__dirname, "node_modules/"),
59 | ],
60 | use: ["style-loader", "css-loader", "postcss-loader"],
61 | },
62 | // loads common image formats
63 | {
64 | test: /\.(svg|png|jpg|gif)$/,
65 | include: [path.resolve(__dirname, "resources/images")],
66 | type: "asset/inline",
67 | },
68 | // loads common font formats
69 | {
70 | test: /\.(eot|woff|woff2|ttf)$/,
71 | include: [path.resolve(__dirname, "resources/fonts")],
72 | type: "asset/inline",
73 | },
74 | ],
75 | },
76 | plugins: [
77 | // fix "process is not defined" error;
78 | // https://stackoverflow.com/a/64553486/1837080
79 | new webpack.ProvidePlugin({
80 | process: "process/browser.js",
81 | }),
82 | new CleanWebpackPlugin(),
83 | ],
84 | };
85 |
--------------------------------------------------------------------------------
/webpack.development.js:
--------------------------------------------------------------------------------
1 | const HtmlWebpackPlugin = require('html-webpack-plugin');
2 | const CspHtmlWebpackPlugin = require('csp-html-webpack-plugin');
3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin');
4 | const { merge } = require('webpack-merge');
5 | const base = require('./webpack.config');
6 | const path = require('path');
7 |
8 | module.exports = merge(base, {
9 | mode: 'development',
10 | devtool: 'source-map', // Show the source map so we can debug when developing locally
11 | devServer: {
12 | host: 'localhost',
13 | port: '40992',
14 | hot: true, // Hot-reload this server if changes are detected
15 | compress: true, // Compress (gzip) files that are served
16 | static: {
17 | directory: path.resolve(__dirname, 'app/dist'), // Where we serve the local dev server's files from
18 | watch: true, // Watch the directory for changes
19 | staticOptions: {
20 | ignored: /node_modules/, // Ignore this path, probably not needed since we define directory above
21 | },
22 | },
23 | },
24 | plugins: [
25 | new MiniCssExtractPlugin(),
26 | new HtmlWebpackPlugin({
27 | template: path.resolve(__dirname, 'app/src/index.html'),
28 | filename: 'index.html',
29 | chunks: ['home'],
30 | }),
31 | new HtmlWebpackPlugin({
32 | filename: 'modal.html',
33 | template: 'app/src/modal/index.html',
34 | chunks: ['modal'],
35 | }),
36 | new HtmlWebpackPlugin({
37 | filename: 'loading.html',
38 | template: 'app/src/loading/index.html',
39 | chunks: ['loading'],
40 | }),
41 | ],
42 | });
43 |
--------------------------------------------------------------------------------
/webpack.production.js:
--------------------------------------------------------------------------------
1 | const HtmlWebpackPlugin = require("html-webpack-plugin");
2 | const CspHtmlWebpackPlugin = require("csp-html-webpack-plugin");
3 | const MiniCssExtractPlugin = require("mini-css-extract-plugin");
4 | const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
5 | const { merge } = require("webpack-merge");
6 | const base = require("./webpack.config");
7 | const path = require("path");
8 |
9 | module.exports = merge(base, {
10 | mode: "production",
11 | devtool: "source-map",
12 | plugins: [
13 | new MiniCssExtractPlugin(),
14 | new HtmlWebpackPlugin({
15 | template: path.resolve(__dirname, "app/src/index.html"),
16 | filename: "index.html",
17 | base: "app://rse",
18 | chunks: ["home"],
19 | }),
20 | new HtmlWebpackPlugin({
21 | filename: "modal.html",
22 | template: "app/src/modal/index.html",
23 | base: "app://rse",
24 | chunks: ["modal"],
25 | }),
26 | new HtmlWebpackPlugin({
27 | filename:"loading.html",
28 | template: "app/src/loading/index.html",
29 | base: "app://rse",
30 | chunks: ["loading"]
31 | })
32 |
33 | // You can paste your CSP in this website https://csp-evaluator.withgoogle.com/
34 | // for it to give you suggestions on how strong your CSP is
35 | ],
36 | optimization: {
37 | minimize: true,
38 | minimizer: [
39 | "...", // This adds default minimizers to webpack. For JS, Terser is used. // https://webpack.js.org/configuration/optimization/#optimizationminimizer
40 | new CssMinimizerPlugin(),
41 | ],
42 | },
43 | });
44 |
--------------------------------------------------------------------------------