├── .npmignore ├── nodemon.json ├── .DS_Store ├── data └── .DS_Store ├── .gitignore ├── client ├── .DS_Store ├── store.js ├── App.jsx ├── components │ ├── SidebarComp.jsx │ ├── RulesUserAgentComp.jsx │ ├── RulesAllComp.jsx │ ├── iFrameComp.jsx │ └── SidebarStyling.jsx ├── stylesheets │ └── styles.css ├── puppeteer │ ├── pupRules.js │ ├── pupProcess.js │ └── pup.js ├── cdp │ ├── cdp2rules.js │ ├── cdp0process.js │ └── cdp1enable.js ├── patchFile.js └── slices │ └── rulesSlice.js ├── assets └── cssexy_logo_npm_2024-05-09@10x.png ├── index.jsx ├── vite.config.js ├── index.html ├── LICENSE ├── scripts ├── startRemoteChrome.sh ├── postInstall.js ├── getTargetPort.js └── startRemoteChrome.js ├── NOTES.md ├── package.json ├── server └── server.js └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | vite.config.js 2 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": [ 3 | "data/*" 4 | ] 5 | } -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/cssexy/HEAD/.DS_Store -------------------------------------------------------------------------------- /data/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/cssexy/HEAD/data/.DS_Store -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist/ 3 | data/*/* 4 | .env 5 | .vscode 6 | client/data 7 | -------------------------------------------------------------------------------- /client/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/cssexy/HEAD/client/.DS_Store -------------------------------------------------------------------------------- /assets/cssexy_logo_npm_2024-05-09@10x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/cssexy/HEAD/assets/cssexy_logo_npm_2024-05-09@10x.png -------------------------------------------------------------------------------- /client/store.js: -------------------------------------------------------------------------------- 1 | import { configureStore } from "@reduxjs/toolkit"; 2 | import { rulesReducer, nodeDataReducer } from './slices/rulesSlice.js'; // rulesReducer from './slices/rulesSlice.js'; 3 | 4 | const store = configureStore({ 5 | reducer: { 6 | rules: rulesReducer, 7 | nodeData: nodeDataReducer 8 | } 9 | }); 10 | 11 | export default store; 12 | -------------------------------------------------------------------------------- /index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import { Provider } from 'react-redux'; 4 | import App from '/client/App.jsx'; 5 | import store from '/client/store.js'; 6 | 7 | import '/client/stylesheets/styles.css'; 8 | 9 | const root = createRoot(document.getElementById('root')); 10 | root.render( 11 | 12 | 13 | 14 | ); 15 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | // vite.config.js 2 | import { defineConfig } from 'vite'; 3 | import react from '@vitejs/plugin-react'; 4 | import path from 'path'; 5 | 6 | export default defineConfig({ 7 | plugins: [react()], 8 | root: path.join(__dirname, '/'), 9 | build: { 10 | outDir: path.join(__dirname, 'dist'), 11 | // sourcemap: true, 12 | }, 13 | // css: { 14 | // devSourcemap: true 15 | // }, 16 | server: { 17 | port: 5555, 18 | proxy: { 19 | '/cdp': 'http://localhost:8888', 20 | '/patch': 'http://localhost:8888', 21 | '/read': 'http://localhost:8888', 22 | } 23 | } 24 | }); 25 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | CSSxe 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /client/App.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import SidebarComp from './components/SidebarComp'; 4 | import IframeComp from './components/iFrameComp'; 5 | 6 | const App = () => { 7 | 8 | // to access .env files on the client when using ES6 modules + Vite, we need to use 'import.meta.env'. 9 | // Vite gives us the import.meta.env object. It's not available in standalone node.js files, which Vite doesn’t touch. 10 | // we also need to prefix each variable with 'VITE_' that we want Vite to treat as an environment variable, ala 'REACT_APP_' for create-react-app builds. 11 | 12 | const proxy = import.meta.env.VITE_PROXY; 13 | const targetUrl = `http://localhost:${proxy}` 14 | return ( 15 |
16 | 17 | 22 |
23 | ) 24 | }; 25 | 26 | export default App; 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 OSLabs Beta 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /scripts/startRemoteChrome.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Starting the first line with a shebang specifies the interpreter. 4 | # in this case, bash. 5 | # which means this script will be executed by bash. 6 | 7 | # Start Chrome with remote debugging and direct the window to the port passed in from the script in package.json. 8 | # For dev mode, the port is 5555. 9 | # For prod mode, the port is 8888. 10 | 11 | # other flags: 12 | 13 | # when not doing '--app': 14 | # --new-window \ 15 | # http://localhost:$PORT & 16 | # --app=http://localhost:$PORT & 17 | 18 | 19 | # --auto-open-devtools-for-tabs \ 20 | 21 | 22 | DIR="$(dirname "$(dirname "$(dirname "$0")")")/data/Chrome/Profiles" 23 | # Delete any existing files in the profile dir before starting chrome. 24 | # If this dir already exists, it might have a profile in it that we don't want. 25 | # -d: checking if dir is a directory 26 | if [ -d "$DIR" ]; then 27 | # rm: remove 28 | # -r: recursively delete 29 | # -f: force delete without asking for confirmation 30 | rm -rf "$DIR"/* 31 | else 32 | # mkdir: create dir if it doesn't exist 33 | # -p: create parent directories if they don't exist 34 | mkdir -p "$DIR" 35 | fi 36 | 37 | /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \ 38 | --remote-debugging-port=9222 \ 39 | --user-data-dir="$DIR" \ 40 | --no-first-run \ 41 | --no-default-browser-check \ 42 | --disable-web-security \ 43 | --new-window \ 44 | http://localhost:$PORT & 45 | -------------------------------------------------------------------------------- /scripts/postInstall.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { fileURLToPath } from 'url'; 4 | import { dirname } from 'path'; 5 | 6 | const __filename = fileURLToPath(import.meta.url); 7 | const __dirname = dirname(__filename); 8 | 9 | // new script commands to add to user's package.json 10 | const newScripts = { 11 | "sexy": "TARGET_DIR=$(pwd) npm run cssxe:dev --prefix node_modules/cssxe", 12 | "sexy-prod": "TARGET_DIR=$(pwd) npm run cssxe:prod --prefix node_modules/cssxe", 13 | }; 14 | 15 | function updateScripts(scripts) { 16 | // scripts: an object containing current scripts from package.json 17 | // shallow copy scripts, otherwise they would be replaced by the new script/s 18 | // returning updated scripts object, containing both old and new scripts 19 | return { ...scripts, ...newScripts }; 20 | } 21 | 22 | // path to package.json 23 | const packageJsonPath = path.join(path.dirname(__dirname), 'package.json'); 24 | 25 | // get the package.json object 26 | // parse: parse json 27 | // readFileSync: read file contents 28 | // packageJsonPath: path to package.json 29 | // utf-8: the encoding of all json files 30 | const json = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); 31 | // json.scripts: setting the scripts object to the object returned by updateScripts 32 | json.scripts = updateScripts(json.scripts); 33 | 34 | // has to be stringified to write to package.json 35 | try { 36 | // rewrites the entire package.json, adding in the new scripts 37 | fs.writeFileSync(packageJsonPath, JSON.stringify(json, null, 2)); 38 | console.log('Added scripts to package.json'); 39 | } 40 | catch (err) { 41 | console.error(err); 42 | } 43 | -------------------------------------------------------------------------------- /NOTES.md: -------------------------------------------------------------------------------- 1 | # COMMIT, PR, ETC. NOTES 2 | 3 | Commit notes: 4 | 5 | keith_2024-04-04: 6 | target port (e.g. 8000 for Backtrack) can be obtained programatically 7 | after linking cssxe in a target repo and adding the 'sexy' script described below to the package.json of the target repo. 8 | and running cssxe by running npm run sexy from the target repo (after running the target repo in its own node process as usual.) 9 | keith_2024-03-28_npmLink: 10 | npm link working. 11 | cssxe can now be run from inside of another repo. 12 | To link, in the cssxe directory first run npm init, then npm link. 13 | Then, in the target repo directory, run npm link cssxe. 14 | Then add the following script to the target repo package.json: 15 | "sexy": "TARGET_DIR=$(pwd) npm run cssxe:dev --prefix node_modules/cssxe". 16 | the cssxe package doesn’t programatically obtain the port (at the moment) due to being run with npm link. so for now its set to 8000, the .env file. But if cssxe was installed as an npm package the logic for getting the port programatically would work now. 17 | 18 | 19 | keith_puppeteer_2024-03-25: 20 | To run CSSxe in puppeteer mode: 21 | - in .env, set VITE_PUPPETEER_MODE to true. 22 | - run dev-pup or prod-pup, respectively (rather than dev or prod). 23 | 24 | To change the target port: 25 | - in .env, change VITE_PROXY to the desired port. 26 | 27 | To change the target directory path (i.e. the path to backtrack on my computer vs yours): 28 | - in .env, change VITE_TARGET_DIR_PATH to the desired path. 29 | - (This is temporary, until CSSxe is a npm package installed in the root of the target repo.) 30 | -------------------------------------------------------------------------------- /client/components/SidebarComp.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import RulesAllComp from "./RulesAllComp.jsx"; 3 | 4 | function SidebarComp() { 5 | // local state variable for toggling the sidebar 6 | const [isCollapsed, setIsCollapsed] = useState(false); 7 | 8 | // Toggling the sidebar visibility when the user presses Shift + Enter 9 | useEffect(() => { 10 | const handleKeyDown = (event) => { 11 | if (event.metaKey && event.key === 'Enter') { 12 | console.log('Shift + Enter pressed. Toggling sidebar visibility.'); 13 | 14 | // The `setIsCollapsed` function is used to update the state. 15 | // The `prevCollapsed` parameter is the previous value of `isCollapsed`. 16 | // The `!prevCollapsed` - the negation of the prior value of this state variable - is the new value of `isCollapsed`. 17 | // In other words, this function toggles the value of `isCollapsed`. 18 | // 'functional update' of state: 19 | // This pattern in React and ensures that the state is always updated correctly, even when multiple calls are made in quick succession. 20 | setIsCollapsed((prevCollapsed) => !prevCollapsed); 21 | } 22 | }; 23 | 24 | // Adding the event listener to the window 25 | window.addEventListener('keydown', handleKeyDown); 26 | 27 | // Cleaning up the event listener when the component unmounts. 28 | // React runs this later, after the component is removed from the DOM. 29 | return () => { 30 | window.removeEventListener('keydown', handleKeyDown); 31 | }; 32 | // not passing any dependencies, as we only want to add the event listener once. 33 | // if we did pass dependencies, the effect would run every time one of them changes 34 | // -> it would first run the cleanup function, and then add the event listener again. 35 | }, []); 36 | 37 | return ( 38 | 39 | // if isCollapsed is true, the sidebar-container and the sidebar will be collapsed 40 |
41 |
42 |

