codelabs-react
Advanced tools
Comparing version 1.3.3 to 1.4.0
{ | ||
"presets": [ | ||
"@babel/react", | ||
"@babel/preset-env" | ||
] | ||
} | ||
"presets": ["@babel/react", "@babel/preset-env"] | ||
} |
{ | ||
"name": "codelabs-react", | ||
"version": "1.3.3", | ||
"version": "1.4.0", | ||
"description": "", | ||
"main": "dist/index.js", | ||
"scripts": { | ||
"test": "mocha -r esm **/*.test.js", | ||
"test": "jest", | ||
"storybook": "start-storybook -p 6006", | ||
@@ -21,2 +21,3 @@ "build-storybook": "build-storybook", | ||
"@babel/preset-env": "^7.12.11", | ||
"@babel/preset-react": "^7.12.10", | ||
"@storybook/addon-actions": "^6.1.11", | ||
@@ -26,5 +27,9 @@ "@storybook/addon-essentials": "^6.1.11", | ||
"@storybook/react": "^6.1.11", | ||
"@testing-library/jest-dom": "^5.11.6", | ||
"@testing-library/react": "^11.2.2", | ||
"babel-jest": "^26.6.3", | ||
"babel-loader": "^8.2.2", | ||
"baseui": "^9.106.2", | ||
"esm": "^3.2.25", | ||
"jest": "^26.6.3", | ||
"mocha": "^8.2.1", | ||
@@ -34,6 +39,12 @@ "prettier": "^2.2.1", | ||
"react-dom": "^17.0.1", | ||
"regenerator-runtime": "^0.13.7", | ||
"styletron-engine-atomic": "^1.4.6", | ||
"styletron-react": "^5.2.7" | ||
}, | ||
"jest": { | ||
"setupFilesAfterEnv": [ | ||
"./setup-tests.js" | ||
] | ||
}, | ||
"dependencies": {} | ||
} |
# codelabs-react | ||
A bring-your-own-styles React library, that turns [Google-style codelabs](https://github.com/googlecodelabs/tools) into React components for easy customization. | ||
A bring-your-own-styles React library, that turns [Google-style codelabs](https://github.com/googlecodelabs/tools/blob/master/FORMAT-GUIDE.md) into React components for easy customization. | ||
**Under active development. Do not use it in production for now.** | ||
You'll need to get the API response from the Google APIs yourself. This project does not handle authentication to the Google APIs. Check out [this guide](https://developers.google.com/docs/api/quickstart/js) if you need help with it. | ||
**Live demo:** | ||
- [With the default components](https://codelabs-react.netlify.app/?path=/story/codelabs-example--default) | ||
- [With overrides using Base Web](https://codelabs-react.netlify.app/?path=/story/codelabs-example--base-web) | ||
## API | ||
@@ -17,23 +22,47 @@ | ||
<Codelabs | ||
// required, response from the google docs api | ||
content={content} | ||
// optional, used for styling | ||
overrides={{ | ||
Page, | ||
Header, | ||
SideNavigation, | ||
Content, | ||
Main, | ||
H2, | ||
H3, | ||
H4, | ||
H5, | ||
H6, | ||
Span, | ||
ButtonLink, | ||
Snippet, | ||
Link, | ||
InfoBox, | ||
WarningBox, | ||
CodeBox, | ||
// Layout overrides | ||
Header: ({ title }) => React.Component, | ||
Content: ({ children }) => React.Component, | ||
SideNavigation: ({ items, setPage, currentPage }) => React.Component, | ||
Button: ({ children }) => React.Component, | ||
// Text overrides | ||
Parapgraph: ({ children }) => React.Component, | ||
H1: ({ children }) => React.Component, | ||
H2: ({ children }) => React.Component, | ||
H3: ({ children }) => React.Component, | ||
H4: ({ children }) => React.Component, | ||
H5: ({ children }) => React.Component, | ||
H6: ({ children }) => React.Component, | ||
Parapgraph: ({ children }) => React.Component, | ||
ListItem: ({ children }) => React.Component, | ||
// Info and warning boxes | ||
InfoBox: ({ children }) => React.Component, | ||
WarningBox: ({ children }) => React.Component, | ||
// Link overrides | ||
ButtonLink: ({ children, href }) => React.Component, | ||
Link: ({ children, href }) => React.Component, | ||
// Code containers | ||
// Snippet: single-line | ||
// Box: multi-line | ||
Snippet: ({ children }) => React.Component, | ||
CodeBox: ({ children }) => React.Component, | ||
}} | ||
/>; | ||
``` | ||
## Roadmap | ||
Currently, the following features are missing, and will be added in the future: | ||
- [ ] Image support | ||
- [ ] YouTube support | ||
- [ ] Per-step time estimation | ||
- [ ] Feedback links |
@@ -97,6 +97,10 @@ import React from "react"; | ||
export function Span({ children }) { | ||
return <span>{children}</span>; | ||
export function Parapgraph({ children }) { | ||
return <p>{children}</p>; | ||
} | ||
export function ListItem({ children }) { | ||
return <li>{children}</li>; | ||
} | ||
export function ButtonLink({ children, href }) { | ||
@@ -103,0 +107,0 @@ return ( |
@@ -1,3 +0,202 @@ | ||
import Utils from "./utils"; | ||
const infoColor = { red: 0.8509804, green: 0.91764706, blue: 0.827451 }; | ||
const warningColor = { red: 0.9882353, green: 0.8980392, blue: 0.8039216 }; | ||
function parse(content) { | ||
const title = extractTitle(content); | ||
const headings = extractHeadings(content); | ||
const rawPages = extractPageNodes(content); | ||
const pages = rawPages.map((page) => { | ||
return page.map((node) => { | ||
if (node.paragraph) { | ||
return parseParagraph(node.paragraph); | ||
} else if (node.table) { | ||
return parseTable(node.table); | ||
} else { | ||
return null; | ||
} | ||
}); | ||
}); | ||
return { | ||
title, | ||
headings, | ||
pages, | ||
}; | ||
} | ||
function parseParagraph(paragraph) { | ||
return { | ||
type: getParagraphType(paragraph), | ||
content: paragraph.elements.map((element) => { | ||
// TODO(): add image support | ||
if (element.inlineObjectElement) { | ||
return null; | ||
} | ||
// TODO(): add horizontal rule support | ||
if (element.horizontalRule) { | ||
return null; | ||
} | ||
return { | ||
content: element.textRun.content, | ||
...getElementProperties(element), | ||
...getBold(element), | ||
}; | ||
}), | ||
}; | ||
} | ||
function parseTable(table) { | ||
const tableType = getTableType(table); | ||
return { | ||
type: tableType, | ||
content: | ||
tableType === "codebox" | ||
? // return an array here as well, so the API is consistent | ||
[parseCode(table.tableRows[0].tableCells[0])] | ||
: parseParagraph(table.tableRows[0].tableCells[0].content[0].paragraph), | ||
}; | ||
} | ||
function parseCode(tableCell) { | ||
return tableCell.content.reduce((acc, current) => { | ||
return (acc += current.paragraph.elements[0].textRun.content); | ||
}, ""); | ||
} | ||
function getTableType(table) { | ||
try { | ||
if ( | ||
table.rows === 1 && | ||
table.columns === 1 && | ||
table.tableRows[0].tableCells[0].content[0].paragraph.elements && | ||
isEqual( | ||
table.tableRows[0].tableCells[0].tableCellStyle.backgroundColor.color | ||
.rgbColor, | ||
infoColor | ||
) | ||
) { | ||
return "infobox"; | ||
} | ||
} catch (ex) { | ||
// do nothing | ||
} | ||
try { | ||
if ( | ||
table.rows === 1 && | ||
table.columns === 1 && | ||
table.tableRows[0].tableCells[0].content[0].paragraph.elements && | ||
isEqual( | ||
table.tableRows[0].tableCells[0].tableCellStyle.backgroundColor.color | ||
.rgbColor, | ||
warningColor | ||
) | ||
) { | ||
return "warningbox"; | ||
} | ||
} catch (ex) { | ||
// do nothing | ||
} | ||
try { | ||
if ( | ||
table.rows === 1 && | ||
table.columns === 1 && | ||
table.tableRows[0].tableCells[0].content[0].paragraph.elements[0].textRun | ||
.textStyle.weightedFontFamily.fontFamily === "Courier New" | ||
) { | ||
return "codebox"; | ||
} | ||
} catch (ex) { | ||
// do nothing | ||
} | ||
return null; | ||
} | ||
function getElementProperties(element) { | ||
return { | ||
...getLinkProperties(element.textRun), | ||
...getButtonLinkProperties(element.textRun), | ||
...getCommandLineSnippet(element.textRun), | ||
}; | ||
} | ||
function getParagraphType(paragraph) { | ||
const mapping = { | ||
NORMAL_TEXT: "p", | ||
HEADING_2: "h2", | ||
HEADING_3: "h3", | ||
HEADING_4: "h4", | ||
HEADING_5: "h5", | ||
HEADING_6: "h6", | ||
}; | ||
try { | ||
if (paragraph.paragraphStyle.spacingMode === "COLLAPSE_LISTS") { | ||
return "li"; | ||
} | ||
} catch (ex) { | ||
// do nothing | ||
} | ||
try { | ||
return mapping[paragraph.paragraphStyle.namedStyleType]; | ||
} catch (e) { | ||
// defaults to p | ||
return "p"; | ||
} | ||
} | ||
function getLinkProperties(textRun) { | ||
try { | ||
if ( | ||
textRun.textStyle && | ||
textRun.textStyle.foregroundColor && | ||
textRun.textStyle.link | ||
) { | ||
return { | ||
type: "link", | ||
href: textRun.textStyle.link.url, | ||
}; | ||
} | ||
} catch (e) { | ||
return undefined; | ||
} | ||
} | ||
function getButtonLinkProperties(textRun) { | ||
try { | ||
if ( | ||
textRun.textStyle && | ||
textRun.textStyle.backgroundColor && | ||
textRun.textStyle.link | ||
) { | ||
return { | ||
type: "button-link", | ||
href: textRun.textStyle.link.url, | ||
}; | ||
} | ||
} catch (e) { | ||
return undefined; | ||
} | ||
} | ||
function getCommandLineSnippet(textRun) { | ||
try { | ||
if (textRun.textStyle.weightedFontFamily.fontFamily === "Consolas") { | ||
return { | ||
type: "command-line-snippet", | ||
}; | ||
} | ||
} catch (e) { | ||
return undefined; | ||
} | ||
} | ||
function extractHeadingNodes(content) { | ||
@@ -12,3 +211,3 @@ return findElements(content, "HEADING_1"); | ||
function extractTitle(content) { | ||
return Utils.getParagraphText(extractTitleNode(content)); | ||
return getParagraphText(extractTitleNode(content)); | ||
} | ||
@@ -18,7 +217,22 @@ | ||
const headingNodes = extractHeadingNodes(content); | ||
return headingNodes | ||
.map(Utils.getParagraphText) | ||
.map((header) => header.trim()); | ||
return headingNodes.map(getParagraphText).map((header) => header.trim()); | ||
} | ||
function isEqual(object1, object2) { | ||
const keys1 = Object.keys(object1); | ||
const keys2 = Object.keys(object2); | ||
if (keys1.length !== keys2.length) { | ||
return false; | ||
} | ||
for (let key of keys1) { | ||
if (object1[key] !== object2[key]) { | ||
return false; | ||
} | ||
} | ||
return true; | ||
} | ||
function extractPageNodes(content) { | ||
@@ -46,10 +260,14 @@ const headingNodes = extractHeadingNodes(content); | ||
export default { | ||
extractHeadingNodes, | ||
extractHeadings, | ||
function getBold(element) { | ||
return { | ||
bold: !!element.textRun.textStyle.bold, | ||
}; | ||
} | ||
extractTitleNode, | ||
extractTitle, | ||
function getParagraphText(node) { | ||
return node.paragraph.elements[0].textRun.content; | ||
} | ||
extractPageNodes, | ||
export default { | ||
parse, | ||
}; |
@@ -1,25 +0,54 @@ | ||
const assert = require("assert"); | ||
import React from "react"; | ||
import { | ||
cleanup, | ||
fireEvent, | ||
render, | ||
screen, | ||
waitFor, | ||
} from "@testing-library/react"; | ||
import "@testing-library/jest-dom/extend-expect"; | ||
const { content } = require("../stories/document-1607876232180.json"); | ||
import assert from "assert"; | ||
import { content } from "../stories/document-1607876232180.json"; | ||
import expected from "./__test/expected.json"; | ||
import Extract from "./extract"; | ||
describe("Extract", () => { | ||
it("finds the title", () => { | ||
const title = Extract.extractTitle(content); | ||
assert.deepStrictEqual(title, "Your First Progressive Web App"); | ||
}); | ||
import { Default } from "../stories/Codelabs.stories"; | ||
it("finds h1 headers", () => { | ||
const headings = Extract.extractHeadings(content); | ||
assert.deepStrictEqual(headings, [ | ||
"Introduction", | ||
"Getting set up", | ||
"Establish a baseline", | ||
"Add a web app manifest", | ||
"Provide a basic offline experience", | ||
"Provide a full offline experience", | ||
"Add install experience", | ||
"Congratulations", | ||
]); | ||
}); | ||
afterEach(cleanup); | ||
test("Extract parses the document", () => { | ||
const tree = Extract.parse(content); | ||
assert.deepStrictEqual(JSON.parse(JSON.stringify(tree)), expected); | ||
}); | ||
test("Page can be navigated using the side navigation", async () => { | ||
render(<Default />); | ||
fireEvent.click(screen.getByText("Getting set up")); | ||
expect(screen.getByText("2. Getting set up")).toBeInTheDocument(); | ||
}); | ||
test("Page can be navigated using the buttons at the bottom of the page", async () => { | ||
render(<Default />); | ||
// navigate to the next page | ||
fireEvent.click( | ||
screen.getByRole("button", { | ||
name: "Next", | ||
}) | ||
); | ||
expect(screen.getByText("2. Getting set up")).toBeInTheDocument(); | ||
// navigate to the previous page | ||
fireEvent.click( | ||
screen.getByRole("button", { | ||
name: "Previous", | ||
}) | ||
); | ||
expect(screen.getByText("1. Introduction")).toBeInTheDocument(); | ||
// now the previous button should be disabled | ||
const button = screen.getByRole("button", { name: "Previous" }); | ||
expect(button).toBeDisabled(); | ||
}); |
260
src/index.js
@@ -14,3 +14,2 @@ import React, { useState } from "react"; | ||
H6, | ||
Span, | ||
Button, | ||
@@ -23,6 +22,7 @@ ButtonLink, | ||
CodeBox, | ||
Parapgraph, | ||
ListItem, | ||
} from "./components"; | ||
import Extract from "./extract"; | ||
import Utils from "./utils"; | ||
@@ -47,4 +47,5 @@ // TODO: this function is a mess, need to break it apart | ||
const H6Component = overrides.H6 || H6; | ||
const ParapgraphComponent = overrides.Parapgraph || Parapgraph; | ||
const ListItemComponent = overrides.ListItem || ListItem; | ||
const SpanComponent = overrides.Span || Span; | ||
const ButtonLinkComponent = overrides.ButtonLink || ButtonLink; | ||
@@ -58,128 +59,167 @@ const ButtonComponent = overrides.Button || Button; | ||
const Text = Utils.TextFactory({ | ||
H2Component, | ||
H3Component, | ||
H4Component, | ||
H5Component, | ||
H6Component, | ||
SpanComponent, | ||
ButtonLinkComponent, | ||
}); | ||
const parsedContent = Extract.parse(content); | ||
const title = Extract.extractTitle(content); | ||
const headings = Extract.extractHeadings(content); | ||
const Mapper = { | ||
p: (props) => <ParapgraphComponent>{props.children}</ParapgraphComponent>, | ||
h2: (props) => <H2Component>{props.children}</H2Component>, | ||
h3: (props) => <H3Component>{props.children}</H3Component>, | ||
h4: (props) => <H4Component>{props.children}</H4Component>, | ||
h5: (props) => <H5Component>{props.children}</H5Component>, | ||
h6: (props) => <H6Component>{props.children}</H6Component>, | ||
li: (props) => <ListItemComponent>{props.children}</ListItemComponent>, | ||
infobox: (props) => <InfoBoxComponent>{props.children}</InfoBoxComponent>, | ||
warningbox: (props) => ( | ||
<WarningBoxComponent>{props.children}</WarningBoxComponent> | ||
), | ||
a: (props) => <LinkComponent {...props}>{props.children}</LinkComponent>, | ||
buttonlink: (props) => ( | ||
<ButtonLinkComponent {...props}>{props.children}</ButtonLinkComponent> | ||
), | ||
commandlinesnippet: (props) => ( | ||
<SnippetComponent {...props}>{props.children}</SnippetComponent> | ||
), | ||
codebox: (props) => ( | ||
<CodeBoxComponent {...props}>{props.children}</CodeBoxComponent> | ||
), | ||
}; | ||
const pageNodes = Extract.extractPageNodes(content); | ||
const pages = parsedContent.pages.map((page, pageIndex) => { | ||
return page.map((node, nodeIndex) => { | ||
switch (node.type) { | ||
case "p": | ||
return MapNode({ | ||
node, | ||
tag: "p", | ||
Mapper, | ||
key: `${pageIndex}-${nodeIndex}`, | ||
}); | ||
case "h2": | ||
return MapNode({ | ||
node, | ||
tag: "h2", | ||
Mapper, | ||
key: `${pageIndex}-${nodeIndex}`, | ||
}); | ||
case "h3": | ||
return MapNode({ | ||
node, | ||
tag: "h3", | ||
Mapper, | ||
key: `${pageIndex}-${nodeIndex}`, | ||
}); | ||
case "h4": | ||
return MapNode({ | ||
node, | ||
tag: "h4", | ||
Mapper, | ||
key: `${pageIndex}-${nodeIndex}`, | ||
}); | ||
case "h5": | ||
return MapNode({ | ||
node, | ||
tag: "h5", | ||
Mapper, | ||
key: `${pageIndex}-${nodeIndex}`, | ||
}); | ||
case "h6": | ||
return MapNode({ | ||
node, | ||
tag: "h6", | ||
Mapper, | ||
key: `${pageIndex}-${nodeIndex}`, | ||
}); | ||
case "li": | ||
return MapNode({ | ||
node, | ||
tag: "li", | ||
Mapper, | ||
key: `${pageIndex}-${nodeIndex}`, | ||
}); | ||
case "infobox": | ||
return MapNode({ | ||
node: node.content, | ||
tag: "infobox", | ||
Mapper, | ||
key: `${pageIndex}-${nodeIndex}`, | ||
}); | ||
case "warningbox": | ||
return MapNode({ | ||
node: node.content, | ||
tag: "warningbox", | ||
Mapper, | ||
key: `${pageIndex}-${nodeIndex}`, | ||
}); | ||
case "codebox": | ||
return MapNode({ | ||
node: node, | ||
tag: "codebox", | ||
Mapper, | ||
key: `${pageIndex}-${nodeIndex}`, | ||
}); | ||
function processParagraphElements({ type }) { | ||
return function ({ textRun }) { | ||
if (!textRun) return null; | ||
if (Utils.isButton(textRun)) { | ||
return ( | ||
<p> | ||
<ButtonLinkComponent href={textRun.textStyle.link.url}> | ||
{textRun.content} | ||
</ButtonLinkComponent> | ||
</p> | ||
); | ||
default: | ||
return null; | ||
} | ||
}); | ||
}); | ||
if (Utils.isCommandLineSnippet(textRun)) { | ||
return ( | ||
<p> | ||
<SnippetComponent>{textRun.content}</SnippetComponent> | ||
</p> | ||
); | ||
} | ||
return ( | ||
<PageComponent | ||
title={parsedContent.title} | ||
navigationItems={parsedContent.headings} | ||
pages={pages} | ||
currentPage={page} | ||
setPage={setPage} | ||
overrides={{ | ||
HeaderComponent, | ||
SideNavigationComponent, | ||
ContentComponent, | ||
MainComponent, | ||
ButtonComponent, | ||
H1Component, | ||
}} | ||
/> | ||
); | ||
} | ||
if (Utils.isLink(textRun)) { | ||
return ( | ||
<LinkComponent href={textRun.textStyle.link.url}> | ||
{textRun.content} | ||
</LinkComponent> | ||
); | ||
} | ||
function MapNode({ tag, node, Mapper, key }) { | ||
const Tag = Mapper[tag]; | ||
if (!Tag) return null; | ||
return ( | ||
<Text type={type} text={textRun.content} bold={Utils.isBold(textRun)} /> | ||
); | ||
}; | ||
if (tag === "codebox") { | ||
return <Mapper.codebox key={key}>{node.content}</Mapper.codebox>; | ||
} | ||
const pages = pageNodes.map((page, index) => { | ||
const reactPage = page.map((node) => { | ||
// we have text node, with possibly multiple elements | ||
if (node.paragraph) { | ||
// we can run into a few special cases based on type or other properties | ||
const type = Utils.getParagraphType(node); | ||
return ( | ||
<Tag> | ||
{node.content.map((element) => { | ||
if (!element) return; | ||
const pContent = node.paragraph.elements.map( | ||
processParagraphElements({ type }) | ||
); | ||
if (Utils.getParagraphSpacingMode(node) === "COLLAPSE_LISTS") { | ||
if (element.type === "link") { | ||
return ( | ||
<ul> | ||
<li>{pContent}</li> | ||
</ul> | ||
<Mapper.a key={key} target="_blank" {...element}> | ||
{element.content} | ||
</Mapper.a> | ||
); | ||
} | ||
return pContent; | ||
} | ||
// we might have a codeblock, info or warning boxes | ||
// they are all tables with the dimension 1x1 | ||
if (node.table) { | ||
if (Utils.isInfoBox(node.table)) { | ||
const pContent = node.table.tableRows[0].tableCells[0].content[0].paragraph.elements.map( | ||
processParagraphElements({ type: "NORMAL_TEXT" }) | ||
if (element.type === "button-link") { | ||
return ( | ||
<Mapper.buttonlink key={key} target="_blank" {...element}> | ||
{element.content} | ||
</Mapper.buttonlink> | ||
); | ||
return <InfoBoxComponent>{pContent}</InfoBoxComponent>; | ||
} | ||
if (Utils.isWarningBox(node.table)) { | ||
const pContent = node.table.tableRows[0].tableCells[0].content[0].paragraph.elements.map( | ||
processParagraphElements({ type: "NORMAL_TEXT" }) | ||
); | ||
return <WarningBoxComponent>{pContent}</WarningBoxComponent>; | ||
} | ||
if (Utils.isCodeBox(node.table)) { | ||
if (element.type === "command-line-snippet") { | ||
return ( | ||
<CodeBoxComponent> | ||
{Utils.getCode(node.table.tableRows[0].tableCells[0])} | ||
</CodeBoxComponent> | ||
<Mapper.commandlinesnippet key={key}> | ||
{element.content} | ||
</Mapper.commandlinesnippet> | ||
); | ||
} | ||
} | ||
return; | ||
}); | ||
// +1, so it's a human readable page index | ||
reactPage.unshift( | ||
<H1Component> | ||
{index + 1}. {headings[index]} | ||
</H1Component> | ||
); | ||
return reactPage; | ||
}); | ||
return ( | ||
<PageComponent | ||
title={title} | ||
navigationItems={headings} | ||
pages={pages} | ||
currentPage={page} | ||
setPage={setPage} | ||
overrides={{ | ||
HeaderComponent, | ||
SideNavigationComponent, | ||
ContentComponent, | ||
MainComponent, | ||
ButtonComponent, | ||
}} | ||
/> | ||
return <span key={key}>{element.content}</span>; | ||
})} | ||
</Tag> | ||
); | ||
@@ -200,2 +240,3 @@ } | ||
ButtonComponent, | ||
H1Component, | ||
}, | ||
@@ -214,2 +255,5 @@ }) { | ||
<> | ||
<H1Component> | ||
{currentPage + 1}. {navigationItems[currentPage]} | ||
</H1Component> | ||
{pages[currentPage]} | ||
@@ -216,0 +260,0 @@ <div |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
181267
5108
68
22
10
1