From cdf8537153a26bbcae8a6ed8a4ff7af4f5f9f302 Mon Sep 17 00:00:00 2001 From: dragonwocky Date: Sun, 3 Oct 2021 19:29:18 +1100 Subject: [PATCH] upload progress from extension repo --- api/README.md | 3 +- api/_.mjs | 25 ++++ api/components/_.mjs | 36 ++++++ api/components/feather.mjs | 36 ++++++ api/components/panel.css | 237 ++++++++++++++++++++++++++++++++++ api/components/panel.mjs | 245 +++++++++++++++++++++++++++++++++++ api/components/tooltip.css | 25 ++++ api/components/tooltip.mjs | 38 ++++++ api/env.mjs | 46 +++++++ api/fmt.mjs | 132 +++++++++++++++++++ api/fs.mjs | 48 +++++++ api/registry-validation.mjs | 248 ++++++++++++++++++++++++++++++++++++ api/registry.mjs | 164 ++++++++++++++++++++++++ api/storage.mjs | 67 ++++++++++ api/web.mjs | 238 ++++++++++++++++++++++++++++++++++ 15 files changed, 1587 insertions(+), 1 deletion(-) create mode 100644 api/_.mjs create mode 100644 api/components/_.mjs create mode 100644 api/components/feather.mjs create mode 100644 api/components/panel.css create mode 100644 api/components/panel.mjs create mode 100644 api/components/tooltip.css create mode 100644 api/components/tooltip.mjs create mode 100644 api/env.mjs create mode 100644 api/fmt.mjs create mode 100644 api/fs.mjs create mode 100644 api/registry-validation.mjs create mode 100644 api/registry.mjs create mode 100644 api/storage.mjs create mode 100644 api/web.mjs diff --git a/api/README.md b/api/README.md index caea1ac..9f4674f 100644 --- a/api/README.md +++ b/api/README.md @@ -1,2 +1,3 @@ # api -the standard api available to and depending on by the enhancer's mods and core + +the standard api used by the enhancer's mods and core diff --git a/api/_.mjs b/api/_.mjs new file mode 100644 index 0000000..334d08c --- /dev/null +++ b/api/_.mjs @@ -0,0 +1,25 @@ +/* + * notion-enhancer: api + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +/** @module notion-enhancer/api */ + +/** environment-specific methods and constants */ +export * as env from './env.mjs'; +/** environment-specific filesystem reading */ +export * as fs from './fs.mjs'; +/** environment-specific data persistence */ +export * as storage from './storage.mjs'; + +/** helpers for formatting, validating and parsing values */ +export * as fmt from './fmt.mjs'; +/** interactions with the enhancer's repository of mods */ +export * as registry from './registry.mjs'; +/** helpers for manipulation of a webpage */ +export * as web from './web.mjs'; +/** shared notion-style elements */ +export * as components from './components/_.mjs'; diff --git a/api/components/_.mjs b/api/components/_.mjs new file mode 100644 index 0000000..e1d98e6 --- /dev/null +++ b/api/components/_.mjs @@ -0,0 +1,36 @@ +/* + * notion-enhancer: api + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +/** + * shared notion-style elements + * @module notion-enhancer/api/components + */ + +/** + * add a tooltip to show extra information on hover + * @param {HTMLElement} $ref - the element that will trigger the tooltip when hovered + * @param {string} text - the markdown content of the tooltip + */ +export { tooltip } from './tooltip.mjs'; + +/** + * generate an icon from the feather icons set + * @param {string} name - the name/id of the icon + * @param {object} attrs - an object of attributes to apply to the icon e.g. classes + * @returns {string} an svg string + */ +export { feather } from './feather.mjs'; + +/** + * adds a view to the enhancer's side panel + * @param {string} param0.id - a uuid, used to restore it on reload if it was last open + * @param {string} param0.icon - an svg string + * @param {string} param0.title - the name of the view + * @param {Element} param0.$content - an element containing the content of the view + */ +export { addPanelView } from './panel.mjs'; diff --git a/api/components/feather.mjs b/api/components/feather.mjs new file mode 100644 index 0000000..3734bda --- /dev/null +++ b/api/components/feather.mjs @@ -0,0 +1,36 @@ +/* + * notion-enhancer: api + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +/** + * shared notion-style elements + * @module notion-enhancer/api/components/feather + */ + +import { fs, web } from '../_.mjs'; + +let _$iconSheet; + +/** + * generate an icon from the feather icons set + * @param {string} name - the name/id of the icon + * @param {object} attrs - an object of attributes to apply to the icon e.g. classes + * @returns {string} an svg string + */ +export const feather = async (name, attrs = {}) => { + if (!_$iconSheet) { + _$iconSheet = web.html`${await fs.getText('dep/feather-sprite.svg')}`; + } + attrs.style = ( + (attrs.style ? attrs.style + ';' : '') + + 'stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;fill:none;' + ).trim(); + attrs.viewBox = '0 0 24 24'; + return ` `${web.escape(key)}="${web.escape(val)}"`) + .join(' ')}>${_$iconSheet.getElementById(name)?.innerHTML}`; +}; diff --git a/api/components/panel.css b/api/components/panel.css new file mode 100644 index 0000000..1294867 --- /dev/null +++ b/api/components/panel.css @@ -0,0 +1,237 @@ +/* + * notion-enhancer core: components + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (c) 2021 CloudHill (https://github.com/CloudHill) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +:root { + --component--panel-width: 260px; +} + +#enhancer--panel-hover-trigger { + height: 100vh; + width: 2.5rem; + max-height: 100%; + z-index: 999; + position: absolute; + top: 0; + right: 0; + flex-grow: 0; + flex-shrink: 0; + transition: width 300ms ease-in-out; +} +#enhancer--panel-hover-trigger[data-enhancer-panel-pinned] { + /* taking up the physical space of the panel to move topbar buttons */ + position: relative; + width: var(--component--panel-width); +} + +.notion-cursor-listener > div[style*='flex-end'] { + transition: margin-right 300ms ease-in-out; +} +.notion-cursor-listener > div[style*='flex-end'][data-enhancer-panel-pinned] { + margin-right: var(--component--panel-width); +} +.notion-frame { + transition: padding-right 300ms ease-in-out; +} +.notion-frame[data-enhancer-panel-pinned] { + padding-right: var(--component--panel-width); +} + +#enhancer--panel { + z-index: 999; + position: absolute; + background: var(--theme--bg_secondary); + width: var(--component--panel-width); + right: calc(-1 * var(--component--panel-width)); + opacity: 0; + height: 100vh; + flex-grow: 0; + flex-shrink: 0; + display: flex; + flex-direction: column; + transition: 300ms ease-in; + + margin-top: 5rem; + max-height: calc(100vh - 10rem); +} +#enhancer--panel-hover-trigger:hover + #enhancer--panel:not([data-enhancer-panel-pinned]), +#enhancer--panel:not([data-enhancer-panel-pinned]):hover { + opacity: 1; + transform: translateX(calc(-1 * var(--component--panel-width))); + box-shadow: var(--theme--ui_shadow, rgba(15, 15, 15, 0.05)) 0px 0px 0px 1px, + var(--theme--ui_shadow, rgba(15, 15, 15, 0.1)) 0px 3px 6px, + var(--theme--ui_shadow, rgba(15, 15, 15, 0.2)) 0px 9px 24px !important; +} +#enhancer--panel[data-enhancer-panel-pinned] { + opacity: 1; + max-height: 100%; + margin-top: 0; + transform: translateX(calc(-1 * var(--component--panel-width))); +} + +.enhancer--panel-view-title { + margin: 0; + height: 1em; + display: flex; + align-items: center; + font-size: 1.1rem; + font-weight: 600; +} +.enhancer--panel-view-title svg, +.enhancer--panel-view-title img { + height: 1em; + width: 1em; +} +.enhancer--panel-view-title .enhancer--panel-view-title-text { + font-size: 0.9em; + margin: 0 0 0 0.75em; + padding-bottom: 0.3em; + white-space: nowrap; + overflow: hidden; +} + +#enhancer--panel-header { + font-size: 1.2rem; + font-weight: 600; + display: flex; + align-items: center; + cursor: pointer; + user-select: none; + padding: 0.75rem 0 0.75rem 1rem; + --scoped--bg: var(--theme--bg_secondary); + background: var(--scoped--bg); +} +#enhancer--panel-header-title { + max-width: calc(100% - 4.25rem); +} +#enhancer--panel-header-title .enhancer--panel-view-title { + font-size: 1.2rem; +} +#enhancer--panel-header-title .enhancer--panel-view-title-text { + max-width: calc(100% - 1.75em); + position: relative; +} +.enhancer--panel-view-title-fade-edge { + display: inline-block; + width: 0.75rem; + height: 1em; +} + +#enhancer--panel-switcher-overlay-container { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 999; + overflow: hidden; +} +#enhancer--panel-switcher { + max-width: 320px; + position: relative; + right: 14px; + border-radius: 3px; + padding: 8px 0; + background: var(--theme--bg_popup); + box-shadow: var(--theme--ui_shadow, rgba(15, 15, 15, 0.05)) 0px 0px 0px 1px, + var(--theme--ui_shadow, rgba(15, 15, 15, 0.1)) 0px 3px 6px, + var(--theme--ui_shadow, rgba(15, 15, 15, 0.2)) 0px 9px 24px !important; + overflow: hidden; +} +.enhancer--panel-switcher-item { + display: flex; + align-items: center; + width: 100%; + padding: 8px 14px; + user-select: none; + cursor: pointer; + overflow: hidden; + position: relative; + --scoped--bg: var(--theme--bg_popup); + background: var(--scoped--bg); +} +#enhancer--panel-header:hover, +#enhancer--panel-header:focus-within, +.enhancer--panel-switcher-item:hover, +.enhancer--panel-switcher-item:focus { + --scoped--bg: var(--theme--ui_interactive-hover); +} +#enhancer--panel-header:active, +.enhancer--panel-switcher-item:active { + background: var(--theme--ui_interactive-active); +} +.enhancer--panel-view-title-fade-edge:after { + content: ''; + height: 100%; + position: absolute; + right: 0; + top: 0; + width: 0.75rem; + background: linear-gradient( + to right, + transparent 0%, + var(--scoped--bg) 50%, + var(--scoped--bg) 100% + ); +} + +#enhancer--panel-content { + margin: 0.75rem 1rem; + font-size: 1rem; +} +#enhancer--panel-header-switcher { + padding: 4px; +} +#enhancer--panel-header-toggle { + margin-left: auto; + padding-right: 1rem; + height: 100%; + width: 2.5em; + opacity: 0; + display: flex; +} +#enhancer--panel-header-toggle > div { + margin: auto 0 auto auto; +} +#enhancer--panel-header-switcher, +#enhancer--panel-header-toggle > div { + color: var(--theme--icon_secondary); + height: 1em; + width: 1em; + cursor: pointer; + display: flex; + flex-direction: column; + transition: 300ms ease-in-out; +} +#enhancer--panel #enhancer--panel-header-toggle svg { + transition: 300ms ease-in-out; +} +#enhancer--panel:not([data-enhancer-panel-pinned]) #enhancer--panel-header-toggle svg { + transform: rotateZ(-180deg); +} +#enhancer--panel:hover #enhancer--panel-header-toggle { + opacity: 1; +} + +#enhancer--panel-resize { + position: absolute; + left: -5px; + height: 100%; + width: 10px; +} +#enhancer--panel[data-enhancer-panel-pinned] #enhancer--panel-resize { + cursor: col-resize; +} +#enhancer--panel-resize div { + transition: background 150ms ease-in-out; + background: transparent; + width: 2px; + margin-left: 4px; + height: 100%; +} +#enhancer--panel[data-enhancer-panel-pinned] #enhancer--panel-resize:hover div { + background: var(--theme--ui_divider); +} diff --git a/api/components/panel.mjs b/api/components/panel.mjs new file mode 100644 index 0000000..f4c743b --- /dev/null +++ b/api/components/panel.mjs @@ -0,0 +1,245 @@ +/* + * notion-enhancer: api + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +/** + * shared notion-style elements + * @module notion-enhancer/api/components/side-panel + */ + +import { web, components, registry } from '../_.mjs'; +const db = await registry.db('36a2ffc9-27ff-480e-84a7-c7700a7d232d'); + +const _views = [], + svgExpand = web.raw` + +`; + +// open + close +let $notionFrame, + $notionRightSidebar, + // resize + dragStartX, + dragStartWidth, + dragEventsFired, + panelWidth, + // render content + $notionApp; + +// open + close +const $panel = web.html`
`, + $pinnedToggle = web.html`
+ ${await components.feather('chevrons-right')} +
`, + $hoverTrigger = web.html`
`, + panelPinnedAttr = 'data-enhancer-panel-pinned', + isPinned = () => $panel.hasAttribute(panelPinnedAttr), + togglePanel = () => { + const $elems = [$notionRightSidebar, $notionFrame, $hoverTrigger, $panel]; + if (isPinned()) { + closeSwitcher(); + for (const $elem of $elems) $elem.removeAttribute(panelPinnedAttr); + } else { + for (const $elem of $elems) $elem.setAttribute(panelPinnedAttr, 'true'); + } + db.set(['panel.pinned'], isPinned()); + }, + // resize + $resizeHandle = web.html`
`, + updateWidth = async () => { + document.documentElement.style.setProperty('--component--panel-width', panelWidth + 'px'); + db.set(['panel.width'], panelWidth); + }, + resizeDrag = (event) => { + event.preventDefault(); + dragEventsFired = true; + panelWidth = dragStartWidth + (dragStartX - event.clientX); + if (panelWidth < 190) panelWidth = 190; + if (panelWidth > 480) panelWidth = 480; + $panel.style.width = panelWidth + 'px'; + $hoverTrigger.style.width = panelWidth + 'px'; + $notionFrame.style.paddingRight = panelWidth + 'px'; + $notionRightSidebar.style.right = panelWidth + 'px'; + }, + resizeEnd = (event) => { + $panel.style.width = ''; + $hoverTrigger.style.width = ''; + $notionFrame.style.paddingRight = ''; + $notionRightSidebar.style.right = ''; + updateWidth(); + $resizeHandle.style.cursor = ''; + document.body.removeEventListener('mousemove', resizeDrag); + document.body.removeEventListener('mouseup', resizeEnd); + }, + resizeStart = (event) => { + dragStartX = event.clientX; + dragStartWidth = panelWidth; + $resizeHandle.style.cursor = 'auto'; + document.body.addEventListener('mousemove', resizeDrag); + document.body.addEventListener('mouseup', resizeEnd); + }, + // render content + $panelTitle = web.html`
`, + $header = web.render(web.html`
`, $panelTitle), + $panelContent = web.html`
`, + $switcher = web.html`
`, + $switcherTrigger = web.html`
+ ${svgExpand} +
`, + $switcherOverlayContainer = web.html`
`, + isSwitcherOpen = () => document.body.contains($switcher), + openSwitcher = () => { + if (!isPinned()) return togglePanel(); + web.render($notionApp, $switcherOverlayContainer); + web.empty($switcher); + for (const view of _views) { + const open = $panelTitle.contains(view.$title), + $item = web.render( + web.html`
`, + web.render( + web.html``, + view.$icon.cloneNode(true), + view.$title.cloneNode(true) + ) + ); + $item.addEventListener('click', () => { + renderView(view); + db.set(['panel.open'], view.id); + }); + web.render($switcher, $item); + } + const rect = $header.getBoundingClientRect(); + web.render( + web.empty($switcherOverlayContainer), + web.render( + web.html`
`, + web.render( + web.html`
`, + $switcher + ) + ) + ); + $switcher.querySelector('[data-open]').focus(); + $switcher.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 200 }); + document.addEventListener('keydown', switcherKeyListeners); + }, + closeSwitcher = () => { + document.removeEventListener('keydown', switcherKeyListeners); + $switcher.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 200 }).onfinish = () => + $switcherOverlayContainer.remove(); + }, + switcherKeyListeners = (event) => { + if (isSwitcherOpen()) { + switch (event.key) { + case 'Escape': + closeSwitcher(); + event.stopPropagation(); + break; + case 'Enter': + document.activeElement.click(); + event.stopPropagation(); + break; + case 'ArrowUp': + const $prev = event.target.previousElementSibling; + ($prev || event.target.parentElement.lastElementChild).focus(); + event.stopPropagation(); + break; + case 'ArrowDown': + const $next = event.target.nextElementSibling; + ($next || event.target.parentElement.firstElementChild).focus(); + event.stopPropagation(); + break; + } + } + }, + renderView = (view) => { + web.render( + web.empty($panelTitle), + web.render( + web.html``, + view.$icon, + view.$title + ) + ); + web.render(web.empty($panelContent), view.$content); + }; + +async function createPanel() { + const notionRightSidebarSelector = '.notion-cursor-listener > div[style*="flex-end"]'; + await web.whenReady([notionRightSidebarSelector]); + $notionFrame = document.querySelector('.notion-frame'); + $notionRightSidebar = document.querySelector(notionRightSidebarSelector); + if (await db.get(['panel.pinned'])) togglePanel(); + web.addHotkeyListener(await db.get(['panel.hotkey']), togglePanel); + $pinnedToggle.addEventListener('click', (event) => { + event.stopPropagation(); + togglePanel(); + }); + web.render( + $panel, + web.render($header, $panelTitle, $switcherTrigger, $pinnedToggle), + $panelContent, + $resizeHandle + ); + + await enablePanelResize(); + await createViews(); + + $notionRightSidebar.after($hoverTrigger, $panel); +} + +async function enablePanelResize() { + panelWidth = await db.get(['panel.width'], 240); + updateWidth(); + $resizeHandle.addEventListener('mousedown', resizeStart); + $resizeHandle.addEventListener('click', () => { + if (dragEventsFired) { + dragEventsFired = false; + } else togglePanel(); + }); +} + +async function createViews() { + $notionApp = document.querySelector('.notion-app-inner'); + $header.addEventListener('click', openSwitcher); + $switcherTrigger.addEventListener('click', openSwitcher); + $switcherOverlayContainer.addEventListener('click', closeSwitcher); +} + +web.loadStylesheet('api/components/panel.css'); + +/** + * adds a view to the enhancer's side panel + * @param {string} param0.id - a uuid, used to restore the last open view on reload + * @param {string} param0.icon - an svg string + * @param {string} param0.title - the name of the view + * @param {Element} param0.$content - an element containing the content of the view + */ +export const addPanelView = async ({ id, icon, title, $content }) => { + const view = { + id, + $icon: web.html` + ${icon} + `, + $title: web.html` + ${web.escape(title)} + + `, + $content, + }; + _views.push(view); + if (_views.length === 1) await createPanel(); + if (_views.length === 1 || (await db.get(['panel.open'])) === id) renderView(view); +}; diff --git a/api/components/tooltip.css b/api/components/tooltip.css new file mode 100644 index 0000000..b919963 --- /dev/null +++ b/api/components/tooltip.css @@ -0,0 +1,25 @@ +/* + * notion-enhancer core: components + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +#enhancer--tooltip { + position: absolute; + background: var(--theme--ui_tooltip); + font-size: 11.5px; + padding: 0.15rem 0.4rem; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06) !important; + border-radius: 3px; + max-width: 20rem; + display: none; +} +#enhancer--tooltip p { + margin: 0.25rem 0; +} +#enhancer--tooltip p:first-child { + color: var(--theme--ui_tooltip-title); +} +#enhancer--tooltip p:not(:first-child) { + color: var(--theme--ui_tooltip-description); +} diff --git a/api/components/tooltip.mjs b/api/components/tooltip.mjs new file mode 100644 index 0000000..e52d3f0 --- /dev/null +++ b/api/components/tooltip.mjs @@ -0,0 +1,38 @@ +/* + * notion-enhancer: api + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +/** + * shared notion-style elements + * @module notion-enhancer/api/components/tooltip + */ + +import { fmt, web } from '../_.mjs'; + +const _$tooltip = web.html`
`; +web.loadStylesheet('api/components/tooltip.css'); + +/** + * add a tooltip to show extra information on hover + * @param {HTMLElement} $ref - the element that will trigger the tooltip when hovered + * @param {string} text - the markdown content of the tooltip + */ +export const tooltip = ($ref, text) => { + web.render(document.body, _$tooltip); + text = fmt.md.render(text); + $ref.addEventListener('mouseover', (event) => { + _$tooltip.innerHTML = text; + _$tooltip.style.display = 'block'; + }); + $ref.addEventListener('mousemove', (event) => { + _$tooltip.style.top = event.clientY - _$tooltip.clientHeight + 'px'; + _$tooltip.style.left = event.clientX - _$tooltip.clientWidth + 'px'; + }); + $ref.addEventListener('mouseout', (event) => { + _$tooltip.style.display = ''; + }); +}; diff --git a/api/env.mjs b/api/env.mjs new file mode 100644 index 0000000..b2bccdc --- /dev/null +++ b/api/env.mjs @@ -0,0 +1,46 @@ +/* + * notion-enhancer: api + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +/** + * environment-specific methods and constants + * @module notion-enhancer/api/env + */ + +import * as env from '../env/env.mjs'; + +/** + * the environment/platform name code is currently being executed in + * @constant + * @type {string} + */ +export const name = env.name; + +/** + * the current version of the enhancer + * @constant + * @type {string} + */ +export const version = env.version; + +/** + * open the enhancer's menu + * @type {function} + */ +export const focusMenu = env.focusMenu; + +/** + * focus an active notion tab + * @type {function} + */ +export const focusNotion = env.focusNotion; + +/** + * reload all notion and enhancer menu tabs to apply changes + * @type {function} + */ +export const reload = env.reload; diff --git a/api/fmt.mjs b/api/fmt.mjs new file mode 100644 index 0000000..f905c01 --- /dev/null +++ b/api/fmt.mjs @@ -0,0 +1,132 @@ +/* + * notion-enhancer: api + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +/** + * helpers for formatting or parsing text + * @module notion-enhancer/api/fmt + */ + +import { web, fs, components } from './_.mjs'; + +import '../dep/prism.min.js'; +/** syntax highlighting using https://prismjs.com/ */ +export const prism = Prism; +Prism.manual = true; +Prism.hooks.add('complete', async (event) => { + event.element.parentElement.removeAttribute('tabindex'); + event.element.parentElement.parentElement + .querySelector('.copy-to-clipboard-button') + .prepend(web.html`${await components.feather('clipboard')}`); +}); + +import '../dep/markdown-it.min.js'; +/** markdown -> html using https://github.com/markdown-it/markdown-it/ */ +export const md = new markdownit({ + linkify: true, + highlight: (str, lang) => + web.html`
${web.escape(
+      str
+    )}
`, +}); +md.renderer.rules.code_block = (tokens, idx, options, env, slf) => { + const attrIdx = tokens[idx].attrIndex('class'); + if (attrIdx === -1) { + tokens[idx].attrPush(['class', 'match-braces language-plaintext']); + } else tokens[idx].attrs[attrIdx][1] = 'match-braces language-plaintext'; + return web.html`${web.escape( + tokens[idx].content + )}\n`; +}; +md.core.ruler.push( + 'heading_ids', + function (md, state) { + const slugs = new Set(); + state.tokens.forEach(function (token, i) { + if (token.type === 'heading_open') { + const text = md.renderer.render(state.tokens[i + 1].children, md.options), + slug = slugger(text, slugs); + slugs.add(slug); + const attrIdx = token.attrIndex('id'); + if (attrIdx === -1) { + token.attrPush(['id', slug]); + } else token.attrs[attrIdx][1] = slug; + } + }); + }.bind(null, md) +); + +/** + * transform a heading into a slug (a lowercase alphanumeric string separated by dashes), + * e.g. for use as an anchor id + * @param {string} heading - the original heading to be slugified + * @param {Set} [slugs] - a list of pre-generated slugs to avoid duplicates + * @returns {string} the generated slug + */ +export const slugger = (heading, slugs = new Set()) => { + heading = heading + .replace(/\s/g, '-') + .replace(/[^A-Za-z0-9-_]/g, '') + .toLowerCase(); + let i = 0, + slug = heading; + while (slugs.has(slug)) { + i++; + slug = `${heading}-${i}`; + } + return slug; +}; + +const patterns = { + alphanumeric: /^[\w\.-]+$/, + uuid: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + semver: + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/i, + email: + /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/i, + url: /^[(http(s)?):\/\/(www\.)?a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)$/i, + color: /^(?:#|0x)(?:[a-f0-9]{3}|[a-f0-9]{6})\b|(?:rgb|hsl)a?\([^\)]*\)$/i, +}; +function test(str, pattern) { + const match = str.match(pattern); + return !!(match && match.length); +} + +/** + * test the type of a value. unifies builtin, regex, and environment/api checks + * @param {*} value - the value to check + * @param {string|array} type - the type the value should be or a list of allowed values + * @returns {boolean} whether or not the value matches the type + */ +export const is = async (value, type, { extension = '' } = {}) => { + extension = !value || !value.endsWith || value.endsWith(extension); + if (Array.isArray(type)) { + return type.includes(value); + } + switch (type) { + case 'array': + return Array.isArray(value); + case 'object': + return value && typeof value === 'object' && !Array.isArray(value); + case 'undefined': + case 'boolean': + case 'number': + return typeof value === type && extension; + case 'string': + return typeof value === type && value.length && extension; + case 'alphanumeric': + case 'uuid': + case 'semver': + case 'email': + case 'url': + case 'color': + return typeof value === 'string' && test(value, patterns[type]) && extension; + case 'file': + return typeof value === 'string' && value && (await fs.isFile(value)) && extension; + } + return false; +}; diff --git a/api/fs.mjs b/api/fs.mjs new file mode 100644 index 0000000..ef05d90 --- /dev/null +++ b/api/fs.mjs @@ -0,0 +1,48 @@ +/* + * notion-enhancer: api + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +/** + * environment-specific filesystem reading + * @module notion-enhancer/api/fs + */ + +import * as fs from '../env/fs.mjs'; + +/** + * transform a path relative to the enhancer root directory into an absolute path + * @type {function} + * @param {string} path - a url or within-the-enhancer filepath + * @returns {string} an absolute filepath + */ +export const localPath = fs.localPath; + +/** + * fetch and parse a json file's contents + * @type {function} + * @param {string} path - a url or within-the-enhancer filepath + * @param {object} [opts] - the second argument of a fetch() request + * @returns {object} the json value of the requested file as a js object + */ +export const getJSON = fs.getJSON; + +/** + * fetch a text file's contents + * @type {function} + * @param {string} path - a url or within-the-enhancer filepath + * @param {object} [opts] - the second argument of a fetch() request + * @returns {string} the text content of the requested file + */ +export const getText = fs.getText; + +/** + * check if a file exists + * @type {function} + * @param {string} path - a url or within-the-enhancer filepath + * @returns {boolean} whether or not the file exists + */ +export const isFile = fs.isFile; diff --git a/api/registry-validation.mjs b/api/registry-validation.mjs new file mode 100644 index 0000000..afe7819 --- /dev/null +++ b/api/registry-validation.mjs @@ -0,0 +1,248 @@ +/* + * notion-enhancer: api + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +import { fmt, registry } from './_.mjs'; + +const check = async ( + mod, + key, + value, + types, + { + extension = '', + error = `invalid ${key} (${extension ? `${extension} ` : ''}${types}): ${JSON.stringify( + value + )}`, + optional = false, + } = {} + ) => { + let test; + for (const type of Array.isArray(types) ? [types] : types.split('|')) { + if (type === 'file') { + test = + value && !value.startsWith('http') + ? await fmt.is(`repo/${mod._dir}/${value}`, type, { extension }) + : false; + } else test = await fmt.is(value, type, { extension }); + if (test) break; + } + if (!test) { + if (optional && (await fmt.is(value, 'undefined'))) return true; + if (error) registry._errors.push({ source: mod._dir, message: error }); + return false; + } + return true; + }, + validateEnvironments = (mod) => { + return check(mod, 'environments', mod.environments, 'array', { optional: true }).then( + (passed) => { + if (!passed) return false; + if (!mod.environments) { + mod.environments = registry.supportedEnvs; + return true; + } + return mod.environments.map((tag) => + check(mod, 'environments.env', tag, registry.supportedEnvs) + ); + } + ); + }, + validateTags = (mod) => { + return check(mod, 'tags', mod.tags, 'array').then((passed) => { + if (!passed) return false; + const containsCategory = mod.tags.filter((tag) => + ['core', 'extension', 'theme'].includes(tag) + ).length; + if (!containsCategory) { + registry._errors.push({ + source: mod._dir, + message: `invalid tags (must contain at least one of 'core', 'extension', or 'theme'): ${JSON.stringify( + mod.tags + )}`, + }); + return false; + } + if ( + (mod.tags.includes('theme') && + !(mod.tags.includes('light') || mod.tags.includes('dark'))) || + (mod.tags.includes('light') && mod.tags.includes('dark')) + ) { + registry._errors.push({ + source: mod._dir, + message: `invalid tags (themes must be either 'light' or 'dark', not neither or both): ${JSON.stringify( + mod.tags + )}`, + }); + return false; + } + return mod.tags.map((tag) => check(mod, 'tags.tag', tag, 'string')); + }); + }, + validateAuthors = (mod) => { + return check(mod, 'authors', mod.authors, 'array').then((passed) => { + if (!passed) return false; + return mod.authors.map((author) => [ + check(mod, 'authors.author.name', author.name, 'string'), + check(mod, 'authors.author.email', author.email, 'email'), + check(mod, 'authors.author.homepage', author.homepage, 'url'), + check(mod, 'authors.author.avatar', author.avatar, 'url'), + ]); + }); + }, + validateCSS = (mod) => { + return check(mod, 'css', mod.css, 'object').then((passed) => { + if (!passed) return false; + const tests = []; + for (let dest of ['frame', 'client', 'menu']) { + if (!mod.css[dest]) continue; + let test = check(mod, `css.${dest}`, mod.css[dest], 'array'); + test = test.then((passed) => { + if (!passed) return false; + return mod.css[dest].map((file) => + check(mod, `css.${dest}.file`, file, 'file', { extension: '.css' }) + ); + }); + tests.push(test); + } + return tests; + }); + }, + validateJS = (mod) => { + return check(mod, 'js', mod.js, 'object').then((passed) => { + if (!passed) return false; + const tests = []; + if (mod.js.client) { + let test = check(mod, 'js.client', mod.js.client, 'array'); + test = test.then((passed) => { + if (!passed) return false; + return mod.js.client.map((file) => + check(mod, 'js.client.file', file, 'file', { extension: '.mjs' }) + ); + }); + tests.push(test); + } + if (mod.js.electron) { + let test = check(mod, 'js.electron', mod.js.electron, 'array'); + test = test.then((passed) => { + if (!passed) return false; + return mod.js.electron.map((file) => + check(mod, 'js.electron.file', file, 'object').then((passed) => { + if (!passed) return false; + return [ + check(mod, 'js.electron.file.source', file.source, 'file', { + extension: '.mjs', + }), + // referencing the file within the electron app + // existence can't be validated, so only format is + check(mod, 'js.electron.file.target', file.target, 'string', { + extension: '.js', + }), + ]; + }) + ); + }); + tests.push(test); + } + return tests; + }); + }, + validateOptions = (mod) => { + return check(mod, 'options', mod.options, 'array').then((passed) => { + if (!passed) return false; + return mod.options.map((option) => + check(mod, 'options.option.type', option.type, registry.optionTypes).then((passed) => { + if (!passed) return false; + const tests = [ + check(mod, 'options.option.key', option.key, 'alphanumeric'), + check(mod, 'options.option.label', option.label, 'string'), + check(mod, 'options.option.tooltip', option.tooltip, 'string', { + optional: true, + }), + check(mod, 'options.option.environments', option.environments, 'array', { + optional: true, + }).then((passed) => { + if (!passed) return false; + if (!option.environments) { + option.environments = registry.supportedEnvs; + return true; + } + return option.environments.map((environment) => + check( + mod, + 'options.option.environments.env', + environment, + registry.supportedEnvs + ) + ); + }), + ]; + switch (option.type) { + case 'toggle': + tests.push(check(mod, 'options.option.value', option.value, 'boolean')); + break; + case 'select': + tests.push( + check(mod, 'options.option.values', option.values, 'array').then((passed) => { + if (!passed) return false; + return option.values.map((value) => + check(mod, 'options.option.values.value', value, 'string') + ); + }) + ); + break; + case 'text': + case 'hotkey': + tests.push(check(mod, 'options.option.value', option.value, 'string')); + break; + case 'number': + case 'color': + tests.push(check(mod, 'options.option.value', option.value, option.type)); + break; + case 'file': + tests.push( + check(mod, 'options.option.extensions', option.extensions, 'array').then( + (passed) => { + if (!passed) return false; + return option.extensions.map((value) => + check(mod, 'options.option.extensions.extension', value, 'string') + ); + } + ) + ); + } + return tests; + }) + ); + }); + }; + +/** + * internally used to validate mod.json files and provide helpful errors + * @private + * @param {object} mod - a mod's mod.json in object form + * @returns {boolean} whether or not the mod has passed validation + */ +export async function validate(mod) { + let conditions = [ + check(mod, 'name', mod.name, 'string'), + check(mod, 'id', mod.id, 'uuid'), + check(mod, 'version', mod.version, 'semver'), + validateEnvironments(mod), + check(mod, 'description', mod.description, 'string'), + check(mod, 'preview', mod.preview, 'file|url', { optional: true }), + validateTags(mod), + validateAuthors(mod), + validateCSS(mod), + validateJS(mod), + validateOptions(mod), + ]; + do { + conditions = await Promise.all(conditions.flat(Infinity)); + } while (conditions.some((condition) => Array.isArray(condition))); + return conditions.every((passed) => passed); +} diff --git a/api/registry.mjs b/api/registry.mjs new file mode 100644 index 0000000..54ee095 --- /dev/null +++ b/api/registry.mjs @@ -0,0 +1,164 @@ +/* + * notion-enhancer: api + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +/** + * interactions with the enhancer's repository of mods + * @module notion-enhancer/api/registry + */ + +import { env, fs, storage } from './_.mjs'; +import { validate } from './registry-validation.mjs'; + +export const _cache = [], + _errors = []; + +/** + * mod ids whitelisted as part of the enhancer's core, permanently enabled + * @constant + * @type {array} + */ +export const core = [ + 'a6621988-551d-495a-97d8-3c568bca2e9e', + '0f0bf8b6-eae6-4273-b307-8fc43f2ee082', + '36a2ffc9-27ff-480e-84a7-c7700a7d232d', +]; + +/** + * all environments/platforms currently supported by the enhancer + * @constant + * @type {array} + */ +export const supportedEnvs = ['linux', 'win32', 'darwin', 'extension']; + +/** + * all available configuration types + * @constant + * @type {array} + */ +export const optionTypes = ['toggle', 'select', 'text', 'number', 'color', 'file', 'hotkey']; + +/** + * the name of the active configuration profile + * @returns {string} + */ +export const profileName = async () => storage.get(['currentprofile'], 'default'); + +/** + * the root database for the current profile + * @returns {object} the get/set functions for the profile's storage + */ +export const profileDB = async () => storage.db(['profiles', await profileName()]); + +/** a notification displayed when the menu is opened for the first time */ +export const welcomeNotification = { + id: '84e2d49b-c3dc-44b4-a154-cf589676bfa0', + color: 'purple', + icon: 'message-circle', + message: 'Welcome! Come chat with us on Discord.', + link: 'https://discord.gg/sFWPXtA', + version: env.version, +}; + +/** + * list all available mods in the repo + * @param {function} filter - a function to filter out mods + * @returns {array} a validated list of mod.json objects + */ +export const list = async (filter = (mod) => true) => { + if (!_cache.length) { + for (const dir of await fs.getJSON('repo/registry.json')) { + try { + const mod = await fs.getJSON(`repo/${dir}/mod.json`); + mod._dir = dir; + if (await validate(mod)) _cache.push(mod); + } catch (e) { + console.log(e); + _errors.push({ source: dir, message: 'invalid mod.json' }); + } + } + } + const list = []; + for (const mod of _cache) if (await filter(mod)) list.push(mod); + return list; +}; + +/** + * list validation errors encountered when loading the repo + * @returns {array} error objects with an error message and a source directory + */ +export const errors = async () => { + if (!_errors.length) await list(); + return _errors; +}; + +/** + * get a single mod from the repo + * @param {string} id - the uuid of the mod + * @returns {object} the mod's mod.json + */ +export const get = async (id) => { + if (!_cache.length) await list(); + return _cache.find((mod) => mod.id === id); +}; + +/** + * checks if a mod is enabled: affected by the core whitelist, + * environment and menu configuration + * @param {string} id - the uuid of the mod + * @returns {boolean} whether or not the mod is enabled + */ +export const enabled = async (id) => { + const mod = await get(id); + if (!mod.environments.includes(env.name)) return false; + if (core.includes(id)) return true; + return (await profileDB()).get(['_mods', id], false); +}; + +/** + * get a default value of a mod's option according to its mod.json + * @param {string} id - the uuid of the mod + * @param {string} key - the key of the option + * @returns {string|number|boolean|undefined} the option's default value + */ +export const optionDefault = async (id, key) => { + const mod = await get(id), + opt = mod.options.find((opt) => opt.key === key); + if (!opt) return undefined; + switch (opt.type) { + case 'toggle': + case 'text': + case 'number': + case 'color': + case 'hotkey': + return opt.value; + case 'select': + return opt.values[0]; + case 'file': + return undefined; + } +}; + +/** + * access the storage partition of a mod in the current profile + * @param {string} id - the uuid of the mod + * @returns {object} an object with the wrapped get/set functions + */ +export const db = async (id) => { + const db = await profileDB(); + return storage.db( + [id], + async (path, fallback = undefined) => { + if (path.length === 2) { + // profiles -> profile -> mod -> option + fallback = (await optionDefault(id, path[1])) ?? fallback; + } + return db.get(path, fallback); + }, + db.set + ); +}; diff --git a/api/storage.mjs b/api/storage.mjs new file mode 100644 index 0000000..4a3e63b --- /dev/null +++ b/api/storage.mjs @@ -0,0 +1,67 @@ +/* + * notion-enhancer: api + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +/** + * environment-specific data persistence + * @module notion-enhancer/api/storage + */ + +import * as storage from '../env/storage.mjs'; + +/** + * get persisted data + * @type {function} + * @param {array} path - the path of keys to the value being fetched + * @param {*} [fallback] - a default value if the path is not matched + * @returns {Promise} value ?? fallback + */ +export const get = storage.get; + +/** + * persist data + * @type {function} + * @param {array} path - the path of keys to the value being set + * @param {*} value - the data to save + * @returns {Promise} resolves when data has been saved + */ +export const set = storage.set; + +/** + * create a wrapper for accessing a partition of the storage + * @type {function} + * @param {array} namespace - the path of keys to prefix all storage requests with + * @param {function} [get] - the storage get function to be wrapped + * @param {function} [set] - the storage set function to be wrapped + * @returns {object} an object with the wrapped get/set functions + */ +export const db = storage.db; + +/** + * add an event listener for changes in storage + * @type {function} + * @param {onStorageChangeCallback} callback - called whenever a change in + * storage is initiated from the current process + */ +export const addChangeListener = storage.addChangeListener; + +/** + * remove a listener added with storage.addChangeListener + * @type {function} + * @param {onStorageChangeCallback} callback + */ +export const removeChangeListener = storage.removeChangeListener; + +/** + * @callback onStorageChangeCallback + * @param {object} event + * @param {string} event.type - 'set' or 'reset' + * @param {string} event.namespace- the name of the store, e.g. a mod id + * @param {string} [event.key] - the key associated with the changed value + * @param {string} [event.new] - the new value being persisted to the store + * @param {string} [event.old] - the previous value associated with the key + */ diff --git a/api/web.mjs b/api/web.mjs new file mode 100644 index 0000000..d7ae5d1 --- /dev/null +++ b/api/web.mjs @@ -0,0 +1,238 @@ +/* + * notion-enhancer: api + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +/** + * helpers for manipulation of a webpage + * @module notion-enhancer/api/web + */ + +import { fs } from './_.mjs'; + +let _hotkeyEventListeners = [], + _documentObserver, + _documentObserverListeners = [], + _documentObserverEvents = []; + +import '../dep/jscolor.min.js'; +/** color picker with alpha channel using https://jscolor.com/ */ +export const jscolor = JSColor; + +/** + * wait until a page is loaded and ready for modification + * @param {array} [selectors=[]] - wait for the existence of elements that match these css selectors + * @returns {Promise} a promise that will resolve when the page is ready + */ +export const whenReady = (selectors = []) => { + return new Promise((res, rej) => { + function onLoad() { + let isReadyInt; + isReadyInt = setInterval(isReadyTest, 100); + function isReadyTest() { + if (selectors.every((selector) => document.querySelector(selector))) { + clearInterval(isReadyInt); + res(true); + } + } + isReadyTest(); + } + if (document.readyState !== 'complete') { + document.addEventListener('readystatechange', (event) => { + if (document.readyState === 'complete') onLoad(); + }); + } else onLoad(); + }); +}; + +/** + * parse the current location search params into a usable form + * @returns {map} a map of the url search params + */ +export const queryParams = () => new URLSearchParams(window.location.search); + +/** + * replace special html characters with escaped versions + * @param {string} str + * @returns {string} escaped string + */ +export const escape = (str) => + str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/'/g, ''') + .replace(/"/g, '"') + .replace(/\\/g, '\'); + +/** + * a tagged template processor for raw html: + * stringifies, minifies, and syntax highlights + * @example web.raw`