styles

43 | 44 |
45 | 50 |
51 | ); 52 | } 53 | 54 | export default SidebarComp; 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cssxe", 3 | "version": "0.1.0", 4 | "description": "A tool for live editing and syncing CSS directly from the browser to your source files", 5 | "main": "index.jsx", 6 | "scripts": { 7 | "start": "NODE_ENV=production PORT=8888 node server/server.js", 8 | "build": "vite build", 9 | "preview": "vite preview --port 8888", 10 | "vite-dev": "concurrently --kill-others-on-fail \"PORT=5555 vite\" \"NODE_ENV=development BROWSER_PORT=5555 nodemon server/server.js\"", 11 | "dev": "npm run vite-dev", 12 | "cssxe:dev": "TARGET_DIR=$TARGET_DIR npm run getTargetPort && TARGET_DIR=$TARGET_DIR npm run vite-dev", 13 | "dev-cdp": "concurrently \"PORT=5555 npm run startRemoteChromeJs\" \"npm run vite-dev\"", 14 | "dev-sh": "PORT=5555 npm run startRemoteChrome && npm run vite-dev", 15 | "prod": "concurrently \"npm run build\" \"npm run start\"", 16 | "cssxe:prod": "npm run getTargetPort && TARGET_DIR=$TARGET_DIR npm run prod", 17 | "prod-cdp": "concurrently --kill-others-on-fail \"npm run build\" \"PORT=8888 npm run startRemoteChromeJs\" \"npm run start\"", 18 | "prod-sh": "npm run build && PORT=8888 npm run startRemoteChromeJs && npm run start", 19 | "startRemoteChrome": "./scripts/startRemoteChrome.sh", 20 | "startRemoteChromeJs": "PORT=$PORT node ./scripts/startRemoteChrome.js", 21 | "postInstall": "node ./scripts/postInstall.js", 22 | "getTargetPort": "node ./scripts/getTargetPort.js" 23 | }, 24 | "type": "module", 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/oslabs-beta/cssxe.git" 28 | }, 29 | "bin": { 30 | "remoteChrome": "startRemoteChrome.sh" 31 | }, 32 | "keywords": [ 33 | "CSS", 34 | "live-edit", 35 | "web-development", 36 | "tooling", 37 | "devtools", 38 | "workspace", 39 | "source-maps" 40 | ], 41 | "author": "pandawhale", 42 | "license": "ISC", 43 | "dependencies": { 44 | "@reduxjs/toolkit": "^2.2.1", 45 | "chrome-dompath": "^2.0.1", 46 | "chrome-remote-interface": "^0.33.0", 47 | "cors": "^2.8.5", 48 | "dotenv": "^16.4.5", 49 | "express": "^4.18.2", 50 | "http-proxy-middleware": "^2.0.6", 51 | "nanoid": "^5.0.7", 52 | "puppeteer": "^22.4.0", 53 | "react-redux": "^9.1.0", 54 | "url": "^0.11.3", 55 | "utf8": "^3.0.0" 56 | }, 57 | "devDependencies": { 58 | "@vitejs/plugin-react": "^4.2.1", 59 | "concurrently": "^8.2.2", 60 | "nodemon": "^3.1.0", 61 | "react": "^18.2.0", 62 | "react-dom": "^18.2.0", 63 | "vite": "^5.1.4" 64 | }, 65 | "bugs": { 66 | "url": "https://github.com/oslabs-beta/cssxe/issues" 67 | }, 68 | "homepage": "https://github.com/oslabs-beta/cssxe#readme" 69 | } 70 | -------------------------------------------------------------------------------- /client/stylesheets/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: 'Inter', sans-serif; 4 | /* background: #252134; */ 5 | color: #e5e0e9; 6 | 7 | } 8 | 9 | .app-container { 10 | display: flex; 11 | height: 100vh; 12 | } 13 | 14 | .site-frame { 15 | flex-grow: 1; /* Allow the site frame to fill the available space */ 16 | border: none; 17 | overflow: hidden; 18 | } 19 | 20 | .sidebar-container, .sidebar { 21 | background: #252134; 22 | } 23 | 24 | 25 | .sidebar-container { 26 | position: relative; 27 | width: 30%; 28 | height: 100%; 29 | border-right: 3px solid #1B1929; /* darker purple for the border */ 30 | /* max-width: 25%; */ 31 | /* overflow-y: scroll; */ 32 | 33 | /* scrollbar-width: thin; */ 34 | /* scrollbar-color: rgba(74, 67, 139, 0.1) transparent; */ 35 | 36 | } 37 | 38 | .sidebar { 39 | height: 97%; /* 97% of the container height. this + 100% for the sidebar container is the combo that allows for the interior to scroll without adding a scrollbar to the right side of the window. */ 40 | padding: 0px 20px 20px 20px; /* top, right, bottom, left */ 41 | 42 | /* allows the sidebar to be scrollable. in this case, this being true allows it to be hidden when the user clicks the collapse button */ 43 | overflow-y: scroll; 44 | overflow-x: hidden; 45 | scrollbar-width: thin; 46 | scrollbar-color: rgba(74, 67, 139, 0.1) transparent; 47 | 48 | /* enable these below to make the sidebar horizontally scrollable */ 49 | /* however, it only scrolls about 10% of the container */ 50 | /* width: calc(93% - 20px); 80% of the container width, minus padding */ 51 | /* max-width: calc(93% - 20px); 80% of the container width, minus padding */ 52 | /* min-width: calc(93% - 20px); 80% of the container width, minus padding */ 53 | /* resize: horizontal; */ 54 | 55 | 56 | 57 | } 58 | 59 | .sidebar.collapsed, .sidebar-container.collapsed { 60 | width: 0; 61 | min-width: 0; 62 | max-width: 0; 63 | padding: 0; 64 | } 65 | 66 | .collapse-button { 67 | position: absolute; 68 | right: -28px; 69 | top: 50%; 70 | font-size: 20px; 71 | cursor: pointer; /* Change cursor to pointer */ 72 | background-color: transparent; 73 | border: none; /* Remove border */ 74 | color: rgba(229, 224, 233, 0.7); /* semi-transparent white */ 75 | } 76 | 77 | .style-container { 78 | border-bottom: 1px solid #504C63; /* Separator beetween rules */ 79 | } 80 | 81 | .selector-div, .style-source-span, .style-container p, .style-paragraph, .style-property-span { 82 | /* color: #D0C9D6; */ 83 | color: #e5e0e9; 84 | margin: 1px 0; /* Margin for spacing around style entries */ 85 | } 86 | 87 | .selector-div { 88 | font-size: 12px; 89 | font-weight: 500; 90 | margin-top: 10px; 91 | margin-bottom: 5px; 92 | } 93 | 94 | /* .style-value-form { 95 | /* not beign used atm */ 96 | /* display: flex; */ 97 | /* display: inline; 98 | } */ 99 | 100 | .style-property-span, .style-value-span, .style-value-input-span { 101 | font-size: 12px; 102 | padding-left: 5px; 103 | } 104 | 105 | .style-value-span, .style-value-input-span { 106 | padding-right: 0px; 107 | color: #906bc7; 108 | font-weight: 300; 109 | border: none; 110 | outline: none; 111 | /* color: #8D86C9; */ 112 | /* width: var(--style-value-span-width); */ 113 | } 114 | 115 | .style-value-input-span { 116 | background: rgba(0,0,0,0.2); 117 | border-radius: 4px; 118 | cursor: text; 119 | } 120 | 121 | .style-value-input-overwritten-span { 122 | background: rgba(0,0,0,0.3); 123 | text-decoration: line-through #906bc7; 124 | } 125 | 126 | 127 | .style-value-overwritten-span { 128 | text-decoration: line-through #906bc7; 129 | } 130 | 131 | .style-value-input-span:focus { 132 | outline: 0.5px solid #906bc7; 133 | } 134 | 135 | .style-property-overwritten-span { 136 | text-decoration: line-through white; 137 | } 138 | -------------------------------------------------------------------------------- /client/puppeteer/pupRules.js: -------------------------------------------------------------------------------- 1 | /** 2 | * pupRules.js 3 | * Retrieves the CSS rules for a specified DOM node, returns the applied rules 4 | * 5 | * @param {object} client - The Puppeteer CDP client 6 | * @param {object} elementNodeId - The node ID of the clicked element 7 | * @return {object} The applied CSS rules 8 | */ 9 | 10 | import fs from 'fs'; 11 | 12 | const pupRules = async (client, elementNodeId) => { 13 | console.log('pupRules: Getting inline styles for elementNodeId:', elementNodeId); 14 | 15 | // Get the inline styles for the element node 16 | const inlineRules = await getInlineRules(client, elementNodeId); 17 | 18 | // get all CSS rules that are applied to the node 19 | // => matchedCSSRules contains CSS rules that are directly applied to the node 20 | // => inherited contains the CSS rules that are passed down from the node's ancestors 21 | // => cssKeyframesRules includes all the @keyframes rules applied to the node 22 | // console.log('pupRules: Getting matched styles for elementNodeId:', elementNodeId); 23 | const { matchedCSSRules, inherited: inheritedRules, cssKeyframesRules: keyframeRules } = await client.send('CSS.getMatchedStylesForNode', { nodeId: elementNodeId }); 24 | const regularRules = []; 25 | const userAgentRules = []; 26 | 27 | // const allRules = await client.send('CSS.getMatchedStylesForNode', { elem }); 28 | 29 | // console.log('pupRules: matchedCSSRules:', matchedCSSRules); 30 | 31 | // this separates the matchedCSSRules into regularRules and userAgentRules 32 | // ahead of them being returned to iframeComp, where they then update the store 33 | // via dispatches. 34 | const parseMatchedRules = async (matchedCSSRules) => { 35 | await matchedCSSRules.forEach((each) => { 36 | if (each.rule.origin === 'regular') { 37 | regularRules.push(each); 38 | } 39 | else if (each.rule.origin === 'user-agent') { 40 | userAgentRules.push(each); 41 | } 42 | }) 43 | } 44 | parseMatchedRules(matchedCSSRules); 45 | 46 | const result = { 47 | inlineRules, 48 | regularRules, 49 | userAgentRules, 50 | inheritedRules, 51 | keyframeRules 52 | } 53 | 54 | // fs.writeFileSync('./data/output/allRules.json', JSON.stringify(allRules, null, 2)); 55 | 56 | // fs.writeFileSync('./data/output/result.json', JSON.stringify(result, null, 2)); 57 | // fs.writeFileSync('./data/output/inlineRules.json', JSON.stringify(inlineRules, null, 2)); 58 | // fs.writeFileSync('./data/output/regularRules.json', JSON.stringify(regularRules, null, 2)); 59 | // fs.writeFileSync('./data/output/userAgentRules.json', JSON.stringify(userAgentRules, null, 2)); 60 | // fs.writeFileSync('./data/output/inheritedRules.json', JSON.stringify(inheritedRules, null, 2)); 61 | // fs.writeFileSync('./data/output/keyframeRules.json', JSON.stringify(keyframeRules, null, 2)); 62 | 63 | // console.log('pupRules: returning result {inlineRules, regularRules, userAgentRules}'); 64 | return result; 65 | } 66 | 67 | const getInlineRules = async (client, elementNodeId) => { 68 | // retrieve the inline styles for the node with the provided elementNodeId 69 | try { 70 | 71 | const { inlineStyle } = await client.send('CSS.getInlineStylesForNode', { nodeId: elementNodeId }); 72 | 73 | // console.log('pupInlineRules: inlineStyle:', inlineStyle); 74 | 75 | const inlineRule = []; 76 | 77 | // check if there are any inline styles for this node 78 | if (inlineStyle) { 79 | // console.log(`Found: inline styles for elementNodeId ${elementNodeId}.`); 80 | // push the inline styles to the inlineRule array 81 | inlineRule.push({ 82 | "rule": { 83 | "origin": "inline", 84 | "style": inlineStyle, 85 | } 86 | }) 87 | 88 | } else { 89 | // if no inline styles are present 90 | console.log(`Not Found: inline styles for elementNodeId ${elementNodeId}.`); 91 | } 92 | return inlineRule; 93 | 94 | } catch (error) { 95 | console.log('pupInlineRules: error:', error); 96 | } 97 | } 98 | export { pupRules } 99 | -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import path from 'path'; 3 | import { fileURLToPath } from 'url'; 4 | import { spawn } from 'child_process'; 5 | 6 | import { config } from 'dotenv'; 7 | 8 | import cdpProcess from '../client/cdp/cdp0process.js'; 9 | import { patchFile } from '../client/patchFile.js'; 10 | 11 | import { callPupProcess } from '../client/puppeteer/pup.js'; 12 | 13 | const __filename = fileURLToPath(import.meta.url); 14 | const __dirname = path.dirname(__filename); 15 | const __envPath = path.resolve(__dirname, '../.env') 16 | const __scripts = path.join(__dirname, '../scripts/'); 17 | 18 | // console.log('filename:', __filename); 19 | // console.log('dirname:', __dirname); 20 | // console.log('path:', __envPath); 21 | // normally we could just use config(), as that looks for the .env file in the root directory. 22 | // but once this is an npm package installed in a given repo, the root directory 23 | // will be that repo. so instead we use config({ path: path.resolve(__dirname, '.env') }) 24 | // to point it at cssxe's own root directory. 25 | config({ path: __envPath }); 26 | 27 | const environment = process.env.NODE_ENV || 'development'; 28 | const browserPort = process.env.BROWSER_PORT || process.env.BROWSER_PORT_BACKUP; 29 | const proxy = process.env.PROXY || process.env.PROXY_BACKUP; 30 | const targetDir = process.env.TARGET_DIR ? process.env.TARGET_DIR.toString().split('\n').slice(-1)[0] : process.env.TARGET_DIR_BACKUP; 31 | 32 | // to run CSSxe in puppeteer mode, set this to 1 in .env. 33 | const puppeteerMode = process.env.PUPPETEER_MODE; 34 | 35 | const PORT = 8888; 36 | const app = express(); 37 | app.use(express()); 38 | app.use(express.json()); 39 | 40 | !browserPort ? console.log('server: error: BROWSER_PORT is not set') && process.exit(1) : null; 41 | !proxy ? console.log('server: error: PROXY is not set') && process.exit(1) : null; 42 | !targetDir ? console.log('server: error: TARGET_DIR is not set') && process.exit(1) : null; 43 | 44 | 45 | // Start Puppeteer if puppeteerMode is set to 1. 46 | if (puppeteerMode == 1) { 47 | // `spawn` from the `child_process` module in Node.js is used to create new child processes. 48 | // These run independently, but can communicate with the parent process via IPC (Inter-Process Communication) channels. 49 | // So in this case, puppeteer is a child process of this server process. 50 | spawn('node', ['../client/puppeteer/pup.js', browserPort]) 51 | } 52 | // else, start the cdp process. 53 | else { 54 | console.log('pup.js: puppeteerMode set to 0. puppeteer will not be called') 55 | spawn('node', [`${__scripts}startRemoteChrome.js`]); 56 | } 57 | 58 | if (environment === 'production') { 59 | // Serve static files (CSSxe UI) when in prod mode 60 | app.use(express.static(path.join(__dirname, '../dist'))); 61 | } 62 | 63 | app.post('/cdp', async (req, res) => { 64 | const data = req.body; 65 | 66 | try { 67 | // if puppeteerMode is set to true, then call the puppeteer process, otherwise call the cdp process 68 | const result = puppeteerMode == 1 ? await callPupProcess(data) : await cdpProcess(data); 69 | 70 | return res.json(result); 71 | } catch (error) { 72 | console.error('Error processing data:', error); 73 | return res.status(500).json({ error: 'Failed to process data' }); 74 | } 75 | }); 76 | 77 | app.post('/patch', async (req, res) => { 78 | const data = req.body; 79 | 80 | try { 81 | const result = await patchFile(data, targetDir); 82 | return res.json(result); 83 | } catch (error) { 84 | console.error('Error processing data:', error); 85 | return res.status(500).json({ error: 'Failed to patch data' }); 86 | } 87 | }); 88 | 89 | app.use((req, res) => res.sendStatus(404)); 90 | 91 | app.use((err, req, res, next) => { 92 | console.error(err); 93 | res.sendStatus(500); 94 | }); 95 | 96 | app.listen(PORT, () => 97 | console.log('\n'), 98 | console.log('\n'), 99 | // console.log(`Server: environment ${environment}`), 100 | console.log(`Server: listening on port ${PORT}`), 101 | console.log(`Server: serving proxy ${proxy} on browserPort ${browserPort}`), 102 | ); 103 | -------------------------------------------------------------------------------- /scripts/getTargetPort.js: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | import { fileURLToPath } from 'url'; 5 | import { dirname } from 'path'; 6 | import { config } from 'dotenv'; 7 | 8 | const getTargetPort = async () => { 9 | try { 10 | 11 | const __filename = fileURLToPath(import.meta.url); 12 | const __dirname = path.dirname(__filename); 13 | const __envPath = path.resolve(__dirname, '../.env') 14 | 15 | config({ path: __envPath }); 16 | 17 | // passed in from the npm run sexy script in the target package.json 18 | const targetDir = process.env.TARGET_DIR ? process.env.TARGET_DIR : process.env.TARGET_DIR_BACKUP 19 | 20 | console.log('getTargetPort: invoked'); 21 | console.log('getTargetPort: targetDir:', targetDir); 22 | // console.log('targetDirBackup:', process.env.TARGET_DIR_BACKUP); 23 | 24 | let proxy; 25 | 26 | if (!targetDir.includes('cssxe')) { 27 | // getting the process IDs of all open files in the target directory 28 | // `lsof` (list open files) 29 | // +D flag: search in directories, instead of files 30 | // targetDir: restrict to files within our current target directory 31 | // 32 | // grep DIR: only look at lines with "DIR" in them. 33 | // 34 | // grep -v cwd: exclude lines with "cwd" (i.e. processes in our current working directory) 35 | // we'll probably need to remove the grep -v part when this is installed as a package 36 | // 37 | // awk: print only unique lines (using the 'seen' array) 38 | // !seen[$2]++: if this line hasn't been seen before, print it and remember it as seen 39 | // print $2: print the second column of the line, which is the process ID. 40 | const pids = execSync(`lsof +D ${targetDir} | grep DIR | grep -v cwd | awk '!seen[$2]++ {print $2}'`) 41 | .toString() // convert the Buffer object to a string 42 | .trim() // remove leading and trailing whitespace 43 | .split('\n'); // split the string into an array of lines 44 | 45 | // console.log('pids:', pids); 46 | 47 | for (const pid of pids) { 48 | // `lsof` (list open files) 49 | // -i flag: include network connections 50 | // -P flag: show only the process ID and process name (not the parent process name) 51 | // -n flag: show numerical IDs instead of names 52 | // -p flag: only show info for processes with the given ID 53 | // ${pid}: the given process ID being searched 54 | // grep ${pid}: regex to match any line that contains the process ID 55 | proxy = execSync(`lsof -i -P -n -p ${pid} | grep ${pid}`) 56 | .toString() 57 | .match(/(?<=..:)\d{4}/) 58 | // (?<=..:) : positive lookahead. only match if it's preceded by ..: 59 | // \d : match any integer 60 | // {4} : four times 61 | ?.[0] // get the first match, if any 62 | 63 | if (proxy) { 64 | // if we found a proxy, we're done 65 | // console.log('proxy:', proxy); 66 | break; 67 | } 68 | } 69 | // if we didn't find a proxy, throw an error 70 | if (!proxy) { 71 | console.log('no proxy found'); 72 | throw new Error('proxy not found'); 73 | } 74 | } 75 | 76 | // getting the cssxe environment variables 77 | const envVars = fs.readFileSync(__envPath, 'utf-8').split('\n'); 78 | // console.log('envVars:', envVars); 79 | 80 | // setting the target port (the proxy) in the .env file if it doesn’t already exist, so that it can be used by our application. 81 | // it gets called in App.jsx. 82 | // finding the line that starts with 'VITE_PROXY=', if any. 83 | const envVarIndex = envVars.findIndex(line => line.startsWith('VITE_PROXY=')); 84 | // if it exists 85 | if (envVarIndex > -1) { 86 | console.log('envVarIndex:', envVarIndex); 87 | // update it 88 | envVars[envVarIndex] = `VITE_PROXY=${proxy}`; 89 | envVars[envVarIndex+1] = `PROXY=${proxy}`; 90 | // if it doesn't exist 91 | } else { 92 | // add it 93 | envVars.push(`VITE_PROXY=${proxy}`); 94 | envVars.push(`PROXY=${proxy}`); 95 | } 96 | // write to .env 97 | fs.writeFileSync('.env', envVars.join('\n')); 98 | 99 | // console.log('proxy found:', proxy); 100 | return proxy; 101 | } catch (err) { 102 | console.error(err); 103 | } 104 | } 105 | 106 | getTargetPort(); 107 | -------------------------------------------------------------------------------- /client/cdp/cdp2rules.js: -------------------------------------------------------------------------------- 1 | /** 2 | * cdp2styles.js 3 | * Retrieves the CSS rules for a specified DOM node, returns the applied rules 4 | * 5 | * @param {object} cdpClient - The Chrome DevTools Protocol client 6 | * @param {object} DOM - The DOM domain object 7 | * @param {object} CSS - The CSS domain object 8 | * @param {object} Network - The Network domain object 9 | * @param {object} Page - The Page domain object 10 | * @param {object} iframeNode - The iframe node object 11 | * @param {string} selector - The CSS selector for the node 12 | * @return {array} The applied CSS rules 13 | */ 14 | 15 | import fs from 'fs'; 16 | const cdpInlineRules = async (CSS, nodeId, selector) => { 17 | // retrieve the inline styles for the node with the provided nodeId 18 | try { 19 | 20 | const { inlineStyle } = await CSS.getInlineStylesForNode({ nodeId }); 21 | 22 | const inlineRule = []; 23 | 24 | // console.log('cdpInlineRules: inlineRule:', inlineRule); 25 | 26 | // check if there are any inline styles for this node 27 | if (inlineStyle) { 28 | 29 | console.log(`Found: inline styles for selector '${selector}' with nodeId ${nodeId}.`); 30 | // push the inline styles to the inlineRule array 31 | inlineRule.push({ 32 | "rule": { 33 | "origin": "inline", 34 | "style": inlineStyle, 35 | } 36 | }) 37 | 38 | } else { 39 | // if no inline styles are present 40 | console.log(`Not Found: inline styles for selector '${selector}' with nodeId ${nodeId}.`); 41 | } 42 | return inlineRule; 43 | 44 | } catch (error) { 45 | console.log('cdpInlineRules: error:', error); 46 | } 47 | } 48 | 49 | const cdpRules = async (cdpClient, DOM, CSS, Network, Page, Overlay, iframeNode, selector, styleSheets) => { 50 | 51 | 52 | 53 | const iframeNodeId = iframeNode.nodeId; 54 | // console.log('cdpRules: root frame node id:', iframeNodeId); 55 | 56 | // Get the nodeId of the node based on its CSS selector 57 | const { nodeId } = await DOM.querySelector({ 58 | nodeId: iframeNodeId, 59 | selector: selector 60 | }); 61 | 62 | // console.log('cdpRules: nodeId for selector', selector, 'is:', nodeId); 63 | 64 | // console.log('cdpRules: Getting inline styles for element:', selector); 65 | // Get the inline styles 66 | const inlineRules = await cdpInlineRules(CSS, nodeId, selector); 67 | 68 | 69 | // console.log('cdpRules: Getting matched styles for element:', selector); 70 | 71 | // get all CSS rules that are applied to the node 72 | // => matchedCSSRules contains CSS rules that are directly applied to the node 73 | // => inherited contains the CSS rules that are passed down from the node's ancestors 74 | // => cssKeyframesRules includes all the @keyframes rules applied to the node 75 | 76 | const { matchedCSSRules, inherited: inheritedRules, cssKeyframesRules: keyframeRules } = await CSS.getMatchedStylesForNode({ nodeId }); 77 | const regularRules = []; 78 | const userAgentRules = []; 79 | 80 | // console.log('cdpRules: matchedCSSRules:', matchedCSSRules); 81 | 82 | // this separates the matchedCSSRules into regularRules and userAgentRules 83 | // ahead of them being returned to iframeComp, where they then update the store 84 | // via dispatches. 85 | const parseMatchedCSSRules = async (matchedCSSRules) => { 86 | await matchedCSSRules.forEach((rule) => { 87 | if (rule.rule.origin === 'regular') { 88 | regularRules.push(rule); 89 | } 90 | else if (rule.rule.origin === 'user-agent') { 91 | userAgentRules.push(rule); 92 | } 93 | }) 94 | } 95 | parseMatchedCSSRules(matchedCSSRules); 96 | 97 | const result = { 98 | inlineRules, 99 | regularRules, 100 | userAgentRules, 101 | styleSheets, 102 | inheritedRules, 103 | // keyframeRules 104 | } 105 | 106 | // fs.writeFileSync('./data/output/allRules.json', JSON.stringify(result, null, 2)); 107 | // fs.writeFileSync('./data/output/inlineRules.json', JSON.stringify(inlineRules, null, 2)); 108 | // fs.writeFileSync('./data/output/regularRules.json', JSON.stringify(regularRules, null, 2)); 109 | // fs.writeFileSync('./data/output/userAgentRules.json', JSON.stringify(userAgentRules, null, 2)); 110 | // fs.writeFileSync('./data/output/inheritedRules.json', JSON.stringify(inheritedRules, null, 2)); 111 | // fs.writeFileSync('./data/output/keyframeRules.json', JSON.stringify(keyframeRules, null, 2)); 112 | 113 | console.log('cdpRules: returning result {inlineRules, regularRules, userAgentRules}'); 114 | return result; 115 | } 116 | 117 | export default cdpRules 118 | -------------------------------------------------------------------------------- /scripts/startRemoteChrome.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | // 'child_process' module: used to run shell commands, to spawn new processes, and to control their behavior. 4 | // exec: executes a shell command 5 | import { exec } from 'child_process'; 6 | 7 | console.log('Starting remote Chrome...'); 8 | // join the current module's URL and '../data/Chrome/Profiles' to get the absolute path to the directory where the remote Chrome user data is stored 9 | // remove 'file:' prefix, if present 10 | const DIR = path.join(path.dirname(import.meta.url), '../data/Chrome/Profiles').replace(/^file:/, ''); 11 | // console.log('DIR:', DIR); 12 | 13 | try { 14 | // check if the directory at 'DIR' exists, and store the result in 'dirExists' 15 | // 'fs.promises.access' is a method on the 'fs' object, and it checks if a file or directory exists. 16 | // if it exists, 'dirExists' will be true, otherwise it will be false 17 | const dirExists = await fs.promises.access(DIR).then(() => true).catch(() => false); 18 | 19 | if (dirExists) { 20 | // 'fs.promises.readdir': returns a Promise which resolves to an array of filenames in the directory. 21 | const files = await fs.promises.readdir(DIR); 22 | 23 | // for each file in the directory 24 | for (const file of files) { 25 | // 'fs.promises.rm': removes a file or directory. 26 | // two arguments: the path of the file or directory to remove, and an options object. 27 | // the options object is { recursive: true }, which means to remove all of a directory's contents recursively. 28 | // we call it on each joined path of the directory and file. 29 | await fs.promises.rm(path.join(DIR, file), { recursive: true }); 30 | } 31 | // if the directory doesn't exist 32 | } else { 33 | // 'fs.promises.mkdir': creates a new directory. 34 | // two arguments: the path of the directory to create, and an options object. 35 | // the options object is { recursive: true }, which means to create any necessary parent directories. This would be helpful if we had multiple user data directories or if we accidentally deleted the Chrome directory itself. 36 | await fs.promises.mkdir(DIR, { recursive: true }); 37 | } 38 | 39 | // get the value of the 'PORT' environment variable. 40 | // PORT is set in whichever script is run from our package.json file, and it is the port that our server is running on. In dev mode, it's set to 5555, giving us access to Vite's dev server. In prod mode, it's set to 8888, giving us access to the production server. 41 | const browserPort = process.env.BROWSER_PORT_BACKUP 42 | 43 | // unused flags: 44 | // --auto-open-devtools-for-tabs 45 | 46 | // a command to start Chrome with remote debugging enabled and a new window opened to 'http://localhost:PORT' 47 | // i tried splitting the command into separate lines to make it easier to read but that caused an error. 48 | // when wanting a normal looking window: 49 | const command = `/Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome --remote-debugging-port=9222 --user-data-dir="${DIR}" --no-first-run --no-default-browser-check --disable-web-security --new-window http://localhost:${browserPort} &`; 50 | // when wanting a window without the address bar, i.e. app mode: 51 | // const command = `/Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome --remote-debugging-port=9222 --user-data-dir="${DIR}" --no-first-run --no-default-browser-check --disable-web-security --app=http://localhost:${browserPort} &` 52 | 53 | // console.log('\n\n\n'); 54 | // console.log('About to run command:', command); 55 | 56 | // 'exec' is a built-in Node.js function that takes three arguments: the command to run, a callback function to execute when the command completes, and an optional options object. 57 | // 'command' is the string of the command to run. 58 | // 'callback' is a function that takes three arguments: an error object if the command failed, the output of the command if it succeeded (stdout), and the error output of the command if it failed (stderr). 59 | // 'error': a built-in object that represents an error. 60 | // 'stdout': a string that contains the standard output of the command. 61 | // 'stderr': a string that contains the error output of the command. 62 | exec(command, (error, stdout, stderr) => { 63 | if (error) { 64 | console.log(`error: ${error.message}`); 65 | return; 66 | } 67 | if (stderr) { 68 | console.log(`stderr: ${stderr}`); 69 | return; 70 | } 71 | // we dont see this logged because we're running a server, which doesn’t produce any output in our case and if it did, we wouldn’t see it until the server closes, i.e. when the process 'finishes'. 72 | // console.log(`stdout: ${stdout}`); 73 | }); 74 | // if an error occurs, log it to the console 75 | } catch (err) { 76 | console.error(err); 77 | } 78 | -------------------------------------------------------------------------------- /client/puppeteer/pupProcess.js: -------------------------------------------------------------------------------- 1 | import { writeFileSync, mkdir } from 'node:fs'; 2 | 3 | import { pupRules } from './pupRules.js'; 4 | 5 | const pupProcess = async (client, styleSheets, data) => { 6 | const proxy = process.env.VITE_PROXY; 7 | const targetUrl = `http://localhost:${proxy}/`; 8 | // console.log('pupEnable: proxy:', proxy); 9 | 10 | const selector = data.selector; 11 | 12 | try { 13 | 14 | // // getDocument: returns the root DOM node of the document. 15 | // // 'nested destructuring' to get the nodeId of the root node. 16 | const { root: { nodeId: rootNodeId } } = await client.send('DOM.getDocument'); 17 | // console.log('pupProcess: rootNodeId:', rootNodeId); 18 | 19 | // // returning all of the nodeIds of the root node document 20 | // // `DOM.querySelectorAll` method called with a `nodeId` and a selector. 21 | // // `nodeId`: the ID of the node in which to search for matching elements. 22 | // // selector: a string containing one or more CSS selectors separated by commas. 23 | // // In this case, the selector is '*', which matches any element. 24 | // // Returns an object with a `nodeIds` property, which is an array of the IDs of the matching nodes. 25 | const { nodeIds } = await client.send('DOM.querySelectorAll', { 26 | nodeId: rootNodeId, 27 | selector: '*' 28 | }); 29 | // // console.log('PupProcess: nodeIds', nodeIds); 30 | 31 | // // returning the full description of each node, i.e. the properties of each node. 32 | // // there are many so we use a Promise.all to execute them async and wait for all of them to be returned. 33 | const nodes = await Promise.all(nodeIds.map(nodeId => client.send('DOM.describeNode', { nodeId }))); 34 | // // console.log('nodes', nodes); 35 | 36 | // // In looking through the nodes, I saw only one node with IFRAME as the nodeName. It corresponded to the root node of the iframe. 37 | // // Find nodes where the nodeName is 'IFRAME' and the contentDocument.baseURL matches the targetUrl. 38 | // // we expect only one, so we set the index to 0. 39 | // // it's an object, with everything inside of the key 'node', so we access the 'node' key. 40 | // // then we nested destructure again to get the contentDocument, 41 | // // which is the html document rendered inside the iframe, i.e. the user's html code. 42 | // // then we nested destructure again to get the nodeId, which is the id of the iframe node. 43 | // // and we assign it to the iframeNodeId variable. 44 | // // maybe we don't need to filter by iframe node like above? 45 | // // const { node: { contentDocument: { nodeId: iframeNodeId } } } = nodes.filter(each => each.node.nodeName === 'IFRAME' && each.node.contentDocument.baseURL === targetUrl)[0]; 46 | 47 | const { node: { contentDocument: { nodeId: iframeNodeId } } } = nodes.filter(each => each.node?.contentDocument?.baseURL === targetUrl)[0]; 48 | 49 | // console.log('iframeNodeId', iframeNodeId); 50 | 51 | // // Get the nodeId of the element node based on its selector. 52 | // DOM.querySelector only searches within the subtree of a specific node 53 | const { nodeId: elementNodeId } = await client.send('DOM.querySelector', { 54 | nodeId: iframeNodeId, 55 | selector: selector 56 | }); 57 | 58 | // // console.log('\n\n'); 59 | // console.log('elementNodeId', elementNodeId); 60 | 61 | // // Create the directory before trying to add files. 62 | // await mkdir((new URL('../../data/output/', import.meta.url)), { recursive: true }, (err) => { 63 | // if (err) throw err; 64 | // }); 65 | // // console.log('pupProcess: calling writeFileSync'); 66 | 67 | // // this saves the nodes 68 | // writeFileSync('./data/output/nodes.json', JSON.stringify(nodes, null, 2)); 69 | 70 | // this saves the contentDocument node of the iframe 71 | // writeFileSync('./data/output/iframeNode.json', JSON.stringify(iframeNode, null, 2)); 72 | 73 | // this saves the element 74 | // writeFileSync('./data/output/element.json', JSON.stringify(element), null, 2); 75 | 76 | // console.log('pupProcess: calling pupRules with elementNodeId:', elementNodeId, 'selector:', selector); 77 | 78 | // right now, result is an object that has the matched and inline styles for the element clicked. 79 | // this retrieves the styles for the clicked element 80 | const result = await pupRules(client, elementNodeId); 81 | result.styleSheets = styleSheets; 82 | return result; 83 | 84 | } catch (err) { 85 | console.error('Error connecting to Chrome via Puppeteer', err); 86 | } 87 | } 88 | 89 | export { pupProcess } 90 | -------------------------------------------------------------------------------- /client/cdp/cdp0process.js: -------------------------------------------------------------------------------- 1 | /** 2 | * cdp0Process.js 3 | * Process the given selector using Chrome DevTools Protocol (CDP). 4 | * 5 | * @imports CDP from 'chrome-remote-interface' 6 | * 7 | * @modules 8 | * cdp1enable.js: Enable the domains 9 | * cdp2rules.js: Process the rules/styles for the given selector 10 | * 11 | */ 12 | import fs from 'fs'; 13 | import CDP from 'chrome-remote-interface'; 14 | import { writeFileSync, mkdir } from 'node:fs'; 15 | 16 | import cdpEnable from './cdp1enable.js'; 17 | import cdpRules from './cdp2rules.js'; 18 | 19 | /** 20 | * cdpProcess 21 | * @param {string} attrs - The aatributes received from the iframe 22 | * @param {string} proxy - The proxy to connect to for the Chrome DevTools Protocol 23 | * 24 | */ 25 | 26 | const cdpProcess = async (data) => { 27 | const id = data?.id; 28 | const innerHTML = data?.innerHTML; 29 | const nodeName = data?.nodeName; 30 | const className = data?.className; 31 | // const proxy = data?.proxy; 32 | const nodeType = data?.nodeType; 33 | const textContent = data?.textContent; 34 | // const attributes = data?.attributes; 35 | const selector = data?.selector; 36 | 37 | // console.log('cdpProcess: proxy:', proxy); 38 | let cdpClient; 39 | try { 40 | // setting our selector based on the attributes we received from the iframe. 41 | // starting with the most specific selector and working our way down. 42 | // let selector = ''; 43 | // if (id) { 44 | // console.log('element id:', id); 45 | // selector = `#${id}`; 46 | // } 47 | // else if (className && !className.includes(' ')) { 48 | // console.log('element class:', className); 49 | // selector = `.${className}`; 50 | // } 51 | // else if (nodeName) { 52 | // console.log('element nodeName:', nodeName); 53 | // console.log('element className:', className); 54 | // selector = `${nodeName}`; 55 | // } 56 | // else if (innerHTML) { 57 | // console.log('element innerHTML:', innerHTML); 58 | // selector = `${innerHTML}`; 59 | // } 60 | // else if (textContent) { 61 | // console.log('element textContent:', textContent); 62 | // selector = `${textContent}`; 63 | // } 64 | 65 | console.log('cdpProcess: selector:', selector); 66 | 67 | // console.log('cdpProcess: trying to connect to CDP'); 68 | 69 | // cdpClient is a newly created object that serves as our interface to send commands 70 | // and listen to events in Chrome via the Chrome DevTools Protocol (CDP) by way of 71 | // chrome-remote-interface, a library that allows for easy access to the Chrome DevTools Protocol. 72 | cdpClient = await CDP(); 73 | // a version where we specify the tab we want to connect to, though I didn’t notice any difference or benefit in trying it. 74 | // cdpClient = await CDP({tab: 'http://localhost:5555'}); 75 | 76 | // console.log('Connected to Chrome DevTools Protocol via chrome-remote-interface'); 77 | 78 | // extracting the 'domains' from the CDP client. 79 | const {DOM, CSS, Network, Page, Overlay, iframeNode, styleSheets } = await cdpEnable(cdpClient); 80 | 81 | // these allow us to see / save all of the methods and properties that the CDP client exposes. 82 | // fs.writeFileSync('./data/domains/DOM.json', JSON.stringify(Object.entries(DOM), null, 2)); 83 | // fs.writeFileSync('./data/domains/Network.json', JSON.stringify(Object.entries(Network), null, 2)); 84 | // fs.writeFileSync('./data/domains/Page.json', JSON.stringify(Object.entries(Page), null, 2)); 85 | // fs.writeFileSync('./data/domains/CSS.json', JSON.stringify(Object.entries(CSS), null, 2)); 86 | 87 | // this is the core functionality of cssxe that retrieves styles from a website 88 | // console.log('cdpProcess: calling cdpRules'); 89 | 90 | // right now, result is an object that has both the matched and inline styles for the element clicked. 91 | const result = await cdpRules(cdpClient, DOM, CSS, Network, Page, Overlay, iframeNode, selector, styleSheets); 92 | // console.log(`Rules for ${selector} retrieved`, result); 93 | return result; 94 | 95 | 96 | } catch (err) { 97 | console.error('Error connecting to Chrome', err); 98 | } 99 | finally { 100 | // It is considered a best practice to close resources such as connections in a finally block. 101 | // This ensures they are properly cleaned up, even in the event of an error. 102 | // Leaving connections open can lead to resource leaks and potential issues with system performance. 103 | if (cdpClient) { 104 | await cdpClient.close(); 105 | console.log('CDP client closed'); 106 | } 107 | } 108 | } 109 | 110 | 111 | 112 | export default cdpProcess; 113 | -------------------------------------------------------------------------------- /client/cdp/cdp1enable.js: -------------------------------------------------------------------------------- 1 | /** 2 | * cdp1Enable.js 3 | * Enables the necessary CDP listeners and state on the browser side to interact with the specified domain. 4 | * 5 | * @param {object} client - The client object for interacting with the browser. 6 | * @param {string} port - The URL of the page to navigate to. 7 | * @return {object} An object containing the enabled DOM, CSS, Network, and Page domains. 8 | */ 9 | 10 | import { writeFileSync, mkdir } from 'node:fs'; 11 | 12 | const cdpEnable = async (cdpClient) => { 13 | // extract the different 'domains' from the client. 14 | const { DOM, CSS, Network, Page, Overlay } = cdpClient; 15 | 16 | // getting the target URL from the environment variables 17 | const targetUrl = `http://localhost:${process.env.VITE_PROXY}/`; 18 | console.log('cdpEnable: targetUrl:', targetUrl); 19 | 20 | // 'enable' on a domain sets up the necessary listeners and state on the browser side to interact with that domain. 21 | // this is a prerequisite step before we can use the methods provided by each of the domains. 22 | // enabling a domain starts the flow of events and allows command execution within that domain. 23 | 24 | // DOM: to interact with the structure of the DOM. 25 | // CSS: to query and manipulate CSS styles. 26 | // Network: to inspect network activity and manage network conditions. 27 | // Page: to control page navigation, lifecycle, and size. 28 | await Promise.all([DOM.enable(() => { }), CSS.enable(() => { }), Network.enable(() => { }), Page.enable(() => { }), Overlay.enable(() => { })]); 29 | 30 | const styleSheets = {} 31 | 32 | // console.log('cdpEnable: DOM, CSS, Network, and Page domains are enabled'); 33 | CSS.styleSheetAdded((param) => { 34 | if (param.header.sourceMapURL) { 35 | // console.log('styleSheetAdded with sourceMapURL'); 36 | const id = param.header.styleSheetId; 37 | 38 | const sourceMapData = Buffer.from(param.header.sourceMapURL.split(',')[1], 'base64').toString('utf-8'); 39 | const decodedMap = JSON.parse(sourceMapData); 40 | // console.log('\n\n\n'); 41 | // console.log('decodedMap', decodedMap); 42 | writeFileSync('./data/output/decodedMap.json', JSON.stringify(decodedMap, null, 2)); 43 | const sources = decodedMap.sources; 44 | const absolutePaths = [] 45 | const relativePaths = []; 46 | sources.forEach(source => { 47 | // splitting the source string on the '://' 48 | // pushing the second part, the path, into the paths array 49 | if (source.includes('://')) { 50 | relativePaths.push(source.split('://')[1]); 51 | } 52 | else { 53 | absolutePaths.push(source); 54 | } 55 | }) 56 | 57 | styleSheets[id] = { 58 | sources, 59 | absolutePaths, 60 | relativePaths 61 | } 62 | } 63 | else { 64 | // console.log('styleSheetAdded: no sourceMapURL'); 65 | // console.log('styleSheetParamHeader:', param.header); 66 | } 67 | }); 68 | 69 | // console.log('getting nodes'); 70 | // getFlattenedDocument: returns a flattened array of the DOM tree at the specified depth 71 | // if no depth is specified, the entire DOM tree is returned. 72 | // depth: depth of the dom tree that we want 73 | // -> -1 means we want to get the entire DOM tree. 74 | // -> >= 0 would correspond to a specific depth of the DOM tree. 75 | // however, it is deprecated. 76 | const { nodes } = await DOM.getFlattenedDocument({ depth: -1 }); 77 | 78 | // Create the directory before trying to add files. 79 | await mkdir((new URL('../../data/output/', import.meta.url)), { recursive: true }, (err) => { 80 | if (err) throw err; 81 | }); 82 | 83 | // writeFileSync('./data/output/nodes.json', JSON.stringify(nodes, null, 2)); 84 | 85 | // Find nodes where the nodeName property is 'IFRAME'. 86 | // In looking through the nodes, I saw only one IFRAME node, which corresponded to the root node of the iframe. 87 | // TBD if there would be more than one if the site we are targeting has iframes within it. 88 | const iframeNodeId = await nodes.filter(node => node.nodeName === 'IFRAME')[0].nodeId; 89 | 90 | // console.log('iframeNodeId', iframeNodeId); 91 | 92 | // describeNode: gets a description of a node with a given DOM nodeId, i.e. the type of node, its name, and its children. 93 | const { node } = await DOM.describeNode({ nodeId: iframeNodeId }); 94 | 95 | // console.log('cdpEnable: node', node); 96 | 97 | // from there we get the contentDocument of the iframeNode, 98 | // which is the html document of the iframe 99 | const iframeNode = node.contentDocument; 100 | // console.log('Node inside iframe', iframeNode); 101 | 102 | // this saves the nodes 103 | writeFileSync('./data/output/nodes.json', JSON.stringify(nodes, null, 2)); 104 | 105 | // this saves the contentDocument node of the iframe 106 | writeFileSync('./data/output/iframeNode.json', JSON.stringify(iframeNode, null, 2)); 107 | 108 | // Return the enabled domains and the nodeId of the iframe root node to the process 109 | return { DOM, CSS, Network, Page, Overlay, iframeNode, styleSheets }; 110 | } 111 | 112 | export default cdpEnable; 113 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CSSexy 2 | 3 |

