DOM

object

%cookiewall

/phlo/resources/DOM/cookiewall.phlo
version 1.2
creator q-ai.nl
summary Subtle GDPR cookie-consent banner (English-only). For multilingual sites: DOM/cookiewall.translated.
package privacy
frontend true
backend true
requires @cookies
tags gdpr consent cookies privacy
static

cookiewall :: __handle

line 10
This method handles the cookie wall functionality, managing user consent for cookies.
null
prop

%cookiewall -> choice

line 12
Retrieves the value of 'cookieChoice' from the %cookies object, returning null if it is not set.
%cookies->cookieChoice ?? null
method

%cookiewall -> hasChosen

line 13
Checks if a choice has been made by verifying that the choice is not null.
$this->choice !== null
method

%cookiewall -> canTrack

line 14
Determines if tracking is allowed based on the user's choice, specifically if it is set to 'all'.
$this->choice === 'all'
method

%cookiewall -> canAnalytics

line 15
Checks if the current choice is set to 'all' to determine if analytics can be enabled.
$this->choice === 'all'
route

route async POST cookiewall accept all

line 17
Defines a route that sets a cookie indicating the user has accepted all cookies and removes the cookiewall view asynchronously.
%cookies->objSet('cookieChoice', 'all', ['expires' => time() + 60 * 60 * 24 * 365, 'httponly' => false])
apply(remove: '#cookiewall')
route

route async POST cookiewall accept essential

line 22
This route sets a cookie named 'cookieChoice' with the value 'essential' that expires in one year and then applies a removal of the element with the ID 'cookiewall'.
%cookies->objSet('cookieChoice', 'essential', ['expires' => time() + 60 * 60 * 24 * 365, 'httponly' => false])
apply(remove: '#cookiewall')
view

%cookiewall -> banner

line 27
The cookiewall:view displays a cookie consent banner that prompts users to choose between essential cookies and all cookies, only if they have not made a choice yet.
<if !$this->hasChosen()>
	<div#cookiewall role=region aria-label="Cookie choice">
		<p>We use essential cookies to make this site work. With your permission we also use analytics to improve the site.</p>
		<div.actions>
			<form.async method=post action=/cookiewall/accept/essential>
				<button.ghost type=submit>Essential only</button>
			</form>
			<form.async method=post action=/cookiewall/accept/all>
				<button.primary type=submit>Accept</button>
			</form>
		</div>
	</div>
</if>
view

style

line 42
Defines the CSS styles for the cookiewall component, including layout, colors, and responsive design.
#cookiewall {
	background: #1a1a1a
	border-radius: 8px
	bottom: 16px
	box-shadow: 0 8px 32px #0004
	color: #fff
	font-size: 13px
	left: 16px
	line-height: 1.5
	max-width: 360px
	padding: 14px 16px
	position: fixed
	right: 16px
	z-index: 9999
	@media(min-width: 600px): right: auto
	p: margin: 0 0 10px
	.actions {
		display: flex
		gap: 8px
		justify-content: flex-end
	}
	button {
		border-radius: 4px
		border: 0
		cursor: pointer
		font-size: 12px
		padding: 6px 12px
	}
	button.ghost {
		background: #444
		color: #fff
	}
	button.primary {
		background: #fff
		color: #1a1a1a
		font-weight: 600
	}
}
object

%cookiewall_translated

/phlo/resources/DOM/cookiewall.translated.phlo
extends cookiewall
class cookiewall_translated
version 1.1
creator q-ai.nl
summary Multilingual cookiewall variant. {en: ...} tags resolve via Phlo's lang system. For English-only: DOM/cookiewall.
package privacy
frontend true
backend true
requires @cookies
tags gdpr consent cookies privacy multilingual
view

%cookiewall_translated -> banner

line 12
This view displays a cookie consent banner that prompts users to accept essential cookies or all cookies, only if they have not previously made a choice.
<if !$this->hasChosen()>
	<div#cookiewall role=region aria-label="{en: Cookie choice}">
		<p>{en: We use essential cookies to make this site work. With your permission we also use analytics to improve the site.}</p>
		<div.actions>
			<form.async method=post action=/cookiewall/accept/essential>
				<button.ghost type=submit>{en: Essential only}</button>
			</form>
			<form.async method=post action=/cookiewall/accept/all>
				<button.primary type=submit>{en: Accept}</button>
			</form>
		</div>
	</div>
</if>
object

%CSS_fixes

/phlo/resources/DOM/CSS.fixes.phlo
version 1.0
creator q-ai.nl
summary Single Page App basic CSS boilerplate fixes
package css
frontend true
backend false
tags css fixes boilerplate reset
view

style

line 9
Applies global CSS styles to ensure consistent box-sizing, touch actions, focus outlines, and appearance for various HTML elements.
*, ::before, ::after: box-sizing: border-box
a, area, button, input, label, select, summary, textarea, [tabindex]: touch-action: manipulation
button:focus, input:focus, select:focus, textarea:focus, [contenteditable]:focus: outline: 0
input::-webkit-outer-spin-button, input::-webkit-inner-spin-button: -webkit-appearance: none
input[type="number"]: -moz-appearance: textfield
table: border-collapse: collapse
::-ms-expand: display: none
object

%CSS_var

/phlo/resources/DOM/CSS.var.phlo
version 1.0
creator q-ai.nl
summary CSS variable proxy via app.var
package css
frontend true
backend false
tags css variables app.var frontend
view

script

