Skip to content

Commit

Permalink
feat(extractor): add support JSX
Browse files Browse the repository at this point in the history
  • Loading branch information
kubosho committed Aug 1, 2021
1 parent db0e251 commit b017da0
Show file tree
Hide file tree
Showing 8 changed files with 301 additions and 15 deletions.
115 changes: 109 additions & 6 deletions src/__tests__/test_extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,33 @@ import test from 'ava';
import { readFile as lagacyReadFile } from 'fs';
import { promisify } from 'util';
import { createExtractor } from '../extractor';
import { SupportFileType } from '../supportFileType';

const readFile = promisify(lagacyReadFile);

test('extract class selectors', async (t) => {
test('HTML: extract class selectors', async (t) => {
const content = await readFile(
`${process.cwd()}/testcases/html/list.html`,
'utf8',
);
const extractor = createExtractor({ filetype: 'html' });
const extractor = createExtractor();
extractor.setFileType(SupportFileType.Html);

const actual = extractor.extractClassName(content);

t.is(actual.length, 6);
t.is(actual[0], '.list');
t.is(actual[1], '.list-item');
});

test('extract multiple class selectors', async (t) => {
test('HTML: extract multiple class selectors', async (t) => {
const content = await readFile(
`${process.cwd()}/testcases/html/multiple-classes.html`,
'utf8',
);
const extractor = createExtractor({ filetype: 'html' });
const extractor = createExtractor();
extractor.setFileType(SupportFileType.Html);

const actual = extractor.extractClassName(content);

t.is(actual.length, 3);
Expand All @@ -32,16 +37,114 @@ test('extract multiple class selectors', async (t) => {
t.is(actual[2], '.article.title');
});

test('extract id selectors', async (t) => {
test('HTML: extract id selectors', async (t) => {
const content = await readFile(
`${process.cwd()}/testcases/html/id.html`,
'utf8',
);
const extractor = createExtractor({ filetype: 'html' });
const extractor = createExtractor();
extractor.setFileType(SupportFileType.Html);

const actual = extractor.extractId(content);

t.is(actual.length, 3);
t.is(actual[0], '#global-header');
t.is(actual[1], '#global-footer');
t.is(actual[2], '#site-title');
});

test('JSX: extract class selectors', async (t) => {
const content = await readFile(
`${process.cwd()}/testcases/jsx/list.jsx`,
'utf8',
);
const extractor = createExtractor();
extractor.setFileType(SupportFileType.Jsx);

const actual = extractor.extractClassName(content);

t.is(actual.length, 6);
t.is(actual[0], '.list');
t.is(actual[1], '.list-item');
});

test('JSX: default export case', async (t) => {
const content = await readFile(
`${process.cwd()}/testcases/jsx/default-export.jsx`,
'utf8',
);
const extractor = createExtractor();
extractor.setFileType(SupportFileType.Jsx);

const actual = extractor.extractClassName(content);

t.is(actual.length, 6);
t.is(actual[0], '.list');
t.is(actual[1], '.list-item');
});

test('JSX: export variable case', async (t) => {
const content = await readFile(
`${process.cwd()}/testcases/jsx/export-with-variable.jsx`,
'utf8',
);
const extractor = createExtractor();
extractor.setFileType(SupportFileType.Jsx);

const actual = extractor.extractClassName(content);

t.is(actual.length, 6);
t.is(actual[0], '.list');
t.is(actual[1], '.list-item');
});

test('JSX: extract multiple class selectors', async (t) => {
const content = await readFile(
`${process.cwd()}/testcases/jsx/multiple-classes.jsx`,
'utf8',
);
const extractor = createExtractor();
extractor.setFileType(SupportFileType.Jsx);

const actual = extractor.extractClassName(content);

t.is(actual.length, 3);
t.is(actual[0], '.container.container-fluid.article');
t.is(actual[1], '.article.content');
t.is(actual[2], '.article.title');
});

test('JSX: extract id selectors', async (t) => {
const content = await readFile(
`${process.cwd()}/testcases/jsx/id.jsx`,
'utf8',
);
const extractor = createExtractor();
extractor.setFileType(SupportFileType.Jsx);

const actual = extractor.extractId(content);

t.is(actual.length, 3);
t.is(actual[0], '#global-header');
t.is(actual[1], '#site-title');
t.is(actual[2], '#global-footer');
});

test('JSX: with hooks', async (t) => {
const content = await readFile(
`${process.cwd()}/testcases/jsx/with-hooks.jsx`,
'utf8',
);
const extractor = createExtractor();
extractor.setFileType(SupportFileType.Jsx);

{
const actual = [
...extractor.extractId(content),
...extractor.extractClassName(content),
];
t.is(actual.length, 2);
t.is(actual[0], '#container');
t.is(actual[1], '.container');
}
});
122 changes: 113 additions & 9 deletions src/extractor.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,96 @@
import { Element, Node, isTag } from 'domhandler';
import * as esprima from 'esprima';
import {
ExportNamedDeclaration,
Expression,
FunctionDeclaration,
} from 'estree';
import { parseDocument } from 'htmlparser2';

type Params = {
filetype: 'html' | 'jsx';
};
import { isNotNullAndUndefined } from 'option-t/lib/Maybe/Maybe';
import { JSXElement, JSXText } from '../typings/esprima_extend';
import { SupportFileType } from './supportFileType';
import {
isClassName,
isExportNamedDeclaration,
isFunctionDeclaration,
isId,
isJSXElement,
isReturnStatement,
} from './utils';

export interface Extractor {
extractClassName(contents: string): string[];
extractId(contents: string): string[];
setFileType(fileType: SupportFileType): void;
}

class ExtractorImpl implements Extractor {
private _classNames: string[];
private _ids: string[];
private _filetype: 'html' | 'jsx';
private _filetype: SupportFileType | null;

constructor({ filetype }: Params) {
constructor() {
this._classNames = [];
this._ids = [];
this._filetype = filetype;
this._filetype = null;
}

extractClassName(contents: string): string[] {
if (this._filetype === 'html') {
if (this._filetype === null) {
return [];
}

if (this._filetype === SupportFileType.Html) {
const root = parseDocument(contents);
this._extractClassNameFromHtml(root.children);
} else {
const { body } = esprima.parseModule(contents, { jsx: true });

const source = getJSXElements([
...getFunctionDeclarations(body.filter(isExportNamedDeclaration)),
...body.filter(isFunctionDeclaration),
]);

source.forEach((src) => {
if (isJSXElement(src)) {
this._extractClassNameFromJsx([src]);
}
});
}

return this._classNames;
}

extractId(contents: string): string[] {
if (this._filetype === 'html') {
if (this._filetype === null) {
return [];
}

if (this._filetype === SupportFileType.Html) {
const root = parseDocument(contents);
this._extractIdFromHtml(root.children);
} else {
const { body } = esprima.parseModule(contents, { jsx: true });

const source = getJSXElements([
...getFunctionDeclarations(body.filter(isExportNamedDeclaration)),
...body.filter(isFunctionDeclaration),
]);

source.forEach((src) => {
if (isJSXElement(src)) {
this._extractIdFromJsx([src]);
}
});
}

return this._ids;
}

setFileType(fileType: SupportFileType): void {
this._filetype = fileType;
}

private _extractClassNameFromHtml(children: Element[] | Node[]): void {
const elements = children.flatMap((child) => (isTag(child) ? [child] : []));
if (elements.length === 0) {
Expand All @@ -53,6 +105,19 @@ class ExtractorImpl implements Extractor {
);
}

private _extractClassNameFromJsx(elements: (JSXElement | JSXText)[]): void {
getPartialPropertyOfElements(elements).forEach(
({ children, openingElement: { attributes } }) => {
attributes
.filter(isClassName)
.map(({ value }) => `${value.value}`.replace(/ /g, '.'))
.forEach((className) => this._classNames.push(`.${className}`));

this._extractClassNameFromJsx(children);
},
);
}

private _extractIdFromHtml(children: Element[] | Node[]): void {
const elements = children.flatMap((child) => (isTag(child) ? [child] : []));
if (elements.length === 0) {
Expand All @@ -64,12 +129,51 @@ class ExtractorImpl implements Extractor {

this._extractIdFromHtml(elements.flatMap((element) => element.children));
}

private _extractIdFromJsx(elements: (JSXElement | JSXText)[]): void {
getPartialPropertyOfElements(elements).forEach(
({ children, openingElement: { attributes } }) => {
attributes
.filter(isId)
.forEach((attr) => this._ids.push(`#${attr.value.value}`));

this._extractIdFromJsx(children);
},
);
}
}

export function createExtractor() {
return new ExtractorImpl();
}

function getFunctionDeclarations(
sources: ExportNamedDeclaration[],
): FunctionDeclaration[] {
return sources
.map(({ declaration }) => declaration)
.filter(isFunctionDeclaration);
}

function getJSXElements(source: FunctionDeclaration[]): Expression[] {
return source
.map(({ body: blockStatement }) => blockStatement)
.flatMap(({ body }) => body)
.filter(isReturnStatement)
.map((returnStatement) => returnStatement.argument)
.filter(isNotNullAndUndefined);
}

function getPartialPropertyOfElements(
elements: (JSXElement | JSXText)[],
): Pick<JSXElement, 'children' | 'openingElement'>[] {
return elements
.filter((element): element is JSXElement => isJSXElement(element))
.map(({ children, openingElement }) => {
return { children, openingElement };
});
}

function getClassNames(elements: Element[]): string[] {
const classNames = elements
.map((child) => child.attribs.class)
Expand Down
13 changes: 13 additions & 0 deletions testcases/jsx/default-export.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
function DefaultExportTestCase() {
return (
<ul className="list">
<li className="list-item">Test 1</li>
<li className="list-item">Test 2</li>
<li className="list-item">Test 3</li>
<li className="list-item">Test 4</li>
<li className="list-item">Test 5</li>
</ul>
);
}

export default DefaultExportTestCase;
13 changes: 13 additions & 0 deletions testcases/jsx/export-with-variable.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
function DefaultExportTestCase() {
return (
<ul className="list">
<li className="list-item">Test 1</li>
<li className="list-item">Test 2</li>
<li className="list-item">Test 3</li>
<li className="list-item">Test 4</li>
<li className="list-item">Test 5</li>
</ul>
);
}

export const DefaultExportTestCase = DefaultExportTestCase;
20 changes: 20 additions & 0 deletions testcases/jsx/id.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export function IdTestCase() {
return (
<div>
<header id="global-header">
<h1 id="site-title">Test case</h1>
</header>

<main>
<article class="content">
<h1 class="article-title">Test title</h1>
<p>Test content</p>
</article>
</main>

<footer id="global-footer">
<small class="copyright">&copy; 2017 kubosho_</small>
</footer>
</div>
);
}
11 changes: 11 additions & 0 deletions testcases/jsx/list.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export function ClassNamesTestCase() {
return (
<ul className="list">
<li className="list-item">Test 1</li>
<li className="list-item">Test 2</li>
<li className="list-item">Test 3</li>
<li className="list-item">Test 4</li>
<li className="list-item">Test 5</li>
</ul>
);
}
Loading

0 comments on commit b017da0

Please sign in to comment.