mirror of
				https://github.com/flynx/pWiki.git
				synced 2025-10-31 19:10:08 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			1127 lines
		
	
	
		
			30 KiB
		
	
	
	
		
			JavaScript
		
	
	
		
			Executable File
		
	
	
	
	
			
		
		
	
	
			1127 lines
		
	
	
		
			30 KiB
		
	
	
	
		
			JavaScript
		
	
	
		
			Executable File
		
	
	
	
	
| /**********************************************************************
 | |
| * 
 | |
| *
 | |
| * XXX might be a good idea to try signature based security:
 | |
| * 		- sign changes
 | |
| * 		- sign sync session
 | |
| * 		- refuse changes with wrong signatures
 | |
| * 		- public keys available on client and on server
 | |
| * 			- check signatures localy
 | |
| * 			- check signatures remotely
 | |
| * 		- private key available only with author
 | |
| * 		- keep both the last signed and superceding unsigned version
 | |
| * 		- on sync ask to overwrite unsigned with signed
 | |
| * 		- check if we can use the same mechanics as ssh...
 | |
| * 		- in this view a user in the system is simply a set of keys and 
 | |
| * 			a signature (a page =))
 | |
| *
 | |
| **********************************************************************/
 | |
| ((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')
 | |
| 
 | |
| 
 | |
| 
 | |
| /*********************************************************************/
 | |
| 
 | |
| 
 | |
| // XXX might be a good idea to make this compatible with node's path API...
 | |
| var path = 
 | |
| module.path = {
 | |
| 
 | |
| 	// The page returned when getting the '/' path...
 | |
| 	ROOT_PAGE: 'WikiHome',
 | |
| 
 | |
| 	// The page returned when listing a path ending with '/'...
 | |
| 	//
 | |
| 	// If set to false treat dirs the same as pages (default)
 | |
| 	// XXX revise...
 | |
| 	//DEFAULT_DIR: 'pages',
 | |
| 	DEFAULT_DIR: false,
 | |
| 
 | |
| 	ALTERNATIVE_PAGES: [
 | |
| 		'EmptyPage',
 | |
| 		'NotFound',
 | |
| 	],
 | |
| 
 | |
| 	SEARCH_PATHS: [
 | |
| 		'./Templates',
 | |
| 		'/System',
 | |
| 	],
 | |
| 
 | |
| 	// NOTE: trailing/leading '/' are represented by '' at end/start of 
 | |
| 	// 		path list...
 | |
| 	normalize: function(path='.', format='auto'){
 | |
| 		format = format == 'auto' ?
 | |
| 			(path instanceof Array ?
 | |
| 				'array'
 | |
| 				: 'string')
 | |
| 			: format
 | |
| 		path = (path instanceof Array ?
 | |
| 				path
 | |
| 				// NOTE: this will also trim the path elements...
 | |
| 				: path.split(/\s*[\\\/]+\s*/))
 | |
| 			.reduce(function(res, e, i, L){
 | |
| 				// special case: leading '..' / '.'
 | |
| 				if(res.length == 0 
 | |
| 						&& e == '..'){
 | |
| 					return [e] }
 | |
| 				;(e == '.' 
 | |
| 						// keep explicit '/' only at start/end of path...
 | |
| 						|| (e == '' 
 | |
| 							&& i != 0 
 | |
| 							&& i != L.length-1)) ?
 | |
| 					undefined
 | |
| 				: e == '..' 
 | |
| 						|| res[res.length-1] == '>>' ?
 | |
| 					res.pop()
 | |
| 				// NOTE: the last '>>' will be retained...
 | |
| 				: res.push(e)
 | |
| 				return res }, []) 
 | |
| 		return format == 'string' ?
 | |
| 			path.join('/') 
 | |
| 			: path },
 | |
| 	relative: function(parent, path, format='auto'){
 | |
| 		format = format == 'auto' ?
 | |
| 			(path instanceof Array ?
 | |
| 				'array'
 | |
| 				: 'string')
 | |
| 			: format
 | |
| 		path = this.normalize(path, 'array')
 | |
| 		// root path...
 | |
| 		if(path[0] == ''){
 | |
| 			return format == 'string' ? 
 | |
| 				path.join('/')
 | |
| 				: path }
 | |
| 		parent = this.normalize(parent, 'array')
 | |
| 		return this.normalize(parent.concat(path), format) },
 | |
| 
 | |
| 	//paths: function*(path='/', leading_slash=true){
 | |
| 	paths: function*(path='/'){
 | |
| 		path = this.normalize(path, 'array')
 | |
| 		// handle '', '.', and '/' paths...
 | |
| 		if(path.length == 0 
 | |
| 				|| (path.length == 1 && path[0] == '')
 | |
| 				|| (path.length == 2 && path[0] == '' && path[1] == '')){
 | |
| 			path = [this.ROOT_PAGE] }
 | |
| 		// normalize relative paths to root...
 | |
| 		path[0] != ''
 | |
| 			&& path.unshift('')
 | |
| 		// paths ending in '/' -- dir lister...
 | |
| 		if(path[path.length-1] == ''){
 | |
| 			path.pop()
 | |
| 			this.DEFAULT_DIR
 | |
| 				&& path.push(this.DEFAULT_DIR) }
 | |
| 		// generate path candidates...
 | |
| 		for(var page of [path.pop(), ...this.ALTERNATIVE_PAGES]){
 | |
| 			for(var tpl of ['.', ...this.SEARCH_PATHS]){
 | |
| 				// search for page up the path...
 | |
| 				var p = path.slice()
 | |
| 				while(p.length > 0){
 | |
| 					yield this.relative(p, tpl +'/'+ page, 'string')
 | |
| 					//yield leading_slash ? 
 | |
| 					//	this.relative(p, tpl +'/'+ page, 'string')
 | |
| 					//	: this.relative(p, tpl +'/'+ page, 'string').slice(1)
 | |
| 					// special case: non-relative template/page path...
 | |
| 					if(tpl[0] == '/'){
 | |
| 						break }
 | |
| 					p.pop() } } } },
 | |
| }
 | |
| 
 | |
| 
 | |
| 
 | |
| //---------------------------------------------------------------------
 | |
| 
 | |
| // NOTE: store keys must be normalized...
 | |
| //
 | |
| // XXX LEADING_SLASH should this be strict about leading '/' in paths???
 | |
| // 		...this may lead to duplicate paths created -- '/a/b' and 'a/b'
 | |
| // XXX would be nice to be able to create sub-stores, i.e. an object that
 | |
| // 		would store multiple sub-pages for things like todo docs... (???)
 | |
| // 		...the question is how to separate the two from the wiki side...
 | |
| // XXX must support store stacks...
 | |
| // XXX path macros???
 | |
| // XXX should we support page symlinking???
 | |
| var store = 
 | |
| module.store = {
 | |
| 	exists: function(path){
 | |
| 		path = module.path.normalize(path, 'string')
 | |
| 		return path in this
 | |
|    			|| (path[0] == '/' ?
 | |
|    				path.slice(1) in this
 | |
| 				: ('/'+ path) in this) },
 | |
| 
 | |
| 	paths: function(){
 | |
| 		return Object.keys(this) },
 | |
| 	pages: function(){
 | |
| 		var that = this
 | |
| 		return this.paths()
 | |
| 			.map(function(p){
 | |
| 				return [p, that[p]] }) },
 | |
| 
 | |
| 	// 
 | |
| 	// 	Resolve page for path
 | |
| 	// 	.match(<path>)
 | |
| 	// 		-> <path>
 | |
| 	//
 | |
| 	// 	Match paths (non-strict mode)
 | |
| 	// 	.match(<pattern>)
 | |
| 	// 	.match(<pattern>, false)
 | |
| 	// 		-> [<path>, ...]
 | |
| 	//
 | |
| 	// 	Match pages (paths in strict mode)
 | |
| 	// 	.match(<pattern>, true)
 | |
| 	// 		-> [<path>, ...]
 | |
| 	//
 | |
| 	// In strict mode the trailing star in the pattern will only match 
 | |
| 	// actual existing pages, while in non-strict mode the pattern will 
 | |
| 	// match all sub-paths.
 | |
| 	//
 | |
| 	match: function(path, strict=false){
 | |
| 		// pattern match * / **
 | |
| 		if(path.includes('*') 
 | |
| 				|| path.includes('**')){
 | |
| 			// NOTE: we are matching full paths only here so leading and 
 | |
| 			// 		trainling '/' are optional...
 | |
| 			var pattern = new RegExp(`^\\/?${
 | |
| 				module.path.normalize(path, 'string')
 | |
| 					.replace(/^\/|\/$/g, '')
 | |
| 					.replace(/\//g, '\\/')
 | |
| 					.replace(/\*\*/g, '.+')
 | |
| 					.replace(/\*/g, '[^\\/]+') }`)
 | |
| 			return [...this.paths()
 | |
| 				.reduce(function(res, p){
 | |
| 					var m = p.match(pattern)
 | |
| 					m
 | |
| 						&& (!strict 
 | |
| 							|| m[0] == p) 
 | |
| 						&& res.add(m[0])
 | |
| 					return res }, new Set())] }
 | |
| 		// search...
 | |
| 		for(var p of module.path.paths(path)){
 | |
| 			if(p in this){
 | |
| 				return p }
 | |
| 			// NOTE: all paths at this point and in store are absolute, 
 | |
| 			// 		so we check both with the leading '/' and without 
 | |
| 			// 		it to make things a bit more relaxed and return the 
 | |
| 			// 		actual matching path...
 | |
| 			if(p[0] == '/' 
 | |
| 					&& p.slice(1) in this){
 | |
| 				return p.slice(1) }
 | |
| 			if(p[0] != '/'
 | |
| 					&& ('/'+p) in this){
 | |
| 				return '/'+p } } },
 | |
| 	// 
 | |
| 	// 	Resolve page
 | |
| 	// 	.get(<path>)
 | |
| 	// 		-> <value>
 | |
| 	//
 | |
| 	// 	Resolve pages (non-strict mode)
 | |
| 	// 	.get(<pattern>)
 | |
| 	// 	.get(<pattern>, false)
 | |
| 	// 		-> [<value>, .. ]
 | |
| 	//
 | |
| 	// 	Get pages (strict mode)
 | |
| 	// 	.get(<pattern>, true)
 | |
| 	// 		-> [<value>, .. ]
 | |
| 	//
 | |
| 	// In strict mode this will not try to resolve pages and will not 
 | |
| 	// return pages at paths that do not explicitly exist.
 | |
| 	//
 | |
| 	// XXX should this call actions???
 | |
| 	// XXX should this return a map for pattern matches???
 | |
| 	get: function(path, strict=false){
 | |
| 		var that = this
 | |
| 		path = this.match(path, strict)
 | |
| 		return path instanceof Array ?
 | |
| 			// XXX should we return matched paths???
 | |
|    			path.map(function(p){
 | |
| 				// NOTE: p can match a non existing page at this point, 
 | |
| 				// 		this can be the result of matching a/* in a a/b/c
 | |
| 				// 		and returning a a/b which can be undefined...
 | |
| 				return that[p] 
 | |
| 					?? that[that.match(p)] })
 | |
| 			: this[path] },
 | |
| 
 | |
| 	// NOTE: deleting and updating only applies to explicit matching 
 | |
| 	// 		paths -- no page acquisition is performed...
 | |
| 	//
 | |
| 	// XXX should these return this or the data???
 | |
| 	// XXX FUNC handle functions as pages...
 | |
| 	update: function(path, data, mode='update'){
 | |
| 		path = module.path.normalize('/'+ path, 'string')
 | |
| 		path = path[path.length-1] == '/' ?
 | |
| 			path.slice(0, -1)
 | |
| 			: path
 | |
| 		this[path] = 
 | |
| 			mode == 'update' ?
 | |
| 				Object.assign(
 | |
| 					this[path] ?? {}, 
 | |
| 					data)
 | |
| 				: data
 | |
| 		return this },
 | |
| 	// XXX revise...
 | |
| 	delete: function(path){
 | |
| 		path = module.path.normalize(path, 'string')
 | |
| 		path = path[path.length-1] == '/' ?
 | |
| 			path.slice(0, -1)
 | |
| 			: path
 | |
| 		// XXX revise...
 | |
| 		delete this[path] 
 | |
| 		delete this['/'+ path] 
 | |
| 		return this },
 | |
| }
 | |
| 
 | |
| 
 | |
| // XXX need to specify page format....
 | |
| // XXX need a way to set the page path...
 | |
| var actions = 
 | |
| module.actions = {
 | |
| 	__proto__: store,
 | |
| 
 | |
| 	// base actions (virtual pages)...
 | |
| 	'System/raw': function(page, path){
 | |
| 		return { text: this.get(path +'/..') } },
 | |
| 	// XXX ...
 | |
| }
 | |
| 
 | |
| 
 | |
| // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 
 | |
| 
 | |
| var relProxy = 
 | |
| function(name){
 | |
| 	return function(path='.', ...args){
 | |
| 		return this.store[name](
 | |
| 			module.path.relative(this.location, path), 
 | |
| 			...args) } } 
 | |
| 
 | |
| // XXX HISTORY do we need history management??? 
 | |
| // XXX FUNC need to handle functions in store...
 | |
| // XXX EVENT add event triggers/handlers...
 | |
| // 		...event handlers must be local and not propogate to the root page.
 | |
| 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,
 | |
| 
 | |
| 	// page location...
 | |
| 	//
 | |
| 	__location: undefined,
 | |
| 	get location(){
 | |
| 		return this.__location ?? '/' },
 | |
| 	// XXX EVENT need to be able to trigger a callback/event on this...
 | |
| 	set location(path){
 | |
| 		this.referrer = this.location
 | |
| 		var cur = this.__location = 
 | |
| 			module.path.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) } },
 | |