hello

` + * @returns {string} the processed html + */ +export const raw = (str, ...templates) => { + const html = str + .map( + (chunk) => + chunk + + (['string', 'number'].includes(typeof templates[0]) + ? templates.shift() + : escape(JSON.stringify(templates.shift(), null, 2) ?? '')) + ) + .join(''); + return html.includes(' line.trim()) + .filter((line) => line.length) + .join(' '); +}; + +/** + * create a single html element inc. attributes and children from a string + * @example web.html`

hello

` + * @returns {Element} the constructed html element + */ +export const html = (str, ...templates) => { + const $fragment = document.createRange().createContextualFragment(raw(str, ...templates)); + return $fragment.children.length === 1 ? $fragment.children[0] : $fragment.children; +}; + +/** + * appends a list of html elements to a parent + * @param $container - the parent element + * @param $elems - the elements to be appended + * @returns {Element} the updated $container + */ +export const render = ($container, ...$elems) => { + $elems = $elems + .map(($elem) => ($elem instanceof HTMLCollection ? [...$elem] : $elem)) + .flat(Infinity) + .filter(($elem) => $elem); + $container.append(...$elems); + return $container; +}; + +/** + * removes all children from an element without deleting them/their behaviours + * @param $container - the parent element + * @returns {Element} the updated $container + */ +export const empty = ($container) => { + while ($container.firstChild && $container.removeChild($container.firstChild)); + return $container; +}; + +/** + * loads/applies a css stylesheet to the page + * @param {string} path - a url or within-the-enhancer filepath + */ +export const loadStylesheet = (path) => { + render( + document.head, + html`` + ); + return true; +}; + +document.addEventListener('keyup', (event) => { + if (document.activeElement.nodeName === 'INPUT') return; + for (const hotkey of _hotkeyEventListeners) { + const pressed = hotkey.keys.every((key) => { + key = key.toLowerCase(); + const modifiers = { + metaKey: ['meta', 'os', 'win', 'cmd', 'command'], + ctrlKey: ['ctrl', 'control'], + shiftKey: ['shift'], + altKey: ['alt'], + }; + for (const modifier in modifiers) { + const pressed = modifiers[modifier].includes(key) && event[modifier]; + if (pressed) return true; + } + if (key === event.key.toLowerCase()) return true; + }); + if (pressed) hotkey.callback(event); + } +}); + +/** + * register a hotkey listener to the page + * @param {array} keys - the combination of keys that will trigger the hotkey. + * key codes can be tested at http://keycode.info/ and are case-insensitive. + * available modifiers are 'alt', 'ctrl', 'meta', and 'shift'. + * @param {function} callback - called whenever the keys are pressed + */ +export const addHotkeyListener = (keys, callback) => { + if (typeof keys === 'string') keys = keys.split('+'); + _hotkeyEventListeners.push({ keys, callback }); +}; +/** + * remove a listener added with web.addHotkeyListener + * @param {function} callback + */ +export const removeHotkeyListener = (callback) => { + _hotkeyEventListeners = _hotkeyEventListeners.filter( + (listener) => listener.callback !== callback + ); +}; + +/** + * add a listener to watch for changes to the dom + * @param {onDocumentObservedCallback} callback + * @param {array} [selectors] + */ +export const addDocumentObserver = (callback, selectors = []) => { + if (!_documentObserver) { + const handle = (queue) => { + while (queue.length) { + const event = queue.shift(); + for (const listener of _documentObserverListeners) { + if ( + !listener.selectors.length || + listener.selectors.some( + (selector) => + event.target.matches(selector) || event.target.matches(`${selector} *`) + ) + ) { + listener.callback(event); + } + } + } + }; + _documentObserver = new MutationObserver((list, observer) => { + if (!_documentObserverEvents.length) + requestIdleCallback(() => handle(_documentObserverEvents)); + _documentObserverEvents.push(...list); + }); + _documentObserver.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + }); + } + _documentObserverListeners.push({ callback, selectors }); +}; + +/** + * remove a listener added with web.addDocumentObserver + * @param {onDocumentObservedCallback} callback + */ +export const removeDocumentObserver = (callback) => { + _documentObserverListeners = _documentObserverListeners.filter( + (listener) => listener.callback !== callback + ); +}; + +/** + * @callback onDocumentObservedCallback + * @param {MutationRecord} event - the observed dom mutation event + */