skypager-project
Version:
skypager project framework
565 lines (461 loc) • 13.9 kB
JavaScript
import url from 'url'
import { parse as parsePath, sep, basename, resolve, relative } from 'path'
import { mixinPropertyUtils } from 'skypager-util/lib/properties'
import * as stringUtils from 'skypager-util/lib/string'
import isAbsolutePath from 'path-is-absolute'
import Collection from '../collection'
import ContextRegistry from 'skypager-registry/lib/context'
import Cache from '../cache'
import mapValues from 'lodash/mapValues'
import isFunction from 'lodash/isFunction'
import vfile from 'vfile'
import set from 'lodash/set'
import omit from 'lodash/omit'
import pick from 'lodash/pick'
import uniq from 'lodash/uniq'
import isEmpty from 'lodash/isEmpty'
import castArray from 'lodash/castArray'
import hashObject from 'object-hash'
export const PATH_SEPARATOR = sep
export const DefaultPatterns = [
'**/*.{js,md,yml,json,svg,html,css,scss,less,json}',
'!node_modules/**'
]
export const patterns = DefaultPatterns
export class Document {
static patterns = DefaultPatterns;
static mountCollection(cwd, options = {}) {
if (!cwd) {
throw new Error('Must pass a folder to Document.mountCollection')
}
return Collection.mount(cwd, options)
}
static cache = new Cache()
static selectors = new ContextRegistry('documentSelectors', {
context: require.context('../selectors/document', true, /\.js$/),
wrapper: function(fn, id) {
return (doc, ...args) =>
Document.cache.fetch(`selectors:${id}:${hashObject({args})}:${doc.cacheKey}`, () => fn.call(doc, doc.exporter, ...args).value())
}
})
constructor (vFileOrPath, options = {}) {
mixinPropertyUtils(this)
if (options.project) {
this.hide('project', options.project)
delete(options.project)
}
if (options.collection) {
this.hide('collection', options.collection)
delete(options.collection)
}
this.hide('options', options)
this.hide('cwd', options.cwd)
const originalURI = normalizeUri(
vFileOrPath.path ? vFileOrPath.path : vFileOrPath,
options.cwd
)
this.hide('originalURI', originalURI)
this.hide('parsedURI', url.parse(this.originalURI))
this.hide('url', this.parsedURI.href)
this.hide('uri', this.url)
this.hide('path', unescape(this.parsedURI.path))
this.hide('_appliedDocTypes', {})
if (vFileOrPath.path) {
this.options.file = vFileOrPath
}
this.lazy('file', () => this.toVFile({
cwd: this.cwd,
...(this.options.file || {})
}))
this.hide('data', {})
this.lazy('id', this.buildId.bind(this))
this.lazy('fileStat', () => this.get('file.stat', {}))
this.lazy('createdAt', () => this.result('fileStat.ctime', () => Math.floor(new Date() / 1000)))
this.lazy('pathInfo', () => parsePath(this.path))
this.lazy('vmSandbox', () => this.project.vm.createContext({
...this.project.sandbox,
doc: this,
global: {},
}))
}
get appliedDocTypes() {
return Object.keys(this._appliedDocTypes)
}
get updatedAt() {
return Math.floor(this.get('data.updatedAt', this.createdAt) / 1000)
}
touch() {
try {
this.set('data.updatedAt', Math.floor(new Date() / 1000))
} catch(e) {
}
return this
}
get decorated() {
this.applyDoctypes()
return this
}
createContext(sandbox = {}) {
return {
...sandbox,
doc: this,
project: this.project,
}
}
relativeDoc(relativeDocId) {
return this.project.documentsMap[
this.project.relative(
this.pathToRelative( relativeDocId )
)
]
}
pathToRelative(relativePath) {
return resolve(this.fileDirname, relativePath)
}
getTransformPresets() {
return this.getTransforms()
}
getTransforms() {
return this.matchingDocTypes
.reduce((memo, docType) => ({
...memo,
[docType.name]: docType.getTransforms ? docType.getTransforms() : {},
}), {})
}
getUtils() {
return this.matchingDocTypes
.reduce((memo, docType) => ({
...memo,
...docType.getUtils ? docType.getUtils() : {},
}))
}
buildId (usingPath = this.path, cwd = this.cwd) {
if (isFunction(this.options.buildId)) {
return this.options.buildId(
relative(cwd, usingPath)
)
} else if (this.collection && isFunction(this.collection.buildId)) {
return this.collection.buildId(
relative(cwd, usingPath)
)
} else {
return relative(cwd, usingPath)
.replace(/\.\w+$/g, '')
.replace(/\.js$/g,'')
}
}
get selectors() {
return this.constructor.selectors
}
select(selector, ...args) {
if (isFunction(selector)) {
const result = selector.call(this, this.exporter, ...args)
return result && typeof result.value === 'function' ? result.value() : result
} else if (typeof selector === 'string') {
return this.constructor.selectors.lookup(selector)(this, ...args)
}
}
selectChain(selector, ...args) {
return this.chain.invoke('select', selector, ...args)
}
selectorMap (config = {}, resolveChain = true) {
const c = this.chain
.plant(config)
.mapValues((selector) => this.select(...castArray(selector)))
return resolveChain ? c.value() : c
}
get chain() {
return this.project.chain.plant(this)
}
get exporter() {
return this.project.chain.plant(this.ready)
}
get ready() {
return this.readFile(true).decorated
}
get doctypes() {
return this.docTypes
}
get docTypes() {
const { project } = this
return project.chain
.get('documentTypes.available', [])
.thru(list => list.map(docType => project.documentType(docType)))
.value()
}
get matchingDocTypes() {
const { project } = this
return project.chain
.get('documentTypes.available', [])
.thru(list => list.map(docType => project.documentType(docType)))
.flatten()
.filter(docType => docType.testDocument(this))
.value()
}
sliceId(removeFromBeginning = 1, removeFromEnd = 1) {
if (removeFromEnd < 0) { removeFromEnd = removeFromEnd * -1 }
return this.idParts.slice(removeFromBeginning, removeFromEnd < 0 ? removeFromEnd * -1 : this.idParts.length)
}
get idParts() {
return this.id.split(PATH_SEPARATOR)
}
get baseFolder() {
return this.idParts[0]
}
get categoryFolderName() {
const length = this.idParts.length
return this.isIndex
? this.idParts[length - 3] || this.idParts[1]
: this.idParts[length - 2] || this.idParts[1]
}
get categoryFolder() {
return (this.isIndex
? this.idParts.slice(0, this.idparts.length - 2)
: this.idParts.slice(0, this.idparts.length - 1)).join(PATH_SEPARATOR)
}
applyDoctypes() {
if (isEmpty(this.appliedDocTypes)) {
this.matchingDocTypes.forEach(docType => {
docType.decorate(this)
this.appliedDocTypes[docType.name] = true
})
}
return this
}
toJson (options = {}) {
return {
id: this.id,
file: omit(this.file, 'contents', '_contents'),
metadata: this.metadata,
idParts: this.idParts,
categoryFolder: this.categoryFolder,
categoryFolderName: this.categoryFolderName,
baseFolder: this.baseFolder,
variations: this.variations,
...(options.pick ? pick(this, options.pick) : {}),
...(options.pickBy ? pickBy(this, options.pickBy) : {}),
...(options.includeContents ? {contents: this.file.contents} : {contents: null}),
}
}
toVFile(fileOptions = {}) {
return vfile({
path: this.path,
cwd: this.cwd,
...fileOptions,
})
}
readFile(cache = true) {
if (cache && this.contents && this.contents.length > 0) {
this.file.contents = this.contents
return this
}
this.file.contents = this.contents = this.project.readFileSync(this.file.path).toString()
return this.touch()
}
readFileAsync() {
return this.project.readFileAsync(this.file.path)
.then((contents) => this.contents = contents.toString())
}
get metadata() {
return {
...this.fileData,
...this.data,
...this.get('exportables.frontmatter', {}),
}
}
get idPair() {
return [this.id, this]
}
get isIndex() {
return !!(
this.id.match(/index$/) ||
this.fileStem === 'index' ||
this.fileBaseName.startsWith('index')
)
}
get isPackageManifest() {
return this.fileBaseName === 'package.json'
}
get variationsBase() {
return this.isIndex
? this.fileDirnameBasename
: this.fileStem
}
get fileExtname() {
return this.file.extname
}
get isMarkdown() {
return this.fileExtname && this.fileExtname.match(/.md$/)
}
get isJavascript() {
return this.fileExtname && this.fileExtname.match(/.js$/)
}
/**
* Create a lodash chain by picking some properties from the project.
* @param {Array[String]} properties List of properties you want to be a part of the chain
* @return {Chain} The lodash chain whose initial value consists of the properties given
*/
slice (...properties) {
return properties.reduce((memo, prop) => {
set(memo, prop, this.result(prop))
return memo
}, {})
}
createEntityFrom(...properties) {
const src = this.ready.slice(...properties)
return require('../entity').createEntity(src)
}
asyncRunner(code, options = {}, context = {}) {
return this.project.createSandboxedScriptRunner(code, {
sandbox: this.vmSandbox
})
}
createStemVariations (base = this.variationsBase, options = {}) {
const { startCase, camelCase, kebabCase, snakeCase, humanize, titleize } = stringUtils
return mapValues({
classified: startCase(camelCase(base)).replace(/\s+/g,''),
camel: camelCase(base),
kebab: kebabCase(base),
snake: snakeCase(base),
underscored: kebabCase(base).replace(/\-/g,'_'),
humanized: humanize(kebabCase(base).replace(/\-/g,'_')),
titleized: titleize(humanize(kebabCase(base).replace(/\-/g,'_'))),
}, (v) => {
const value = `${options.prefix || ''}${v}${options.suffix || ''}`
const modifier = options.pluralize
? stringUtils.pluralize
: options.singularize ? stringUtils.singularize : ((v) => v)
return modifier(value)
})
}
get fileData () {
return isFunction(this.options.getFileData)
? this.options.getFileData.call(this, this.file)
: this.result('file.data', () => Object.assign(this.file, {
data: {},
}))
}
get fileMessages () {
return this.get('file.messages', {})
}
get variationsWithDirname () {
const base = `${this.fileDirnameBasename}_${this.fileStem}`
return {
...this.createStemVariations(base),
singularized: this.createStemVariations(base, {
singularize: true,
}),
pluralized: this.createStemVariations(base, {
pluralize: true,
}),
}
}
get variations () {
return {
...this.createStemVariations(this.variationsBase),
singularized: this.createStemVariations(this.variationsBase, {
singularize: true,
}),
pluralized: this.createStemVariations(this.variationsBase, {
pluralize: true,
}),
}
}
get nameVariations() {
const variations = this.variations
return uniq(Object.values(omit(variations, 'pluralized', 'singularized')).concat(
Object.values(variations.pluralized),
Object.values(variations.singularized)
))
}
get indexVariations() {
return this.createStemVariations(this.variationsBase, {
suffix: `/index${this.fileExtname}`,
})
}
get filenameVariations() {
return {
...this.createStemVariations(this.variationsBase, {
suffix: this.fileExtname,
})
}
}
get fileStem() {
return this.file.stem
}
get fileBaseName() {
return this.file.basename
}
get fileDirname () {
return this.file.dirname
}
get relativeDirname() {
return relative(
this.cwd,
this.fileDirname
)
}
get fileDirnameBasename () {
return basename(this.fileDirname)
}
get baseRelativePath() {
return relative(
this.cwd,
this.path
)
}
get contentLength() {
const statSize = this.result('stat.size')
if (typeof statSize === 'undefined') {
return 0
} else {
return (this.get('contents', this.get('file.contents', '')) || '').length
}
}
get stat() {
return this.get('file.stat', {})
}
get cacheKey() {
return [
this.baseRelativePath,
this.updatedAt.toString(),
this.contentLength.toString()
].join(':')
}
get isRemote() {
return this.uri.protocol.indexOf('http') !== -1
}
reloadFileContents() {
return this.contents = this.file.contents = this.readFileSync(false)
}
getFileContents() {
return this.result('file.contents', '').toString()
}
}
export default Document
export function normalizeUri (input, cwd = process.cwd()) {
if (typeof input !== 'string') {
throw('Must pass the URI as a string')
}
const parsed = url.parse(input)
const isFilePath = parsed.protocol === null || parsed.protocol.indexOf('file') !== -1
const isRemoteUrl = !isFilePath && parsed.protocol.indexOf('http') !== -1
const isAbsoluteFilePath = isFilePath && isAbsolutePath(parsed.path)
if (isRemoteUrl) {
return url.format(parsed)
}
if (isFilePath && isAbsoluteFilePath) {
return url.format({
protocol: 'file:',
slashes: true,
pathname: parsed.path,
})
} else {
return url.format({
protocol: 'file:',
slashes: true,
pathname: resolve(cwd, parsed.path),
})
}
return input
}