├── .DS_Store
├── .gitignore
├── .npmignore
├── LICENSE
├── NOTES.md
├── README.md
├── assets
└── cssexy_logo_npm_2024-05-09@10x.png
├── client
├── .DS_Store
├── App.jsx
├── cdp
│ ├── cdp0process.js
│ ├── cdp1enable.js
│ └── cdp2rules.js
├── components
│ ├── RulesAllComp.jsx
│ ├── RulesUserAgentComp.jsx
│ ├── SidebarComp.jsx
│ ├── SidebarStyling.jsx
│ └── iFrameComp.jsx
├── patchFile.js
├── puppeteer
│ ├── pup.js
│ ├── pupProcess.js
│ └── pupRules.js
├── slices
│ └── rulesSlice.js
├── store.js
└── stylesheets
│ └── styles.css
├── data
└── .DS_Store
├── index.html
├── index.jsx
├── nodemon.json
├── package-lock.json
├── package.json
├── scripts
├── getTargetPort.js
├── postInstall.js
├── startRemoteChrome.js
└── startRemoteChrome.sh
├── server
└── server.js
└── vite.config.js
/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/cssexy/cf8adcd362c7ea9495c7d4d7dcc1713949ce43d0/.DS_Store
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist/
3 | data/*/*
4 | .env
5 | .vscode
6 | client/data
7 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | vite.config.js
2 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # CSSexy
2 |
3 |
4 |
5 |
6 |
7 | Because your website deserves to look hot.
8 |
9 | 
10 | 
11 | 
12 | 
13 | 
14 | 
15 | 
16 | 
17 | 
18 | 
19 | 
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 | [](https://github.com/mikebasta) | [](https://www.linkedin.com/in/mikebasta/) |
59 | | Elena Netepenko | [](https://github.com/Elena-Netepenko) | [](https://www.linkedin.com/in/elena-netepenko/) |
60 | | Rob Sand | [](https://github.com/rjsandman) | [](https://www.linkedin.com/in/) |
61 | | Keith Gibson | [](https://github.com/keithgibson) | [](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 |
--------------------------------------------------------------------------------
/assets/cssexy_logo_npm_2024-05-09@10x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/cssexy/cf8adcd362c7ea9495c7d4d7dcc1713949ce43d0/assets/cssexy_logo_npm_2024-05-09@10x.png
--------------------------------------------------------------------------------
/client/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/cssexy/cf8adcd362c7ea9495c7d4d7dcc1713949ce43d0/client/.DS_Store
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/client/components/SidebarStyling.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import { useSelector, useDispatch } from 'react-redux';
3 | import { updateInlineRules, updateRegularRules, updateUserAgentRules, updateInheritedRules, updateKeyframeRules, updateStyleSheets, findActiveStyles, updateShortLongMaps, setIsActiveFlag, updateNodeData, updateMidShortMap } from '../slices/rulesSlice.js';
4 |
5 | function SidebarStyling(props) {
6 |
7 | const dispatch = useDispatch();
8 |
9 | const data = useSelector(state => state.nodeData.data);
10 | const inlineRules = useSelector(state => state.rules.inlineRules);
11 |
12 | // console.log('SidebarStyling: props', props);
13 |
14 | // spread operator to make a deep copy of props, so that we can then modify it.
15 | const liveProps = {...props};
16 |
17 | // console.log('liveProps', liveProps);
18 |
19 | const [values, setValues] = useState({});
20 |
21 | // this useEffect ensures that 'values' is updated only when props.cssProperties changes, rather than on every re-render.
22 | // i was getting some rerendering errors prior to this when modifying source files.
23 | useEffect(() => {
24 | setValues(
25 | liveProps.cssProperties.reduce((acc, cssProp) => {
26 | acc[cssProp.name] = cssProp.value;
27 | return acc;
28 | }, {})
29 | );
30 | }, [props.cssProperties]);
31 |
32 | // this is the same as the fetch and reducer code in iFrameComp.jsx.
33 | // a good refactor would be to place this into its own reducer and fetch function, or at least in a nother file that is called by both iFrameComp and this file.
34 | const callCdp = async () => {
35 | if (!data) {
36 | console.log('RunCdp: runCdp: data is undefined');
37 | return;
38 | }
39 | const response = await fetch('/cdp', {
40 | method: 'POST',
41 | headers: {
42 | 'Content-Type': 'application/json',
43 | },
44 | body: JSON.stringify(data),
45 | });
46 |
47 | const result = await response.json();
48 | console.log('sidebarStyling: runCdp: result', result);
49 |
50 | if (result) {
51 | console.log('sidebarStyling: runCdp: data', data);
52 | dispatch(updateNodeData(data));
53 |
54 | dispatch(updateInlineRules(result.inlineRules));
55 | dispatch(updateRegularRules(result.regularRules));
56 | dispatch(updateUserAgentRules(result.userAgentRules));
57 | dispatch(updateStyleSheets(result.styleSheets));
58 | dispatch(updateInheritedRules(result.inheritedRules));
59 |
60 | // actions needed for style overwrite functionality
61 | dispatch(updateShortLongMaps());
62 | dispatch(updateMidShortMap());
63 | dispatch(setIsActiveFlag());
64 | dispatch(findActiveStyles());
65 |
66 | }
67 | };
68 |
69 | const handleSubmit = async (cssProp, event) => {
70 | // console.log('cssProp', cssProp);
71 | const updatedCssProp = {...cssProp, value: values[cssProp.name]};
72 | updatedCssProp.valuePrev = cssProp.value;
73 | updatedCssProp.textPrev = updatedCssProp.text;
74 | updatedCssProp.text = `${cssProp.name}: ${values[updatedCssProp.name]};`;
75 | // console.log('\n');
76 | // console.log(cssProp.name);
77 | // console.log(cssProp.value);
78 | // console.log('->');
79 | // console.log(updatedCssProp.value);
80 | // console.log('\n');
81 |
82 | updatedCssProp.selector = liveProps.selector;
83 | updatedCssProp.sourcePath = liveProps.sourcePath;
84 |
85 | const textPrevAll = inlineRules[0].rule.style.cssText;
86 | updatedCssProp.textPrevAll = textPrevAll;
87 |
88 | console.log('updatedCssProp', updatedCssProp);
89 | // console.log('TRY: /patch');
90 | try {
91 | const response = await fetch('/patch', {
92 | method: 'POST',
93 | headers: {
94 | 'Content-Type': 'application/json',
95 | },
96 | body: JSON.stringify(updatedCssProp),
97 |
98 | });
99 | const result = await response.json();
100 |
101 | if (result === true) {
102 | // console.log('TRY: /runCdp');
103 |
104 | // wait for .5 seconds. not doing this atm leads to a mismatch between the value in the input field and the corresponding value in the file, which then prevents further editing of that value (until the element is clicked again) because our patchFile function matches one to the other in order to replace the value with the new value.
105 | // inline styles need a bit more time, so if theres no source path, wait 1 second.
106 | await new Promise(resolve => updatedCssProp.sourcePath ? setTimeout(resolve, 500) : setTimeout(resolve, 1000));
107 |
108 | // running CDP again to update our redux store after patching the file.
109 | await callCdp();
110 |
111 | // probably around here is where we'll track undo/redo.
112 | }
113 | }
114 | catch(error) {
115 | console.log('error in runCdp', error);
116 | }
117 | };
118 |
119 | const styleParagraphs = liveProps.cssProperties.map((cssProp, idx) => {
120 | if ((liveProps.origin === 'user-agent')) {
121 | return (
122 |
130 | )
131 | }
132 | // if not user agent style, then it's a regular or inline style (at the moment 2024-04-03), which we make editable below.
133 | else if ((liveProps.origin && cssProp.text)) {
134 | // cssProperties arr includes both user defined 'shorthand' styles and css 'longhand' styles. We want to render only user defined styles => style is user defined if it has .text property
135 | // shorthand example: 'border-bottom: 3px solid blueviolet'
136 | // longhand example: border-bottom-width: 3px; border-bottom-style: solid; border-bottom-color: blueviolet
137 | return (
138 |
139 |
140 | {cssProp.name}:
141 |
142 | setValues({...values, [cssProp.name]: e.target.value})}
146 | onKeyDown={(e) => {
147 | if (e.key === 'Enter') {
148 | handleSubmit(cssProp, e)
149 | // 'blur': makes the input field lose focus, i.e. the cursor disappears, it won't be editable until it's clicked again
150 | e.currentTarget.blur()
151 | }
152 | }}
153 | spellCheck='false' /* Disable spellcheck, i.e. no more red squiggles under the values when clicked/edited but not a complete word */
154 | />
155 |
156 |
171 | )
172 | };
173 |
174 | export default SidebarStyling;
175 |
--------------------------------------------------------------------------------
/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 |
153 | );
154 | };
155 |
156 | export default iFrameComp;
157 |
--------------------------------------------------------------------------------
/client/patchFile.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import path from 'path';
3 |
4 | const patchFile = async (data, targetDir) => {
5 | try {
6 | // properties with Prev are the previous values to be searched for and replaced in the matching file.
7 |
8 | const selector = data.selector;
9 | const text = data.text;
10 | const textPrev = data.textPrev;
11 | const textPrevJs = data.textPrevJs;
12 | const name = data.name;
13 | const value = data.value;
14 | const valuePrev = data.valuePrev;
15 | const textPrevAll = data.textPrevAll;
16 | const sourcePath = data.sourcePath;
17 |
18 | // console.log('data', data);
19 | // Checking if textPrevAll has content and sourcePath is not provided.
20 | // if that's the case, we have an inline style.
21 | if (textPrevAll.length > 0 && !sourcePath) {
22 | console.log('No sourcePath provided, so this is an inline style');
23 |
24 | // we need to convert the strings to js format
25 | // helper function to convert css string format to js format
26 | const cssToJsText = (prev) => {
27 | return prev.replace(new RegExp(`\: `, 'g'), `\: \'`)
28 | .replace(new RegExp(`\; `, 'g'), `', `)
29 | .replace(new RegExp(`\;`, 'g'), `'`);
30 | }
31 |
32 | // example: old values
33 | // from: 'position: absolute; top: 60%; color: white;'
34 | // to: "position: 'absolute', top: '60%', color: 'white'"
35 | const textPrevAllJs = cssToJsText(textPrevAll);
36 | // console.log('textPrevAll', textPrevAll);
37 |
38 | console.log('textPrevAllJs', textPrevAllJs);
39 |
40 | // the property and its old value to be replaced
41 | // from: 'color: white;',
42 | // to: "color: 'white'",
43 | const textPrevJs = cssToJsText(textPrev);
44 |
45 | // new value
46 | // from: 'color: red;',
47 | // to: "color: 'red'",
48 | const textJs = cssToJsText(text);
49 |
50 | // Replacing textPrevJs occurrences with textJs in textPrevAllJs
51 | // We will eventually replace textPrevAllJs with textAllJs in the matching file
52 | // from: "position: 'absolute', top: '60%', color: 'white'"
53 | // to: "position: 'absolute', top: '60%', color: 'red'",
54 | const textAllJs = textPrevAllJs.replace(new RegExp(textPrevJs, 'g'), textJs);
55 |
56 | // arrays will be helpful for string matching when there are line breaks rather than spaces
57 | // between the styles in a given react component.
58 | // const textPrevAllJsArr = textPrevAllJs.split(', ');
59 | // const textAllJsArr = textAllJs.split(', ');
60 |
61 | // adding them to data, in case we want to console log the object to see the changes. This has no functional role beyond that.
62 | data.textPrevAllJs = textPrevAllJs;
63 | data.textPrevJs = textPrevJs;
64 | data.textAllJs = textAllJs;
65 | data.textJs = textJs;
66 |
67 | // console.log('data updated', data);
68 |
69 | // Recursive function to find .jsx files in the target directory
70 | async function findJsxFiles(dir) {
71 | // Creating an empty array to store the found .jsx file paths
72 | let jsxFiles = [];
73 | // Creating a Promise that will be resolved with an array of entries
74 | const entriesPromise = new Promise(
75 | (resolve, reject) => {
76 | // Use fs.readdir to asynchronously read the contents of a directory.
77 | fs.readdir(
78 | // The 'dir' parameter is the path to the directory to read.
79 | dir,
80 | // 'withFileTypes' makes fs.readdir return an array of Dirent (directory entry) objects.
81 | // Each Dirent entry object represents a file or directory and has methods like isFile and isDirectory to check the type.
82 | { withFileTypes: true },
83 | // A callback function to handle the response
84 | (err, entries) => {
85 | // Checking if there was an error
86 | if (err) {
87 | // If there was an error, reject the Promise with it
88 | reject(err);
89 | } else {
90 | // If there was no error, resolve the Promise with the array of entries
91 | resolve(entries);
92 | }
93 | }
94 | );
95 | }
96 | );
97 | // Waiting for the Promise to be resolved with an array of entries
98 | const entries = await entriesPromise;
99 |
100 | // Looping through the array of entries
101 | for (let entry of entries) {
102 | // Joining the current directory with the entry name to get the full file path
103 | const fullPath = path.join(dir, entry.name);
104 | if (entry.isDirectory()) { // If the entry is a directory
105 | // Recursively search subdirectories by calling findJsxFiles on the full path
106 | jsxFiles = jsxFiles.concat(
107 | await findJsxFiles(fullPath)
108 | );
109 | // If the entry is a file with a .jsx extension
110 | } else if (entry.isFile() && path.extname(fullPath) === '.jsx') {
111 | // Add the file path to the array of .jsx file paths
112 | jsxFiles.push(fullPath);
113 | }
114 | }
115 | // Return the array of .jsx file paths
116 | return jsxFiles;
117 | }
118 | const inlineFileMatches = []
119 |
120 | const jsxFiles = await findJsxFiles(targetDir); // Call the findJsxFiles function to get .jsx file paths
121 |
122 | for (const jsxFilePath of jsxFiles) {
123 | const fileData = await fs.promises.readFile(jsxFilePath, 'utf8'); // Read the content of each .jsx file
124 |
125 | // Use regex to find matches of textPrevAllJs in the file content
126 | const inlineContents = fileData.match(new RegExp(textPrevAllJs));
127 | if (!inlineContents) {
128 | // If no matches found, move on to the next .jsx file
129 | continue;
130 | }
131 |
132 | // console.log('inlineContents', inlineContents);
133 |
134 | inlineFileMatches.push(jsxFilePath);
135 | console.log('inlineFileMatches', inlineFileMatches);
136 |
137 | const inlineContentsStr = inlineContents[0]; // Get the matched content
138 | // Replace inlineContentsStr with textAllJs
139 | const newFileData = fileData.replace(new RegExp(inlineContentsStr, 'g'), textAllJs);
140 | // Write the modified content back to the file
141 | await fs.promises.writeFile(jsxFilePath, newFileData, 'utf8');
142 | }
143 |
144 | if (inlineFileMatches.length > 0) {
145 | console.log(`patchFile: inlineSyle: matches for textPrevAllJs ${textPrevAllJs} found in the following files : ${inlineFileMatches.join(', ')}`);
146 | }
147 | else {
148 | console.log(`patchFile: inlineSyle: No files matching ${textPrevAllJs} were found in ${targetDir} for selector ${selector}`);
149 | }
150 | }
151 |
152 | // otherwise, it's a regular style
153 | else {
154 | console.log('sourcePath provided, so this is a regular style');
155 |
156 | const targetFilePath = sourcePath[0] === '.' ? `${targetDir}${data.sourcePath.slice(1)}` : sourcePath;
157 |
158 | // Read file contents
159 | const fileData = await fs.promises.readFile(targetFilePath, 'utf8');
160 |
161 | // Use regex to find matches
162 | const selectorContents = fileData.match(new RegExp(`${selector}\\s*\\{([^}]*)`));
163 |
164 | if (!selectorContents) {
165 | console.log(`Selector ${selector} not found in file ${targetFilePath}`);
166 | return false;
167 | }
168 |
169 | const selectorContentsStr = selectorContents[0];
170 |
171 | const matches = selectorContentsStr.match(new RegExp(`\\s*${name}:([^;]*);`));
172 |
173 | if (!matches) {
174 | console.log(`Line with ${name}: not found in selector ${selector} in file ${targetFilePath}`);
175 | return false;
176 | }
177 |
178 | const line = matches[0];
179 |
180 | const patchedLine = line.replace(new RegExp(valuePrev, 'g'), value);
181 |
182 | const selectorContentsStrNew = selectorContentsStr.replace(line, patchedLine);
183 | const newFileData = fileData.replace(selectorContentsStr, selectorContentsStrNew);
184 |
185 | await fs.promises.writeFile(targetFilePath, newFileData, 'utf8');
186 | }
187 | // Return true after successful patching
188 | return true;
189 |
190 | } catch (err) {
191 | console.error('Error writing to file', err);
192 | return undefined;
193 | }
194 | }
195 |
196 | export { patchFile }
197 |
--------------------------------------------------------------------------------
/client/puppeteer/pup.js:
--------------------------------------------------------------------------------
1 | import puppeteer from 'puppeteer'
2 | import { writeFileSync, mkdir } from 'node:fs';
3 | import path from 'path';
4 | import { fileURLToPath } from 'url';
5 | import { config } from 'dotenv';
6 |
7 | const __filename = fileURLToPath(import.meta.url);
8 | const __dirname = path.dirname(__filename);
9 | const __envPath = path.resolve(__dirname, '../../.env')
10 |
11 | config({ path: __envPath });
12 | // console.log('filename:', __filename);
13 | // console.log('dirname:', __dirname);
14 | // console.log('path:', __envPath);
15 |
16 | // we import this so that we can call it from here, passing the client and styleSheets variables to it when we do.
17 | import { pupProcess } from './pupProcess.js';
18 |
19 | const puppeteerMode = process.env.PUPPETEER_MODE;
20 |
21 | const coder = process.env.CODER;
22 |
23 | // declaring client and styleSheets here (outside of the unnamed function) so we can have access to them in the 'callPupProcess' function further down.
24 | let client;
25 | const styleSheets = {};
26 |
27 | // if puppeteerMode is set to 1, then we start Puppeteer.
28 | if (puppeteerMode == 1) {
29 | // this is the equivalent of the calling startRemoteChrome in prior, pre-puppeteer versions of our code.
30 | // this entire file gets called in the server, and the function below is called immediately
31 | // as we have set it up to be an IIFE (Immediately Invoked Function Expression).
32 | (async () => {
33 |
34 | // console.log('pup.js', process);
35 | const browserPort = process.env.BROWSER_PORT;
36 | const cssxeUrl = `http://localhost:${browserPort}/`;
37 |
38 | const pupArgs = [
39 | // uncomment this to turn on 'app' mode, i.e. no visible address bar and dev tools opens in a separate window.
40 | // probably do this for prod.
41 | // `--app=${cssxeUrl}`,
42 | // this is what so far allows us to pass data from inside of the iframe to the parent window, cssxe.
43 | '--disable-web-security',
44 | ]
45 |
46 | // for keith's environemnt. opens the browser on second screen
47 | coder == 'KEITH' ? pupArgs.push('--window-position=2000,200') : null;
48 |
49 | // puppeteer: library for controlling Chrome/Chromium over a network protocol.
50 | const browser = await puppeteer.launch({
51 | // open browser window
52 | headless: false,
53 | // don't set a default viewport size. without this, i get a funky view where cssxe and the target site only take up a third of the browser window.
54 | defaultViewport: null,
55 | // open devtools. good for cssxe development mode. prob should be false for prod.
56 | devtools: true,
57 | // array of command-line args we define above to pass to Chrome
58 | args: pupArgs
59 | });
60 |
61 | // grab a reference to the single page that Puppeteer just opened up
62 | // the 'pages' method returns an array of all the pages that Puppeteer knows about, and we only want the one that it just created
63 | // so we use array destructuring to pull out the first (and only) element of that array, and assign it to our variable 'page'
64 | // now we can interact with the page, e.g. navigate to a URL, fill out a form, click a button, take a screenshot, etc.
65 | let [ page ] = await browser.pages();
66 |
67 | // if pupArgs doesn't include --app, we need to navigate the Page to the cssxeUrl
68 | if (!pupArgs[0].includes('--app')) {
69 | // console.log('pupArgs does not include --app');
70 | await page.goto(cssxeUrl);
71 | }
72 |
73 | // setting client as the main connection to the Page,
74 | // through which we can send commands to it and receive events from it.
75 | // 'createCDPSession' is a method on the 'Page' object that Puppeteer provides.
76 | // it creates a new 'CDPSession', which is a connection to the DevTools Protocol for a specific target (in this case, the page).
77 | // this gives us access to a variety of APIs from the browser,
78 | // which we can use to interact with the page in various ways.
79 | client = await page.createCDPSession();
80 |
81 | // an array of CDP domains that we will enable
82 | // DOM: to interact with the structure of the DOM.
83 | // CSS: to query and manipulate CSS styles.
84 | // Network: to inspect network activity and manage network conditions.
85 | // Page: to control page navigation, lifecycle, and size.
86 | // Overlay: to control the browser overlay, such as the inspector.
87 | const domains = [ 'DOM', 'CSS', 'Network', 'Page', 'Overlay' ];
88 |
89 | // 'enable' is a prerequisite step before we can use the methods provided by each of the CDP domains.
90 | // enabling a domain starts the flow of events and allows command execution within that domain.
91 | // as Rob discovered, we also need to pass a callback to enable it, otherwise it won't do anything.
92 | // so we pass an empty function to it.
93 | await Promise.all(domains.map(async (domain) => {
94 | await client.send(`${domain}.enable`, () => { })
95 | }))
96 |
97 | // styleSheetAdded is a CDP event that is fired whenever a new stylesheet is added to the stylesheet cache.
98 | client.on('CSS.styleSheetAdded', async (param) => {
99 | // console.log('styleSheetAdded');
100 | const id = param.header.styleSheetId;
101 |
102 | // passing these values now, so we have them in the store. none of them have yet been or are being used as of 2024-04-02.
103 | styleSheets[id] = {
104 | frameId: param.header.frameId,
105 | // the url from importing fonts and the like, ala 'https://fonts.googleapis.com/...'
106 | sourceURL: param.header?.sourceURL,
107 | // the id of the styleSheet
108 | styleSheetId: param.header.styleSheetId,
109 | // the ownerNode of the styleSheet. I assume the same as the parent node?
110 | ownerNode: param.header?.ownerNode,
111 | // seems like its always 0? at least with webpack.
112 | startLine: param.header.startLine,
113 | // seems like its always 0? at least with webpack.
114 | startColumn: param.header.startColumn,
115 | // not sure the meaning of the below three yet. they differ for each stylesheet that has a sourcemap.
116 | length: param.header.length,
117 | endLine: param.header.endLine,
118 | endColumn: param.header.endColumn,
119 |
120 | }
121 | // if the sourceMapURL is present, add those paths to the styleSheets object.
122 | // this gets us the paths to the regular styles source files, i.e. .css, .scss.
123 | if (param.header.sourceMapURL) {
124 | // console.log('styleSheetAdded: sourceMapURL TRUE');
125 | // console.log('styleSheetParamHeader:', new Date().toISOString(), param.header);
126 |
127 | // the sourceMapURL comes in as a base64 string, so we need to decode it
128 | const sourceMapData = Buffer.from(param.header.sourceMapURL.split(',')[1], 'base64').toString('utf-8');
129 | // parse the JSON
130 | const decodedMap = JSON.parse(sourceMapData);
131 |
132 | // console.log('decodedMap', decodedMap);
133 | // write the decodedMap to a file
134 | writeFileSync('./data/output/decodedMap.json', JSON.stringify(decodedMap, null, 2));
135 | const sources = decodedMap.sources;
136 | const absolutePaths = []
137 | const relativePaths = [];
138 | // loop through the sources
139 | sources.forEach(source => {
140 | // we see this for s/css files when using webpack. it's the path relative to the project root
141 | if (source.includes('://')) {
142 | // splitting the source string on the '://', getting the second part, which is the relative path
143 | relativePaths.push(source.split('://')[1]);
144 | }
145 | // otherwise, it's an absolute path. havent seen these recently but i did at some point in development.
146 | else {
147 | absolutePaths.push(source);
148 | }
149 | })
150 |
151 | // add the various source paths of the id to its object in the styleSheets object.
152 | Object.assign(styleSheets[id], { sources, absolutePaths, relativePaths });
153 | }
154 | // console.log('pup:styleSheets', styleSheets);
155 | // console.log('\n\n');
156 | // console.log('END OF STYLESHEETS');
157 | // console.log('\n\n');
158 | });
159 | })(pupProcess);
160 | } else {
161 | console.log('pup.js: puppeteerMode set to 0. puppeteer will not be called');
162 | }
163 | // data passed from server = ...args
164 | const callPupProcess = async (...args) => await pupProcess(client, styleSheets, ...args);
165 |
166 | // callPupProcess is called when the the /cdp endpoint is hit.
167 | // // -> /cdp is hit by iFrameComp when a click event occurs inside of the iFrame.
168 | export { callPupProcess };
169 |
170 |
171 |
172 |
173 |
174 | // rob's initial puppeteer code, which shows how to get the styles for a specific node.
175 | // const doc = await session.send('DOM.getDocument');
176 |
177 | // //Query the nodes on the page by selector type use * to get all nodes on the page
178 | // const nodes = await session.send('DOM.querySelectorAll', {
179 | // nodeId: doc.root.nodeId,
180 | // selector: 'loadingText'
181 | // });
182 |
183 | // const stylesForNodes = []
184 |
185 | // for (const id of nodes.nodeIds) {
186 | // stylesForNodes.push(await session.send('CSS.getMatchedRulesForNode', {nodeId: id}));
187 | // }
188 | // //See all the Nodes Requested
189 | // console.log("Rules for Nodes Array ===>", stylesForNodes)
190 |
191 | // //Get Rules for single Node Id
192 | // const styleForSingleNode = await session.send('CSS.getMatchedRulesForNode', {nodeId: 4});
193 | // console.log("Single Node Id: 4 ===>", styleForSingleNode)
194 |
195 |
196 | //Get Specificity
197 | // console.log("Specificity of specific Node ===> ", stylesForNodes[0].matchedCSSRules[0].rule.selectorList.selectors[0].specificity)
198 |
199 | // leave the browser open so we can inspect it
200 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/client/slices/rulesSlice.js:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit";
2 |
3 | // regularRules are styles specified in .css files (origin=regular)
4 | // inlineRules are styles specified directly on components (origin=inline)
5 | // userAgentRules are default browser styles (origin=user-agent)
6 | const initialState = {
7 | regularRules: [],
8 | inlineRules: [],
9 | userAgentRules: [],
10 | inheritedRules: [],
11 | keyframeRules: [],
12 | styleSheets: {},
13 | loaded: false, // if we want to track if styles have been loaded
14 | error: null, // if we want to track errors
15 | shortToLongMap: {},
16 | longToShortMap: {},
17 | // stores mapping between mid level properties and its high level parent (border-style: border)
18 | midToShortMap: {},
19 | isActiveCache: {},
20 | };
21 |
22 | const rulesSlice = createSlice({
23 | name: 'rules',
24 | initialState,
25 | reducers: {
26 | // every time user selects a DOM element, inline, regular, and user-agent rules are dispatched by the iFrameComp, updating the store via the reducers below.
27 | updateInlineRules: (state, action) => {
28 | // console.log('rulesSlice: state.inlineRules: updated', action.payload);
29 | state.inlineRules = action.payload;
30 | },
31 | updateRegularRules: (state, action) => {
32 | // console.log('rulesSlice: state.regularRules: updated', action.payload);
33 | state.regularRules = action.payload;
34 | },
35 | updateUserAgentRules: (state, action) => {
36 | // console.log('rulesSlice: state.userAgentRules: updated', action.payload);
37 | state.userAgentRules = action.payload;
38 | },
39 | updateInheritedRules: (state, action) => {
40 | // console.log('rulesSlice: state.inheritedRules: updated', action.payload);
41 | state.inheritedRules = action.payload;
42 | },
43 | updateKeyframeRules: (state, action) => {
44 | // console.log('rulesSlice: state.keyframeRules: updated', action.payload);
45 | state.keyframeRules = action.payload;
46 | },
47 | updateStyleSheets: (state, action) => {
48 | // console.log('rulesSlice: state.styleSheets: updated', action.payload);
49 | state.styleSheets = action.payload;
50 | },
51 | // iterates over shorthandEntries arr on all types of styles, builds:
52 | // 1) mapping of each shorthand property to corresponding longhand properties and saves to shortToLongMap state;
53 | // 2) reverse mapping of each longhand property to its corresponding shorthand property and saves to longToShortMap state
54 | updateShortLongMaps: (state) => {
55 | const dummy = document.querySelector('#longhand-getter');
56 |
57 | const allStyles = [state.userAgentRules, state.regularRules, state.inlineRules];
58 |
59 | // gets all longhand styles for passed in shorthand and update shortToLongMap and longToShortMap redux state
60 | const getLonghandStyles = (dummy, propName, propVal, shortToLongMap, longToShortMap) => {
61 | // assign 1 shorthand property to a dummy DOM element
62 | dummy.style.setProperty(propName, propVal);
63 | // get names of all longhand properties corresponding to a shorthand property
64 | const longhandProps = [...dummy.style];
65 | // for each shorthand, add all corresponding longhands to shortToLongMap state. If longhandProps array has only 1 element, propName passed in was not a shorthand
66 | if (longhandProps.length > 1) {
67 | shortToLongMap[propName] = longhandProps;
68 | // for each longhand, add its shorthand property to longToShortMap state
69 | longhandProps.forEach(ls => {
70 | if (!longToShortMap[ls]) {
71 | longToShortMap[ls] = {
72 | allParents: [],
73 | highestParent: ''
74 | }
75 | }
76 | longToShortMap[ls].allParents.push(propName);
77 | });
78 | }
79 | // reset the dummy element for the next iteration
80 | dummy.style.removeProperty(propName);
81 | };
82 |
83 | allStyles.forEach(array => {
84 | array.forEach(each => {
85 | for (let shortProp of each.rule.style.shorthandEntries) {
86 | // only add props to maps which have valid values and not add them again if they're already in the maps
87 | if (shortProp.value && !state.shortToLongMap[shortProp.name]) {
88 | getLonghandStyles(dummy, shortProp.name, shortProp.value, state.shortToLongMap, state.longToShortMap);
89 | }
90 | };
91 |
92 | for (let cssProperty of each.rule.style.cssProperties) {
93 | if (each.rule.origin === 'regular' &&
94 | cssProperty.text &&
95 | !state.shortToLongMap[cssProperty.name]) {
96 | getLonghandStyles(dummy, cssProperty.name, cssProperty.value, state.shortToLongMap, state.longToShortMap);
97 | }
98 | }
99 | })
100 | })
101 | },
102 | // iterates over all longhand properties from longToShort redux state, identifies high level vs mid level properties and adds them to midToShortMap in redux state (mid is mid level, short is high level, e.g. border-style: border)
103 | updateMidShortMap: (state) => {
104 | for (let longhand in state.longToShortMap) {
105 | const parentCandidates = state.longToShortMap[longhand].allParents;
106 | // If array has more than 1 element, that would mean that this is a complex hierarchy (high-mid-low) => need to add to midToShortMap
107 | let minChars = Infinity;
108 | let parent;
109 |
110 | // find high level parent
111 | parentCandidates.forEach(candidate => {
112 | if (candidate.length < minChars) {
113 | minChars = candidate.length;
114 | parent = candidate;
115 | };
116 | });
117 | // for each longhand set its high level parent
118 | state.longToShortMap[longhand].highestParent = parent;
119 |
120 | // assumption is that high level properties (e.g. border) will have shorter length than mid-level (e.g. border-style). This should work in majority cases
121 | if (parentCandidates.length > 1) {
122 | // find all mid level styles and add them to midToShortMap
123 | parentCandidates.forEach(candidate => {
124 | if (candidate !== parent) {
125 | if (!state.midToShortMap[candidate]) state.midToShortMap[candidate] = parent;
126 | }
127 | });
128 | }
129 | }
130 | },
131 | // iterates over all types of styles and sets isActive flag only on the css properties which get rendered
132 | setIsActiveFlag: (state) => {
133 | const allStyles = [state.userAgentRules, state.regularRules, state.inlineRules];
134 |
135 | allStyles.forEach(array => {
136 | array.forEach(each => {
137 | // for user-agent styles, if shorthands are available they get rendered => add isActive for all shorthand properties first
138 | if (each.rule.origin === 'user-agent' && each.rule.style.shorthandEntries.length) {
139 | for (let shortStyle of each.rule.style.shorthandEntries) {
140 | if (shortStyle.value) shortStyle.isActive = true;
141 | }
142 | };
143 |
144 | for (let cssProperty of each.rule.style.cssProperties) {
145 | // for user-agent styles, only longhand properties which do not have corresponding shorthand properties get rendered. If property is in longToShortMap it means it was already added as a shorthand property
146 | if ((each.rule.origin === 'user-agent' && cssProperty.value && !state.longToShortMap[cssProperty.name]) ||
147 | // for regular and inline styles, add isActive only to user-defined properties (which have text property on them)
148 | (each.rule.origin === 'regular' && cssProperty.text) ||
149 | (each.rule.origin === 'inline' && cssProperty.text)) {
150 | cssProperty.isActive = true;
151 | }
152 | }
153 | })
154 | });
155 | },
156 | // finds highest specificity among all selectors based on matchingSelectors array and sets it as a new property 'calculatedSpecificity' on each rule
157 | setSpecificity: state => {
158 | const allStyles = [state.userAgentRules, state.regularRules, state.inlineRules];
159 |
160 | const compareSpecificity = (obj1, obj2) => {
161 | if (obj1.specificity.a !== obj2.specificity.a) {
162 | return obj1.specificity.a > obj2.specificity.a ? 1 : -1;
163 | }
164 | // If 'a' values are equal, compare the 'b' values
165 | else if (obj1.specificity.b !== obj2.specificity.b) {
166 | return obj1.specificity.b > obj2.specificity.b ? 1 : -1;
167 | }
168 | // If 'b' values are equal, compare the 'c' values
169 | else if (obj1.specificity.c !== obj2.specificity.c) {
170 | return obj1.specificity.c > obj2.specificity.c ? 1 : -1;
171 | }
172 | else return 0;
173 | };
174 |
175 | allStyles.forEach(array => {
176 | array.forEach(each => {
177 | let specificity;
178 | if (each.rule.origin === 'inline') {
179 | // inline styles have the highest specificity' => hard coding at 999 should ensure inline styles have the highest specificity among other styles
180 | specificity = {
181 | 'a': 9,
182 | 'b': 9,
183 | 'c': 9
184 | }
185 | }
186 | // origin is regular/ user-agent
187 | else {
188 | // matchingSelectors array contains indices of selectors matching css rules. In most cases there's only 1 selector
189 | if (each.matchingSelectors.length === 1) {
190 | specificity = each.rule.selectorList.selectors[each.matchingSelectors[0]].specificity;
191 | }
192 | else {
193 | // example of cases with multiple selectors:
194 | // react:
195 | // css file: .btn, #active { background: pink};
196 | // in case above, matchingSelectors will point to 2 selectors inside selectorList.selectors array, and specificity for this rule set has to be set at highest specificity among the 2
197 | let bestSelector = each.rule.selectorList.selectors[each.matchingSelectors[0]];
198 | for (let selectorIdx of each.matchingSelectors) {
199 | const curSelector = each.rule.selectorList.selectors[selectorIdx];
200 | if (compareSpecificity(bestSelector, curSelector) === -1) bestSelector = curSelector;
201 | };
202 | specificity = bestSelector.specificity;
203 | }
204 | };
205 |
206 | each.calculatedSpecificity = specificity;
207 | });
208 | });
209 |
210 | },
211 | findActiveStyles: (state) => {
212 | const cache = {};
213 |
214 | const allStyles = [state.userAgentRules, state.regularRules, state.inlineRules];
215 |
216 | const compareSpecificity = (obj1, obj2) => {
217 | if (obj1.specificity.a !== obj2.specificity.a) {
218 | return obj1.specificity.a > obj2.specificity.a ? 1 : -1;
219 | }
220 | // If 'a' values are equal, compare the 'b' values
221 | else if (obj1.specificity.b !== obj2.specificity.b) {
222 | return obj1.specificity.b > obj2.specificity.b ? 1 : -1;
223 | }
224 | // If 'b' values are equal, compare the 'c' values
225 | else if (obj1.specificity.c !== obj2.specificity.c) {
226 | return obj1.specificity.c > obj2.specificity.c ? 1 : -1;
227 | }
228 | else return 0;
229 | };
230 |
231 | // for all types for styles, add the styles which have isActive property to cache
232 | // if parent and children properties are present (e.g. 'border', 'border-style', 'border-top-style'), they should be all added into an array corresponding to highest level property (border: [border obj, border-style obj, border-top-style obj])
233 | allStyles.forEach(array => {
234 | array.forEach(each => {
235 | let specificity = each.calculatedSpecificity;
236 | let properties;
237 | if (each.rule.origin === 'user-agent') properties = [each.rule.style.shorthandEntries, each.rule.style.cssProperties];
238 | else properties = [each.rule.style.cssProperties];
239 |
240 | const bundleRelatedProperties = (origin, targetProps) => {
241 | targetProps.forEach(arr => {
242 | for (let prop of arr) {
243 | if (prop.hasOwnProperty('isActive')) {
244 | // checks if cur property is a mid level property (has a high level parent)
245 | // e.g. if 'border-width' has a parent and it does ('border'), push it to property 'border' of isActiveCache
246 | if ((origin === 'user-agent' && state.midToShortMap[prop.name]) ||
247 | // regular/inline properties which have 'longhandProperties' array are shorthands - either high or mid level
248 | ((origin === 'regular' || origin === 'inline') && prop.longhandProperties && state.midToShortMap[prop.name])) {
249 | const highLevelProp = state.midToShortMap[prop.name];
250 | if (!cache[highLevelProp]) cache[highLevelProp] = [];
251 | cache[highLevelProp].push({
252 | specificity,
253 | source: prop,
254 | origin
255 | });
256 | }
257 | // checks if cur property is a longhand property which has a shorthand parent)
258 | // e.g. if 'background-image' has a parent and it does ('background'), push it to property 'background' of isActiveCache
259 | else if ((origin === 'user-agent' && state.longToShortMap[prop.name]) ||
260 | ((origin === 'regular' || origin === 'inline') && !prop.longhandProperties && state.longToShortMap[prop.name])) {
261 | const highLevelProp = state.longToShortMap[prop.name].highestParent;
262 | if (!cache[highLevelProp]) cache[highLevelProp] = [];
263 | cache[highLevelProp].push({
264 | specificity,
265 | source: prop,
266 | origin
267 | });
268 | }
269 | // checks if cur property is high level property (e.g.'border') or standalone property with no corresponding shorthand/longhand
270 | else {
271 | if (!cache[prop.name]) cache[prop.name] = [];
272 | cache[prop.name].push({
273 | specificity,
274 | source: prop,
275 | origin
276 | });
277 | };
278 | };
279 | };
280 | });
281 | };
282 |
283 | bundleRelatedProperties(each.rule.origin, properties);
284 | });
285 | });
286 |
287 | const compareOriginsAndNames = (obj1, obj2) => {
288 | const score = {
289 | ['user-agent']: 0,
290 | regular: 10,
291 | inline: 20
292 | };
293 |
294 | if (score[obj1.origin] !== score[obj2.origin]) {
295 | return score[obj1.origin] > score[obj2.origin] ? 1 : -1;
296 | }
297 | // If specificity and origins are the same but property names are different, we want to keep both as active
298 | else if (score[obj1.origin] === score[obj2.origin] && obj1.source.name !== obj2.source.name) return 1;
299 | // if specificities, origins and property names are all the same, keep the latter one reflecting cascading nature of css rules
300 | else if (score[obj1.origin] === score[obj2.origin] && obj1.source.name === obj2.source.name) return -1;
301 | // IF YOU'RE GETTING THE ERROR BELOW, COMMENT THE ELSE BLOCK OUT AND TELL ELENA TO INVESTIGATE
302 | else {
303 | throw new Error(`Error in rulesSlice.js: findActiveStyles reducer: compare func \n\nStyle-1: ${JSON.stringify(obj1)} \n\nStyle-2: ${JSON.stringify(obj2)}`);
304 | }
305 | };
306 |
307 | for (let key in cache) {
308 | if (cache[key].length > 1) {
309 | // Step 1: find max specificity and count how many objs have max specificity. Also handles !important tags
310 | let bestObj = cache[key][0];
311 | const countCache = {};
312 |
313 | cache[key].forEach(curObj => {
314 | // styles with !important tag have property 'important' set to true
315 | // by adding 10 to their specificity we make their specificity higher than inline styles (which have specificity 999). But we want to maintain 'actual specificity + 10' for cases when there're multiple !important styles. In this case, !important styles will be higher than any other styles, but we want to compare among !important styles themselves and choose the prevailing one, that's why we keep their original specificity but increasing it by 10.
316 | if (curObj.source.important) {
317 | curObj.specificity.a += 10;
318 | curObj.specificity.b += 10;
319 | curObj.specificity.c += 10;
320 | }
321 |
322 | // find max specificity and count how many objs have max specificity
323 | const curSpecificity = `${curObj.specificity.a}${curObj.specificity.b}${curObj.specificity.c}`;
324 | if (!countCache[curSpecificity]) countCache[curSpecificity] = 0;
325 | countCache[curSpecificity]++;
326 |
327 | if (compareSpecificity(bestObj, curObj) === -1) {
328 | bestObj.source.isActive = false;
329 | bestObj = curObj;
330 | }
331 | })
332 |
333 | // Step 2: turn off isActive for everything that is less than max specificity
334 | cache[key].forEach(obj => {
335 | if (compareSpecificity(bestObj, obj) === 1) obj.source.isActive = false;
336 | });
337 |
338 | // Step 3: If there're more than 1 active max specificities, compare them by other parameters
339 | // this comparison accounts for cases: 1) when specificities are same but origins are different, and
340 | // 2) when all specificities, origins and property names are the same - applying rule of cascading styles (latter overwrites previous)
341 | const maxSpecificity = `${bestObj.specificity.a}${bestObj.specificity.b}${bestObj.specificity.c}`;
342 | if (countCache[maxSpecificity] > 1) {
343 | const bestObjs = cache[key].filter(obj => obj.source.isActive === true);
344 |
345 | let bestObj = bestObjs[0];
346 | for (let i = 1; i < bestObjs.length; i++) {
347 | const curObj = bestObjs[i];
348 | if (compareOriginsAndNames(bestObj, curObj) === -1) {
349 | bestObj.source.isActive = false;
350 | bestObj = curObj;
351 | }
352 | }
353 | }
354 | }
355 | }
356 |
357 | state.isActiveCache = cache;
358 | },
359 | resetCache: (state) => {
360 | state.shortToLongMap = {};
361 | state.longToShortMap = {};
362 | state.midToShortMap = {};
363 | state.isActiveCache = {};
364 | },
365 | updateStyleSheets: (state, action) => {
366 | // console.log('rulesSlice: state.styleSheets: updated', action.payload);
367 | state.styleSheets = action.payload;
368 | },
369 | },
370 | });
371 |
372 | const initialNodeDataState = {
373 | data: {},
374 | error: null, // if we want to track errors
375 | };
376 |
377 | const nodeDataSlice = createSlice({
378 | name: 'nodeData',
379 | // createSlice expects the initial state to be passed as 'initialState'.
380 | // so we pass initialNodeDataState as the value of 'initialState'.
381 | initialState: initialNodeDataState,
382 | reducers: {
383 | // every time user selects a DOM element, inline, regular, and user-agent rules are dispatched by the iFrameComp, updating the store via the reducers below.
384 | updateNodeData: (state, action) => {
385 | // console.log('nodeDataSlice: state.nodeData: updated', action.payload);
386 | // console.log('\n\n\n');
387 | state.data = action.payload;
388 | },
389 | },
390 | });
391 |
392 |
393 | export const {
394 | updateInlineRules,
395 | updateRegularRules,
396 | updateUserAgentRules,
397 | updateInheritedRules,
398 | updateKeyframeRules,
399 | updateStyleSheets,
400 | findActiveStyles,
401 | updateShortLongMaps,
402 | updateMidShortMap,
403 | setIsActiveFlag,
404 | setSpecificity,
405 | resetCache
406 | } = rulesSlice.actions;
407 |
408 | export const {
409 | updateNodeData
410 | } = nodeDataSlice.actions;
411 |
412 | export const rulesReducer = rulesSlice.reducer;
413 | export const nodeDataReducer = nodeDataSlice.reducer;
414 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/data/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/cssexy/cf8adcd362c7ea9495c7d4d7dcc1713949ce43d0/data/.DS_Store
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | CSSxe
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/nodemon.json:
--------------------------------------------------------------------------------
1 | {
2 | "ignore": [
3 | "data/*"
4 | ]
5 | }
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------