/********************************************************************** * * * **********************************************************************/ ((typeof define)[0]=='u'?function(f){module.exports=f(require)}:define) (function(require){ var module={} // make module AMD/node compatible... /*********************************************************************/ // XXX //var object = require('lib/object') var object = require('ig-object') var types = require('ig-types') var pwpath = require('./lib/path') var parser = require('./parser') var filters = require('./filters/base') var markdown = require('./filters/markdown') //--------------------------------------------------------------------- // Page... var relProxy = function(name){ var func = function(path='.', ...args){ return this.store[name]( pwpath.relative(this.location, path), ...args) } Object.defineProperty(func, 'name', {value: name}) return func } var relMatchProxy = function(name){ var func = function(path='.', strict=this.strict){ if(path === true || path === false){ strict = path path = '.' } return this.store[name]( pwpath.relative(this.location, path), strict) } Object.defineProperty(func, 'name', {value: name}) return func } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - var __HANDLE_NAVIGATE = module.__HANDLE_NAVIGATE = types.event.EventCommand('HANDLE_NAVIGATE') // XXX PATH_VARS // XXX HISTORY do we need history management??? // XXX FUNC need to handle functions in store... var BasePage = module.BasePage = object.Constructor('BasePage', { // NOTE: this can be inherited... //store: undefined, // root page used to clone new instances via the .clone(..) method... //root: undefined, // Path variables... // // XXX PATH_VARS should these be here??? // other places path variables can be resolved: // - navigation (below) // - macro expansion... // XXX EXPERIMENTAL... path_vars: { NOW: function(){ return Date.now() }, PATH: function(){ return this.path }, NAME: function(){ return this.name }, DIR: function(){ return this.dir }, //TITLE: function(){ // return this.title }, /*/ XXX this needs: // - macro context... // - sort order... INDEX: function(context){ return context.index }, //*/ }, resolvePathVars: function(path, context={}){ var that = this return Object.entries(this.path_vars) .reduce(function(res, [key, func]){ return res .replace( new RegExp('(\\${'+key+'}|\\$'+key+')', 'g'), func.call(that, context)) }, path) }, // page location... // // NOTE: path variables are resolved relative to the page BEFORE // navigation... // NOTE: the actual work is done by the .onNavigate(..) method... __location: undefined, get location(){ return this.__location ?? '/' }, set location(path){ // trigger the event... this.onNavigate(path) }, // referrer -- a previous page location... referrer: undefined, // events... // // XXX revise naming... // XXX should this be able to prevent navigation??? onBeforeNavigate: types.event.Event('beforeNavigate'), onNavigate: types.event.Event('navigate', function(handle, path){ // special case: we are triggering handlers only... // NOTE: this usually means that we are setting .__location // externally... // XXX HISTORY this is only used for history at this point... if(path === module.__HANDLE_NAVIGATE){ handle() return } this.onBeforeNavigate(path) this.referrer = this.location var cur = this.__location = this.resolvePathVars( pwpath.relative( this.location, path)) //* XXX HISTORY... if(this.history !== false){ this.history.includes(this.__location) && this.history.splice( this.history.indexOf(this.__location)+1, this.history.length) this.history.push(cur) } // trigger handlers... handle() }), // .path is a proxy to .location // XXX do we need this??? get path(){ return this.location }, set path(value){ this.location = value }, // XXX do we need this... get resolvedPath(){ return this.match() }, // XXX should these be writable??? get name(){ return pwpath.split(this.path).pop() }, //set name(value){ }, get dir(){ return pwpath.relative(this.location, '..') }, //set dir(value){ }, get isPattern(){ return this.location.includes('*') }, // history... // //* XXX HISTORY... // NOTE: set this to false to disable history... __history: undefined, get history(){ if(this.__history === false){ return false } if(!this.hasOwnProperty('__history')){ this.__history = [] } //this.__history = (this.__history ?? []).slice() } return this.__history }, back: function(offset=1){ var h = this.history if(h === false || h.length <= 1){ return this } // get position in history... var p = h.indexOf(this.location) // if outside of history go to last element... p = p < 0 ? h.length : p p = Math.max( Math.min( h.length-1 - p + offset, h.length-1), 0) this.onBeforeNavigate(this.path) this.referrer = this.location var path = this.__location = h[h.length-1 - p] this.onNavigate(module.__HANDLE_NAVIGATE, path) return this }, forward: function(offset=1){ return this.back(-offset) }, //*/ // store interface... // // XXX we are only doing modifiers here... // ...these ar mainly used to disable writing in .ro(..) __update__: function(data){ return this.store.update(this.location, data) }, __delete__: function(path='.'){ return this.store.delete(pwpath.relative(this.location, path)) }, // page data... // strict: undefined, get data(){ return (async function(){ var that = this // NOTE: we need to make sure each page gets the chance to handle // its context.... if(this.isPattern){ return this .map(function(page){ return page.data }) } // single page... var res = await this.store.get(this.location, !!this.strict) return typeof(res) == 'function' ? res.bind(this) : res }).call(this) }, //return this.store.get(this.location, !!this.strict) }, set data(value){ this.__update__(value) }, // metadata... // // NOTE: in the general case this is the same as .data but in also allows // storing of data (metadata) for pattern paths... get metadata(){ return this.store.metadata(this.location) }, set metadata(value){ this.__update__(value) }, // number of matching pages... // NOTE: this can be both sync and async... get length(){ var p = this.resolve(this.location) return p instanceof Array ? p.length : p instanceof Promise ? p.then(function(res){ return res instanceof Array ? res.length : 1 }) : 1 }, // relative proxies to store... exists: relProxy('exists'), find: relProxy('find'), match: relMatchProxy('match'), resolve: relMatchProxy('resolve'), delete: function(path='.'){ this.__delete__() return this }, // // .get([, ]) // .get(, [, ]) // -> // get: function(path, strict, data={}){ if(strict instanceof Object){ data = strict strict = undefined } return this.clone({ location: path, ...data, referrer: data.referrer ?? this.location, strict, }) }, // XXX should this be an iterator??? each: function(path){ var that = this // NOTE: we are trying to avoid resolving non-pattern paths unless // we really have to... path = path ? pwpath.relative(this.path, path) : this.path //var paths = this.match(path) var paths = path.includes('*') ? this.resolve(path) : path paths = paths instanceof Array ? paths : paths instanceof Promise ? paths.iter() : [paths] return paths .map(function(path){ return that.get('/'+ path) }) }, map: function(func){ return this.each().map(func) }, filter: function(func){ return this.each().filter(func) }, reduce: function(func, dfl){ return this.each().reduce(func, dfl) }, // sorting... // // XXX should this be page-level (current) store level??? // XXX when this is async, should this return a promise???? sort: async function(cmp){ // not sorting single pages... if(this.length <= 1){ return this } // sort... this.metadata = { order: await this.each() .sort(...arguments) .map(function(p){ return p.path }) } return this }, reverse: async function(){ // not sorting single pages... if(this.length <= 1){ return this } this.metadata = { order: (await this.match()).reverse() } return this }, // // Clone a page optionally asigning data into it... // .clone() // .clone({ .. }[, ]) // -> // // Fully clone a page optionally asigning data into it... // .clone(true[, ]) // .clone(true, { .. }[, ]) // -> // // // Normal cloning will inherit all the "clones" from the original // page overloading .location and .referrer // // NOTE: by default is false unless fully cloning // // XXX HISTORY should we clear history by default... clone: function(data={}, history=false){ var [data, ...args] = [...arguments] var full = data === true history = typeof(args[args.length-1]) == 'boolean' ? args.pop() : full data = full ? args[0] ?? {} : data return Object.assign( full ? // full copy... this.constructor(this.path, this.referrer, this.store) // NOTE: this will restrict all the clones to the first // generation maintaining the original (.root) page as // the common root... // this will make all the non-shadowed attrs set on the // root visible to all sub-pages. : Object.create(this.root ?? this), { root: this.root ?? this, location: this.location, referrer: this.referrer, }, // XXX HISTORY... this.__history !== false ? { __history: history ? (this.__history ?? []).slice() : [] } :{}, //*/ data) }, // Create a read-only page... // // NOTE: all pages that are created via a read-only page are also // read-only. // XXX EXPERIMENTAL... ro: function(data={}){ return Object.assign({ __proto__: this, __update__: function(){ return this }, __delete__: function(){ return this }, }, data) }, // Create a virtual page at current path... // // Virtual pages do not affect store data in any way but behave like // normal pages. // // NOTE: .get(..) / .clone(..) will return normal non-virtual pages // unless the target path is the same as the virtual page .path... // NOTE: changing .path/.location is not supported. // XXX EXPERIMENTAL... virtual: function(data={}){ var that = this return { __proto__: this, // make the location read-only... get location(){ // NOTE: since we are not providing this as a basis for // inheritance we do not need to properly access // the parent prop... // ...otherwise use: // object.parentProperty(..) return this.__proto__.location }, __update__: function(data){ Object.assign(this.data, data) return this }, __delete__: function(){ return this }, // NOTE: we need to proxy .clone(..) back to parent so as to // avoid overloading .data in the children too... // NOTE: we are also keeping all first level queries resolving // to current path also virtual... clone: function(...args){ var res = that.clone(...args) return res.path == this.path ? that.virtual(this.data) : res }, data: Object.assign( { ctime: Date.now(), mtime: Date.now(), }, data), } }, // XXX should this be update or assign??? // XXX how should this work on multiple pages... // ...right now this will write what-ever is given, even if it // will never be explicitly be accessible... // XXX sync/async??? update: function(...data){ return Object.assign(this, ...data) }, __init__: function(path, referrer, store){ // NOTE: this will allow inheriting .store from the prototype if(store){ this.store = store } this.location = path this.referrer = referrer }, }) // pepper in event functionality... types.event.EventMixin(BasePage.prototype) //--------------------------------------------------------------------- // XXX should these be something more generic like Object.assign(..) ??? // XXX do we need anything else like .doc, attrs??? var Macro = module.Macro = function(spec, func){ var args = [...arguments] // function... func = args.pop() // arg sepc... ;(args.length > 0 && args[args.length-1] instanceof Array) && (func.arg_spec = args.pop()) // XXX do we need anything else like .doc, attrs??? return func } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - module.PAGE_NOT_FOUND = '404: PAGE NOT FOUND: $PATH' // XXX PATH_VARS need to handle path variables... // XXX filters (and macros?) should be features for simpler plugin handlng (???) // XXX STUB filters... // XXX rename to pWikiPage??? var Page = module.Page = object.Constructor('Page', BasePage, { __parser__: parser.parser, // Filter that will isolate the page/include/.. from parent filters... ISOLATED_FILTERS: 'isolated', // list of macros that will get raw text of their content... QUOTING_MACROS: ['quote'], // templates used to render a page via .text PAGE_TPL: '_text', // NOTE: comment this out to make the system fail when nothing is // resolved, not even the System/NotFound page... // NOTE: we can't use any of the page actions here (like @source(./path)) // as if we reach this it's likely all the bootstrap is either also // not present or broken. // NOTE: to force the system to fail set this to undefined. PAGE_NOT_FOUND: module.PAGE_NOT_FOUND, // // () // -> // -> undefined // // XXX might be a good idea to fix filter order... filters: { // placeholders... nofilters: function(){}, isolated: function(){}, // XXX TESTING... dummy: function(){}, test: function(source){ return source .replace(/test/g, 'TEST') }, wikiword: filters.wikiWord, 'quote-wikiword': filters.quoteWikiWord, markdown: markdown.markdown, 'quote-markdown': markdown.quoteMarkdown, text: function(source){ return `
${source}
` }, }, // // (, , ){ .. } // -> undefined // -> // -> // -> // -> () // -> ... // // XXX ASYNC make these support async page getters... macros: { // // // now: function(){ return ''+ Date.now() }, // // @filter() // /> // // > // ... // // // ::= // // | - // filter: function(args, body, state, expand=true){ var that = this var filters = state.filters = state.filters ?? [] // separate local filters... if(body){ var outer_filters = filters filters = state.filters = [outer_filters] } // merge in new filters... var local = Object.keys(args) filters.splice(filters.length, 0, ...local) // trigger quote-filter... var quote = local .map(function(filter){ return (that.filters[filter] ?? {})['quote'] ?? [] }) .flat() quote.length > 0 && this.macros['quote-filter'] .call(this, Object.fromEntries(Object.entries(quote)), null, state) // local filters... if(body){ // isolate from parent... state.filters.includes(this.ISOLATED_FILTERS) && state.filters[0] instanceof Array && state.filters.shift() // expand the body... var ast = expand ? // XXX async... //[...this.__parser__.expand(this, body, state)] this.__parser__.expand(this, body, state) : body instanceof Array ? body // NOTE: wrapping the body in an array effectively // escapes it from parsing... : [body] filters = state.filters state.filters = outer_filters // parse the body after we are done expanding... return async function(state){ var outer_filters = state.filters state.filters = this.__parser__.normalizeFilters(filters) var res = this.parse(ast, state) .iter() .flat() .join('') state.filters = outer_filters return { data: await res } } } }, // // @include() // // @include( isolated recursive=) // @include(src= isolated recursive=) // // .. > // // // // XXX RECURSION recursion detection is still a bit off... // XXX 'text' argument is changed to 'recursive'... // XXX revise recursion checks.... // XXX should this be lazy??? include: Macro( ['src', 'recursive', ['isolated']], async function(args, body, state, key='included', handler){ var macro = 'include' if(typeof(args) == 'string'){ var [macro, args, body, state, key="included", handler] = arguments } // positional args... var src = args.src var recursive = args.recursive || body var isolated = args.isolated if(!src){ return } // parse arg values... src = await this.parse(src, state) var full = this.get(src).path handler = handler ?? function(){ return this.get(src) .parse( isolated ? {[key]: state[key]} : state) } // handle recursion... var parent_seen = state[key] var seen = state[key] = state[key] ?? [] // recursion detected... if(seen.includes(full)){ if(!recursive){ throw new Error( macro +': recursion detected: ' + seen.concat([full]).join(' -> ')) } // have the 'recursive' arg... return this.parse(recursive, state) } seen.push(full) // load the included page... var res = await handler.call(this) // restore previous include chain... if(parent_seen){ state[key] = parent_seen } else { delete state[key] } return res }), source: Macro( ['src'], async function(args, body, state){ var src = args.src // parse arg values... src = src ? await this.parse(src, state) : src return this.macros.include.call(this, 'source', args, body, state, 'sources', async function(){ return await this.parse(await this.get(src).raw +'', state) }) }), // // @quote() // // [ filter=" ..."]/> // // // // // .. // // // // NOTE: src ant text arguments are mutually exclusive, src takes // priority. // NOTE: the filter argument has the same semantics as the filter // macro with one exception, when used in quote, the body is // not expanded... // // XXX need a way to escape macros -- i.e. include in a quoted text... quote: Macro( ['src', 'filter', 'text'], async function(args, body, state){ var src = args.src //|| args[0] var text = args.text ?? body ?? [] // parse arg values... src = src ? await this.parse(src, state) : src text = src ? // source page... await this.get(src).raw : text instanceof Array ? text.join('') : text // empty... if(!text){ return } var filters = args.filter && args.filter .trim() .split(/\s+/g) // NOTE: we are delaying .quote_filters handling here to // make their semantics the same as general filters... // ...and since we are internally calling .filter(..) // macro we need to dance around it's architecture too... // NOTE: since the body of quote(..) only has filters applied // to it doing the first stage of .filter(..) as late // as the second stage here will have no ill effect... return function(state){ // add global quote-filters... filters = (state.quote_filters && !(filters ?? []).includes(this.ISOLATED_FILTERS)) ? [...state.quote_filters, ...(filters ?? [])] : filters if(filters){ filters = Object.fromEntries(Object.entries(filters)) return this.macros.filter .call(this, filters, text, state, false) .call(this, state) } return text } }), // very similar to @filter(..) but will affect @quote(..) filters... 'quote-filter': function(args, body, state){ var filters = state.quote_filters = state.quote_filters ?? [] filters.splice(filters.length, 0, ...Object.keys(args)) }, // // /> // // text=/> // // > // ... // // // Force show a slot... // // // Force hide a slot... //