line 9
Defines a proxy for accessing and modifying CSS custom properties (variables) on the document's root element, allowing for dynamic updates and retrieval of their values.
Object.defineProperty(app, 'var', {get(){return new Proxy({}, {get(_, key){return getComputedStyle(document.documentElement).getPropertyValue(`--${key}`).trim()}, set(_, key, value){ return document.documentElement.style.setProperty(`--${key}`, value)}})}, configurable: true})
app.mod.setvar = (key, value) => app.var[key] = value
object

%datatags

/phlo/resources/DOM/datatags.phlo
version 1.0
creator q-ai.nl
summary Single Page App datatag plugin
package dom
frontend true
backend false
requires @DOM
tags dom datatag dataset spa events
view

script

line 10
Handles click events on elements with data attributes for HTTP methods, preventing default actions and executing the corresponding method with the specified path and data.
on('click', '[data-get], [data-post], [data-put], [data-patch], [data-delete]', (el, e) => {
	if (el.dataset.confirm) return
	e.preventDefault()
	let method, path, data = null
	if ((path = el.dataset.get) !== undefined) method = 'get'
	else if ((path = el.dataset.delete) !== undefined) method = 'delete'
	else [data = {}, Object.keys(el.dataset).forEach((key) => key === 'post' || key === 'put' || key === 'patch' ? [method = key, path = el.dataset[key]] : data[key] = el.dataset[key])]
	app[method](path, data)
})
object

%dialog

/phlo/resources/DOM/dialog.phlo
version 1.0
creator q-ai.nl
summary Single Page App dialog resource
package dom
frontend true
backend false
requires @DOM
tags dom dialog modal confirm prompt alert
view

script

line 10
Creates a dialog interface for alert, confirm, and prompt interactions, allowing users to display messages and receive input. It returns a promise that resolves based on user actions within the dialog.
window.alert = app.mod.alert = msg => phlo.dialog('alert', msg)
window.confirm = msg => phlo.dialog('confirm', msg)
window.prompt = (msg, defaultValue) => phlo.dialog('prompt', msg, defaultValue)

phlo.dialog = async (type, message, defaultValue = '') => new Promise((resolve) => {
	app.mod.append('body', '<dialog id="phloDialog" class="phlo-dialog" role="dialog" aria-modal="true">\n<form method="dialog">\n<p class="phlo-dialog__message"></p>\n' + (type === 'prompt' ? '<input class="phlo-dialog__input" name="value">' : '') + '\n<menu class="phlo-dialog__actions">\n<button value="1" autofocus>OK</button>\n' + (type !== 'alert' ? '<button value="0">Cancel</button>' : '') + '\n</menu>\n</form>\n</dialog>')
	const dialog = obj('#phloDialog')
	const messageEl = dialog.querySelector('.phlo-dialog__message')
	messageEl && (messageEl.textContent = String(message ?? ''))
	if (type === 'prompt') dialog.querySelector('input').value = String(defaultValue ?? '')
	dialog.showModal()
	dialog.addEventListener('close', () => {
		const value = dialog.returnValue
		const input = dialog.querySelector('input')
		dialog.remove()
		if (type === 'alert') return resolve()
		if (type === 'confirm') return resolve(value === '1')
		if (type === 'prompt') return resolve(value === '1' ? input.value : null)
	})
})

on('click', '[data-confirm]', async (el, e) => {
	e.preventDefault()
	if (!await window.confirm(el.dataset.confirm)) return
	delete el.dataset.confirm
	app.update()
	el.click()
})
object

%exists

/phlo/resources/DOM/exists.phlo
version 1.0
creator q-ai.nl
summary onExist helper for dynamic SPA elements
package dom
frontend true
backend false
requires @DOM
tags dom onexist spa lifecycle
view

script

line 10
This function tracks elements and their associated callbacks, executing the callbacks for elements that exist but have not been previously registered.
phlo.exist = []
phlo.existing = new WeakMap

const onExist = (els, cb) => phlo.exist.push({els, cb})

app.updates.push(() => {
	const existing = []
	phlo.exist.forEach(item => objects(item.els).forEach(el => phlo.existing.has(el) || existing.push({el, cb: item.cb})))
	existing.forEach(item => [phlo.existing.has(item.el) || phlo.existing.set(item.el, 'exist'), item.cb(item.el)])
})
object

%form

/phlo/resources/DOM/form.phlo
version 1.0
creator q-ai.nl
summary Single Page App form handler and input state saver
package dom
frontend true
backend false
requires @DOM
tags dom form input state spa
view

script

line 10
Handles input events for form elements, updating their attributes based on user interaction, and submits the form asynchronously using the specified method.
on('input change', 'input, select, textarea', input => {
	if (input.tagName === 'SELECT') input.querySelectorAll('option').forEach((option, index) => option.selected ? option.setAttribute('selected', '') : option.removeAttribute('selected'))
	if (input.type === 'checkbox') input.checked ? input.setAttribute('checked', '') : input.removeAttribute('checked')
	if (input.type === 'text' && input.value !== input.getAttribute('value')) input.setAttribute('value', input.value)
	if (input.type === 'textarea' && input.value !== input.innerHTML) input.innerHTML = input.value
	phlo.state.replace()
	return false
})
on('submit', 'form.async', (form, e) => [e.preventDefault(), app[(form.attributes.method?.value ?? 'GET').toLowerCase()](new URL(form.action).pathname.substr(1), new FormData(form))])
object

%image_resizer

/phlo/resources/DOM/image.resizer.phlo
version 1.0
creator q-ai.nl
summary Client-side file upload image resizer
package dom
frontend true
backend false
tags dom image resize upload canvas
view