| 	// referrer -- a previous page location...
 | |
| 	referrer: undefined,
 | |
| 
 | |
| 	//* 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 },
 | |
| 	// XXX EVENT trigger location change event..,
 | |
| 	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.referrer = this.location
 | |
| 		this.__location = h[h.length-1 - p]
 | |
| 		return this },
 | |
| 	forward: function(offset=1){
 | |
| 		return this.back(-offset) },
 | |
| 	//*/
 | |
| 	
 | |
| 	// page data...
 | |
| 	//
 | |
| 	// XXX FUNC handle functions as pages...
 | |
| 	// XXX need to support pattern pages...
 | |
| 	get data(){
 | |
| 		return this.store.get(this.location) },
 | |
| 	set data(value){
 | |
| 		this.store.update(this.location, value) },
 | |
| 
 | |
| 	// relative proxies to store...
 | |
| 	exists: relProxy('exists'), 
 | |
| 	match: relProxy('match'), 
 | |
| 	delete: relProxy('delete'),
 | |
| 
 | |
| 	// XXX how should this handle functions as values???
 | |
| 	get: function(path, referrer){
 | |
| 		return this.clone({
 | |
| 				location: path, 
 | |
| 				referrer: referrer 
 | |
| 					?? this.location,
 | |
| 			}) },
 | |
