skypager-project
Version:
skypager project framework
796 lines (642 loc) • 21.8 kB
JavaScript
import yaml from 'js-yaml'
import markdown, { utilLoaders as markdownUtils, profiles as markdownProfiles } from '../transformers/markdown'
import testRule from 'skypager-util/lib/path-matcher'
import { kebabCase, camelCase, snakeCase } from 'skypager-util/lib/string'
import omit from 'lodash/omit'
import { transpile } from './babel-es6'
import lodash from 'lodash'
export const rules = [
/\.md$/
]
export const transformPresets = () => {
return {
markdown: Object.keys(markdownProfiles),
}
}
export const getTransforms = () => {
return {
markdown: require('../transformers/markdown').default
}
}
export const getUtils = () => {
const loaders = markdownUtils()
return Object.keys(loaders).reduce((memo, key) => (
Object.assign(memo, {
get [key]() {
return loaders[key]()
}
})
), {})
}
export const compile = (contents, options = {}) => {
const profile = options.profile || 'standard'
const html = 'html'
try {
const ast = toAST(contents, {
profile
})
return markdown({profile:'html'}).stringify(ast)
} catch(error) {
const stack= ["```", error.stack, "```"].join("\n")
return markdown({profile:'html'})
.stringify(
toAST(`# Error\n\n${error.message}\n\n${stack}\n`)
)
}
}
export const toAST = (contents, options) => {
const profile = options.profile || 'standard'
try {
return markdown(profile, options).parse(contents)
} catch(error) {
return markdown(profile, options).parse(`# Error\n\n${error.message}\n\n${createBlock(error.stack)}\n## Contents\n${createBlock(contents)}`)
}
}
export const testDocument = (doc) => (
typeof rules.find(rule => testRule(rule, doc.fileBaseName)) !== 'undefined'
)
export const decorate = (doc) => {
doc.lazy('exportables', () => doc.createExportable())
doc.lazy('exports', () => doc.createExportable())
Object.assign(doc, {
stringify() {
return this.markdown('stringify', {}).stringify(doc.ast)
},
toCode(options = {}) {
const codeBlocks = doc.compileCodeBlocks({keyBy:'accessor'})
return codeBlocks.mapValues((block, id) => (`
// ${ block.docPath } at ${ block.index }
${block.value}
`.trim())).values().join("\n\n")
},
toModule(options = {}) {
return doc.project.createModule(doc.toCode(), options)
},
toEntity(selectorMap = {}) {
const obj = doc.exportables
const frontmatter = obj.frontmatter
const instance = Object.assign({}, obj, frontmatter)
if (lodash.isEmpty(selectorMap)) {
Object.assign(instance, doc.se)
}
},
transformWith(transformerFn, options = {}) {
const doc = this
transformerFn.call(doc, doc, options)
return doc
},
createExportable(options = {}) {
const html = doc.markdown('html', options)
const source = typeof options.transform === 'function'
? doc.transformWith(options.transform, options) || doc
: doc
const contents = source.contents || source.result('file.contents', () => doc.readFile().get('file.contents', ''))
const exportable = Object.assign({}, doc.frontmatter, {
id: source.id,
get contents() {
return contents
},
get frontmatter() {
return doc.frontmatter
},
get ast() {
return source.ast
},
get file() {
return omit(doc.file, 'contents')
},
get html() {
return doc.remarkHTML({
ast: source.renderAST || source.ast
})
}
})
return exportable
},
compileCodeBlocks(options = {}, context = {}) {
const babelRc = doc.result('project.babelRc', {presets:['skypager'],plugins:['transform-decorators-legacy']})
return doc
.selectChain('code-blocks', options)
.mapValues((nodes) => {
return nodes.map(node => {
let result
try {
result = transpile(node.value, babelRc)
} catch(error) {
result = {error, code: '', metadata: {}}
}
Object.assign(node, {
run() {
if (result.error) {
return Promise.resolve(Object.assign(node, {error}))
}
return doc.asyncRunner(result.code)()
.then((result) => {
return Object.assign(node, {
get result() {
return typeof result.value === 'function'
? result.value()
: result
},
inspectResult() {
return require('util').inspect(node.result)
}
})
})
.catch((error) => {
return Object.assign(node, {error})
})
},
get compiled() {
return result.code
},
get metadata() {
return result.metadata
}
})
return node
})
})
},
updateFrontmatter(data = {}, save = true) {
const ast = doc.ast
let frontmatter = doc.frontmatter
if (!this.hasFrontmatter()) {
this.ast.children.unshift({})
}
frontmatter = Object.assign(frontmatter, {updatedAt: (Math.floor(new Date() / 1000))}, data)
const { children } = doc.markdown().parse(`---\n${yaml.safeDump(frontmatter)}\n---\n`)
this.ast.children[0] = children[0]
if (save) {
const contents = doc.contents = doc.file.contents = doc.stringify()
doc.project.writeFileSync(doc.path, contents)
}
return this
},
hasFrontmatter() {
const zero = this.firstNode()
return zero && zero.type === 'yaml'
},
remarkAST(options = {}) {
const profile = options.profile || 'standard'
const contents = options.contents || this.contents || this.getFileContents()
try {
const result = doc.markdown(profile, options).parse(contents)
return Object.assign(result, {
children: result.children.map(node => doc.decorateNode(node))
})
} catch(error) {
return doc.markdown(profile, options).parse(`# Error\n\n${error.message}\n\n${createBlock(error.stack)}\n## Contents\n${createBlock(contents)}`)
}
},
remarkHTML(options = {}) {
const profile = options.profile || 'standard'
const parser = options.parser || doc.markdown(profile, options)
const contents = !options.ast && (options.contents || this.contents || this.getFileContents())
const ast = options.ast || parser.parse(contents)
const html = 'html'
try {
return doc.markdown(html, options).stringify(ast)
} catch(error) {
const stack= ["```", error.stack, "```"].join("\n")
return doc
.markdown(html, {})
.stringify(
doc.markdown(profile, {}).parse(`# Error\n\n${error.message}\n\n${stack}\n`)
)
}
},
markdown(profile = 'parser', options) {
return markdown({ profile, ...options })
},
createTree() {
const headings = this.createHeadingsMap('headingPath')
const tree = {}
Object.keys(headings).forEach(headingPath => {
const treePath = headingPath.split('/').map(v => camelCase(kebabCase(v)))
lodash.set(tree, treePath, headings[headingPath])
})
return tree
},
createHeadingsMap(keyBy = 'accessor', stayChained = false) {
const obj = this.chain
.invoke('selectHeadingNodes')
.map(heading => {
heading.accessorMethod = camelCase(snakeCase(heading.textContent()))
heading.headingPath = heading.getHeadingPath()
heading.accessors = heading.headingPath.split('/').map(i => camelCase(snakeCase(i)))
heading.accessor = camelCase(snakeCase(heading.headingPath.replace(/\//g,'_')))
return heading
})
.keyBy(keyBy)
return stayChained ? obj : obj.value()
},
stringifyNode(node) {
return require('mdast-util-to-string')(node)
},
getSourceForNode(node) {
return require('unist-util-source')(node)
},
decorateNode(node) {
const original = lodash.cloneDeep(node)
return Object.assign(node, {
original() {
return original
},
slug() {
return lodash.kebabCase(this.textContent())
},
textContent() {
return doc.stringifyNode(node) || ''
},
getHeadingPath() {
if (node.type !== 'heading') {
const prev = node.previousHeading()
return prev && prev.getHeadingPath()
}
const textContent = kebabCase(node.textContent().toLowerCase())
const prevSuperior = node.getPreviousSuperior()
const previousPath = typeof prevSuperior === 'undefined'
? ``
: (prevSuperior.depth >= 2 && prevSuperior.getHeadingPath()) || ''
return [previousPath, textContent].filter(i => i && i.length > 0).join('/')
},
headingRangeSync() {
const alpha = node
const bravo = node.getFinalHeading() || doc.finalNode()
return doc.getNodesBetweenPositions(alpha, bravo || 50000).value()
},
headingRange(test = node.textContent()) {
const ast = doc.ast
return new Promise((resolve,reject) => {
try {
require('mdast-util-heading-range')(ast, test, (startNode, nodes, endNode) => {
resolve({startNode, nodes, endNode})
})
} catch(error) {
reject(error)
}
})
},
getFinalHeading() {
return doc.findAllNodesAfter(node, (n) => n.type === 'heading' && n.depth <= node.depth )[0]
},
getPreviousSuperior() {
return node && node.getNodesBefore && node.getNodesBefore(n => n.type === 'heading' && (n.depth < node.depth))[0]
},
previousHeading() {
return node.getNodesBefore(n => n.type === 'heading').shift()
},
nextHeading() {
return node.type === 'heading'
? node.getNextNode((n) => n.type === 'heading' && n.depth >= node.depth)
: node.getNextNode(n => n.type === 'heading')
},
getNextNode(...args) {
return doc.findNodeAfter(node, ...args)
},
getNodesAfter(...args) {
return doc.findAllNodesAfter(node, ...args)
},
getNodesBefore(...args) {
return doc.findAllNodesBefore(node, ...args)
},
selectChildren(selector, testFn) {
if(node.type === 'heading') {
return node.selectHeadingChildren(selector, testFn)
}
const results = require('unist-util-select')(node)(selector)
return typeof testFn === 'function'
? results.filter(r => testFn.call(doc, r))
: results
},
virtualAST() {
return {
type: 'root',
children: node.headingRangeSync().map(n => n.original()).map((n,i) => {
n.index = i
return n
})
}
},
selectHeadingChildrenSync(selector = '*', testFn) {
let results = require('unist-util-select')(node.virtualAST())(selector)
results = typeof testFn === 'function'
? results.filter(r => testFn.call(doc, r))
: results
return results
},
selectHeadingChildren(selector = '*', testFn) {
return node.headingRange()
.then(({nodes}) => {
const base = {
type: 'root',
children: nodes
}
return require('unist-util-select')(base)(selector)
})
.then((results) => {
return typeof testFn === 'function'
? results.filter(r => testFn.call(doc, r))
: results
})
}
})
},
headingRange(test) {
const ast = doc.ast
return new Promise((resolve,reject) => {
try {
require('mdast-util-heading-range')(ast, test, (startNode, nodes, endNode) => {
resolve({startNode, nodes, endNode})
})
} catch(error) {
reject(error)
}
})
},
documentTitle() {
const titleNode = doc.titleNode()
if (!titleNode) {
return this.get('frontmatter.title', doc.variations.humanized)
} else {
return titleNode.textContent()
}
},
yamlBlocks() {
return this.selectNodes('code[lang="yaml"], yaml')
},
introParagraphs() {
const until = this.selectHeadingNodes()[1] || this.finalNode()
if (!until) {
return doc.selectNodes('paragraph')
} else {
const children = until.getNodesBefore((node) => (
['paragraph'].indexOf(node.type) >= 0
)).reverse()
return require('mdast-util-to-string')({type:'root', children})
}
},
wrapSections() {
const asts = this.sectionHeadingNodes()
.map(node => this.createAstFromSection(node, {
offset: false,
type: 'div',
data: {
hName: 'section',
hProperties: {
'data-section-path': node.getHeadingPath(),
'data-node-index': node.index,
},
}
}))
return asts
},
createDocumentFromSection(node, options = {}) {
const { startNode = node, endNode, nodes = [] } = node.headingRange()
const ast = doc.createAstFromSection(startNode, nodes)
return {
id: `${doc.id}/~/${node.headingPath || node.getHeadingPath()}`,
ast,
contents: require('unist-util-source')(ast),
}
},
createAstFromSection(node, children, options = {}) {
const startDepth = node.depth
const offset = options.offset !== false
? 1 - startDepth
: 0
children = children
.map(node => node.original ? node.original() : node)
.map(child =>
child.type === 'heading' ? (Object.assign(child, {depth: child.depth + offset})) : child
)
const newAST = {
type: options.type || 'root',
children,
data: options.data || {},
}
return newAST
},
withoutNodeIndexes(indexList = []) {
const ast = this.ast
return {
type: 'root',
children: children.filter(node => indexList.indexOf(node.index) === -1),
data: ast.data,
}
},
replaceSection(sectionNode, newNode) {
return this.extractSectionNodes(sectionNode)
.then((nodes) => this.withoutNodeIndexes(nodes.map(n => n.index)))
.then((newAst) => {
return newAst
})
},
createContentNode(tagName, attributes = {}, value) {
const node = {
type: 'div',
data: {
hName: tagName || 'div',
hProperties: attributes,
}
}
if (typeof value === 'string') {
node.value = value
}
return node
},
createAST(options = {}) {
const newAST = {
type: options.type || 'root',
children: options.children || [],
data: options.data || {},
}
return newAST
},
extractSectionNodes(node) {
return node.headingRange().then(({nodes}) => nodes)
},
inspectOutline() {
const headings = this.selectHeadingNodes()
return headings.map(headingNode => {
const content = `- ${headingNode.textContent()}`
return lodash.padStart(content, content.length + (headingNode.depth - 1) * 2)
}).join("\n")
},
headingIndexes(depth) {
return this.chain
.invoke('selectHeadingNodes', depth && `[depth=${depth}]`)
.sortBy(node => node.position ? node.position.start.line : node.index)
.map(node => node.index)
.value()
},
references() {
return Object.assign({}, this.referenceImages(), this.referenceLinks())
},
referenceImages() {
return this.chain
.invoke('selectNodes', 'imageReference')
.keyBy('identifier')
.mapValues((refLink, identifier) => {
const definitionNodes = doc.definitionNodes()
return Object.assign(refLink, {
definitionNode() {
return definitionNodes[identifier]
},
get definition() {
return lodash.pick(definitionNodes[identifier], 'title', 'url', 'identifier')
}
})
})
.value()
},
imageNodes() {
return this.selectNodes('image')
},
referenceLinks() {
return this.chain
.invoke('selectNodes', 'linkReference')
.keyBy('identifier')
.mapValues((refLink, identifier) => {
const definitionNodes = doc.definitionNodes()
return Object.assign(refLink, {
definitionNode() {
return definitionNodes[identifier]
},
get definition() {
return lodash.pick(definitionNodes[identifier], 'title', 'url', 'identifier')
}
})
})
.value()
},
definitionNodes() {
return this.chain
.invoke('selectNodes', 'definition')
.keyBy('identifier')
.value()
},
selectHeadingsByDepth(depth = 1) {
return this.selectHeadingNodes(`[depth=${depth}]`)
},
selectOneNode(selector) {
return require('unist-util-select')(this.ast)(selector)[0]
},
selectNodes(selector = '*', testFn) {
const results = require('unist-util-select')(this.ast)(selector)
return typeof testFn === 'function'
? results.filter(r => testFn.call(doc, r))
: results
},
selectHeadingNodes(enhance = '') {
return doc.selectNodes(`heading${enhance}`.trim())
},
selectCodeBlocks(lang = 'javascript', enhance='') {
return doc.selectNodes(`code[lang=${lang}]${enhance}`.trim())
},
findNodeAfter(node, ...args) {
return require('unist-util-find-after')(this.ast, node, ...args)
},
findAllNodesAfter(node, ...args) {
return require('unist-util-find-all-after')(this.ast, node, ...args)
},
findAllNodesBefore(node, ...args) {
return require('unist-util-find-all-before')(this.ast, node, ...args)
},
nodesIndexedByPosition() {
return doc.chain
.get('ast.children')
.keyBy(node => parseInt(node.position.start.line))
.value()
},
introNodes() {
const f = this.selectNodes('heading')
const m = f.find(n => n.depth > 1)
const nodes = m
? m.getNodesBefore().reverse()
: this.ast.children
return nodes.filter(n => n.type !== 'yaml')
},
finalHeading() {
return this.selectNodes('heading:last-of-type')
},
titleNode() {
return this.selectOneNode('heading[depth=1]') || this.selectOneNode('heading:first-of-type')
},
firstSectionHeadingNode() {
return this.selectOneNode('heading[depth=2]')
},
sectionHeadingNodes() {
return this.selectNodes('heading[depth=2]')
},
articleHeadingNodes() {
return this.selectNodes('heading[depth=3]')
},
subarticleHeadingNodes() {
return this.selectNodes('heading').filter(n => n.depth >= 4)
},
getNodesBetweenPositions(alpha, bravo) {
const start = isNode(alpha) ? alpha.position.start.line : parseInt(alpha)
const end = isNode(bravo) ? bravo.position.start.line : parseInt(bravo)
return doc.chain.get('ast.children').filter(node => {
const { position: {
start: {
line
}
}} = node
return line > start && line < end
})
},
firstNode() {
return this.chain.get('ast.children', []).first().value()
},
finalNode() {
return this.chain.get('ast.children', []).last().value()
},
getAST() {
const _ast = doc.remarkAST()
return Object.assign(_ast, {
children: _ast.children.map((n,i) => {
const d = doc.decorateNode(n)
d.index = i
return d
}),
})
},
getResults() {
const codeBlocks = doc.compileCodeBlocks().values().flatten().invokeMap('run').thru(r => Promise.all(r)).value()
return codeBlocks
.then((results) => {
doc.hide('codeBlockResults', results)
})
.then(() => {
return this
})
}
})
doc.lazy('ast', () => doc.getAST())
doc.lazy('renderAST', () => {
return doc.remarkAST()
})
doc.hide('parseFrontmatter', (val) => {
if (!val) {
const firstNode = doc.get('ast.children[0]', {})
val = firstNode.type === 'yaml' ? firstNode.value : undefined
}
return val && val.length > 0 ? yaml.safeLoad(val) : {}
})
doc.lazy('frontmatter', () => {
const firstNode = doc.get('ast.children[0]', {})
return firstNode.type === 'yaml' ? yaml.load(firstNode.value) : {}
})
doc.lazy('headingsMap', () => doc.createHeadingsMap())
return doc
}
export default decorate
function isNode(obj) {
return typeof obj === 'object' && typeof obj.type === 'string' && obj.position
}
function createBlock(contents, lang='') {
return ["```" + lang, contents, "```"].join("\n")
}