script

line 9
Resizes an image file to specified maximum dimensions while maintaining the aspect ratio, and returns the resized image as a data URL through a callback function.
const imageResizer = (file, maxWidth, maxHeight, cb, quality = .8) => {
	const img = new Image
	img.onload = () => {
		let width = img.width, height = img.height
		const aspectRatio = width / height
		if (width > maxWidth || height > maxHeight){
			if (width > height){
				width = maxWidth
				height = Math.round(maxWidth / aspectRatio)
			}
			else {
				height = maxHeight
				width = Math.round(maxHeight * aspectRatio)
			}
		}
		const canvas = document.createElement('canvas')
		canvas.width = width
		canvas.height = height
		canvas.getContext('2d').drawImage(img, 0, 0, width, height)
		cb(canvas.toDataURL(file.type, quality))
	}
	img.src = URL.createObjectURL(file)
}
object
/phlo/resources/DOM/link.phlo
version 1.0
creator q-ai.nl
summary Single Page App async link handler
package dom
frontend true
backend false
requires @DOM
tags dom link async navigation spa
view

script

line 10
This function handles click events on anchor tags, preventing default behavior under certain conditions and managing asynchronous navigation or hash changes in the URL.
on('click', 'a', (a, e) => {
	if (e.ctrlKey || e.shiftKey || e.metaKey || a.dataset.confirm) return false
	const isAsync = a.classList.contains('async')
	const [uri, hash] = a.getAttribute('href').split('#')
	if (isAsync || hash) e.preventDefault()
	phlo.anchor = hash ? `#${hash}` : ''
	if (hash && (!uri || uri === location.pathname + location.search)) location.hash = phlo.anchor
	else if (isAsync) app.get(uri.substr(1))
})
object

%markdown

/phlo/resources/DOM/markdown.phlo
version 1.0
creator q-ai.nl
summary Client-side markdown parser
package dom
frontend true
backend false
tags dom markdown parser frontend
view

script