| 
 | |
| 	// XXX should this be an iterator???
 | |
| 	each: function(path){
 | |
| 		var that = this
 | |
| 		var paths = this.match(path)
 | |
| 		paths = paths instanceof Array ? 
 | |
| 			paths 
 | |
| 			: [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) },
 | |
| 
 | |
| 	//
 | |
| 	// 	Clone a page optionally asigning data into it...
 | |
| 	// 	.clone()
 | |
| 	// 	.clone({ .. }[, <clone-history>])
 | |
| 	// 		-> <page>
 | |
| 	//
 | |
| 	// 	Fully clone a page optionally asigning data into it...
 | |
| 	// 	.clone(true[, <clone-history>])
 | |
| 	// 	.clone(true, { .. }[, <clone-history>])
 | |
| 	// 		-> <page>
 | |
| 	//
 | |
| 	//
 | |
| 	// Normal cloning will inherit all the "clones" from the original 
 | |
| 	// page overloading .location and .referrer
 | |
| 	//
 | |
| 	// NOTE: <clone-history> by default is false unless fully cloning
 | |
| 	//
 | |
| 	// XXX revise...
 | |
| 	// 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) },
 | |
| 
 | |
| 	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 },
 | |
| })
 | |
| 
 | |
| 
 | |
| 
 | |
