UNPKG

gridsome

Version:

A JAMstack framework for building blazing fast websites with Vue.js

567 lines (456 loc) 14.2 kB
const path = require('path') const fs = require('fs-extra') const LRU = require('lru-cache') const crypto = require('crypto') const invariant = require('invariant') const initWatcher = require('./watch') const { Collection } = require('lokijs') const { FSWatcher } = require('chokidar') const { parseQuery } = require('../graphql') const pathToRegexp = require('path-to-regexp') const createPageQuery = require('./createPageQuery') const { HookMap, SyncWaterfallHook, SyncBailHook } = require('tapable') const { snakeCase, trimEnd } = require('lodash') const validateInput = require('./schemas') const TYPE_STATIC = 'static' const TYPE_DYNAMIC = 'dynamic' const isDev = process.env.NODE_ENV === 'development' const createHash = value => crypto.createHash('md5').update(value).digest('hex') const getRouteType = value => /:/.test(value) ? TYPE_DYNAMIC : TYPE_STATIC class Pages { constructor (app) { this.app = app this.hooks = { parseComponent: new HookMap(() => new SyncBailHook(['source', 'resource'])), createRoute: new SyncWaterfallHook(['options']), createPage: new SyncWaterfallHook(['options']), pageContext: new SyncWaterfallHook(['context', 'data']) } this._componentCache = new LRU({ max: 100 }) this._queryCache = new LRU({ max: 100 }) this._watched = new Map() this._watcher = null this._routes = new Collection('routes', { indices: ['id', 'internal.priority'], unique: ['id', 'path'], disableMeta: true }) this._pages = new Collection('pages', { indices: ['id'], unique: ['id', 'path'], disableMeta: true }) if (isDev) { this._watcher = new FSWatcher({ disableGlobbing: true }) initWatcher(app, this) } } routes () { return this._routes .chain() .compoundsort([ ['internal.priority', /* desc */ true], ['path', /* asc */ false] ]) .data() .map(route => { return new Route(route, this) }) } pages () { return this._pages.data.slice() } clearCache () { this._componentCache.reset() this._queryCache.reset() } clearComponentCache (component) { this._componentCache.del(component) this._queryCache.del(component) } disableIndices () { ['_routes', '_pages'].forEach(prop => { this[prop].configureOptions({ adaptiveBinaryIndices: false }) }) } enableIndices () { ['_routes', '_pages'].forEach(prop => { this[prop].ensureAllIndexes() this[prop].configureOptions({ adaptiveBinaryIndices: true }) }) } createRoute (input, meta = {}) { const validated = validateInput('route', input) const options = this._createRouteOptions(validated, meta) const oldRoute = this._routes.by('id', options.id) if (oldRoute) { const newOptions = Object.assign({}, options, { $loki: oldRoute.$loki, meta: oldRoute.meta }) this._routes.update(newOptions) return new Route(newOptions, this) } this._routes.insert(options) this._watchComponent(options.component) return new Route(options, this) } updateRoute (input, meta = {}) { const validated = validateInput('route', input) this.clearComponentCache( this.app.resolve(validated.component) ) const options = this._createRouteOptions(validated, meta) const oldOptions = this._routes.by('id', options.id) const newOptions = Object.assign({}, options, { $loki: oldOptions.$loki, meta: oldOptions.meta }) this._routes.update(newOptions) const route = new Route(newOptions, this) if (options.internal.query.source !== oldOptions.internal.query.source) { for (const page of route.pages()) { const vars = page.internal.queryVariables || page.context || {} const { paginate, variables, filters } = this._createPageQuery(route.internal.query, vars) const newOptions = { ...page } newOptions.internal.query = { paginate, variables, filters } this._pages.update(newOptions) } } return route } removeRoute (id) { const options = this._routes.by('id', id) this._pages.findAndRemove({ 'internal.route': id }) this._routes.findAndRemove({ id }) this._unwatchComponent(options.component) } createPage (input, meta = {}) { const options = validateInput('page', input) const type = getRouteType(options.path) const route = this.createRoute({ type, path: options.path, component: options.component, name: options.route.name, meta: options.route.meta }, meta) return route.addPage({ id: options.id, path: options.path, context: options.context, queryVariables: options.queryVariables }) } updatePage (input, meta = {}) { const options = validateInput('page', input) const type = getRouteType(options.path) const route = this.updateRoute({ type, name: options.name, path: options.path, component: options.component, meta: options.route.meta }, meta) return route.updatePage({ id: options.id, path: options.path, context: options.context, queryVariables: options.queryVariables }) } removePage (id) { const page = this.getPage(id) if (!page) return const route = this.getRoute(page.internal.route) if (route.internal.isDynamic) { route.removePage(id) } else { this.removeRoute(route.id) } } removePageByPath (path) { const query = { path: trimEnd(path, '/') || '/' } this._pages .find(query) .forEach(page => this.removePage(page.id)) } removePagesByComponent (path) { const component = this.app.resolve(path) this._routes .find({ component }) .forEach(options => { this.removeRoute(options.id) }) } findAndRemovePages (query) { this._pages.find(query).forEach(page => { this.removePage(page.id) }) } findPages (query) { const matchingPages = this._pages.find(query) return matchingPages } findPage (query) { const [ matchingPage ] = this._pages.find(query) return matchingPage } getRoute (id) { const options = this._routes.by('id', id) return options ? new Route(options, this) : null } getRouteByPath (path) { const options = this._routes.by('path', path) return options ? new Route(options, this) : null } getMatch (path) { let route = this._routes.by('path', path) if (typeof route !== 'object') { const chain = this._routes.chain().simplesort('internal.priority', true) route = chain.data().find(route => route.internal.regexp.test(path) ) } if (typeof route !== 'object') { return { route: null, params: {} } } const { internal } = route const length = internal.keys.length const m = internal.regexp.exec(path) const params = {} for (let i = 0; i < length; i++) { const key = internal.keys[i] const param = m[i + 1] if (!param) continue params[key.name] = decodeURIComponent(param) if (key.repeat) { params[key.name] = params[key.name].split(key.delimiter) } } return { route: new Route(route, this), params } } getPage (id) { return this._pages.by('id', id) } _createRouteOptions (options, meta = {}) { const component = this.app.resolve(options.component) const { pageQuery } = this._parseComponent(component) const query = this._parseQuery(pageQuery, component) const { permalinks: { trailingSlash }} = this.app.config let path = options.path.replace(/\/+/g, '/') let name = options.name const type = options.type const prettyPath = trimEnd(path, '/') || '/' const hasTrailingSlash = /\/$/.test(options.path) const isDynamic = /:/.test(options.path) if (query.directives.paginate) { path = trimEnd(path, '/') + '/:page(\\d+)?' + (hasTrailingSlash ? '/' : '') } if (type === TYPE_STATIC && trailingSlash) { path = trimEnd(path, '/') + '/' } else if (type === TYPE_DYNAMIC) { path = trimEnd(path, '/') || '/' name = name || `__${snakeCase(path)}` } const keys = [] const regexp = pathToRegexp(trimEnd(path, '/') || '/', keys) const id = options.id || createHash(`route-${prettyPath}`) const priority = this._resolvePriority(path) return this.hooks.createRoute.call({ id, type, name, path, component, internal: Object.assign({}, meta, { meta: options.meta || {}, path: prettyPath, isDynamic, priority, regexp, query, keys }) }) } _parseQuery (query, component) { if (this._queryCache.has(component)) { return this._queryCache.get(component) } const schema = this.app.schema.getSchema() const res = parseQuery(schema, query, component) this._queryCache.set(component, res) return res } _createPageQuery (parsedQuery, vars = {}) { return createPageQuery(parsedQuery, vars) } _createPageContext (page, queryVariables = {}) { const route = this.getRoute(page.internal.route) return this.hooks.pageContext.call({ ...page.context }, { pageQuery: route.internal.query.source, queryVariables }) } _resolvePriority (path) { const segments = path.split('/').filter(Boolean) const scores = segments.map(segment => { let score = Math.max(segment.charCodeAt(0) || 0, 90) const parts = (segment.match(/-/g) || []).length if (/^:/.test(segment)) score -= 10 if (/:/.test(segment)) score -= 10 if (/\(.*\)/.test(segment)) score += 5 if (/\/[^:]$/.test(segment)) score += 3 if (/(\?|\+|\*)$/.test(segment)) score -= 3 if (/\(\.\*\)/.test(segment)) score -= 10 if (parts) score += parts return score }) return scores.reduce( (sum, score) => sum + score, segments.length * 100 ) } _parseComponent (component) { if (this._componentCache.has(component)) { return this._componentCache.get(component) } const ext = path.extname(component).substring(1) const hook = this.hooks.parseComponent.get(ext) let results = {} if (!fs.existsSync(component)) { throw new Error(`Could not find component ${component}.`) } if (hook) { const source = fs.readFileSync(component, 'utf8') results = hook.call(source, { resourcePath: component }) } this._componentCache.set(component, validateInput('component', results || {})) return results } _watchComponent (component) { if (!this._watched.has(component)) { this._watched.set(component, true) if (this._watcher) this._watcher.add(component) } } _unwatchComponent (component) { if (this._routes.find({ component }).length <= 0) { this._watched.delete(component) if (this._watcher) this._watcher.unwatch(component) } } } class Route { constructor (options, factory) { this.type = options.type this.id = options.id this.name = options.name this.path = options.path this.component = options.component this.internal = options.internal this.options = options this.createPath = pathToRegexp.compile(options.path) Object.defineProperty(this, '_factory', { value: factory }) Object.defineProperty(this, '_pages', { value: factory._pages }) Object.defineProperty(this, '_createPage', { value: factory.hooks.createPage }) } pages () { return this._pages .chain() .simplesort('path', false) .find({ 'internal.route': this.id }) .data() } addPage (input) { const options = this._createPageOptions(input) const oldPage = this._pages.by('id', options.id) if (oldPage) { options.$loki = oldPage.$loki options.meta = oldPage.meta this._pages.update(options) } else { this._pages.insert(options) } return options } updatePage (input) { const options = this._createPageOptions(input) const oldOptions = this._pages.by('id', options.id) if (!oldOptions) { throw new Error( `Cannot update page "${options.path}". ` + `Existing page with id "${options.id}" could not be found.` ) } const newOptions = Object.assign({}, options, { $loki: oldOptions.$loki, meta: oldOptions.meta }) this._pages.update(newOptions) return newOptions } removePage (id) { this._pages.findAndRemove({ id, 'internal.route': this.id }) } _createPageOptions (input) { const { permalinks: { trailingSlash }} = this._factory.app.config const { regexp, digest, isManaged, query } = this.internal const { id: _id, path: _path, context, queryVariables } = validateInput('routePage', input) let path = trimEnd(_path.replace(/\/+/g, '/'), '/') || '/' if (path[0] !== '/') path = '/' + path let publicPath = path const isDynamic = /:/.test(path) const id = _id || createHash(`page-${path}`) if (this.type === TYPE_STATIC) { if (trailingSlash) { publicPath = trimEnd(path, '/') + '/' } if (this.internal.path !== path) { invariant( regexp.test(path), `The path ${path} does not match ${regexp}` ) } } if (this.type === TYPE_DYNAMIC) { invariant( this.internal.path === path, `Dynamic page must equal the route path: ${this.internal.path}` ) } const vars = queryVariables || context || {} const { paginate, variables, filters } = this._factory._createPageQuery(query, vars) return this._createPage.call({ id, path, publicPath, context, internal: { queryVariables, route: this.id, digest, isManaged, isDynamic, query: { paginate, variables, filters } } }) } } module.exports = Pages