line 9
Parses Markdown text into HTML, supporting various features like tables, lists, and block quotes, with options for GitHub Flavored Markdown (GFM) and custom header IDs.
function parse_markdown(md, opts = {}){
  const o = {
    gfm: opts.gfm !== false,
    breaks: !!opts.breaks,
    headerIds: opts.headerIds !== false,
    headerPrefix: opts.headerPrefix || '',
    smartypants: !!opts.smartypants
  }
  const unnull = x => (x == null ? '' : String(x))
  let src = unnull(md).replace(/\r\n?/g, "\n")
  const escHtml = s => s.replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]))
  const trimEndNL = s => s.replace(/\s+$/,'')
  const isBlank = s => /^\s*$/.test(s)
  const slugmap = new Map()
  const slug = (t) => {
    let s = t.toLowerCase().replace(/<\/?[^>]+>/g, '').replace(/[^\p{L}\p{N}\- _]+/gu, '').trim().replace(/[\s_]+/g, '-')
    const base = o.headerPrefix + s
    let k = base, i = 1
    while (slugmap.has(k)) k = `${base}-${++i}`
    slugmap.set(k, true)
    return k
  }
  const smart = s => {
    if (!o.smartypants) return s
    return s.replace(/---/g, "-").replace(/--/g, "–").replace(/(^|[\s"(\[])(?=')/g, "$1‘").replace(/'/g, "’").replace(/(^|[\s(\[])(?=")/g, "$1“").replace(/"/g, "”").replace(/\.{3}/g, "…")
  }
  const refs = Object.create(null)
  src = src.replace(
    /^ {0,3}\[([^\]]+)\]:\s*<?([^\s>]+)>?(?:\s+(?:"([^"]*)"|'([^']*)'|\(([^)]+)\)))?\s*$/gm,
    (_, label, url, t1, t2, t3) => {
      const key = label.trim().replace(/\s+/g, ' ').toLowerCase()
      if (!refs[key]) refs[key] = { href: url, title: t1 || t2 || t3 || '' }
      return ''
    }
  )
  const tokens = []
  const lines = src.split("\n")
  function takeWhile(start, pred){
    let end = start
    while (end < lines.length && pred(lines[end], end)) end++
    return { start, end }
  }
  function pushParagraph(buf){
    const text = buf.join("\n").trimEnd()
    if (text) tokens.push({ type: "paragraph", text })
    buf.length = 0
  }
  function parseBlock(start = 0, end = lines.length){
    const para = []
    let l = start
    while (l < end){
      const line = lines[l]
      if (isBlank(line)){ pushParagraph(para); l++; continue; }
      let m = line.match(/^ {0,3}(`{3,}|~{3,})([^\n]*)$/)
      if (m){
        pushParagraph(para)
        const fenceLen = m[1].length
        const info = (m[2] || '').trim()
        let body = []
        l++
        while (l < end){
          const s = lines[l]
          const close = s.match(new RegExp(`^ {0,3}${m[1][0]}{${fenceLen},}\\s*$`))
          if (close){ l++; break; }
          body.push(s)
          l++
        }
        tokens.push({ type: "code", lang: info.split(/\s+/)[0] || '', text: trimEndNL(body.join("\n")) })
        continue
      }
      if (/^(?: {4}|\t)/.test(line)){
        pushParagraph(para)
        const { end: j } = takeWhile(l, s => /^(?: {4}|\t)/.test(s) || isBlank(s))
        const block = lines.slice(l, j).map(s => s.replace(/^(?: {4}|\t)/, '')).join("\n")
        tokens.push({ type: "code", lang: '', text: trimEndNL(block) })
        l = j; continue
      }
      if (/^ {0,3}<(?:!--|\/?(?:html|head|body|pre|script|style|table|thead|tbody|tfoot|tr|td|th|div|p|h[1-6]|blockquote|ul|ol|li|section|article|aside|details|summary|figure|figcaption)\b)/i.test(line)){
        pushParagraph(para)
        const { end: j } = takeWhile(l, (s, idx) => !(idx > l && isBlank(lines[idx-1]) && isBlank(s)))
        const html = lines.slice(l, j).join("\n")
        tokens.push({ type: "html", text: html })
        l = j; continue
      }
      if (/^ {0,3}(?:-+\s*|-{3,}|_{3,}|\*{3,})\s*$/.test(line)){
        pushParagraph(para)
        tokens.push({ type: "hr" })
        l++; continue
      }
      m = line.match(/^ {0,3}(#{1,6})[ \t]*([^#\n]*?)[ \t#]*$/)
      if (m){
        pushParagraph(para)
        tokens.push({ type: "heading", depth: m[1].length, text: m[2].trim() })
        l++; continue
      }
      if (l + 1 < end && /^[^\s].*$/.test(line) && /^ {0,3}(=+|-+)\s*$/.test(lines[l + 1])){
        pushParagraph(para)
        const depth = lines[l + 1].trim().startsWith("=") ? 1 : 2
        tokens.push({ type: "heading", depth, text: line.trim() })
        l += 2; continue
      }
      if (/^ {0,3}>\s?/.test(line)){
        pushParagraph(para)
        const { end: j } = takeWhile(l, s => /^ {0,3}>\s?/.test(s) || isBlank(s))
        const inner = lines.slice(l, j).map(s => s.replace(/^ {0,3}>\s?/, '')).join("\n")
        const sub = parse_markdown(inner, { ...o })
        tokens.push({ type: "blockquote", html: sub })
        l = j; continue
      }
      m = line.match(/^ {0,3}((?:[*+-])|\d{1,9}[.)])\s+/)
      if (m){
        pushParagraph(para)
        const bulletRe = /^ {0,3}((?:[*+-])|\d{1,9}[.)])\s+/
        const { end: j } = takeWhile(l, (s, idx) =>
          bulletRe.test(s) ||
          (/^(?: {4}|\t)/.test(s)) ||
          (!isBlank(s) && idx > l && !/^(?: {0,3}(?:[*+-]|\d{1,9}[.)])\s+)/.test(s))
        )
        const block = lines.slice(l, j)
        const ordered = /^\d/.test(m[1])
        const items = []
        let cur = []
        for (let k = 0; k < block.length; k++){
          const ln = block[k]
          const head = ln.match(bulletRe)
          if (head){
            if (cur.length) items.push(cur), cur = []
            cur.push(ln.replace(bulletRe, ''))
          } else {
            cur.push(ln.replace(/^(?: {4}|\t)/, ''))
          }
        }
        if (cur.length) items.push(cur)
        const parsedItems = items.map(linesArr => {
          let raw = linesArr.join("\n").replace(/\n\s+$/,'')
          let checked = null
          if (o.gfm){
            const t = raw.match(/^\[([ xX])\][ \t]+/)
            if (t){ checked = t[1].toLowerCase() === 'x'; raw = raw.replace(/^\[[ xX]\][ \t]+/, ''); }
          }
          const html = parse_markdown(raw, o)
          return { html, checked }
        })
        tokens.push({ type: "list", ordered, items: parsedItems })
        l = j; continue
      }
      if (o.gfm){
        const hdr = line
        const alignLn = lines[l + 1] || ''
        if (/\|/.test(hdr) && /^ {0,3}\|? *:?-+:? *(?:\| *:?-+:? *)*\|? *$/.test(alignLn)){
          pushParagraph(para)
          const aligns = alignLn
            .trim().replace(/^(\|)|(\|)$/g,'')
            .split("|").map(s => s.trim()).map(s => s.startsWith(":-") && s.endsWith("-:") ? "center" : s.endsWith("-:") ? "right" : s.startsWith(":-") ? "left" : null)
          const headerCells = hdr.trim().replace(/^(\|)|(\|)$/g,'').split("|").map(s => s.trim())
          l += 2
          const rows = []
          while (l < end && /\|/.test(lines[l]) && !isBlank(lines[l])){
            rows.push(lines[l].trim().replace(/^(\|)|(\|)$/g,'').split("|").map(s => s.trim()))
            l++
          }
          tokens.push({ type: "table", header: headerCells, aligns, rows })
          continue
        }
      }
      para.push(line)
      const next = lines[l + 1] || ''
      const endPara =
        isBlank(next) ||
        /^ {0,3}(?:`{3,}|~{3,})/.test(next) ||
        /^(?: {4}|\t)/.test(next) ||
        /^ {0,3}((?:[*+-])|\d{1,9}[.)])\s+/.test(next) ||
        /^ {0,3}(#{1,6})/.test(next) ||
        /^ {0,3}>\s?/.test(next) ||
        /^ {0,3}(?:-+\s*|-{3,}|_{3,}|\*{3,})\s*$/.test(next) ||
        (o.gfm && /\|/.test(next) && /^ {0,3}\|? *:?-+:? *(?:\| *:?-+:? *)*\|? *$/.test(lines[l + 2] || ''))
      if (endPara) pushParagraph(para)
      l++
    }
    pushParagraph(para)
  }
  parseBlock(0, lines.length)
  function renderInline(s){
    if (!s) return ''
    s = s.replace(/(`+)([^`]|[^`][\s\S]*?[^`])\1/g, (_, ticks, code) => `<code>${escHtml(code)}</code>`)
    s = s.replace(/!\[([^\]]*)\]\(\s*<?([^\s)<>]+)>?\s*(?:(?:"([^"]*)"|'([^']*)'|\(([^)]+)\)))?\s*\)/g,
      (_, alt, url, t1, t2, t3) => `<img src="${escHtml(url)}" alt="${escHtml(alt)}"${t1||t2||t3?` title="${escHtml(t1||t2||t3)}"`:''}>`)
    s = s.replace(/!\[([^\]]*)\]\[([^\]]*)\]/g, (_, alt, id) => {
      const ref = refs[(id || alt).trim().replace(/\s+/g,' ').toLowerCase()]
      return ref ? `<img src="${escHtml(ref.href)}" alt="${escHtml(alt)}"${ref.title?` title="${escHtml(ref.title)}"`:''}>` : _
    })
    s = s.replace(/\[([^\]]+)\]\(\s*<?([^\s)<>]+)>?\s*(?:(?:"([^"]*)"|'([^']*)'|\(([^)]+)\)))?\s*\)/g,
      (_, text, url, t1, t2, t3) => `<a href="${escHtml(url)}"${t1||t2||t3?` title="${escHtml(t1||t2||t3)}"`:''}>${text}</a>`)
    s = s.replace(/\[([^\]]+)\]\s*\[([^\]]*)\]/g, (_, text, id) => {
      const key = (id || text).trim().replace(/\s+/g,' ').toLowerCase()
      const ref = refs[key]
      return ref ? `<a href="${escHtml(ref.href)}"${ref.title?` title="${escHtml(ref.title)}"`:''}>${text}</a>` : _
    })
    s = s.replace(/<([a-zA-Z][a-zA-Z0-9+.-]{1,31}:[^ <>"']+)>/g, (_, url) => `<a href="${escHtml(url)}">${escHtml(url)}</a>`)
    s = s.replace(/<([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})>/g, (_, mail) => `<a href="mailto:${escHtml(mail)}">${escHtml(mail)}</a>`)
    if (o.gfm){
      s = s.replace(/(?:(?<=\s)|^)(https?:\/\/[^\s<]+)(?=\s|$)/g, '<a href="$1">$1</a>')
      s = s.replace(/(?:(?<=\s)|^)(www\.[^\s<]+)(?=\s|$)/g, '<a href="http://$1">$1</a>')
    }
    s = s.replace(/\*\*([\s\S]+?)\*\*/g, '<strong>$1</strong>').replace(/__([\s\S]+?)__/g, '<strong>$1</strong>')
    s = s.replace(/\*([^*\n]+?)\*/g, '<em>$1</em>').replace(/_([^_\n]+?)_/g, '<em>$1</em>')
    if (o.gfm) s = s.replace(/~~([\s\S]+?)~~/g, '<del>$1</del>')
    s = s.replace(/ {2,}\n/g, "<br>\n")
    if (o.breaks) s = s.replace(/\n/g, "<br>\n")
    s = s.replace(/&(?!#?\w+;)/g, "&amp;").replace(/<(?!\/?[A-Za-z][^>]*>)/g, "&lt;")
    return smart(s)
  }
  let out = ''
  for (const t of tokens){
    switch (t.type){
      case "paragraph":
        out += `<p>${renderInline(t.text)}</p>\n`
        break
      case "heading": {
        const text = renderInline(t.text)
        const id = o.headerIds ? slug(text.replace(/<[^>]+>/g, '')) : null
        out += id ? `<h${t.depth} id="${id}">${text}</h${t.depth}>\n` : `<h${t.depth}>${text}</h${t.depth}>\n`
        break
      }
      case "code": {
        const cls = t.lang ? ` class="language-${escHtml(t.lang)}"` : ''
        out += `<pre><code${cls}>${escHtml(t.text)}</code></pre>\n`
        break
      }
      case "blockquote":
        out += `<blockquote>\n${t.html.trim()}\n</blockquote>\n`
        break
      case "list": {
        const tag = t.ordered ? "ol" : "ul"
        out += `<${tag}>\n`
        for (const it of t.items){
          const task = it.checked === null ? '' : `<input ${it.checked ? 'checked="" ' : ''}disabled="" type="checkbox"> `
          const body = it.html.trim().replace(/^<p>/, task + "<p>")
          out += `<li>${body}</li>\n`
        }
        out += `</${tag}>\n`
        break
      }
      case "table": {
        const ths = t.header.map((h, i) => {
          const a = t.aligns[i]
          return a ? `<th align="${a}">${renderInline(h)}</th>` : `<th>${renderInline(h)}</th>`
        }).join("\n")
        let body = ''
        for (const row of t.rows){
          const tds = row.map((cell, i) => {
            const a = t.aligns[i]
            return a ? `<td align="${a}">${renderInline(cell)}</td>` : `<td>${renderInline(cell)}</td>`
          }).join("\n")
          body += `<tr>\n${tds}\n</tr>\n`
        }
        out += `<table>\n<thead>\n<tr>\n${ths}\n</tr>\n</thead>\n` + (body ? `<tbody>\n${body}</tbody>\n` : '') + `</table>\n`
        break
      }
      case "hr":
        out += "<hr>\n"
        break
      case "html":
        out += t.text + "\n"
        break
    }
  }
  return out.trim()
}
object

%shorthands

/phlo/resources/DOM/shorthands.phlo
version 1.0
creator q-ai.nl
summary onChange, onClick and onInput event shorthands
package dom
frontend true
backend false
requires @DOM
tags dom events shorthand frontend
view

script

line 10
Defines shorthand functions for common event listeners: onChange, onClick, and onInput, which simplify the process of attaching event handlers to elements.
function onChange(els, cb){ on('change', els, cb) }
function onClick(els, cb){ on('click', els, cb) }
function onInput(els, cb){ on('input', els, cb) }
object

%store

/phlo/resources/DOM/store.phlo
version 1.1
creator q-ai.nl
summary Stateful binding engine
package dom
frontend true
backend false
requires @DOM
tags dom store binding state signals calc reactive
view

script

line 10
The store:script manages a reactive state store in Phlo, allowing for the storage, retrieval, and notification of state changes. It supports computed values, dependency tracking, and synchronization with the DOM through data binding.
phlo.store = {
	signals: {},
	listeners: {},
	calcs: {},
	calcDeps: {},
	calcVals: {},
	calcTick: false,
	split: (path) => path.replace(/\]/g, '').split(/\.|\[/),
	get(path){
		if (!path) return undefined
		let ctx = phlo.store.signals
		const keys = phlo.store.split(path)
		for (let i = 0; i < keys.length; i++){
			if (ctx == null) return undefined
			ctx = ctx[keys[i]]
		}
		return ctx
	},
	setPath(path, value){
		let keys = phlo.store.split(path)
		let ctx = phlo.store.signals
		while (keys.length > 1){
			const k = keys.shift()
			ctx[k] ??= isNaN(keys[0]) ? {} : []
			ctx = ctx[k]
		}
		const k = keys[0]
		const old = ctx[k]
		if (old === value) return false
		ctx[k] = value
		return true
	},
	set(path, value){
		if (!phlo.store.setPath(path, value)) return
		phlo.store.notify(path, phlo.store.get(path))
		phlo.store.recalc(path)
		phlo.store.schedule()
	},
	on(path, cb){ (phlo.store.listeners[path] ??= new Set).add(cb) },
	off(path, cb){ phlo.store.listeners[path] && phlo.store.listeners[path].delete(cb) },
	reset(){
		phlo.store.signals = {}
		phlo.store.listeners = {}
		phlo.store.calcs = {}
		phlo.store.calcDeps = {}
		phlo.store.calcVals = {}
		phlo.store.calcTick = false
	},
	signal(path, initial){
		if (phlo.store.get(path) === undefined) phlo.store.set(path, initial)
		return { subscribe: (cb) => phlo.store.on(path, cb), unsubscribe: (cb) => phlo.store.off(path, cb) }
	},
	notify(path, val){
		const set = phlo.store.listeners[path]
		if (set) set.forEach(cb => cb(val))
	},
	match(dep, changed){
		if (!dep) return false
		if (dep === changed) return true
		return changed.startsWith(dep + '.') || changed.startsWith(dep + '[') || dep.startsWith(changed + '.') || dep.startsWith(changed + '[')
	},
	depsReady(list){
		const arr = Array.isArray(list) ? list : (list ? [list] : [])
		return arr.every(d => phlo.store.get(d) !== undefined)
	},
	evalCalc(name){
		const fn = phlo.store.calcs[name]
		if (!fn) return
		let deps = []
		let val
		try {
			const out = fn()
			if (Array.isArray(out) && out.length === 2) deps = out[0], val = out[1]
			else val = out
		}
		catch(e){
			deps = []
			val = undefined
		}
		const list = Array.isArray(deps) ? deps : (deps ? [deps] : [])
		phlo.store.calcDeps[name] = list
		if (!phlo.store.depsReady(list)) return
		const old = phlo.store.calcVals[name]
		if (old !== val){
			phlo.store.calcVals[name] = val
			const p = `calc.${name}`
			phlo.store.setPath(p, val)
			phlo.store.notify(p, val)
		}
	},
	recalc(changed){
		const names = Object.keys(phlo.store.calcs)
		for (let i = 0; i < names.length; i++){
			const name = names[i]
			const deps = phlo.store.calcDeps[name] || []
			for (let j = 0; j < deps.length; j++){
				if (phlo.store.match(deps[j], changed)){
					phlo.store.evalCalc(name)
					break
				}
			}
		}
	},
	recalcAll(){
		const names = Object.keys(phlo.store.calcs)
		for (let i = 0; i < names.length; i++) phlo.store.evalCalc(names[i])
	},
	schedule(){
		if (phlo.store.calcTick) return
		phlo.store.calcTick = true
		setTimeout(() => {
			phlo.store.calcTick = false
			phlo.store.recalcAll()
		})
	},
	proxy(base){
		return new Proxy({}, {
			get(t, k){
				if (typeof k === 'symbol') return undefined
				const seg = /^\d+$/.test(k) ? `[${k}]` : (base ? `.${k}` : String(k))
				const path = base + seg
				const v = phlo.store.get(path)
				if (v !== undefined && (typeof v !== 'object' || v === null)) return v
				return phlo.store.proxy(path)
			},
			set(t, k, v){
				const seg = /^\d+$/.test(k) ? `[${k}]` : (base ? `.${k}` : String(k))
				phlo.store.set(base + seg, v)
				return true
			},
			has(t, k){ return phlo.store.get(base + (base ? '.' : '') + String(k)) !== undefined },
			ownKeys(){ return Object.keys(phlo.store.get(base) || {}) },
			getOwnPropertyDescriptor(){ return { enumerable: true, configurable: true } }
		})
	}
}

app.store = phlo.store.proxy('')

app.mod.store = (key, value) => {
	const cur = phlo.store.get(key)
	if (JSON.stringify(cur) === JSON.stringify(value)) return
	const walk = (base, obj) => {
		if (typeof obj !== 'object' || obj === null) return phlo.store.set(base, obj)
		Object.entries(obj).forEach(([k, v]) => walk(isNaN(k) ? `${base}.${k}` : `${base}[${k}]`, v))
	}
	walk(key, value)
}

phlo.calc = new Proxy({}, {
	set(t, k, fn){
		if (typeof fn !== 'function') return false
		phlo.store.calcs[k] = fn
		phlo.store.evalCalc(k)
		setTimeout(() => phlo.store.evalCalc(k))
		return true
	},
	get(t, k){ return phlo.store.calcs[k] },
	has(t, k){ return k in phlo.store.calcs },
	deleteProperty(t, k){
		delete phlo.store.calcs[k]
		delete phlo.store.calcDeps[k]
		delete phlo.store.calcVals[k]
		return true
	}
})

app.calc = new Proxy({}, {
	get(t, k){ return phlo.store.calcVals[k] },
	has(t, k){ return k in phlo.store.calcVals },
	ownKeys(){ return Object.keys(phlo.store.calcVals) },
	getOwnPropertyDescriptor(){ return { enumerable: true, configurable: true } }
})

onExist('[data-bind]', (el) => {
	const key = el.dataset.bind
	const isCalc = key.startsWith('calc.')
	const isInput = el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA'
	const fromDom = isInput ? el.value : el.textContent
	const fromStore = phlo.store.get(key)
	const domNonEmpty = (fromDom ?? '').trim() !== ''
	const storeEmpty = fromStore === undefined || (typeof fromStore === 'string' && fromStore.trim() === '')
	const domLeads = !isCalc && domNonEmpty && storeEmpty
	const S = (v) => v == null ? '' : (typeof v === 'object' ? '' : String(v))
	const apply = (v) => {
		const s = S(v)
		if (isInput) el.value = s
		else el.textContent = s
	}
	phlo.store.on(key, apply)
	if (domLeads) phlo.store.set(key, fromDom)
	const initial = domLeads ? fromDom : fromStore
	apply(initial)
	if (!isCalc && isInput) el.oninput = (e) => phlo.store.set(key, e.target.value)
})

onExist('[data-bind-attr]', (el) => {
	const spec = el.getAttribute('data-bind-attr')
	if (!spec) return
	const BOOL = new Set(['disabled','checked','hidden','required','readonly','selected','autofocus','multiple'])
	let meta = phlo.existing.get(el)
	if (!meta || typeof meta !== 'object'){
		meta = { exist: true }
		phlo.existing.set(el, meta)
	}
	meta.attr || (meta.attr = {})
	meta.attr.cls || (meta.attr.cls = [])
	spec.split(/\s*,\s*/).filter(Boolean).forEach(pair => {
		const m = pair.match(/^\s*([^:]+)\s*:\s*(.+)\s*$/)
		if (!m) return
		const name = m[1]
		const path = m[2]
		const isCalc = path.startsWith('calc.')
		const isInputVal = name === 'value' && (el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA')
		const domVal =
			name === 'text' ? el.textContent :
			name === 'html' ? el.innerHTML :
			name === 'value' ? el.value :
			(name === 'class' ? null : el.getAttribute(name))
		const fromStore = phlo.store.get(path)
		const domNonEmpty = (domVal ?? '').trim() !== ''
		const storeEmpty = fromStore === undefined || (typeof fromStore === 'string' && fromStore.trim() === '')
		const domLeads = !isCalc && name !== 'class' && domNonEmpty && storeEmpty
		const S = (v) => v == null ? '' : (typeof v === 'object' ? '' : String(v))
		const apply = (v) => {
			if (name === 'text') el.textContent = S(v)
			else if (name === 'html') app.mod.inner(el, S(v))
			else if (name === 'value') app.mod.value(el, S(v))
			else if (name === 'class'){
				const next = Array.isArray(v) ? v : (v && typeof v === 'object') ? Object.keys(v).filter(k => v[k]) : String(v ?? '').split(/\s+/)
				const uniq = [...new Set(next.filter(Boolean))]
				const prev = meta.attr.cls
				for (let i = 0; i < prev.length; i++) el.classList.remove(prev[i])
				for (let i = 0; i < uniq.length; i++) el.classList.add(uniq[i])
				meta.attr.cls = uniq
			}
			else if (BOOL.has(name)){
				const on = !!v
				app.mod.attr(el, { [name]: on ? '' : null })
				if (name in el) el[name] = on
			}
			else app.mod.attr(el, { [name]: S(v) })
		}
		phlo.store.on(path, apply)
		if (domLeads) phlo.store.set(path, domVal)
		const initial = domLeads ? domVal : fromStore
		apply(initial)
		if (!isCalc && isInputVal) el.oninput = (e) => phlo.store.set(path, e.target.value)
	})
})
object

%template

/phlo/resources/DOM/template.phlo
version 1.0
creator q-ai.nl
summary Single Page App client-side templating
advice Add cb's to the templates object and output via apply(template: [$name => $rows, $name2 => $rows2, etc])
package dom
frontend true
backend false
tags dom template spa frontend render
view

script

line 10
Defines a function that applies a specified template to each row of data, invoking the template with the values from the row.
app.mod.template = (template, rows) => rows.forEach(row => templates[template](...Object.values(row)))
const templates = {}
object

%timestamps

/phlo/resources/DOM/timestamps.phlo
version 1.0
creator q-ai.nl
summary DOM live timestamps
advice Create an app.tsLabels array to overwrite the tsBase labels in any language
package dom
frontend true
backend false
tags dom timestamps time live frontend
view

script

line 10
Updates the text content of elements with a 'data-ts' attribute to display the time elapsed since a timestamp in a human-readable format, refreshing every second.
app.tsBase = {seconds: 60, minutes: 60, hours: 24, days: 7, weeks: 4, months: 13, years: 1}
const tsUpdate = () => (ranges = app.tsLabels && (tsValues = Object.values(app.tsBase)) ? Object.fromEntries(app.tsLabels.map((k, i) => [k, tsValues[i]])) : app.tsBase) && objects('[data-ts]').forEach(el => {
	let age = Math.round(Date.now() / 1000) - Number(el.dataset.ts), text = ''
	const future = age < 0
	if (future) age = -age
	for (const [range, multiplier] of Object.entries(ranges)){
		if (text) continue
		if (age / multiplier < 1.6583) text = `${Math.round(age)} ${range}`
		age /= multiplier
	}
	text ||= `${Math.round(age)} ${Object.keys(ranges).at(-1)}`
	text = `${future ? '-' : ''}${text}`
	el.innerText === text || (el.innerText = text)
})
setInterval(() => document.hidden || tsUpdate(), 1000)
setTimeout(tsUpdate, 1)
object

%toasts

/phlo/resources/DOM/toasts.phlo
version 1.0
creator q-ai.nl
summary Simple toast resource
package dom
frontend true
backend false
requires @DOM
tags dom toast notification frontend
view

script

line 10
Creates a toast notification that displays a message for a short duration and can be dismissed by clicking on it.
app.mod.toast = msg => {
	obj('#toasts') || app.mod.append('body', '<div id="toasts"></div>')
	const toast = document.createElement('div')
	toast.innerHTML = msg
	toast.onclick = () => toast.remove()
	obj('#toasts').insertAdjacentElement('beforeend', toast)
	setTimeout(() => toast.remove(), 4000)
}
view

style

line 21
Defines the CSS styles for toast notifications, positioning them fixed in the top right corner with specific background color, border radius, and text styling.
#toasts {
	position: fixed
	right: 10px
	top: 5px
	z-index: 1001
	> * {
		background-color: #000A
		border-radius: 10px
		clear: both
		color: white
		cursor: zoom-out
		float: right
		margin-top: 5px
		padding: 3px 6px
	}
}
object

%visible

/phlo/resources/DOM/visible.phlo
version 1.0
creator q-ai.nl
summary onVisible and onVisibleIn helpers for DOM visibility
package dom
frontend true
backend false
requires @DOM
tags dom visible intersection observer frontend
view

script

line 10
Sets up an IntersectionObserver to execute callback functions when specified elements become visible or hidden in the viewport.
phlo.observe = []
phlo.observing = new WeakMap

const onVisible = (els, cbIn, cbOut) => onVisibleIn(els, null, cbIn, cbOut)
const onVisibleIn = (els, root, cbIn, cbOut) => phlo.observe.push({els, root, cbIn, cbOut})

app.updates.push(() => {
	const observers = []
	phlo.observe.forEach(item => objects(item.els).forEach(el => phlo.observing.has(el) || observers.push({el, root: item.root, cbIn: item.cbIn, cbOut: item.cbOut})))
	observers.forEach(item => [phlo.observing.has(item.el) || phlo.observing.set(item.el, 'observe'), (observer = new IntersectionObserver(entries => entries.forEach(entry => entry.isIntersecting ? !item.cbIn && item.cbOut ? [observer.unobserve(entry.target), item.cbOut(entry.target)] : item.cbIn(entry.target) : item.cbIn && item.cbOut && item.cbOut(entry.target)), {root: obj(item.root), threshold: .1})).observe(item.el)])
})
object

%websocket

/phlo/resources/DOM/websocket.phlo
version 1.0
creator q-ai.nl
summary Client-side WebSocket handler
package realtime
frontend true
backend false
requires @DOM
tags websocket realtime frontend dom
view

script

line 10
Establishes a WebSocket connection to the specified path and manages its lifecycle, including handling messages, errors, and reconnections.
app.websocket = {
	get open(){
		app.options.contains('wss') && delay('websocket', app.websocket.retry, () => {
			phlo.wss?.close()
			phlo.wss = new WebSocket(`wss://${location.host}/${app.websocket.path}`)
			phlo.wss.onmessage = e => [{trans, state, ...cmds} = JSON.parse(e.data), apply(cmds, trans, state)]
			phlo.wss.onopen = e => [(cb = app.websocket.connect) && cb(e), phlo.log('🖧 Websocket connected', e), app.websocket.retry = 333]
			phlo.wss.onerror = e => [(cb = app.websocket.error) && cb(e), phlo.log('🖧 Websocket error', e)]
			phlo.wss.onclose = e => [(cb = app.websocket.close) && cb(e), phlo.log('🖧 Websocket close', e), app.websocket.retry && [app.websocket.open, app.websocket.retry *= 3]]
		})
	},
	path: 'websocket',
	send: data => phlo.wss?.readyState === 1 ? [phlo.log('🖧 app.websocket.send', '\n', data), phlo.wss.send(JSON.stringify(data))] : phlo.error('🖧 Could not send websocket data over closed socket'),
	retry: 333,
}
app.websocket.open

We use essential cookies to make this site work. With your permission we also use analytics to improve the site.