| //---------------------------------------------------------------------
 | |
| 
 | |
| // XXX BUG? '<slot name=x text="moo <now/> foo">' is parsed semi-wrong...
 | |
| var parser =
 | |
| module.parser = {
 | |
| 	// patterns...
 | |
| 	//
 | |
| 	// NOTE: the actual macro pattern is not stored as it depends on 
 | |
| 	// 		the macro name list which is page dependant...
 | |
| 	// XXX add escaping...
 | |
| 	MACRO_PATTERN_STR: [[
 | |
| 			// @macro(arg ..)
 | |
| 			// XXX add support for '\)' in args...
 | |
| 			'\\\\?@(?<nameInline>MACROS)\\((?<argsInline>([^)])*)\\)',
 | |
| 			// <macro ..> | <macro ../>
 | |
| 			// XXX revise escaped > and />
 | |
| 			'<\\s*(?<nameOpen>MACROS)(?<argsOpen>\\s+([^>/])*)?/?>',
 | |
| 			// </macro>
 | |
| 			'</\\s*(?<nameClose>MACROS)\\s*>',
 | |
| 		].join('|'), 'smig'],
 | |
| 	// NOTE: this depends on .MACRO_PATTERN_STR and thus is lazily generated...
 | |
| 	__MACRO_PATTERN_GROUPS: undefined,
 | |
| 	get MACRO_PATTERN_GROUPS(){
 | |
| 		return this.__MACRO_PATTERN_GROUPS 
 | |
| 			?? (this.__MACRO_PATTERN_GROUPS =
 | |
| 				'<MACROS>'.split(new RegExp(`(${ this.MACRO_PATTERN_STR })`)).length-2) },
 | |
| 	// XXX still buggy...
 | |
| 	MACRO_ARGS_PATTERN: RegExp('('+[
 | |
| 			// named args...
 | |
| 			'(?<nameQuoted>[a-zA-Z-_]+)\\s*=([\'"])(?<valueQuoted>([^\\3]|\\\\3)*)\\3\\s*',
 | |
| 			'(?<nameUnquoted>[a-zA-Z-_]+)\\s*=(?<valueUnquoted>[^\\s]*)',
 | |
| 			// positional args...
 | |
| 			'([\'"])(?<argQuoted>([^\\8]|\\\\8)*)\\8',
 | |
| 			'(?<arg>[^\\s]+)',
 | |
| 		].join('|') +')', 'smig'),
 | |
| 	// XXX do we need basic inline and block commets a-la lisp???
 | |
| 	COMMENT_PATTERN: RegExp('('+[
 | |
| 			// <!--[pwiki[ .. ]]-->
 | |
| 			'<!--\\[pwiki\\[(?<uncomment>.*)\\]\\]-->',
 | |
| 
 | |
| 			// <pwiki-comment> .. </pwiki-comment>
 | |
| 			'<\\s*pwiki-comment[^>]*>.*<\\/\\s*pwiki-comment\\s*>',
 | |
| 			// <pwiki-comment .. />
 | |
| 			'<\\s*pwiki-comment[^\\/>]*\\/>',
 | |
| 		].join('|') +')', 'smig'),
 | |
| 
 | |
| 	// helpers...
 | |
| 	//
 | |
| 	getPositional: function(args){
 | |
| 		return Object.entries(args)
 | |
| 			.reduce(function(res, [key, value]){
 | |
| 				/^[0-9]+$/.test(key)
 | |
| 					&& (res[key*1] = value)
 | |
| 				return res }, []) },
 | |
| 	normalizeFilters: function(filters){
 | |
| 		var skip = new Set()
 | |
| 		return filters
 | |
| 			.flat()
 | |
| 			.tailUnique()
 | |
| 			.filter(function(filter){
 | |
| 				filter[0] == '-'
 | |
| 					&& skip.add(filter.slice(1))
 | |
| 				return filter[0] != '-' }) 
 | |
| 			.filter(function(filter){
 | |
| 				return !skip.has(filter) })},
 | |
| 
 | |
| 	// Strip comments...
 | |
| 	//
 | |
| 	stripComments: function(str){
 | |
| 		return str
 | |
| 			.replace(this.COMMENT_PATTERN, 
 | |
| 				function(...a){
 | |
| 					return a.pop().uncomment 
 | |
| 						|| '' }) },
 | |
| 
 | |
| 	// Lexically split the string...
 | |
| 	//
 | |
| 	// 	<item> ::=
 | |
| 	// 		<string>
 | |
| 	// 		| {
 | |
| 	// 			name: <string>,
 | |
| 	// 			type: 'inline'
 | |
| 	// 				| 'element'
 | |
| 	// 				| 'opening'
 | |
| 	// 				| 'closing',
 | |
| 	// 			args: {
 | |
| 	// 				<index>: <value>,
 | |
| 	// 				<key>: <value>,
 | |
| 	// 				...
 | |
| 	// 			}
 | |
| 	// 			match: <string>,
 | |
| 	// 		}
 | |
| 	//
 | |
| 	//
 | |
| 	// NOTE: this internally uses macros' keys to generate the lexing pattern.
 | |
| 	lex: function*(page, str){
 | |
| 		str = str 
 | |
| 			?? page.raw
 | |
| 		// NOTE: we are doing a separate pass for comments to completely 
 | |
| 		// 		decouple them from the base macro syntax, making them fully 
 | |
| 		// 		transparent...
 | |
| 		str = this.stripComments(str)
 | |
| 
 | |
| 		// XXX should this be cached???
 | |
| 		var MACRO_PATTERN = new RegExp(
 | |
| 			'('+ this.MACRO_PATTERN_STR[0]
 | |
| 				.replace(/MACROS/g, Object.keys(page.macros).join('|')) +')',
 | |
| 			this.MACRO_PATTERN_STR[1]) 
 | |
| 
 | |
| 		var lst = str.split(MACRO_PATTERN)
 | |
| 
 | |
| 		var macro = false
 | |
| 		while(lst.length > 0){
 | |
| 			if(macro){
 | |
| 				var match = lst.splice(0, this.MACRO_PATTERN_GROUPS)[0]
 | |
| 				// NOTE: we essentially are parsing the detected macro a 
 | |
| 				// 		second time here, this gives us access to named groups
 | |
| 				// 		avoiding maintaining match indexes with the .split(..) 
 | |
| 				// 		output...
 | |
| 				// XXX for some reason .match(..) here returns a list with a string...
 | |
| 				var cur = [...match.matchAll(MACRO_PATTERN)][0].groups
 | |
| 				// special case: escaped inline macro -> keep as text...
 | |
| 				if(match.startsWith('\\@')){
 | |
| 					yield match
 | |
| 					macro = false 
 | |
| 					continue }
 | |
| 				// args...
 | |
| 				var args = {}
 | |
| 				var i = -1
 | |
| 				for(var {groups} 
 | |
| 						of (cur.argsInline ?? cur.argsOpen ?? '')
 | |
| 							.matchAll(this.MACRO_ARGS_PATTERN)){
 | |
| 					i++
 | |
| 					args[groups.nameQuoted ?? groups.nameUnquoted ?? i] =
 | |
| 						groups.valueQuoted 
 | |
| 						?? groups.valueUnquoted 
 | |
| 						?? groups.argQuoted 
 | |
| 						?? groups.arg }
 | |
| 				// macro-spec...
 | |
| 				yield {
 | |
| 					name: (cur.nameInline 
 | |
| 							?? cur.nameOpen 
 | |
| 							?? cur.nameClose)
 | |
| 						.toLowerCase(),
 | |
| 					type: match[0] == '@' ?
 | |
| 							'inline'
 | |
| 						: match[1] == '/' ?
 | |
| 							'closing'
 | |
| 						: match[match.length-2] == '/' ?
 | |
| 							'element'
 | |
| 						: 'opening',
 | |
| 					args, 
 | |
| 					match,
 | |
| 				}
 | |
| 				macro = false
 | |
| 			// normal text...
 | |
| 			} else {
 | |
| 				var str = lst.shift()
 | |
| 				// skip empty strings from output...
 | |
| 				if(str != ''){
 | |
| 					yield str }
 | |
| 				macro = true } } },
 | |
| 
 | |
| 	// Group block elements...
 | |
| 	//
 | |
| 	// 	<item> ::=
 | |
| 	// 		<string>
 | |
| 	// 		| {
 | |
| 	// 			type: 'inline'
 | |
| 	// 				| 'element'
 | |
| 	// 				| 'block',
 | |
| 	// 			body: [
 | |
| 	// 				<item>,
 | |
| 	// 				...
 | |
| 	// 			],
 | |
| 	//
 | |
| 	//			// rest of items are the same as for lex(..)
 | |
| 	// 			...
 | |
| 	// 		}
 | |
| 	//
 | |
| 	// NOTE: this internaly uses macros to check for propper nesting
 | |
| 	group: function*(page, lex, to=false){
 | |
| 		lex = lex
 | |
| 			?? this.lex(page) 
 | |
| 		lex = typeof(lex) == 'string' ?
 | |
| 			this.lex(page, lex)
 | |
| 			: lex
 | |
| 		// NOTE: we are not using for .. of .. here as it depletes the 
 | |
| 		// 		generator even if the end is not reached...
 | |
| 		while(true){
 | |
| 			var {value, done} = lex.next()
 | |
| 			// check if unclosed blocks remaining...
 | |
| 			if(done){
 | |
| 				if(to){
 | |
| 					throw new Error(
 | |
| 						'Premature end of unpit: Expected closing "'+ to +'"') }
 | |
| 				return }
 | |
| 			// assert nesting rules...
 | |
| 			if(page.macros[value.name] instanceof Array
 | |
| 					&& page.macros[value.name].includes(to)){
 | |
| 				throw new Error(
 | |
| 					'Unexpected "'+ value.name +'" macro' 
 | |
| 						+(to ? 
 | |
| 							' in "'+to+'"' 
 | |
| 							: '')) }
 | |
| 			// open block...
 | |
| 			if(value.type == 'opening'){
 | |
| 				value.body = [...this.group(page, lex, value.name)]
 | |
| 				value.type = 'block'
 | |
| 				yield value
 | |
| 				continue
 | |
| 			// close block...
 | |
| 			} else if(value.type == 'closing'){
 | |
| 				if(value.name != to){
 | |
| 					throw new Error('Unexpected closing "'+ value.name +'"') }
 | |
| 				// NOTE: we are intentionally not yielding the value here...
 | |
| 				return } 
 | |
| 			yield value } }, 
 | |
| 
 | |
| 	// Expand macros...
 | |
| 	//
 | |
| 	// 	<item> ::=
 | |
| 	// 		<string>
 | |
| 	// 		// returned by .macros.filter(..)
 | |
| 	// 		| {
 | |
| 	// 			filters: [
 | |
| 	// 				'<filter>'
 | |
| 	// 					| '-<filter>',
 | |
| 	// 				...
 | |
| 	// 			],
 | |
| 	// 			data: [ <item>, .. ],
 | |
| 	// 		}
 | |
| 	//
 | |
| 	expand: function*(page, ast, state={}){
 | |
| 		ast = ast == null ?
 | |
| 				this.group(page)
 | |
| 			: typeof(ast) == 'string' ?
 | |
| 				this.group(page, ast)
 | |
| 			: ast instanceof types.Generator ?
 | |
| 				ast
 | |
| 			: ast.iter()
 | |
| 
 | |
| 		while(true){
 | |
| 			var {value, done} = ast.next()
 | |
| 			if(done){
 | |
| 				return }
 | |
| 
 | |
| 			// text block...
 | |
| 			if(typeof(value) == 'string'){
 | |
| 				yield value 
 | |
| 				continue }
 | |
| 
 | |
| 			// macro...
 | |
| 			var {name, args, body} = value
 | |
| 			var res = 
 | |
| 				page.macros[name].call(page, args, body, state)
 | |
| 					?? ''
 | |
| 			if(res instanceof Array 
 | |
| 					|| page.macros[name] instanceof types.Generator){
 | |
| 				yield* res
 | |
| 			} else {
 | |
| 				yield res } } },
 | |
| 
 | |
| 	// Fully parse a page...
 | |
| 	//
 | |
| 	// This runs in two stages:
 | |
| 	// 	- expand the page
 | |
| 	// 		- lex the page -- .lex(..)
 | |
| 	// 		- group block elements -- .group(..)
 | |
| 	// 		- expand macros -- .expand(..)
 | |
| 	// 	- apply filters
 | |
| 	//
 | |
| 	// XXX add a special filter to clear pending filters... (???)
 | |
| 	parse: function(page, ast, state={}){
 | |
| 		var that = this
 | |
| 		// XXX should we handle strings as input???
 | |
| 		ast = ast 
 | |
| 			?? this.expand(page, null, state)
 | |
| 		ast = typeof(ast) == 'string' ?
 | |
| 			this.expand(page, ast, state)
 | |
| 			: ast
 | |
| 
 | |
| 		return [...ast]
 | |
| 			// post handlers...
 | |
| 			.map(function(section){
 | |
| 				return section instanceof Function ? 
 | |
| 					section.call(page, state)
 | |
| 					: section })
 | |
| 			.flat()
 | |
| 			// filters...
 | |
| 			.map(function(section){
 | |
| 				return (
 | |
| 					// expand section...
 | |
| 					typeof(section) != 'string' ?
 | |
| 						section.data
 | |
| 					// global filters... 
 | |
| 					: state.filters ?
 | |
| 						that.normalizeFilters(state.filters)
 | |
| 							.reduce(function(res, filter){
 | |
| 								if(page.filters[filter] == null){
 | |
| 									throw new Error(
 | |
| 										'.parse(..): unsupported filter: '+ filter) }
 | |
| 								return page.filters[filter].call(page, res) 
 | |
| 									?? res }, section)
 | |
| 					// no global filters...
 | |
| 					: section ) })
 | |
| 			.flat()
 | |
| 			.join('') },
 | |
| }
 | |
