element && this.initDataEditor(element)} />
133 |
134 |
135 | )
136 | }
137 |
138 | public componentDidMount() {
139 | this.updateEditors()
140 | }
141 |
142 | public componentDidUpdate() {
143 | this.updateEditors()
144 | }
145 |
146 | public componentWillUnmount() {
147 | for (const editor of Editors.editors.values()) {
148 | editor.editor.dispose()
149 | }
150 | Editors.editors.clear()
151 | Editors.markupEditor = null
152 | Editors.styleEditor = null
153 | Editors.dataEditor = null
154 | }
155 |
156 | private updateEditors() {
157 | Editors.editors.forEach(editor => {
158 | editor.setComponent(this.props.activeComponent)
159 | })
160 | }
161 |
162 | private inspect(element: HTMLElement) {
163 | const location = element.getAttribute('data-location')
164 | if (!location) {
165 | Editors.styleEditor!.setMessages('inspector', [])
166 | return
167 | }
168 | const locationData = JSON.parse(location)
169 | const lineNumber = locationData.ln as number
170 | const column = locationData.c as number
171 | const endLineNumber = locationData.eln as number
172 | const endColumn = locationData.ec as number
173 | Editors.markupEditor!.editor.revealLinesInCenterIfOutsideViewport(lineNumber, endLineNumber)
174 | Editors.markupEditor!.editor.setPosition({
175 | lineNumber,
176 | column
177 | })
178 | if (endLineNumber !== undefined && endColumn !== undefined) {
179 | Editors.markupEditor!.editor.setSelection({
180 | startLineNumber: lineNumber,
181 | startColumn: column,
182 | endLineNumber,
183 | endColumn
184 | })
185 | }
186 | this.focusVisibleEditor()
187 |
188 | const matches = (elem: HTMLElement, selector: string): HTMLElement | null => {
189 | if (elem.matches('.preview-content')) return null
190 | if (elem.matches(selector)) return elem
191 | if (elem.parentElement) return matches(elem.parentElement, selector)
192 | return null
193 | }
194 |
195 | const { activeComponent } = this.props
196 | const component = workspace.getComponent(activeComponent)
197 | const messages: Message[] = []
198 | component.style.iterateSelectors(info => {
199 | const match = matches(element, info.selector)
200 | if (match) {
201 | const type = match === element ? 'success' : 'info'
202 | const text = match === element ? 'Matching selector' : 'Parent matching selector'
203 | info.children.forEach(mapping => {
204 | const affects =
205 | match === element || inheritedProperties.includes(mapping.declaration.prop)
206 | if (affects) {
207 | messages.push({
208 | position: new monaco.Position(mapping.line, mapping.column),
209 | text,
210 | type
211 | })
212 | }
213 | })
214 | messages.push({
215 | position: new monaco.Position(info.mapping.line, info.mapping.column),
216 | text,
217 | type
218 | })
219 | }
220 | })
221 | Editors.styleEditor!.setMessages('inspector', messages)
222 | }
223 |
224 | private focusVisibleEditor() {
225 | const editor = Editors.editors.get(this.state.selectedTabId)
226 | if (editor) {
227 | editor.editor.focus()
228 | }
229 | }
230 |
231 | private stopInspecting() {
232 | Editors.styleEditor!.cleanUpMessages('inspector')
233 | }
234 |
235 | private handleTabChange(selectedTabId: string) {
236 | this.setState({ selectedTabId })
237 | const editor = Editors.editors.get(selectedTabId)
238 | if (editor) {
239 | editor.setDirty()
240 | }
241 | }
242 |
243 | private initMarkupEditor(element: HTMLDivElement) {
244 | if (Editors.markupEditor) return
245 | const editor = new MarkupEditor(element, errorHandler)
246 | Editors.markupEditor = editor
247 | Editors.editors.set('markup', editor)
248 | actions.forEach(action => editor.editor.addAction(action))
249 |
250 | // Hack to get the first previews.render() with the editor loaded and ready
251 | let first = true
252 | editor.editor.onDidChangeModelContent(() => {
253 | if (first) {
254 | workspace.emit('componentUpdated')
255 | first = false
256 | }
257 | })
258 | }
259 |
260 | private initStyleEditor(element: HTMLDivElement) {
261 | if (Editors.styleEditor) return
262 | const editor = new StyleEditor(element, errorHandler)
263 | Editors.styleEditor = editor
264 | Editors.editors.set('style', editor)
265 | actions.forEach(action => editor.editor.addAction(action))
266 | }
267 |
268 | private initDataEditor(element: HTMLDivElement) {
269 | if (Editors.dataEditor) return
270 | const editor = new JSONEditor(element, errorHandler)
271 | Editors.dataEditor = editor
272 | Editors.editors.set('data', editor)
273 | actions.forEach(action => editor.editor.addAction(action))
274 | }
275 | }
276 |
277 | export default Editors
278 |
--------------------------------------------------------------------------------
/src/settings.tsx:
--------------------------------------------------------------------------------
1 | import { Collapse, Form, Icon, Input, Radio, Switch } from 'antd'
2 | import * as React from 'react'
3 | import errorHandler from './error-handler'
4 | import workspace from './workspace'
5 |
6 | import electron = require('electron')
7 |
8 | const { BrowserWindow, dialog } = electron.remote
9 |
10 | const FormItem = Form.Item
11 | const { Button: RadioButton, Group: RadioGroup } = Radio
12 | const { TextArea, Search } = Input
13 | const { Panel } = Collapse
14 |
15 | const formItemLayout = {
16 | labelCol: {
17 | xs: { span: 12 },
18 | sm: { span: 4 }
19 | },
20 | wrapperCol: {
21 | xs: { span: 24 },
22 | sm: { span: 16 }
23 | }
24 | }
25 |
26 | class WebComponentsSettings extends React.Component
{
27 | public render() {
28 | return (
29 |
30 |
35 | {
40 | const paths = dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), {
41 | properties: ['openDirectory'],
42 | defaultPath: workspace.metadata.web && workspace.metadata.web.dir
43 | })
44 | if (!paths || paths.length === 0) return
45 | workspace.metadata.web = Object.assign({}, workspace.metadata.web)
46 | workspace.metadata.web.dir = paths[0]
47 | workspace
48 | .saveMetadata()
49 | .then(() => workspace.generate(errorHandler))
50 | .catch(errorHandler)
51 | }}
52 | />
53 |
54 |
55 |
56 | React
57 | Angular
58 | Vue
59 | Web components
60 |
61 |
62 |
63 |
64 | SCSS
65 | CSS
66 |
67 |
68 |
69 |
70 | JavaScript
71 | TypeScript
72 |
73 |
74 |
79 | This must be a valid .browserslistrc file. Read more about browserlist
80 | and its configuration:{' '}
81 |
82 | browserlist
83 |
84 |
85 | }
86 | >
87 |
96 |
97 | )
98 | }
99 | }
100 |
101 | class EmailTemplateSettings extends React.Component {
102 | public render() {
103 | return (
104 |
105 |
110 |
115 |
116 |
117 |
118 | Handlebars
119 | EJS
120 | Nunjucks
121 |
122 |
123 |
128 | Inky is an HTML-based templating language
129 | that converts simple HTML into complex, responsive email-ready HTML.
130 |
131 | }
132 | >
133 |
134 |
135 |
136 | )
137 | }
138 | }
139 |
140 | class ReactNativeSettings extends React.Component {
141 | public render() {
142 | return (
143 |
144 |
149 |
150 |
151 |
152 |
153 | JavaScript
154 | TypeScript
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 | )
165 | }
166 | }
167 |
168 | class GeneralSettings extends React.Component {
169 | public render() {
170 | const data = workspace.metadata.general || {
171 | prettier: {
172 | printWidth: 100,
173 | singleQuote: true
174 | }
175 | }
176 | return (
177 |
178 |
183 | {
187 | const paths = dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), {
188 | properties: ['openDirectory']
189 | })
190 | if (!paths || paths.length === 0) return
191 | // TODO workspace.setDirectory(paths[0])
192 | }}
193 | readOnly
194 | />
195 |
196 |
201 | This must be a valid prettier.json file. Read more about Prettier and its
202 | configuration:{' '}
203 |
204 | prettier configuration
205 |
206 |
207 | }
208 | >
209 |
217 |
218 | )
219 | }
220 | }
221 |
222 | class Settings extends React.Component {
223 | public componentDidMount() {
224 | workspace.on('metadataChanged', () => {
225 | this.forceUpdate()
226 | })
227 | }
228 | public render() {
229 | return (
230 |
280 | )
281 | }
282 |
283 | public handleSubmit() {}
284 | }
285 |
286 | export default Settings
287 |
--------------------------------------------------------------------------------
/src/component.ts:
--------------------------------------------------------------------------------
1 | import * as sass from 'node-sass'
2 | import * as parse5 from 'parse5'
3 | import { SourceMapConsumer } from 'source-map'
4 |
5 | import Typer from './typer'
6 | import {
7 | PostCSSNode,
8 | PostCSSPosition,
9 | PostCSSRoot,
10 | PostCSSRule,
11 | States,
12 | StripedCSS,
13 | PostCSSDeclaration
14 | } from './types'
15 |
16 | import { stripeCSS } from './css-striper'
17 |
18 | const postcss = require('postcss')
19 |
20 | interface SourceMapMapping {
21 | generatedLine: number
22 | generatedColumn: number
23 | source: string
24 | name: string | null
25 | originalLine: number
26 | originalColumn: number
27 | }
28 |
29 | interface MappedPosition {
30 | line: number
31 | column: number
32 | source: string
33 | }
34 |
35 | interface MappedDeclaration extends MappedPosition {
36 | declaration: PostCSSDeclaration
37 | }
38 |
39 | type SelectorIterator = (
40 | info: {
41 | selector: string
42 | originalSelector: string
43 | mapping: MappedPosition
44 | children: MappedDeclaration[]
45 | }
46 | ) => any
47 |
48 | interface ComponentCSS {
49 | readonly source: string
50 | readonly map: sourceMap.RawSourceMap
51 | readonly ast: PostCSSRoot
52 | readonly striped: StripedCSS
53 | }
54 |
55 | type UnvisitedNodesIterator = (node: parse5.AST.Default.Element) => any
56 |
57 | class ComponentData {
58 | private states: States
59 |
60 | constructor(source: string) {
61 | this.setData(source)
62 | }
63 |
64 | public setData(source: string) {
65 | try {
66 | this.states = JSON.parse(source)
67 | } catch (err) {
68 | // Ignore this error. This should be handled by json.update()
69 | }
70 | }
71 |
72 | public getStates() {
73 | return this.states
74 | }
75 | }
76 |
77 | class ComponentStyle {
78 | private componentName: string
79 | private style: string
80 | private css: ComponentCSS | null
81 |
82 | constructor(componentName: string, style: string) {
83 | this.setComponentName(componentName)
84 | this.setStyle(style)
85 | }
86 |
87 | public setComponentName(componentName: string) {
88 | this.componentName = componentName
89 | this.css = null
90 | }
91 |
92 | public setStyle(style: string) {
93 | this.style = style
94 | this.css = null
95 | }
96 |
97 | public getCSS(): ComponentCSS {
98 | if (this.css) return this.css
99 | const result = sass.renderSync({
100 | data: this.style || '/**/',
101 | outFile: 'source.map',
102 | sourceMap: true
103 | })
104 | const source = result.css.toString()
105 | const ast = postcss.parse(source) as PostCSSRoot
106 | this.css = {
107 | source,
108 | ast,
109 | striped: stripeCSS(this.componentName, ast),
110 | map: JSON.parse(result.map.toString())
111 | }
112 | return this.css
113 | }
114 |
115 | public iterateSelectors(iterator: SelectorIterator) {
116 | try {
117 | const css = this.getCSS()
118 | const smc = new SourceMapConsumer(css.map)
119 | const allMappings: SourceMapMapping[] = []
120 | smc.eachMapping((m: SourceMapMapping) => {
121 | allMappings.push(m)
122 | })
123 | const findOriginalPosition = (position: PostCSSPosition): MappedPosition | null => {
124 | const mapping = smc.originalPositionFor({
125 | ...position,
126 | bias: SourceMapConsumer.LEAST_UPPER_BOUND
127 | })
128 | const { line, column, source } = mapping
129 | if (mapping && line != null && column != null) {
130 | return {
131 | line,
132 | column,
133 | source: source || ''
134 | }
135 | }
136 | return null
137 | }
138 | const findMapping = (position: PostCSSPosition): MappedPosition | null => {
139 | let lastMapping: SourceMapMapping | null = null
140 | for (const m of allMappings) {
141 | if (m.generatedLine === position.line) {
142 | lastMapping = m
143 | }
144 | }
145 | if (!lastMapping) return null
146 | const { originalLine: line, originalColumn: column, source } = lastMapping
147 | return { line, column, source }
148 | }
149 |
150 | const findDeclarations = (children: MappedDeclaration[], node: PostCSSNode) => {
151 | if (node.type === 'decl') {
152 | // console.log('decl', this.style.split('\n')[node.source.start.line])
153 | const startMapping = findOriginalPosition(node.source.start)
154 | if (startMapping) {
155 | children.push({
156 | ...startMapping,
157 | declaration: node as PostCSSDeclaration
158 | })
159 | }
160 | }
161 | if (node.nodes) {
162 | node.nodes.forEach(child => findDeclarations(children, child))
163 | }
164 | }
165 |
166 | const iterateNode = (node: PostCSSNode) => {
167 | if (node.type === 'rule') {
168 | const rule = node as PostCSSRule
169 | const startMapping = findMapping(node.source.start)
170 | // TODO: calculate endMapping with rule.selector.split(/[\\r\\n]/)
171 | // increase lineNumber depending on arr.length - 1
172 | // calculate column with last line's length
173 | if (startMapping) {
174 | if (rule.ids) {
175 | const joinedIds = rule.ids!.map(id => `.${id}`).join('')
176 | const selector = joinedIds + ' ' + rule.selector
177 | const children: MappedDeclaration[] = []
178 | if (node.nodes) {
179 | findDeclarations(children, node)
180 | }
181 | iterator({
182 | selector,
183 | originalSelector: selector,
184 | mapping: startMapping,
185 | children
186 | })
187 | } else {
188 | console.warn('selector without ids', rule)
189 | }
190 | }
191 | } else if (node.nodes) {
192 | node.nodes.forEach(iterateNode)
193 | }
194 | }
195 | iterateNode(css.ast)
196 | } catch (err) {
197 | if (err.line == null && err.column == null) {
198 | throw err
199 | }
200 | }
201 | }
202 | }
203 |
204 | class ComponentMarkup {
205 | private source: string
206 | private markup: parse5.AST.Default.DocumentFragment | null
207 |
208 | constructor(source: string) {
209 | this.setMarkup(source)
210 | }
211 |
212 | public setMarkup(source: string) {
213 | this.source = source
214 | this.markup = null
215 | }
216 |
217 | public getDOM(): parse5.AST.Default.DocumentFragment {
218 | if (this.markup) return this.markup
219 | this.markup = parse5.parseFragment(this.source, {
220 | locationInfo: true
221 | }) as parse5.AST.Default.DocumentFragment
222 | return this.markup
223 | }
224 |
225 | public getRootNode() {
226 | return this.getDOM().childNodes[0]
227 | }
228 |
229 | public cleanUpVisits() {
230 | this.cleanUpVisitsOf(this.getRootNode())
231 | }
232 |
233 | public iterateUnvisitedNodes(iterator: UnvisitedNodesIterator) {
234 | const rootNode = this.getRootNode()
235 | this.iterateUnvisitedNodesOf(rootNode, iterator)
236 | }
237 |
238 | public calculateEventHanlders() {
239 | const eventHandlers = new Map()
240 | const calculate = (node: parse5.AST.Default.Node) => {
241 | const element = node as parse5.AST.Default.Element
242 | if (!element.childNodes) return
243 | element.attrs.forEach(attr => {
244 | if (attr.name.startsWith('@on')) {
245 | const required = !attr.name.endsWith('?')
246 | const value = attr.value
247 | if (eventHandlers.has(value)) {
248 | eventHandlers.set(value, eventHandlers.get(value)! || required)
249 | } else {
250 | eventHandlers.set(value, required)
251 | }
252 | }
253 | })
254 | element.childNodes.forEach(child => calculate(child))
255 | }
256 | calculate(this.getDOM().childNodes[0])
257 | return eventHandlers
258 | }
259 |
260 | private cleanUpVisitsOf(node: parse5.AST.Default.Node) {
261 | const nodeCounter = node as any
262 | delete nodeCounter.visits
263 | const elem = node as Element
264 | if (elem.childNodes) {
265 | elem.childNodes.forEach(child => this.cleanUpVisitsOf(child))
266 | }
267 | }
268 |
269 | private iterateUnvisitedNodesOf(node: parse5.AST.Default.Node, iterator: UnvisitedNodesIterator) {
270 | const nodeCounter = node as any
271 | const elem = node as parse5.AST.Default.Element
272 | if (elem.childNodes) {
273 | if (!nodeCounter.visits) {
274 | iterator(elem)
275 | }
276 | elem.childNodes.forEach(child => this.iterateUnvisitedNodesOf(child, iterator))
277 | }
278 | }
279 | }
280 |
281 | export default class Component {
282 | public readonly markup: ComponentMarkup
283 | public readonly style: ComponentStyle
284 | public readonly data: ComponentData
285 | // tslint:disable-next-line:variable-name
286 | private _name: string
287 |
288 | constructor(name: string, markup: string, style: string, data: string) {
289 | this._name = name
290 | this.markup = new ComponentMarkup(markup)
291 | this.style = new ComponentStyle(name, style)
292 | this.data = new ComponentData(data)
293 | }
294 |
295 | get name() {
296 | return this._name
297 | }
298 |
299 | public setName(name: string) {
300 | this._name = name
301 | this.style.setComponentName(name)
302 | }
303 |
304 | public calculateTyper(includeEventHandlers: boolean) {
305 | const typer = new Typer()
306 | this.data.getStates().forEach(state => typer.addDocument(state.props))
307 |
308 | if (includeEventHandlers) {
309 | for (const entry of this.markup.calculateEventHanlders().entries()) {
310 | const [key, value] = entry
311 | typer.addRootField(key, 'function', value)
312 | }
313 | }
314 | return typer
315 | }
316 | }
317 |
--------------------------------------------------------------------------------
/src/typer.ts:
--------------------------------------------------------------------------------
1 | import * as prettier from 'prettier'
2 |
3 | const camelcase = require('camelcase')
4 |
5 | type StringToBoolean = {
6 | [index: string]: boolean
7 | }
8 |
9 | type Keypath = {
10 | [index: string]: StringToBoolean
11 | }
12 |
13 | interface Field {
14 | name: string
15 | required: boolean
16 | value: AST[]
17 | }
18 |
19 | interface Interface {
20 | name: string
21 | fields: Field[]
22 | }
23 |
24 | interface AST {
25 | type: string
26 | interfaceName?: string
27 | values?: AST[]
28 | }
29 |
30 | class Typer {
31 | keypaths: { [index: string]: Keypath }
32 | interfaces: Interface[]
33 | prefix: string
34 |
35 | constructor() {
36 | this.keypaths = {}
37 | }
38 |
39 | calculateType(keypath: string, data: any) {
40 | let type: string = typeof data
41 | let paths: StringToBoolean = {}
42 | if (type === 'object') {
43 | if (data === null) {
44 | type = 'null'
45 | } else if (Array.isArray(data)) {
46 | type = 'array'
47 | paths = { '[]': true }
48 | data.map(value => this.calculateType(keypath + '[]', value))
49 | } else {
50 | paths = Object.keys(data).reduce((obj, key) => {
51 | obj[key] = true
52 | return obj
53 | }, {} as StringToBoolean)
54 | Object.keys(data).forEach(key =>
55 | this.calculateType(keypath ? keypath + '.' + key : key, data[key])
56 | )
57 | }
58 | }
59 | const current = this.keypaths[keypath] || {}
60 | const currentKeypath = current[type]
61 | if (currentKeypath) {
62 | Object.keys(currentKeypath).forEach(key => {
63 | if (!paths[key]) {
64 | currentKeypath[key] = false
65 | }
66 | })
67 | Object.keys(paths).forEach(key => {
68 | if (!currentKeypath[key]) {
69 | currentKeypath[key] = false
70 | }
71 | })
72 | } else {
73 | current[type] = paths
74 | }
75 | this.keypaths[keypath] = current
76 | }
77 |
78 | interfaceName(key: string) {
79 | const name = camelcase((this.prefix + ' ' + key).replace(/\[\]/g, 'Value'))
80 | return 'I' + name.substring(0, 1).toUpperCase() + name.substring(1)
81 | }
82 |
83 | addDocument(doc: any) {
84 | this.calculateType('', doc)
85 | }
86 |
87 | addRootField(keyPath: string, type: string, required: boolean) {
88 | this.keypaths[''].object[keyPath] = required
89 | this.keypaths[keyPath] = { [type]: {} }
90 | }
91 |
92 | createAST(prefix: string) {
93 | this.prefix = prefix
94 | this.interfaces = []
95 | const root = this.createASTForKeypath('')
96 | return {
97 | interfaces: this.interfaces,
98 | root
99 | }
100 | }
101 |
102 | createASTForKeypath(keypath: string): AST[] {
103 | const current = this.keypaths[keypath] || {} // can be undefined for an empty array
104 | // create interfaces
105 | Object.keys(current).forEach(type => {
106 | if (type !== 'object') return
107 | const name = this.interfaceName(keypath)
108 | this.interfaces.push({
109 | name,
110 | fields: Object.keys(current[type]).map(key => {
111 | return {
112 | name: key,
113 | required: current[type][key],
114 | value: this.createASTForKeypath(keypath ? keypath + '.' + key : key)
115 | }
116 | })
117 | })
118 | })
119 |
120 | // return current keypath AST
121 | return Object.keys(current).map(type => {
122 | if (type === 'object') {
123 | return {
124 | type: 'object',
125 | interfaceName: this.interfaceName(keypath)
126 | }
127 | }
128 | if (type === 'array') {
129 | return {
130 | type: 'array',
131 | values: Object.keys(current[type]).reduce((arr, key) => {
132 | return arr.concat(this.createASTForKeypath(keypath + '[]'))
133 | }, [] as AST[])
134 | }
135 | }
136 | return { type }
137 | })
138 | }
139 |
140 | createTypeScript(prefix: string) {
141 | const ast = this.createAST(prefix)
142 | const codeForArray = (arr: AST[]): string =>
143 | arr.length === 0 ? 'any' : arr.map(codeForValue).join(' | ')
144 | const codeForValue = (value: AST) => {
145 | if (value.type === 'array') {
146 | return `Array<${codeForArray(value.values!)}>`
147 | }
148 | if (value.type === 'object') {
149 | return value.interfaceName
150 | }
151 | if (value.type === 'function') {
152 | return '{ (): any }'
153 | }
154 | return value.type
155 | }
156 | const code = `${ast.interfaces
157 | .map(interfaceObj => {
158 | const { name, fields } = interfaceObj
159 | return `
160 | interface ${name} {
161 | ${fields
162 | .map(
163 | field =>
164 | `${field.name}${field.required ? '' : '?'}: ${codeForArray(
165 | field.value
166 | )}`
167 | )
168 | .join(';\n')}
169 | }
170 | `
171 | })
172 | .join('\n')}
173 | export type ${prefix} = ${codeForArray(ast.root)}
174 | `
175 | return prettier.format(code, { semi: false })
176 | }
177 |
178 | _filterNotNullables(field: Field) {
179 | let required = field.required
180 | const value = field.value.filter(val => {
181 | const notNullable = val.type !== 'null' && val.type !== 'undefined'
182 | required = required && notNullable
183 | return notNullable
184 | })
185 | return {
186 | value,
187 | required
188 | }
189 | }
190 |
191 | createPropTypes(initialCode: string) {
192 | const ast = this.createAST('')
193 | const codeForArray = (values: AST[]): string =>
194 | values.length === 1
195 | ? codeForValue(values[0], false)
196 | : `PropTypes.oneOfType([${values
197 | .map(value => codeForValue(value, false))
198 | .join(',')}])`
199 | const codeForValue = (value: AST, isRoot: boolean): string => {
200 | if (value.type === 'array') {
201 | const values = value.values!
202 | if (values.length === 0) {
203 | return 'PropTypes.array'
204 | } else if (values.length === 1) {
205 | return `PropTypes.arrayOf(${codeForValue(values[0], false)})`
206 | }
207 | return `PropTypes.arrayOf(${codeForArray(values)})`
208 | }
209 | if (['string', 'number', 'symbol'].includes(value.type)) {
210 | return `PropTypes.${value.type}`
211 | }
212 | if (value.type === 'boolean') {
213 | return 'PropTypes.bool'
214 | }
215 | if (value.type === 'function') {
216 | return 'PropTypes.func'
217 | }
218 | if (value.type === 'object') {
219 | const interfaceName = value.interfaceName!
220 | const definition = this.interfaces.find(i => i.name === interfaceName)!
221 | const fields = definition.fields
222 | if (fields.length === 0) {
223 | return isRoot ? '' : 'PropTypes.object'
224 | }
225 | const code = `{
226 | ${fields.map(field => {
227 | const _field = this._filterNotNullables(field)
228 | return `"${field.name}": ${codeForArray(
229 | _field.value
230 | )}${_field.required ? '.isRequired' : ''}`
231 | })}
232 | }`
233 | return isRoot ? code : `PropTypes.shape(${code})`
234 | }
235 | return 'Unknown'
236 | }
237 | const code =
238 | ast.root.length === 1 && ast.root[0].type === 'object'
239 | ? codeForValue(ast.root[0], true)
240 | : codeForArray(ast.root)
241 |
242 | return code
243 | ? prettier.format(`${initialCode} = ${code}`, { semi: false })
244 | : ''
245 | }
246 |
247 | createVueValidation(options?: prettier.Options) {
248 | const ast = this.createAST('')
249 | if (ast.root.length !== 1 || ast.root[0].type !== 'object') return ''
250 | const root = ast.root[0]
251 | const interfaceName = root.interfaceName!
252 | const definition = this.interfaces.find(i => i.name === interfaceName)!
253 | const fields = definition.fields
254 |
255 | const codeForSingleValue = (value: AST, required: boolean): string => {
256 | const type = value.type
257 | const upper = type.substring(0, 1).toUpperCase() + type.substring(1)
258 | if (!required) {
259 | return upper
260 | }
261 | return `{ type: ${upper}, required: true }`
262 | }
263 |
264 | const codeForValue = (value: AST[], required: boolean): string => {
265 | if (value.length === 1) return codeForSingleValue(value[0], required)
266 | return `[${value
267 | .map(val => codeForSingleValue(val, required))
268 | .join(', ')}]`
269 | }
270 |
271 | const codeForField = (field: Field): string => {
272 | const _field = this._filterNotNullables(field)
273 | return `${JSON.stringify(field.name)}: ${codeForValue(
274 | _field.value,
275 | _field.required
276 | )}`
277 | }
278 |
279 | const code = `{ ${fields.map(field => codeForField(field)).join(', ')} }`
280 | const prefix = 'const foo = '
281 | return prettier.format(prefix + code, options).substring(prefix.length)
282 | }
283 | }
284 |
285 | const user1 = {
286 | id: 123,
287 | firstName: 'John',
288 | lastName: 'Smith',
289 | image: 'http://...',
290 | publicProfile: true,
291 | links: [
292 | { url: 'http://...', text: 'Personal website' },
293 | { url: 'http://...', text: null }
294 | ],
295 | mixed: [{ foo: '' }, 'bar', 123, null],
296 | foo: []
297 | }
298 |
299 | const user2 = {
300 | id: 123,
301 | firstName: 'John',
302 | lastName: 'Smith',
303 | publicProfile: true,
304 | links: [
305 | { url: 'http://...', text: 'Personal website' },
306 | { url: 'http://...', text: 'Personal website' }
307 | ]
308 | }
309 |
310 | export default Typer
311 |
312 | if (module.id === require.main!.id) {
313 | const typer = new Typer()
314 | typer.addDocument(user1)
315 | typer.addDocument(user2)
316 | // typer.addDocument('whatever')
317 |
318 | // const ast = typer.createAST('UsersResponse')
319 | // console.log(JSON.stringify(ast, null, 2))
320 |
321 | // console.log(typer.createTypeScript('UsersResponse'))
322 | console.log(typer.createPropTypes('MyComponent.propTypes'))
323 | }
324 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | import { ObjectStringToString } from './types'
2 |
3 | const camelcase = require('camelcase')
4 |
5 | export const docComment = (text: string) => {
6 | const lines = text.trim().split('\n')
7 | const docLines = lines.map(line => ` * ${line}\n`)
8 | return `/*\n${docLines.join('')} */`
9 | }
10 |
11 | export const replaceRoot = (selector: string, text: string) => {
12 | return selector.replace(
13 | /\\"|"(?:\\"|[^"])*"|\\'|'(?:\\'|[^'])*'|(\:root)/g,
14 | str => (str.charAt(0) === ':' ? text : str)
15 | )
16 | }
17 |
18 | // see https://facebook.github.io/react/docs/dom-elements.html
19 | const validAttributes = new Set([
20 | // HTML attributes
21 | 'accept',
22 | 'acceptCharset',
23 | 'accessKey',
24 | 'action',
25 | 'allowFullScreen',
26 | 'allowTransparency',
27 | 'alt',
28 | 'async',
29 | 'autoComplete',
30 | 'autoFocus',
31 | 'autoPlay',
32 | 'capture',
33 | 'cellPadding',
34 | 'cellSpacing',
35 | 'challenge',
36 | 'charSet',
37 | 'checked',
38 | 'cite',
39 | 'classID',
40 | 'className',
41 | 'colSpan',
42 | 'cols',
43 | 'content',
44 | 'contentEditable',
45 | 'contextMenu',
46 | 'controls',
47 | 'coords',
48 | 'crossOrigin',
49 | 'data',
50 | 'dateTime',
51 | 'default',
52 | 'defer',
53 | 'dir',
54 | 'disabled',
55 | 'download',
56 | 'draggable',
57 | 'encType',
58 | 'form',
59 | 'formAction',
60 | 'formEncType',
61 | 'formMethod',
62 | 'formNoValidate',
63 | 'formTarget',
64 | 'frameBorder',
65 | 'headers',
66 | 'height',
67 | 'hidden',
68 | 'high',
69 | 'href',
70 | 'hrefLang',
71 | 'htmlFor',
72 | 'httpEquiv',
73 | 'icon',
74 | 'id',
75 | 'inputMode',
76 | 'integrity',
77 | 'is',
78 | 'keyParams',
79 | 'keyType',
80 | 'kind',
81 | 'label',
82 | 'lang',
83 | 'list',
84 | 'loop',
85 | 'low',
86 | 'manifest',
87 | 'marginHeight',
88 | 'marginWidth',
89 | 'max',
90 | 'maxLength',
91 | 'media',
92 | 'mediaGroup',
93 | 'method',
94 | 'min',
95 | 'minLength',
96 | 'multiple',
97 | 'muted',
98 | 'name',
99 | 'noValidate',
100 | 'nonce',
101 | 'open',
102 | 'optimum',
103 | 'pattern',
104 | 'placeholder',
105 | 'poster',
106 | 'preload',
107 | 'profile',
108 | 'radioGroup',
109 | 'readOnly',
110 | 'rel',
111 | 'required',
112 | 'reversed',
113 | 'role',
114 | 'rowSpan',
115 | 'rows',
116 | 'sandbox',
117 | 'scope',
118 | 'scoped',
119 | 'scrolling',
120 | 'seamless',
121 | 'selected',
122 | 'shape',
123 | 'size',
124 | 'sizes',
125 | 'span',
126 | 'spellCheck',
127 | 'src',
128 | 'srcDoc',
129 | 'srcLang',
130 | 'srcSet',
131 | 'start',
132 | 'step',
133 | 'style',
134 | 'summary',
135 | 'tabIndex',
136 | 'target',
137 | 'title',
138 | 'type',
139 | 'useMap',
140 | 'value',
141 | 'width',
142 | 'wmode',
143 | 'wrap',
144 |
145 | // RDF attributes
146 | 'about',
147 | 'datatype',
148 | 'inlist',
149 | 'prefix',
150 | 'property',
151 | 'resource',
152 | 'typeof',
153 | 'vocab',
154 |
155 | // non-standard
156 | 'autoCapitalize',
157 | 'autoCorrect',
158 | 'color',
159 | 'itemProp',
160 | 'itemScope',
161 | 'itemType',
162 | 'itemRef',
163 | 'itemID',
164 | 'security',
165 | 'unselectable',
166 | 'results',
167 | 'autoSave',
168 |
169 | // SVG attributes
170 | 'accentHeight',
171 | 'accumulate',
172 | 'additive',
173 | 'alignmentBaseline',
174 | 'allowReorder',
175 | 'alphabetic',
176 | 'amplitude',
177 | 'arabicForm',
178 | 'ascent',
179 | 'attributeName',
180 | 'attributeType',
181 | 'autoReverse',
182 | 'azimuth',
183 | 'baseFrequency',
184 | 'baseProfile',
185 | 'baselineShift',
186 | 'bbox',
187 | 'begin',
188 | 'bias',
189 | 'by',
190 | 'calcMode',
191 | 'capHeight',
192 | 'clip',
193 | 'clipPath',
194 | 'clipPathUnits',
195 | 'clipRule',
196 | 'colorInterpolation',
197 | 'colorInterpolationFilters',
198 | 'colorProfile',
199 | 'colorRendering',
200 | 'contentScriptType',
201 | 'contentStyleType',
202 | 'cursor',
203 | 'cx',
204 | 'cy',
205 | 'd',
206 | 'decelerate',
207 | 'descent',
208 | 'diffuseConstant',
209 | 'direction',
210 | 'display',
211 | 'divisor',
212 | 'dominantBaseline',
213 | 'dur',
214 | 'dx',
215 | 'dy',
216 | 'edgeMode',
217 | 'elevation',
218 | 'enableBackground',
219 | 'end',
220 | 'exponent',
221 | 'externalResourcesRequired',
222 | 'fill',
223 | 'fillOpacity',
224 | 'fillRule',
225 | 'filter',
226 | 'filterRes',
227 | 'filterUnits',
228 | 'floodColor',
229 | 'floodOpacity',
230 | 'focusable',
231 | 'fontFamily',
232 | 'fontSize',
233 | 'fontSizeAdjust',
234 | 'fontStretch',
235 | 'fontStyle',
236 | 'fontVariant',
237 | 'fontWeight',
238 | 'format',
239 | 'from',
240 | 'fx',
241 | 'fy',
242 | 'g1',
243 | 'g2',
244 | 'glyphName',
245 | 'glyphOrientationHorizontal',
246 | 'glyphOrientationVertical',
247 | 'glyphRef',
248 | 'gradientTransform',
249 | 'gradientUnits',
250 | 'hanging',
251 | 'horizAdvX',
252 | 'horizOriginX',
253 | 'ideographic',
254 | 'imageRendering',
255 | 'in',
256 | 'in2',
257 | 'intercept',
258 | 'k',
259 | 'k1',
260 | 'k2',
261 | 'k3',
262 | 'k4',
263 | 'kernelMatrix',
264 | 'kernelUnitLength',
265 | 'kerning',
266 | 'keyPoints',
267 | 'keySplines',
268 | 'keyTimes',
269 | 'lengthAdjust',
270 | 'letterSpacing',
271 | 'lightingColor',
272 | 'limitingConeAngle',
273 | 'local',
274 | 'markerEnd',
275 | 'markerHeight',
276 | 'markerMid',
277 | 'markerStart',
278 | 'markerUnits',
279 | 'markerWidth',
280 | 'mask',
281 | 'maskContentUnits',
282 | 'maskUnits',
283 | 'mathematical',
284 | 'mode',
285 | 'numOctaves',
286 | 'offset',
287 | 'opacity',
288 | 'operator',
289 | 'order',
290 | 'orient',
291 | 'orientation',
292 | 'origin',
293 | 'overflow',
294 | 'overlinePosition',
295 | 'overlineThickness',
296 | 'paintOrder',
297 | 'panose1',
298 | 'pathLength',
299 | 'patternContentUnits',
300 | 'patternTransform',
301 | 'patternUnits',
302 | 'pointerEvents',
303 | 'points',
304 | 'pointsAtX',
305 | 'pointsAtY',
306 | 'pointsAtZ',
307 | 'preserveAlpha',
308 | 'preserveAspectRatio',
309 | 'primitiveUnits',
310 | 'r',
311 | 'radius',
312 | 'refX',
313 | 'refY',
314 | 'renderingIntent',
315 | 'repeatCount',
316 | 'repeatDur',
317 | 'requiredExtensions',
318 | 'requiredFeatures',
319 | 'restart',
320 | 'result',
321 | 'rotate',
322 | 'rx',
323 | 'ry',
324 | 'scale',
325 | 'seed',
326 | 'shapeRendering',
327 | 'slope',
328 | 'spacing',
329 | 'specularConstant',
330 | 'specularExponent',
331 | 'speed',
332 | 'spreadMethod',
333 | 'startOffset',
334 | 'stdDeviation',
335 | 'stemh',
336 | 'stemv',
337 | 'stitchTiles',
338 | 'stopColor',
339 | 'stopOpacity',
340 | 'strikethroughPosition',
341 | 'strikethroughThickness',
342 | 'string',
343 | 'stroke',
344 | 'strokeDasharray',
345 | 'strokeDashoffset',
346 | 'strokeLinecap',
347 | 'strokeLinejoin',
348 | 'strokeMiterlimit',
349 | 'strokeOpacity',
350 | 'strokeWidth',
351 | 'surfaceScale',
352 | 'systemLanguage',
353 | 'tableValues',
354 | 'targetX',
355 | 'targetY',
356 | 'textAnchor',
357 | 'textDecoration',
358 | 'textLength',
359 | 'textRendering',
360 | 'to',
361 | 'transform',
362 | 'u1',
363 | 'u2',
364 | 'underlinePosition',
365 | 'underlineThickness',
366 | 'unicode',
367 | 'unicodeBidi',
368 | 'unicodeRange',
369 | 'unitsPerEm',
370 | 'vAlphabetic',
371 | 'vHanging',
372 | 'vIdeographic',
373 | 'vMathematical',
374 | 'values',
375 | 'vectorEffect',
376 | 'version',
377 | 'vertAdvY',
378 | 'vertOriginX',
379 | 'vertOriginY',
380 | 'viewBox',
381 | 'viewTarget',
382 | 'visibility',
383 | 'widths',
384 | 'wordSpacing',
385 | 'writingMode',
386 | 'x',
387 | 'x1',
388 | 'x2',
389 | 'xChannelSelector',
390 | 'xHeight',
391 | 'xlinkActuate',
392 | 'xlinkArcrole',
393 | 'xlinkHref',
394 | 'xlinkRole',
395 | 'xlinkShow',
396 | 'xlinkTitle',
397 | 'xlinkType',
398 | 'xmlns',
399 | 'xmlnsXlink',
400 | 'xmlBase',
401 | 'xmlLang',
402 | 'xmlSpace',
403 | 'y',
404 | 'y1',
405 | 'y2',
406 | 'yChannelSelector',
407 | 'z',
408 | 'zoomAndPan'
409 |
410 | // TODO: allow?
411 | // 'dangerouslySetInnerHTML'
412 | ])
413 |
414 | // See: https://facebook.github.io/react/docs/events.html
415 | const validEventNames = [
416 | 'onCopy',
417 | 'onCut',
418 | 'onPaste',
419 | 'onCompositionEnd',
420 | 'onCompositionStart',
421 | 'onCompositionUpdate',
422 | 'onKeyDown',
423 | 'onKeyPress',
424 | 'onKeyUp',
425 | 'onFocus',
426 | 'onBlur',
427 | 'onChange',
428 | 'onInput',
429 | 'onSubmit',
430 | 'onClick',
431 | 'onContextMenu',
432 | 'onDoubleClick',
433 | 'onDrag',
434 | 'onDragEnd',
435 | 'onDragEnter',
436 | 'onDragExit',
437 | 'onDragLeave',
438 | 'onDragOver',
439 | 'onDragStart',
440 | 'onDrop',
441 | 'onMouseDown',
442 | 'onMouseEnter',
443 | 'onMouseLeave',
444 | 'onMouseMove',
445 | 'onMouseOut',
446 | 'onMouseOver',
447 | 'onMouseUp',
448 | 'onSelect',
449 | 'onTouchCancel',
450 | 'onTouchEnd',
451 | 'onTouchMove',
452 | 'onTouchStart',
453 | 'onScroll',
454 | 'onWheel',
455 | 'onAbort',
456 | 'onCanPlay',
457 | 'onCanPlayThrough',
458 | 'onDurationChange',
459 | 'onEmptied',
460 | 'onEncrypted',
461 | 'onEnded',
462 | 'onError',
463 | 'onLoadedData',
464 | 'onLoadedMetadata',
465 | 'onLoadStart',
466 | 'onPause',
467 | 'onPlay',
468 | 'onPlaying',
469 | 'onProgress',
470 | 'onRateChange',
471 | 'onSeeked',
472 | 'onSeeking',
473 | 'onStalled',
474 | 'onSuspend',
475 | 'onTimeUpdate',
476 | 'onVolumeChange',
477 | 'onWaiting',
478 | 'onLoad',
479 | 'onError',
480 | 'onAnimationStart',
481 | 'onAnimationEnd',
482 | 'onAnimationIteration',
483 | 'onTransitionEnd'
484 | ].reduce((map, value) => {
485 | map.set(value.toLowerCase(), value)
486 | return map
487 | }, new Map())
488 |
489 | export function isPackaged() {
490 | const { mainModule } = process
491 | return mainModule && mainModule.filename.includes('app.asar')
492 | }
493 |
494 | export function uppercamelcase(str: string): string {
495 | const cased: string = camelcase(str)
496 | return cased.charAt(0).toUpperCase() + cased.slice(1)
497 | }
498 |
499 | const mapping: ObjectStringToString = {
500 | class: 'className',
501 | tabindex: 'tabIndex'
502 | }
503 | export function toReactAttributeName(name: string): string | null {
504 | if (name.startsWith('aria-') || name.startsWith('data-')) {
505 | return name.toLowerCase()
506 | }
507 | const rname = camelcase(mapping[name] || name)
508 | return validAttributes.has(rname) ? rname : null
509 | }
510 |
511 | export function toReactEventName(name: string): string | null {
512 | return validEventNames.get(name) || null
513 | }
514 |
515 | const CSS_URL_REGEXP = new RegExp(/(url\(\s*['"]?)([^"')]+)(["']?\s*\))/g)
516 | export { CSS_URL_REGEXP }
517 |
--------------------------------------------------------------------------------
/src/json.ts:
--------------------------------------------------------------------------------
1 | /*
2 | json_parse.js
3 | 2016-05-02
4 | Public Domain.
5 | NO WARRANTY EXPRESSED OR IMPLIED. USE AT YOUR OWN RISK.
6 | This file creates a json_parse function.
7 | json_parse(text, reviver)
8 | This method parses a JSON text to produce an object or array.
9 | It can throw a SyntaxError exception.
10 | The optional reviver parameter is a function that can filter and
11 | transform the results. It receives each of the keys and values,
12 | and its return value is used instead of the original value.
13 | If it returns what it received, then the structure is not modified.
14 | If it returns undefined then the member is deleted.
15 | Example:
16 | // Parse the text. Values that look like ISO date strings will
17 | // be converted to Date objects.
18 | myData = json_parse(text, function (key, value) {
19 | var a;
20 | if (typeof value === "string") {
21 | a =
22 | /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*)?)Z$/.exec(value);
23 | if (a) {
24 | return new Date(Date.UTC(+a[1], +a[2] - 1, +a[3], +a[4],
25 | +a[5], +a[6]));
26 | }
27 | }
28 | return value;
29 | });
30 | This is a reference implementation. You are free to copy, modify, or
31 | redistribute.
32 | This code should be minified before deployment.
33 | See http://javascript.crockford.com/jsmin.html
34 | USE YOUR OWN COPY. IT IS EXTREMELY UNWISE TO LOAD CODE FROM SERVERS YOU DO
35 | NOT CONTROL.
36 | */
37 |
38 | /*jslint for */
39 |
40 | /*property
41 | at, b, call, charAt, f, fromCharCode, hasOwnProperty, message, n, name,
42 | prototype, push, r, t, text
43 | */
44 |
45 | // This is a function that can parse a JSON text, producing a JavaScript
46 | // data structure. It is a simple, recursive descent parser. It does not use
47 | // eval or regular expressions, so it can be used as a model for implementing
48 | // a JSON parser in other languages.
49 |
50 | // We are defining the function inside of another function to avoid creating
51 | // global variables.
52 |
53 | const locationsSymbol = Symbol('locations')
54 | const coverageSymbol = Symbol('coverage')
55 |
56 | interface Location {
57 | line: number
58 | col: number
59 | }
60 |
61 | interface Range {
62 | start: Location
63 | end: Location
64 | }
65 |
66 | const coverable = (obj: any) => {
67 | const coverage = (obj[coverageSymbol] = new Set())
68 | return new Proxy(obj, {
69 | get(target: any, propKey: string | number) {
70 | if (Array.isArray(target) && typeof propKey === 'number') {
71 | coverage.add(propKey)
72 | } else if (target.hasOwnProperty(propKey)) {
73 | coverage.add(propKey)
74 | }
75 | return target[propKey]
76 | }
77 | })
78 | }
79 |
80 | let at: number // The index of the current character
81 | let line: number // line
82 | let col: number // collumn
83 | let ch: any // The current character
84 | let previous = { line: 0, col: 0 }
85 | const escapee: { [index: string]: string } = {
86 | '"': '"',
87 | '\\': '\\',
88 | '/': '/',
89 | b: '\b',
90 | f: '\f',
91 | n: '\n',
92 | r: '\r',
93 | t: '\t'
94 | }
95 | let text: string
96 |
97 | const error = (m: string) => {
98 | // Call error when something is wrong.
99 |
100 | const err = new Error('SyntaxError')
101 | Object.assign(err, {
102 | name: 'SyntaxError',
103 | message: m,
104 | at,
105 | text
106 | })
107 | return err
108 | }
109 |
110 | const next = (c?: string) => {
111 | // If a c parameter is provided, verify that it matches the current character.
112 |
113 | if (c && c !== ch) {
114 | throw error(`Expected '${c}' instead of '${ch}'`)
115 | }
116 |
117 | previous = { line, col }
118 |
119 | // Get the next character. When there are no more characters,
120 | // return the empty string.
121 |
122 | ch = text.charAt(at)
123 | at += 1
124 | col += 1
125 | if (ch === '\n') {
126 | line += 1
127 | col = 0
128 | }
129 | return ch
130 | }
131 |
132 | const parseNumber = () => {
133 | // Parse a number value.
134 |
135 | let numberValue: number
136 | let stringValue = ''
137 |
138 | if (ch === '-') {
139 | stringValue = '-'
140 | next('-')
141 | }
142 | while (ch >= '0' && ch <= '9') {
143 | stringValue += ch
144 | next()
145 | }
146 | if (ch === '.') {
147 | stringValue += '.'
148 | while (next() && ch >= '0' && ch <= '9') {
149 | stringValue += ch
150 | }
151 | }
152 | if (ch === 'e' || ch === 'E') {
153 | stringValue += ch
154 | next()
155 | if (ch === '-' || ch === '+') {
156 | stringValue += ch
157 | next()
158 | }
159 | while (ch >= '0' && ch <= '9') {
160 | stringValue += ch
161 | next()
162 | }
163 | }
164 | numberValue = +stringValue
165 | if (!isFinite(numberValue)) {
166 | throw error('Bad number')
167 | } else {
168 | return numberValue
169 | }
170 | }
171 |
172 | const parseString = () => {
173 | // Parse a string value.
174 |
175 | let hex
176 | let i
177 | let stringValue = ''
178 | let uffff
179 |
180 | // When parsing for string values, we must look for " and \ characters.
181 |
182 | if (ch === '"') {
183 | while (next()) {
184 | if (ch === '"') {
185 | next()
186 | return stringValue
187 | }
188 | if (ch === '\\') {
189 | next()
190 | if (ch === 'u') {
191 | uffff = 0
192 | for (i = 0; i < 4; i += 1) {
193 | hex = parseInt(next(), 16)
194 | if (!isFinite(hex)) {
195 | break
196 | }
197 | uffff = uffff * 16 + hex
198 | }
199 | stringValue += String.fromCharCode(uffff)
200 | } else if (typeof escapee[ch] === 'string') {
201 | stringValue += escapee[ch]
202 | } else {
203 | break
204 | }
205 | } else {
206 | stringValue += ch
207 | }
208 | }
209 | }
210 | throw error('Bad string')
211 | }
212 |
213 | const white = () => {
214 | // Skip whitespace.
215 |
216 | while (ch && ch <= ' ') {
217 | next()
218 | }
219 | }
220 |
221 | const parseWord = () => {
222 | // true, false, or null.
223 |
224 | switch (ch) {
225 | case 't':
226 | next('t')
227 | next('r')
228 | next('u')
229 | next('e')
230 | return true
231 | case 'f':
232 | next('f')
233 | next('a')
234 | next('l')
235 | next('s')
236 | next('e')
237 | return false
238 | case 'n':
239 | next('n')
240 | next('u')
241 | next('l')
242 | next('l')
243 | return null
244 | }
245 | throw error(`Unexpected '${ch}'`)
246 | }
247 |
248 | const parseArray = () => {
249 | // Parse an array value.
250 |
251 | const arr: any = coverable([])
252 | const locations = new Map()
253 | arr[locationsSymbol] = locations
254 |
255 | if (ch === '[') {
256 | next('[')
257 | white()
258 | if (ch === ']') {
259 | next(']')
260 | return arr // empty array
261 | }
262 | while (ch) {
263 | const start = { line, col }
264 | arr.push(parseValue())
265 | locations.set(arr.length - 1, {
266 | start,
267 | end: { line, col }
268 | })
269 | white()
270 | if (ch === ']') {
271 | next(']')
272 | return arr
273 | }
274 | next(',')
275 | white()
276 | }
277 | }
278 | throw error('Bad array')
279 | }
280 |
281 | const parseObject = () => {
282 | // Parse an object value.
283 |
284 | let key: string | undefined
285 | const obj: { [index: string]: any } = coverable({})
286 |
287 | const locations = new Map()
288 | obj[locationsSymbol] = locations
289 |
290 | if (ch === '{') {
291 | next('{')
292 | white()
293 | if (ch === '}') {
294 | next('}')
295 | return obj // empty object
296 | }
297 | while (ch) {
298 | const start = previous
299 | key = parseString()
300 | white()
301 | next(':')
302 | if (Object.hasOwnProperty.call(obj, key)) {
303 | throw error(`Duplicate key '${key}'`)
304 | }
305 | obj[key] = parseValue()
306 | locations.set(key, {
307 | start,
308 | end: previous
309 | })
310 | white()
311 | if (ch === '}') {
312 | next('}')
313 | return obj
314 | }
315 | next(',')
316 | white()
317 | }
318 | }
319 | throw error('Bad object')
320 | }
321 |
322 | const parseValue = () => {
323 | // Parse a JSON value. It could be an object, an array, a string, a number,
324 | // or a word.
325 |
326 | white()
327 | switch (ch) {
328 | case '{':
329 | return parseObject()
330 | case '[':
331 | return parseArray()
332 | case '"':
333 | return parseString()
334 | case '-':
335 | return parseNumber()
336 | default:
337 | return ch >= '0' && ch <= '9' ? parseNumber() : parseWord()
338 | }
339 | }
340 |
341 | // Return the json_parse function. It will have access to all of the above
342 | // functions and variables.
343 |
344 | const parseJSON = (source: string, reviver?: any) => {
345 | text = source
346 | at = 0
347 | ch = ' '
348 | line = 0
349 | col = 0
350 | previous = { line, col }
351 | const result = parseValue()
352 | white()
353 | if (ch) {
354 | throw error('Syntax error')
355 | }
356 |
357 | // If there is a reviver function, we recursively walk the new structure,
358 | // passing each name/value pair to the reviver function for possible
359 | // transformation, starting with a temporary root object that holds the result
360 | // in an empty key. If there is not a reviver function, we simply return the
361 | // result.
362 |
363 | return typeof reviver === 'function'
364 | ? (function walk(holder: any, key: string) {
365 | let k: string
366 | let v: any
367 | const val = holder[key]
368 | if (val && typeof val === 'object') {
369 | for (k in val) {
370 | if (Object.prototype.hasOwnProperty.call(val, k)) {
371 | v = walk(val, k)
372 | if (v !== undefined) {
373 | val[k] = v
374 | } else {
375 | delete val[k]
376 | }
377 | }
378 | }
379 | }
380 | return reviver.call(holder, key, val)
381 | })({ '': result }, '')
382 | : result
383 | }
384 |
385 | /* if (module === require.main) {
386 | const json = parseJSON(`{
387 | "hello": "world",
388 | "array": [
389 | 1, 2, 3, 4
390 | ]
391 | }`)
392 | console.log(json)
393 | const message = json.hello
394 | const foo = json.array
395 |
396 | for (const key in json) {
397 | if (!json[coverageSymbol].has(key)) {
398 | const locations = json[locationsSymbol]
399 | console.log('key not used', key, 'at', locations.get(key))
400 | }
401 | }
402 | // console.log(json)
403 | } */
404 |
405 | export { locationsSymbol }
406 |
407 | export default parseJSON
408 |
--------------------------------------------------------------------------------
/src/sketch.ts:
--------------------------------------------------------------------------------
1 | import * as parse5 from 'parse5'
2 | import { inheritedProperties, textInheritedProperties } from './common'
3 |
4 | const prettier = require('prettier')
5 |
6 | interface Frame {
7 | x: number
8 | y: number
9 | width: number
10 | height: number
11 | area?: number
12 | }
13 |
14 | interface SketchCSS {
15 | [index: string]: string
16 | }
17 |
18 | interface SketchLayout {
19 | hasFixedHeight: boolean
20 | hasFixedWidth: boolean
21 | hasFixedBottom: boolean
22 | hasFixedTop: boolean
23 | hasFixedRight: boolean
24 | hasFixedLeft: boolean
25 | }
26 |
27 | interface SketchLayer {
28 | name: string
29 | frame: Frame
30 | css: SketchCSS
31 | layout: SketchLayout | {}
32 | children: SketchLayer[]
33 | svg?: string
34 | image?: string
35 | text?: string
36 | textAlign?: string
37 | classNames?: string[]
38 | row?: boolean
39 | }
40 |
41 | interface TreeCSS {
42 | attributes: SketchCSS
43 | subSelectors: {
44 | [index: string]: TreeCSS
45 | }
46 | }
47 |
48 | const frameContains = (frame: Frame, otherFrame: Frame): boolean => {
49 | return (
50 | frame.x <= otherFrame.x &&
51 | frame.y <= otherFrame.y &&
52 | frame.x + frame.width >= otherFrame.x + otherFrame.width &&
53 | frame.y + frame.height >= otherFrame.y + otherFrame.height
54 | )
55 | }
56 |
57 | const createNode = (
58 | parent: parse5.AST.Element,
59 | layer: SketchLayer,
60 | indentLevel: number
61 | ) => {
62 | const attrs: SketchCSS = {}
63 | const css = layer.css
64 | if (Object.keys(css).length > 0)
65 | attrs.style = Object.keys(css)
66 | .map(key => `${key}: ${css[key]}`)
67 | .join('; ')
68 | if (layer.classNames && layer.classNames.length > 0) {
69 | attrs.class = layer.classNames.join(' ')
70 | }
71 | const attributes = Object.keys(attrs).map(key => ({
72 | name: key,
73 | value: attrs[key]
74 | }))
75 | if (layer.image) {
76 | attributes.push({
77 | name: 'src',
78 | value: `data:image/png;base64,${layer.image}`
79 | })
80 | }
81 | const parseSvg = (
82 | text: string,
83 | additionalAttributes: parse5.AST.Default.Attribute[]
84 | ) => {
85 | const parsedSVG = parse5.parseFragment(
86 | text
87 | ) as parse5.AST.Default.DocumentFragment
88 | const root = parsedSVG.childNodes.find(
89 | node => node.nodeName === 'svg'
90 | )! as parse5.AST.Default.Element
91 |
92 | // if it's a super simple SVG, ignore it
93 | const isSuperSimple = (node: parse5.AST.Default.Node): boolean => {
94 | const elemnt = node as parse5.AST.Default.Element
95 | if (!elemnt.childNodes) return true
96 | const notSimpleElement = !['svg', 'path', 'g', 'defs', 'desc'].includes(
97 | elemnt.nodeName
98 | )
99 | if (notSimpleElement) {
100 | return false
101 | }
102 | if (elemnt.nodeName === 'path') {
103 | const d = elemnt.attrs.find(attr => attr.name === 'd')
104 | if (d) {
105 | // simple paths are like M155,9 L155,33
106 | const coordinates = d.value.match(/\d+,\d+/g)
107 | if (coordinates && coordinates.length > 2) return false
108 | }
109 | }
110 | return !elemnt.childNodes.find(childNode => !isSuperSimple(childNode))
111 | }
112 | if (isSuperSimple(root)) {
113 | return null
114 | }
115 |
116 | root.attrs = root.attrs.concat(additionalAttributes)
117 | return root
118 | }
119 | const svg =
120 | layer.svg && layer.children.length === 0 && parseSvg(layer.svg, attributes)
121 | const element =
122 | svg ||
123 | parse5.treeAdapters.default.createElement(
124 | layer.image ? 'img' : 'div',
125 | '',
126 | attributes
127 | )
128 | parse5.treeAdapters.default.appendChild(parent, element)
129 | if (layer.text) {
130 | // element.style.textAlign = layer.textAlign
131 | parse5.treeAdapters.default.insertText(element, layer.text)
132 | } else if (layer.children.length > 0) {
133 | layer.children.forEach(child => {
134 | parse5.treeAdapters.default.insertText(
135 | element,
136 | '\n' + ' '.repeat(indentLevel + 1)
137 | )
138 | createNode(element, child, indentLevel + 1)
139 | })
140 | parse5.treeAdapters.default.insertText(
141 | element,
142 | '\n' + ' '.repeat(indentLevel)
143 | )
144 | }
145 | }
146 |
147 | const calculateRows = (layer: SketchLayer) => {
148 | let children = layer.children.slice(0)
149 | children = children.sort((a, b) => a.frame.y - b.frame.y)
150 | layer.children = children.reduce((layerChildNodes, child1, i) => {
151 | let y = child1.frame.y + child1.frame.height
152 | const childNodes = children.slice(i + 1).reduce(
153 | (arr, child2) => {
154 | if (child2.frame.y < y) {
155 | arr.push(child2)
156 | children.splice(i, 1)
157 | child2.children.forEach(calculateRows)
158 | y = Math.max(y, child2.frame.y + child2.frame.height)
159 | }
160 | return arr
161 | },
162 | [child1]
163 | )
164 | if (childNodes.length > 1) {
165 | const frame = childNodes.reduce(
166 | (frme, node) => {
167 | frme.x = Math.min(frme.x, node.frame.x)
168 | frme.y = Math.min(frme.y, node.frame.y)
169 | frme.width = Math.max(frme.width, node.frame.x + node.frame.width)
170 | frme.height = Math.max(frme.height, node.frame.y + node.frame.height)
171 | return frme
172 | },
173 | {
174 | x: Number.MAX_SAFE_INTEGER,
175 | y: Number.MAX_SAFE_INTEGER,
176 | width: Number.MIN_SAFE_INTEGER,
177 | height: Number.MIN_SAFE_INTEGER
178 | }
179 | )
180 | childNodes.forEach(node => {
181 | node.frame.x -= frame.x
182 | node.frame.y -= frame.y
183 | })
184 | const row = {
185 | name: '',
186 | frame,
187 | css: {
188 | // tslint:disable-next-line:object-literal-key-quotes
189 | display: 'flex',
190 | 'justify-content': 'space-between'
191 | },
192 | layout: {},
193 | children: childNodes.sort((a, b) => a.frame.x - b.frame.x),
194 | row: true
195 | }
196 | layerChildNodes.push(row)
197 | } else {
198 | layerChildNodes.push(child1)
199 | calculateRows(child1)
200 | }
201 | return layerChildNodes
202 | }, new Array())
203 | }
204 |
205 | const calculateContainers = (layer: SketchLayer) => {
206 | let children = layer.children.slice(0)
207 | children.forEach(sublayer => {
208 | sublayer.frame.area = sublayer.frame.width * sublayer.frame.height
209 | })
210 | children = children.sort((a, b) => b.frame.area! - a.frame.area!)
211 | children.forEach((child1, i) => {
212 | const childNodes = children.slice(i + 1).reduce((arr, child2) => {
213 | if (frameContains(child1.frame, child2.frame)) {
214 | arr.push(child2)
215 | children.splice(i, 1)
216 | }
217 | return arr
218 | }, new Array())
219 |
220 | if (childNodes.length > 0) {
221 | child1.children = child1.children.concat(childNodes)
222 | childNodes.forEach(node => {
223 | node.frame.x -= child1.frame.x
224 | node.frame.y -= child1.frame.y
225 | const index = layer.children.indexOf(node)
226 | if (index >= 0) layer.children.splice(index, 1)
227 | })
228 | }
229 | calculateContainers(child1)
230 | })
231 | layer.children = layer.children.sort((a, b) => a.frame.y - b.frame.y)
232 | }
233 |
234 | const simplifyCSSRules = (layer: SketchLayer) => {
235 | const allKeys: { [index: string]: { [index: string]: number } } = {}
236 | const extraInfo = {
237 | hasText: !!layer.text
238 | }
239 | if (layer.children.length === 0 && layer.svg) {
240 | layer.css['box-sizing'] = 'border-box'
241 | delete layer.css.background
242 | }
243 | if (layer.text && layer.textAlign) {
244 | layer.css['text-align'] = layer.textAlign
245 | }
246 | if (layer.image || layer.svg) {
247 | layer.css.width = `${layer.frame.width}px`
248 | layer.css.height = `${layer.frame.height}px`
249 | }
250 |
251 | layer.children.forEach(child => {
252 | const info = simplifyCSSRules(child)
253 | extraInfo.hasText = extraInfo.hasText || info.hasText
254 |
255 | inheritedProperties.forEach(key => {
256 | let currentValue = child.css[key]
257 | if (!currentValue) {
258 | if (!info.hasText && textInheritedProperties.includes(key)) {
259 | currentValue = '*'
260 | } else {
261 | return
262 | }
263 | }
264 | let allValues = allKeys[key]
265 | if (!allValues) {
266 | allValues = { [currentValue]: 1 }
267 | } else if (!allValues[currentValue]) {
268 | allValues[currentValue] = 1
269 | } else {
270 | allValues[currentValue]++
271 | }
272 | allKeys[key] = allValues
273 | })
274 | })
275 | Object.keys(allKeys).forEach(key => {
276 | const allValues = allKeys[key]
277 | Object.keys(allValues).forEach(value => {
278 | if (value === '*') return
279 | const count = allValues[value] + (allValues['*'] || 0)
280 | if (count === layer.children.length) {
281 | layer.css[key] = value
282 | layer.children.forEach(sublayer => delete sublayer.css[key])
283 | }
284 | })
285 | })
286 | return extraInfo
287 | }
288 |
289 | const calculateClassName = (layerName: string) => {
290 | const name = (layerName.match(/[_a-zA-Z0-9]+/g) || []).join('-').toLowerCase()
291 | if (!name) return name
292 | return name.match(/^[_a-zA-Z]/) ? name : 'layer-' + name
293 | }
294 |
295 | const calculateClassesAndSelectors = (
296 | layer: SketchLayer,
297 | parentLayer: SketchLayer | null,
298 | parentCSS: TreeCSS,
299 | nthChild: number
300 | ) => {
301 | const layerName = layer.row
302 | ? `${parentLayer!.name}-row-${nthChild}`
303 | : layer.name
304 | const className = calculateClassName(layerName)
305 | const selector = className
306 | ? `.${className}`
307 | : `& > :nth-child(${nthChild + 1})`
308 | const css = {
309 | attributes: layer.css,
310 | subSelectors: {}
311 | }
312 | parentCSS.subSelectors[selector] = css
313 | layer.css = {}
314 | layer.classNames = [className].filter(Boolean)
315 | layer.children.forEach((child, childIndex) => {
316 | calculateClassesAndSelectors(child, layer, css || parentCSS, childIndex)
317 | })
318 | }
319 |
320 | const simplifyCSSSelectors = (css: TreeCSS) => {
321 | Object.keys(css.subSelectors).forEach(selector => {
322 | const tree = css.subSelectors[selector]
323 | if (Object.keys(tree.attributes).length === 0) {
324 | const keys = Object.keys(tree.subSelectors)
325 | const nSelectors = keys.length
326 | if (nSelectors === 0) {
327 | delete css.subSelectors[selector]
328 | return
329 | } else if (nSelectors === 1) {
330 | const first = keys[0]
331 | const subTree = tree.subSelectors[first]
332 | delete css.subSelectors[selector]
333 | css.subSelectors[[selector, first].join(' ')] = subTree
334 | simplifyCSSSelectors(subTree)
335 | return
336 | }
337 | }
338 | return simplifyCSSSelectors(tree)
339 | })
340 | }
341 |
342 | const serializeCSS = (css: TreeCSS) => {
343 | let str = ''
344 | Object.keys(css.attributes).forEach(key => {
345 | str += `${key}: ${css.attributes[key]};\n`
346 | })
347 | Object.keys(css.subSelectors).forEach(selector => {
348 | str += `\n\n${selector} {\n${serializeCSS(css.subSelectors[selector])}}\n\n`
349 | })
350 | return str
351 | }
352 |
353 | interface SketchResult {
354 | markup: string
355 | style: string
356 | }
357 |
358 | export default async (input: string): Promise => {
359 | return new Promise((resolve, reject) => {
360 | const data = JSON.parse(input) as SketchLayer[]
361 | calculateContainers(data[0])
362 | calculateRows(data[0])
363 | simplifyCSSRules(data[0])
364 | const css: TreeCSS = {
365 | attributes: {},
366 | subSelectors: {}
367 | }
368 | calculateClassesAndSelectors(data[0], null, css, 0)
369 | simplifyCSSSelectors(css)
370 |
371 | const doc = parse5.treeAdapters.default.createDocumentFragment()
372 | createNode(doc, data[0], 0)
373 | resolve({
374 | markup: parse5.serialize(doc),
375 | style: prettier.format(
376 | `/*
377 | This SCSS was generated automatically. It is not perfect.
378 | It is meant to be a good starting point.
379 | You will specially need to add margins and paddings to the elements to
380 | recreate the full layout.
381 | */
382 |
383 | ` + serializeCSS(css),
384 | { parser: 'postcss' }
385 | )
386 | })
387 | })
388 | }
389 |
--------------------------------------------------------------------------------