4 | CSSexy Logo 5 |

6 | 7 | Because your website deserves to look hot. 8 | 9 | ![Javascript](https://img.shields.io/badge/JavaScript-323330?style=for-the-badge&logo=javascript&logoColor=F7DF1E) 10 | ![npm](https://img.shields.io/badge/npm-CB3837?style=for-the-badge&logo=npm&logoColor=white) 11 | ![Puppeteer](https://img.shields.io/badge/Puppeteer-40B5A4?style=for-the-badge&logo=Puppeteer&logoColor=white) 12 | ![NodeJS](https://img.shields.io/badge/node.js-6DA55F?style=for-the-badge&logo=node.js&logoColor=white) 13 | ![Express.js](https://img.shields.io/badge/Express%20js-000000?style=for-the-badge&logo=express&logoColor=white) 14 | ![React](https://img.shields.io/badge/react-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB) 15 | ![Redux](https://img.shields.io/badge/Redux-593D88?style=for-the-badge&logo=redux&logoColor=white) 16 | ![Vite](https://img.shields.io/badge/Vite-B73BFE?style=for-the-badge&logo=vite&logoColor=FFD62E) 17 | ![HTML5](https://img.shields.io/badge/html5-%23E34F26.svg?style=for-the-badge&logo=html5&logoColor=white) 18 | ![CSS3](https://img.shields.io/badge/css3-%231572B6.svg?style=for-the-badge&logo=css3&logoColor=white) 19 | ![Jest](https://img.shields.io/badge/-jest-%23C21325?style=for-the-badge&logo=jest&logoColor=white) 20 | 21 | ## Table of Contents 22 | 23 | - [Introduction](#introduction) 24 | - [Features](#features) 25 | - [Installation](#installation) 26 | - [The CSSexy Team](#team) 27 | - [Contributing](#contributing) 28 | - [License](#license) 29 | 30 | ## Introduction 31 | 32 | We are thrilled to unveil CSSexy, a powerful tool designed to streamline CSS editing and management within your web applications. CSSexy taps into the power of Puppeteer and Chrome Developer Tools to provide a comprehensive interface for viewing and modifying CSS styling rules in real time. You can view where in the source code that the CSS rule is applied, and modify that source with a simple, clean interface. 33 | 34 | ## Features 35 | 36 | - Load your target applicaiton into CSSexy 37 | - View CSS properties by clicking an element on the DOM 38 | - Modify CSS properties in CSSexy and save changes to source code 39 | 40 | ## Installation 41 | 42 | 1. Clone the repo 43 | 2. Run your application on localhost:8000 44 | 3. Run the scripts to install dependencies and start the app 45 | 46 | ```bash 47 | # Example installation steps 48 | git clone https://github.com/oslabs-beta/cssexy.git 49 | cd cssexy 50 | npm install 51 | npm run dev 52 | ``` 53 | 54 | ## The CSSexy Team 55 | 56 | | Developed By | GitHub | LinkedIn | 57 | | :-------------: | :------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------: | 58 | | Mike Basta | [![Github](https://img.shields.io/badge/github-%23121011.svg?style=for-the-badge&logo=github&logoColor=white)](https://github.com/mikebasta) | [![LinkedIn](https://img.shields.io/badge/LinkedIn-%230077B5.svg?logo=linkedin&logoColor=white)](https://www.linkedin.com/in/mikebasta/) | 59 | | Elena Netepenko | [![Github](https://img.shields.io/badge/github-%23121011.svg?style=for-the-badge&logo=github&logoColor=white)](https://github.com/Elena-Netepenko) | [![LinkedIn](https://img.shields.io/badge/LinkedIn-%230077B5.svg?logo=linkedin&logoColor=white)](https://www.linkedin.com/in/elena-netepenko/) | 60 | | Rob Sand | [![Github](https://img.shields.io/badge/github-%23121011.svg?style=for-the-badge&logo=github&logoColor=white)](https://github.com/rjsandman) | [![LinkedIn](https://img.shields.io/badge/LinkedIn-%230077B5.svg?logo=linkedin&logoColor=white)](https://www.linkedin.com/in/) | 61 | | Keith Gibson | [![Github](https://img.shields.io/badge/github-%23121011.svg?style=for-the-badge&logo=github&logoColor=white)](https://github.com/keithgibson) | [![LinkedIn](https://img.shields.io/badge/LinkedIn-%230077B5.svg?logo=linkedin&logoColor=white)](https://www.linkedin.com/in/keithrgibson/) | 62 | | | 63 | 64 | ## Contributing 65 | 66 | Contributions are the foundation of the open-source community. Your contributions help improve our application for developers around the world and are greatly appreciated. 67 | 68 | Feel free to fork the project, implement changes, and submit pull requests to help perfect this product and solve problems others might be facing. 69 | 70 | If you like what CSSexy is doing, please star our project on GitHub! Stars will help boost CSSexy's visibility to developers who may find our product useful or be interested in contributing. 71 | 72 | If you notice any bugs or would like to request features, please browse our [Issues page.](https://github.com/oslabs-beta/cssxe/issues) 73 | 74 | ## License 75 | 76 | CSSexy is developed under the [MIT license.](https://en.wikipedia.org/wiki/MIT_License) 77 | -------------------------------------------------------------------------------- /client/components/RulesUserAgentComp.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { nanoid } from 'nanoid'; 3 | import { useSelector } from 'react-redux'; 4 | import SidebarStyling from './SidebarStyling.jsx'; 5 | 6 | /* Styles included: default browser styles*/ 7 | 8 | function RulesUserAgentComp() { 9 | const userAgentRulesData = useSelector(state => state.rules.userAgentRules); 10 | const shortToLongMap = useSelector(state => state.rules.shortToLongMap); 11 | // userAgentRules stores data by selector, so that we can display by selector 12 | const userAgentRules = {}; 13 | 14 | const ObjToArr = stylesObj => { 15 | const arr = []; 16 | for (let style in stylesObj) { 17 | arr.push({ 18 | name: style, 19 | value: stylesObj[style].val, 20 | isActive: stylesObj[style].isActive 21 | }) 22 | } 23 | return arr; 24 | }; 25 | 26 | const compareSpecificityDescending = (obj1, obj2) => { 27 | if (obj1.specificity.a !== obj2.specificity.a) { 28 | return obj1.specificity.a < obj2.specificity.a ? 1 : -1; 29 | } 30 | // If 'a' values are equal, compare the 'b' values 31 | else if (obj1.specificity.b !== obj2.specificity.b) { 32 | return obj1.specificity.b < obj2.specificity.b ? 1 : -1; 33 | } 34 | // If 'b' values are equal, compare the 'c' values 35 | else if (obj1.specificity.c !== obj2.specificity.c) { 36 | return obj1.specificity.c < obj2.specificity.c ? 1 : -1; 37 | } 38 | else return 0; 39 | }; 40 | 41 | userAgentRulesData.forEach(style => { 42 | let userAgentSelector; 43 | // user-agent styles typically have only 1 matching selector 44 | if (style.matchingSelectors.length === 1) { 45 | userAgentSelector = style.rule.selectorList.selectors[style.matchingSelectors[0]].text; 46 | const specificity = style.calculatedSpecificity; 47 | // we are only showing valid selectors which have styles attached to them 48 | if (style.rule.style.cssProperties.length) { 49 | if (!userAgentRules[userAgentSelector]) { 50 | userAgentRules[userAgentSelector] = { 51 | properties: {}, 52 | specificity 53 | }; 54 | }; 55 | }; 56 | } 57 | // if you encounter the error below, add the logic that iterates through all matching selectors and finds the one with highest specificity 58 | else throw new Error('MULTIPLE MATCHING SELECTORS ARE FOUND IN "matchingSelectors" ARRAY!'); 59 | 60 | // add all longhand properties 61 | for (let cssProperty of style.rule.style.cssProperties) { 62 | if (cssProperty.value) { 63 | userAgentRules[userAgentSelector]['properties'][cssProperty.name] = { 64 | val: cssProperty.value, 65 | isActive: cssProperty.isActive 66 | } 67 | } 68 | } 69 | const shorthandStyles = style.rule.style.shorthandEntries; 70 | if (shorthandStyles.length) { 71 | for (let shortStyle of shorthandStyles) { 72 | // add all shorthand properties 73 | if (shortStyle.value) { 74 | userAgentRules[userAgentSelector]['properties'][shortStyle.name] = { 75 | val: shortStyle.value, 76 | isActive: shortStyle.isActive 77 | }; 78 | 79 | // get and remove longhand properties corresponding to each shorthand 80 | const longhands = shortToLongMap[shortStyle.name]; 81 | longhands.forEach(lh => { 82 | if (userAgentRules[userAgentSelector]['properties'][lh]) delete userAgentRules[userAgentSelector]['properties'][lh]; 83 | }) 84 | } 85 | } 86 | } 87 | }); 88 | 89 | // convert userAgentRules object into array, sort it by specificity in descending order and generate jsx components to render 90 | const userAgentRulesAr = []; 91 | for (let selector in userAgentRules) { 92 | userAgentRulesAr.push({ 93 | selector: selector, 94 | properties: userAgentRules[selector].properties, 95 | specificity: userAgentRules[selector].specificity 96 | }); 97 | }; 98 | 99 | userAgentRulesAr.sort(compareSpecificityDescending); 100 | 101 | const sidebarStylingComponents = userAgentRulesAr.map(each => { 102 | return ( 103 | 109 | ) 110 | }); 111 | 112 | return ( 113 |
114 |

user agent

115 | {/* making this conditionally rendered as otherwise there is a bottom border where there's not one for inline and regular */} 116 | {sidebarStylingComponents.length > 0 && sidebarStylingComponents} 117 |
118 | ) 119 | }; 120 | 121 | export default RulesUserAgentComp; 122 | -------------------------------------------------------------------------------- /client/components/RulesAllComp.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useMemo, useEffect } from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | import SidebarStyling from './SidebarStyling.jsx'; 4 | import RulesUserAgentComp from "./RulesUserAgentComp.jsx"; 5 | 6 | function RulesAllComp() { 7 | // contains all of the inline styles (styles specified directly on a component) 8 | const inlineRules = useSelector(state => state.rules.inlineRules); 9 | // contains all of the regular styles (styles specified in .css files) 10 | const regularRules = useSelector(state => state.rules.regularRules); 11 | // contains information about all of the .css files that are currently loaded 12 | const styleSheets = useSelector(state => state.rules.styleSheets); 13 | 14 | // tracks the path of the currently selected .s/css file 15 | const [sourcePath, setSourcePath] = useState(null); 16 | // tracks the name of the currently selected .s/css file 17 | const [sourceName, setSourceName] = useState(null); 18 | // tracks the path of the first .s/css file in the array of .css files 19 | const [firstSourcePath, setFirstSourcePath] = useState(null); 20 | 21 | const compareSpecificityDescending = (obj1, obj2) => { 22 | if (obj1.calculatedSpecificity.a !== obj2.calculatedSpecificity.a) { 23 | return obj1.calculatedSpecificity.a < obj2.calculatedSpecificity.a ? 1 : -1; 24 | } 25 | // If 'a' values are equal, compare the 'b' values 26 | else if (obj1.calculatedSpecificity.b !== obj2.calculatedSpecificity.b) { 27 | return obj1.calculatedSpecificity.b < obj2.calculatedSpecificity.b ? 1 : -1; 28 | } 29 | // If 'b' values are equal, compare the 'c' values 30 | else if (obj1.calculatedSpecificity.c !== obj2.calculatedSpecificity.c) { 31 | return obj1.calculatedSpecificity.c < obj2.calculatedSpecificity.c ? 1 : -1; 32 | } 33 | else return 0; 34 | }; 35 | 36 | useEffect(() => { 37 | if (regularRules.length > 0) { 38 | // styleSheetId is a variable that we use to keep track of which .css file we want to look at 39 | const styleSheetId = regularRules[0]?.rule.style.styleSheetId; 40 | // we set the firstSourcePath variable to the absolute path (if it exists) or the relative path of the first .css file returned by the styleSheets object for the clicked element. 41 | setFirstSourcePath(styleSheets[styleSheetId]?.absolutePaths[0] ? styleSheets[styleSheetId].absolutePaths[0] : styleSheets[styleSheetId]?.relativePaths[0]); 42 | // if the first .css file is different from the currently selected .css file, we update the sourcePath variable to reflect the new selection 43 | if (styleSheets[styleSheetId] && sourcePath !== firstSourcePath) { 44 | setSourcePath(firstSourcePath); 45 | const splitPaths = firstSourcePath.split('/'); 46 | // sourceNameString is a variable that we use to keep track of the name of the currently selected .css file 47 | const sourceNameString = `/${splitPaths[splitPaths.length - 1]}`; 48 | // we update the sourceName variable to reflect the new selection 49 | setSourceName(sourceNameString); 50 | } 51 | } 52 | // useEffect is a hook provided by React. It lets us run code when specific pieces of data change. In this case, if the regularRules or styleSheets data changes, we want to run the code inside the useEffect block 53 | }, [styleSheets, regularRules]); 54 | 55 | // map() to create an array of SidebarStyling components 56 | // from the inlineRules data and another from the regularRules data. 57 | const RulesInlineComp = inlineRules.map((each, idx) => { 58 | return ( 59 | 64 | ) 65 | }); 66 | 67 | // sort all selector blocks rendered in UI - based on specificity, from highest to lowest 68 | const regularRulesSorted = regularRules.toSorted(compareSpecificityDescending); 69 | const RulesRegularComp = regularRulesSorted.map((each, idx) => { 70 | let regularSelector = ''; 71 | if (each.matchingSelectors.length === 1) regularSelector = each.rule.selectorList.selectors[each.matchingSelectors[0]].text; 72 | // combine selectors where there're multiple selectors in matchingSelectors array, e.g. '.btn, #active' 73 | else if (each.matchingSelectors.length > 1) { 74 | for (let i = 0; i < each.matchingSelectors.length; i++) { 75 | const idx = each.matchingSelectors[i]; 76 | regularSelector += each.rule.selectorList.selectors[idx].text; 77 | if (i !== each.matchingSelectors.length - 1) regularSelector += ', '; 78 | } 79 | }; 80 | return ( 81 | 88 | ) 89 | }); 90 | 91 | return ( 92 |
93 |

inline

94 | {/* ternary to render a line break if there are no rules. Improves readability imo */} 95 | <>{RulesInlineComp.length ? RulesInlineComp :
} 96 | {/*

.css

*/} 97 |

{sourceName ? sourceName : 'css'}

98 | {/* same ternary, same reason */} 99 | <>{RulesRegularComp.length ? RulesRegularComp :
} 100 | 101 |
102 | ) 103 | }; 104 | 105 | export default RulesAllComp; 106 | -------------------------------------------------------------------------------- /client/components/iFrameComp.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import { updateInlineRules, updateRegularRules, updateUserAgentRules, updateInheritedRules, updateKeyframeRules, updateStyleSheets, findActiveStyles, updateShortLongMaps, updateMidShortMap, setIsActiveFlag, setSpecificity, resetCache, updateNodeData } from '../slices/rulesSlice.js'; 4 | import DOMPath from 'chrome-dompath'; 5 | 6 | /** 7 | * Renders an iframe component with event handling for click events. 8 | * 9 | * @param {Object} props - The component props. 10 | * @param {string} props.src - The source URL for the iframe. 11 | * @param {string} props.className - The CSS class name for the iframe. 12 | * @returns {JSX.Element} The rendered iframe component. 13 | */ 14 | 15 | const iFrameComp = ({ src, proxy, className }) => { 16 | // const inlineRules = useSelector(state => state.rules.inlineRules); 17 | // console.log('INLINE RULES: ', inlineRules); 18 | // const regularRules = useSelector(state => state.rules.regularRules); 19 | // console.log('REGULAR RULES: ', regularRules); 20 | // const userAgentRules = useSelector(state => state.rules.userAgentRules); 21 | // console.log('USER AGENT RULES: ', userAgentRules); 22 | // const isActiveCache = useSelector(state => state.rules.isActiveCache); 23 | // console.log('IS ACTIVE CACHE: ', isActiveCache); 24 | 25 | const dispatch = useDispatch(); 26 | 27 | // waiting for the iframe DOM to load before we add event listeners 28 | // without this, the event listeners would try to be added to an unexisting iframe 29 | useEffect(() => { 30 | // getting our iframe 31 | const iframe = document.querySelector(`.${className}`); 32 | // console.log('iFrameComp: iframe', iframe); 33 | 34 | const handleLoad = () => { 35 | try { 36 | const iframeDoc = iframe.contentDocument || iframe.contentWindow.document; 37 | // console.log('iFrameComp: iframeDoc', iframeDoc); 38 | 39 | const handleClick = async (element) => { 40 | console.log('iFrameComp: element', element); 41 | 42 | // getting the 'selector' of the element, i.e. the same thing that one would get by inspecting the element with dev tools, then right clicking in the dom tree and selecting copy selector. 43 | // we get this using the DOMPath library, which is a port of the relevant piece of Chromium's Chrome Dev Tools front-end code that does the same thing. This should get us a unique, specific selector for the clicked element every time. In testing so far it has always worked. 2024-04-01_08-14-PM. 44 | // https://github.com/testimio/DOMPath 45 | 46 | const selector = DOMPath.fullQualifiedSelector(element); 47 | // with true, we get an 'optimized' selector. doesn’t seem to matter which we choose so far. they both have worked. I'm including true now so we recall its an option. if for some reason it doesn’t work, we can switch to false (i.e. only pass one param, the selector) 48 | // true: #landingAndSticky > div > h1 49 | // false: div#landingAndSticky > div > h1 50 | // console.log('\n\n'); 51 | console.log('iFrameComp: selector', selector); 52 | // console.log('\n\n'); 53 | 54 | const data = { 55 | // id: element.id, 56 | // innerHTML: element.innerHTML, 57 | // nodeName: element.nodeName, 58 | // className: element.className, 59 | // proxy: proxy, 60 | // nodeType: element.nodeType, 61 | // textContent: element.textContent, 62 | // attributes: element.attributes, 63 | selector 64 | }; 65 | 66 | // a POST request to the /cdp endpoint 67 | const response = await fetch('/cdp', { 68 | method: 'POST', 69 | headers: { 70 | 'Content-Type': 'application/json', 71 | }, 72 | body: JSON.stringify(data), 73 | }); 74 | 75 | // console.log('iFrameComp: response', response); 76 | 77 | const result = await response.json(); 78 | 79 | dispatch(updateNodeData(data)); 80 | // console.log('iFrameComp: Result returned from /cdp'); 81 | // console.log('iFrameComp: Result : ', result); 82 | 83 | // dispatching the results from the /cdp endpoint to the store 84 | dispatch(updateInlineRules(result.inlineRules)); 85 | dispatch(updateRegularRules(result.regularRules)); 86 | dispatch(updateUserAgentRules(result.userAgentRules)); 87 | dispatch(updateStyleSheets(result.styleSheets)); 88 | dispatch(updateInheritedRules(result.inheritedRules)); 89 | // dispatch(updateKeyframeRules(result.keyframeRules)); 90 | 91 | // actions needed for style overwrite functionality 92 | dispatch(resetCache()); 93 | dispatch(updateShortLongMaps()); 94 | dispatch(updateMidShortMap()); 95 | dispatch(setIsActiveFlag()); 96 | dispatch(setSpecificity()); 97 | dispatch(findActiveStyles()); 98 | }; 99 | 100 | 101 | // This event listener needs to be added to the iframe's contentDocument because 102 | // we're listening for clicks inside the iframe, and those clicks won't be 103 | // handled by React's event delegation system. By adding this event listener, 104 | // we're essentially making the iframe's contentDocument a "portal" for 105 | // clicks to be handled by React. 106 | iframeDoc.addEventListener('click', (event) => { 107 | const element = event.target; 108 | const localName = element.localName; 109 | 110 | // Calling the handleClick function 111 | handleClick(element); 112 | 113 | 114 | // switch the focus to cssxe when the user clicks on something that isnt an input, textarea, or dropdown (select) field. 115 | // without this their interaction with those elements is broken/interrupted, e.g. clicking in a text field in bookswap. 116 | if (localName !== 'input' && localName !== 'textarea' && localName !== 'select') { 117 | 118 | // Set focus back to the parent document 119 | // This allows CSSxe to receive keyboard events again after a click has taken place inside the iframe. 120 | // before doing this, CSSxe would not receive keyboard events again until we clicked inside of the sidebar 121 | window.parent.focus(); 122 | } 123 | 124 | }, false); 125 | 126 | return () => { 127 | // Cleanup function to remove event listener 128 | // Prevents memory leaks 129 | iframeDoc.removeEventListener('click', handleClick, false); 130 | }; 131 | } catch (error) { 132 | console.error("Can't access iframe content:", error); 133 | } 134 | }; 135 | 136 | if (iframe) { 137 | iframe.addEventListener('load', handleLoad); 138 | // Cleanup function to remove event listener 139 | return () => { 140 | iframe.removeEventListener('load', handleLoad); 141 | }; 142 | } 143 | }, []); 144 | 145 | return ( 146 |