| 
 | |
| 
 | |
| // XXX PATH_VARS need to handle path variables...
 | |
| // XXX macros and filters should be features for simpler plugin handlng (???)
 | |
| var Page =
 | |
| module.Page = 
 | |
| object.Constructor('Page', BasePage, {
 | |
| 	//NO_FILTERS: 'nofilters',
 | |
| 	ISOLATED_FILTERS: 'isolated',
 | |
| 
 | |
| 	//
 | |
| 	// 	<filter>(<source>)
 | |
| 	// 		-> <result>
 | |
| 	// 		-> 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: function(source){
 | |
| 			// XXX
 | |
| 			return source },
 | |
| 		markdown: function(source){
 | |
| 			// XXX
 | |
| 			return source },
 | |
| 	},
 | |
| 
 | |
| 	macros: {
 | |
| 		// XXX move to docs...
 | |
| 		test: function*(args, body, state){
 | |
| 			if(body){
 | |
| 				state.testBlock = (state.testBlock ?? 0) + 1
 | |
| 
 | |
| 				yield '\n<test>\n\n'
 | |
| 				yield* this.expand(body) 
 | |
| 				yield '\n\n</test>\n'
 | |
| 
 | |
| 				--state.testBlock == 0
 | |
| 					&& (delete state.testBlock)
 | |
| 			} else {
 | |
| 				yield '<test/>' } },
 | |
| 
 | |
| 		now: function(){
 | |
| 			return ''+ Date.now() },
 | |
| 		//
 | |
| 		// 	@filter(<filter-spec>)
 | |
| 		// 	<filter <filter-spec>/>
 | |
| 		//
 | |
| 		// 	<filter <filter-spec>>
 | |
| 		// 		...
 | |
| 		// 	</filter>
 | |
| 		//
 | |
| 		// 	<filter-spec> ::=
 | |
| 		// 		<filter> <filter-spec>
 | |
| 		// 		| -<filter> <filter-spec>
 | |
| 		//
 | |
| 		// XXX support .NO_FILTERS ...
 | |
| 		filter: function*(args, body, state){
 | |
| 			var filters = state.filters = 
 | |
| 				state.filters ?? []
 | |
| 			// separate local filters...
 | |
| 			if(body){
 | |
| 				var outer_filters = filters
 | |
| 				filters = state.filters =
 | |
| 					[outer_filters] }
 | |
| 
 | |
| 			// merge in new filters...
 | |
| 			filters.splice(filters.length, 0, ...Object.values(args))
 | |
| 
 | |
| 			// 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 = [...this.__parser__.expand(this, body, state)]
 | |
| 				filters = state.filters
 | |
| 
 | |
| 				state.filters = outer_filters
 | |
| 
 | |
| 				// parse the body after we are done expanding...
 | |
| 				yield function(state){
 | |
| 					var outer_filters = state.filters
 | |
| 					state.filters = this.__parser__.normalizeFilters(filters)
 | |
| 					var res = [...this.__parser__.parse(this, ast, state)]
 | |
| 						.flat()
 | |
| 						.join('') 
 | |
| 					state.filters = outer_filters
 | |
| 					return { data: res } } } },
 | |
| 		//
 | |
| 		// 	@include(<path>)
 | |
| 		//
 | |
| 		// 	@include(<path> isolated recursive=<text>)
 | |
| 		// 	@include(src=<path> isolated recursive=<text>)
 | |
| 		//
 | |
| 		// 	<include src=<path> .. >
 | |
| 		// 		<text>
 | |
| 		// 	</include>
 | |
| 		//
 | |
| 		// XXX 'text' argument is changed to 'recursive'...
 | |
| 		// XXX should we track recursion via the resolved (current) path 
 | |
| 		// 		or the given path???
 | |
| 		// XXX should this be lazy???
 | |
| 		include: function(args, body, state, key='included', handler){
 | |
| 			// positional args...
 | |
| 			var src = args.src || args[0]
 | |
| 			var recursive = args.recursive || body
 | |
| 			var isolated = this.__parser__.getPositional(args).includes('isolated')
 | |
| 
 | |
| 			if(!src){
 | |
| 				return '' }
 | |
| 
 | |
| 			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] ?? [this.location]).slice()
 | |
| 			var target = this.match(src)
 | |
| 			target = target instanceof Array ?
 | |
| 				target.join(',')
 | |
| 				: target
 | |
| 			// recursion detected...
 | |
| 			if(this.match() == this.match(src)
 | |
| 					|| seen.includes(target)){
 | |
| 				if(!recursive){
 | |
| 					throw new Error(
 | |
| 						'include: include recursion detected: '
 | |
| 							+ seen.concat([target]).join(' -> ')) }
 | |
| 				// have the 'recursive' arg...
 | |
| 				return this.__parser__.parse(this, recursive, state) }
 | |
| 			seen.push(target)
 | |
| 
 | |
| 			// load the included page...
 | |
| 			var res = handler.call(this)
 | |
| 
 | |
| 			// restore previous include chain...
 | |
| 			if(parent_seen){
 | |
| 				state[key] = parent_seen
 | |
| 			} else {
 | |
| 				delete state[key] }
 | |
| 
 | |
| 			return res },
 | |
| 		source: function(args, body, state){
 | |
| 			var src = args.src || args[0]
 | |
| 			return this.macros.include.call(this, 
 | |
| 				args, body, state, 'sources', 
 | |
| 				function(){
 | |
| 					return this.__parser__.parse(this, this.get(src).raw, state) }) },
 | |
| 
 | |
| 		// XXX this will need to quote pWiki code...
 | |
| 		// 		...not sure about anything else...
 | |
| 		quote: function(){},
 | |
| 
 | |
| 		//
 | |
| 		//	<slot name=<name>/>
 | |
| 		//
 | |
| 		//	<slot name=<name> text=<text>/>
 | |
| 		//
 | |
| 		//	<slot name=<name>>
 | |
| 		//		...
 | |
| 		//	</slot>
 | |
| 		//
 | |
| 		//	Force show a slot...
 | |
| 		//	<slot shown ... />
 | |
| 		//
 | |
| 		//	Force hide a slot...
 | |
| 		//	<slot hidden ... />
 | |
| 		//
 | |
| 		//
 | |
| 		// NOTE: by default only the first slot with <name> is visible, 
 | |
| 		// 		all other slot with <name> will replace its content, unless
 | |
| 		// 		explicit shown/hidden arguments are given.
 | |
| 		// NOTE: hidden has precedence over shown if both are given.
 | |
| 		//
 | |
| 		// XXX how do we handle a slot defined within a slot????
 | |
| 		slot: function(args, body, state){
 | |
| 			var name = args.name
 | |
| 			var text = args.text ?? body
 | |
| 
 | |
| 			var slots = state.slots = 
 | |
| 				state.slots 
 | |
| 					?? {}
 | |
| 
 | |
| 			//var hidden = name in slots
 | |
| 			// XXX EXPERIMENTAL
 | |
| 			var pos = this.__parser__.getPositional(args)
 | |
| 			var hidden = 
 | |
| 				// 'hidden' has priority... 
 | |
| 				(pos.includes('hidden') || args.hidden)
 | |
| 					// explicitly show... ()
 | |
| 					|| ((pos.includes('shown') || args.shown) ?
 | |
| 						false
 | |
| 						// show first instance...
 | |
| 						: name in slots)
 | |
| 
 | |
| 			slots[name] = [...this.__parser__.expand(this, text, state)]
 | |
| 
 | |
| 			return hidden ?
 | |
| 				''
 | |
| 				: function(state){
 | |
| 					return state.slots[name] } }, 
 | |
| 		macro: function(){},
 | |
| 
 | |
| 		// nesting rules...
 | |
| 		'else': ['macro'],
 | |
| 	},
 | |
| 
 | |
| 	// page parser...
 | |
| 	//
 | |
| 	__parser__: module.parser,
 | |
| 	parse: function(state={}){
 | |
| 		return this.__parser__.parse(this, null, state) },
 | |
| 
 | |
| 
 | |
| 	// raw page text...
 | |
| 	//
 | |
| 	// NOTE: writing to .raw is the same as writing to .text...
 | |
| 	// XXX FUNC handle functions as pages...
 | |
| 	// XXX need to support pattern pages...
 | |
| 	get raw(){
 | |
| 		var data = this.data
 | |
| 		return data instanceof Function ?
 | |
| 			// XXX FUNC not sure about this...
 | |
| 			data.call(this, 'text')
 | |
|    			: data.text	},
 | |
| 	set raw(value){
 | |
| 		this.store.update(this.location, {text: value}) },
 | |
| 
 | |
| 	// expanded page text...
 | |
| 	//
 | |
| 	// NOTE: writing to .raw is the same as writing to .text...
 | |
| 	// XXX FUNC handle functions as pages...
 | |
| 	// XXX need to support pattern pages...
 | |
| 	get text(){
 | |
| 		return this.parse() },
 | |
| 	set text(value){
 | |
| 		this.store.update(this.location, {text: value}) },
 | |
| 
 | |
| 
 | |
| })
 | |
| 
 | |
| 
 | |
| 
 | |
| //---------------------------------------------------------------------
 | |
| 
 | |
| var WIKIWORD_PATTERN =
 | |
| 	RegExp('('+[
 | |
| 		//'\\\\?(\\/|\\./|\\.\\./|>>|[A-Z][_a-z0-9]+[A-Z/])[_a-zA-Z0-9/]*',
 | |
| 		'\\\\?\\/?(\\./|\\.\\./|>>|[A-Z][_a-z0-9]+[A-Z/])[_a-zA-Z0-9/]*',
 | |
| 		'\\\\?\\[[^\\]]+\\]',
 | |
| 	].join('|') +')', 'g')
 | |
| 
 | |
| 
 | |
| 
 | |
| //---------------------------------------------------------------------
 | |
| // XXX experiments and testing...
 | |
| 
 | |
| 
 | |
| // NOTE: in general the root wiki api is simply a page instance.
 | |
| // XXX not yet sure how to organize the actual alient -- UI, hooks, .. etc
 | |
| var pwiki =
 | |
| module.pwiki = 
 | |
| Page('/', '/', 
 | |
| 	Object.assign(
 | |
| 		Object.create(store), 
 | |
| 		require('./bootstrap')))
 | |
| 
 | |
| 
 | |
| // XXX TEST...
 | |
| // XXX add filter tests...
 | |
| console.log('loading test page...')
 | |
| pwiki
 | |
| 	.update({
 | |
| 		location: '/page',
 | |
| 		text: 'PAGE\n'
 | |
| 			+'\n'
 | |
| 			// XXX BUG this is parsed incorrectly -- macro pattern...
 | |
| 			//+'@include(/test recursive="Recursion type 2 (<now/>)")\n',
 | |
| 			+'@include(/test recursive="Recursion type 2 <now/>")\n'
 | |
| 			+'\n'
 | |
| 			+'@slot(name=b text="filled slot")\n',
 | |
| 	})
 | |
| 	.update({
 | |
| 		location: '/other',
 | |
| 		text: 'OTHER',
 | |
| 	})
 | |
| 	.update({
 | |
| 		location: '/test',
 | |
| 		text: 'TEST\n'
 | |
| 			+'\n'
 | |
| 			+'globally filtered test text...\n'
 | |
| 			+'\n'
 | |
| 			+'<filter -test>...unfiltered test text</filter>\n'
 | |
| 			+'\n'
 | |
| 			//+'<filter test>locally filtered test text</filter>\n'
 | |
| 			+'\n'
 | |
| 			+'@slot(name=a text="non-filled slot")\n'
 | |
| 			+'\n'
 | |
| 			+'@slot(name=b text="non-filled slot")\n'
 | |
| 			+'\n'
 | |
| 			+'Including /other #1: @include(/other)\n'
 | |
| 			+'Including /other #2: @include(/other)\n'
 | |
| 			+'\n'
 | |
| 			// XXX BUG this is parsed incorrectly -- macro pattern...
 | |
| 			//+'Including /test: @include(/test recursive="Recursion type 1 (<now/>)")\n'
 | |
| 			+'Including /test: @include(/test recursive="Recursion type 1 <now/>")\n'
 | |
| 			+'\n'
 | |
| 			+'Including /page: @include(/page)\n'
 | |
| 			+'\n'
 | |
| 			+'Including /: \\@include(/)\n'
 | |
| 			+'\n'
 | |
| 			+'@filter(test)',
 | |
| 	})
 | |
| 
 | |
| 
 | |
| 
 | |
| 
 | |
| 
 | |
| /**********************************************************************
 | |
| * vim:set ts=4 sw=4 nowrap :                        */ return module })
 |