Improved search result rendering and icon search

This commit is contained in:
squidfunk 2021-02-14 14:51:08 +01:00
parent a8b5cafa73
commit 4ef15bd440
50 changed files with 836 additions and 145 deletions

View File

@ -34,6 +34,7 @@ manifest.json
site site
# Configuration # Configuration
typings
webpack.config.ts webpack.config.ts
# Distribution files # Distribution files

1
.gitignore vendored
View File

@ -58,6 +58,7 @@ mkdocs_material.egg-info
.DS_Store .DS_Store
# Temporary files # Temporary files
TODO
tmp tmp
# IDEs # IDEs

View File

@ -11,15 +11,22 @@ and used in `mkdocs.yml`, documents and templates.
## Search ## Search
<input id="icon-search" class="md-input" placeholder="Search the icon database" /> <div class="mdx-icon-search" data-mdx-component="icon-search">
<input
<div class="tx-icon-result" markdown="1"> class="md-input md-input--stretch mdx-icon-search__input"
placeholder="Search the icon database"
data-mdx-component="icon-search-query"
/>
<div class="mdx-icon-search-result" data-mdx-component="icon-search-result">
<div class="mdx-icon-search-result__meta"></div>
<ol class="mdx-icon-search-result__list"></ol>
</div>
</div>
<small> <small>
:octicons-light-bulb-16: :octicons-light-bulb-16:
**Tip:** Enter some keywords to find the perfect icon and click on the **Tip:** Enter some keywords to find the perfect icon and click on the
shortcode to copy it to your clipboard. shortcode to copy it to your clipboard.
</small> </small>
</div>
## Configuration ## Configuration

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,18 +1,18 @@
{ {
"assets/javascripts/bundle.js": "assets/javascripts/bundle.b0aa5de8.min.js", "assets/javascripts/bundle.js": "assets/javascripts/bundle.3e5f7fbe.min.js",
"assets/javascripts/bundle.js.map": "assets/javascripts/bundle.b0aa5de8.min.js.map", "assets/javascripts/bundle.js.map": "assets/javascripts/bundle.3e5f7fbe.min.js.map",
"assets/javascripts/vendor.js": "assets/javascripts/vendor.e32ed4d0.min.js", "assets/javascripts/vendor.js": "assets/javascripts/vendor.00ecb175.min.js",
"assets/javascripts/vendor.js.map": "assets/javascripts/vendor.e32ed4d0.min.js.map", "assets/javascripts/vendor.js.map": "assets/javascripts/vendor.00ecb175.min.js.map",
"assets/javascripts/worker/search.js": "assets/javascripts/worker/search.b9424174.min.js", "assets/javascripts/worker/search.js": "assets/javascripts/worker/search.b9424174.min.js",
"assets/javascripts/worker/search.js.map": "assets/javascripts/worker/search.b9424174.min.js.map", "assets/javascripts/worker/search.js.map": "assets/javascripts/worker/search.b9424174.min.js.map",
"assets/stylesheets/main.css": "assets/stylesheets/main.25519675.min.css", "assets/stylesheets/main.css": "assets/stylesheets/main.f080134c.min.css",
"assets/stylesheets/main.css.map": "assets/stylesheets/main.25519675.min.css.map", "assets/stylesheets/main.css.map": "assets/stylesheets/main.f080134c.min.css.map",
"assets/stylesheets/palette.css": "assets/stylesheets/palette.e70b70b6.min.css", "assets/stylesheets/palette.css": "assets/stylesheets/palette.e70b70b6.min.css",
"assets/stylesheets/palette.css.map": "assets/stylesheets/palette.e70b70b6.min.css.map", "assets/stylesheets/palette.css.map": "assets/stylesheets/palette.e70b70b6.min.css.map",
"overrides/assets/javascripts/bundle.js": "overrides/assets/javascripts/bundle.c607e7b3.min.js", "overrides/assets/javascripts/bundle.js": "overrides/assets/javascripts/bundle.b60297a2.min.js",
"overrides/assets/javascripts/bundle.js.map": "overrides/assets/javascripts/bundle.c607e7b3.min.js.map", "overrides/assets/javascripts/bundle.js.map": "overrides/assets/javascripts/bundle.b60297a2.min.js.map",
"overrides/assets/javascripts/vendor.js": "overrides/assets/javascripts/vendor.1aa446d9.min.js", "overrides/assets/javascripts/vendor.js": "overrides/assets/javascripts/vendor.be34bb11.min.js",
"overrides/assets/javascripts/vendor.js.map": "overrides/assets/javascripts/vendor.1aa446d9.min.js.map", "overrides/assets/javascripts/vendor.js.map": "overrides/assets/javascripts/vendor.be34bb11.min.js.map",
"overrides/assets/stylesheets/main.css": "overrides/assets/stylesheets/main.d67efea2.min.css", "overrides/assets/stylesheets/main.css": "overrides/assets/stylesheets/main.552eceec.min.css",
"overrides/assets/stylesheets/main.css.map": "overrides/assets/stylesheets/main.d67efea2.min.css.map" "overrides/assets/stylesheets/main.css.map": "overrides/assets/stylesheets/main.552eceec.min.css.map"
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -39,7 +39,7 @@
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block styles %} {% block styles %}
<link rel="stylesheet" href="{{ 'assets/stylesheets/main.25519675.min.css' | url }}"> <link rel="stylesheet" href="{{ 'assets/stylesheets/main.f080134c.min.css' | url }}">
{% if config.theme.palette %} {% if config.theme.palette %}
{% set palette = config.theme.palette %} {% set palette = config.theme.palette %}
<link rel="stylesheet" href="{{ 'assets/stylesheets/palette.e70b70b6.min.css' | url }}"> <link rel="stylesheet" href="{{ 'assets/stylesheets/palette.e70b70b6.min.css' | url }}">
@ -216,8 +216,8 @@
</script> </script>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ 'assets/javascripts/vendor.e32ed4d0.min.js' | url }}"></script> <script src="{{ 'assets/javascripts/vendor.00ecb175.min.js' | url }}"></script>
<script src="{{ 'assets/javascripts/bundle.b0aa5de8.min.js' | url }}"></script> <script src="{{ 'assets/javascripts/bundle.3e5f7fbe.min.js' | url }}"></script>
{% for path in config["extra_javascript"] %} {% for path in config["extra_javascript"] %}
<script src="{{ path | url }}"></script> <script src="{{ path | url }}"></script>
{% endfor %} {% endfor %}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,2 +0,0 @@
!function(e,t){for(var n in t)e[n]=t[n]}(window,function(e){function t(t){for(var r,i,a=t[0],s=t[1],u=t[2],f=0,p=[];f<a.length;f++)i=a[f],Object.prototype.hasOwnProperty.call(o,i)&&o[i]&&p.push(o[i][0]),o[i]=0;for(r in s)Object.prototype.hasOwnProperty.call(s,r)&&(e[r]=s[r]);for(l&&l(t);p.length;)p.shift()();return c.push.apply(c,u||[]),n()}function n(){for(var e,t=0;t<c.length;t++){for(var n=c[t],r=!0,a=1;a<n.length;a++){var s=n[a];0!==o[s]&&(r=!1)}r&&(c.splice(t--,1),e=i(i.s=n[0]))}return e}var r={},o={0:0},c=[];function i(t){if(r[t])return r[t].exports;var n=r[t]={i:t,l:!1,exports:{}};return e[t].call(n.exports,n,n.exports,i),n.l=!0,n.exports}i.m=e,i.c=r,i.d=function(e,t,n){i.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:n})},i.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},i.t=function(e,t){if(1&t&&(e=i(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(i.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var r in e)i.d(n,r,function(t){return e[t]}.bind(null,r));return n},i.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return i.d(t,"a",t),t},i.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},i.p="";var a=window.webpackJsonp=window.webpackJsonp||[],s=a.push.bind(a);a.push=t,a=a.slice();for(var u=0;u<a.length;u++)t(a[u]);var l=s;return c.push([37,1]),n()}({37:function(e,t,n){"use strict";n.r(t);var r=n(20),o=n(3),c=n(38),i=n(29),a=n(25);n(32),n(39);function s(e,t=document){return t.querySelector(e)||void 0}function u(e,t=document){const n=s(e,t);if(void 0===n)throw new ReferenceError(`Missing element: expected "${e}" to be present`);return n}n(50);var l=n(51);var f=n(17),p=n(40),b=n(41),d=n(42),g=n(43),h=n(44);n(45),n(46);const v=new f.a;Object(p.a)(()=>Object(b.a)(new ResizeObserver(e=>{for(const t of e)v.next(t)}))).pipe(Object(i.a)(e=>d.a.pipe(Object(l.a)(e)).pipe(Object(g.a)(()=>e.disconnect()))),Object(h.a)(1));n(31);u("[data-md-toggle=drawer]"),u("[data-md-toggle=search]");n(47);function O(){return new URL(location.href)}n(52),n(53);n(48),n(49);const j=u("#__config"),m=JSON.parse(j.textContent);function y(e,t){if("string"==typeof t||"number"==typeof t)e.innerHTML+=t.toString();else if(t instanceof Node)e.appendChild(t);else if(Array.isArray(t))for(const n of t)y(e,n)}function w(e,t,...n){const r=document.createElement(e);if(t)for(const e of Object.keys(t))"boolean"!=typeof t[e]?r.setAttribute(e,t[e]):t[e]&&r.setAttribute(e,"");for(const e of n)y(r,e);return r}m.base=new URL(m.base,O()).toString().replace(/\/$/,"");function x(e,t){return t.length?w("div",{class:""},w("span",null,function(e){if(e>999){return((e+1e-6)/1e3).toFixed(+((e-950)%1e3>99))+"k"}return e.toString()}(e.length)," results"),w("ul",{class:"tx-icon-search__list"},e.slice(0,10).map(e=>w("li",{class:"tx-icon-search__item"},w("span",{class:"twemoji"},w("img",{src:"https://raw.githubusercontent.com/squidfunk/mkdocs-material/master/material/.icons/"+e,style:"width: 18px; height: 18px"}))," ",w("button",{class:"md-clipboard--inline","data-clipboard-text":":"+e.replace(/\.svg$/,"").replace(/\//g,"-")+":"},w("code",null,function(e,t){return`:${Object(r.wrap)(e.replace(/\.svg$/,"").replace(/\//g,"-"),t,{wrap:{tagOpen:"<b>",tagClose:"</b>"}})}:`}(e,t))))))):w("div",{class:""})}const _=m,k=Object(o.a)(fetch(_.base+"/overrides/assets/javascripts/icons.json").then(e=>e.json())),S=s("#icon-search");S&&k.pipe(Object(i.a)(e=>Object(c.a)(S,"keyup").pipe(Object(a.a)(()=>Object(r.filter)(e,S.value))))).subscribe(e=>{const t=u(".tx-icon-result");t.innerHTML="",t.appendChild(x(e,S.value))}),Object(c.a)(document.body,"click").subscribe(e=>{if(e.target instanceof HTMLElement){e.target.closest("a[href^=http]")instanceof HTMLLinkElement&&ga("send","event","outbound","click",el.href)}})}}));
//# sourceMappingURL=bundle.c607e7b3.min.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -22,7 +22,7 @@
<meta name="twitter:title" content="{{ title }}"> <meta name="twitter:title" content="{{ title }}">
<meta name="twitter:description" content="{{ config.site_description }}"> <meta name="twitter:description" content="{{ config.site_description }}">
<meta name="twitter:image" content="{{ image }}"> <meta name="twitter:image" content="{{ image }}">
<link rel="stylesheet" href="{{ 'overrides/assets/stylesheets/main.d67efea2.min.css' | url }}"> <link rel="stylesheet" href="{{ 'overrides/assets/stylesheets/main.552eceec.min.css' | url }}">
{% endblock %} {% endblock %}
{% block announce %} {% block announce %}
<a href="https://twitter.com/squidfunk"> <a href="https://twitter.com/squidfunk">
@ -53,6 +53,6 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
{{ super() }} {{ super() }}
<script src="{{ 'overrides/assets/javascripts/vendor.1aa446d9.min.js' | url }}"></script> <script src="{{ 'overrides/assets/javascripts/vendor.be34bb11.min.js' | url }}"></script>
<script src="{{ 'overrides/assets/javascripts/bundle.c607e7b3.min.js' | url }}"></script> <script src="{{ 'overrides/assets/javascripts/bundle.b60297a2.min.js' | url }}"></script>
{% endblock %} {% endblock %}

View File

@ -21,6 +21,7 @@
*/ */
import { translation } from "~/_" import { translation } from "~/_"
import { round } from "~/utilities"
/* ---------------------------------------------------------------------------- /* ----------------------------------------------------------------------------
* Functions * Functions
@ -49,7 +50,7 @@ export function setSearchResultMeta(
/* Multiple result */ /* Multiple result */
default: default:
el.textContent = translation("search.result.other", value) el.textContent = translation("search.result.other", round(value))
} }
} }

View File

@ -21,7 +21,9 @@
*/ */
import { Observable, fromEvent, merge } from "rxjs" import { Observable, fromEvent, merge } from "rxjs"
import { map, startWith } from "rxjs/operators" import { distinctUntilChanged, map, startWith } from "rxjs/operators"
import { getElementContentSize, getElementSize } from "../size"
/* ---------------------------------------------------------------------------- /* ----------------------------------------------------------------------------
* Types * Types
@ -74,3 +76,30 @@ export function watchElementOffset(
startWith(getElementOffset(el)) startWith(getElementOffset(el))
) )
} }
/**
* Watch element threshold
*
* This function returns an observable which emits whether the bottom scroll
* offset of an elements is within a certain threshold.
*
* @param el - Element
* @param threshold - Threshold
*
* @returns Element threshold observable
*/
export function watchElementThreshold(
el: HTMLElement, threshold = 16
): Observable<boolean> {
return watchElementOffset(el)
.pipe(
map(({ y }) => {
const visible = getElementSize(el)
const content = getElementContentSize(el)
return y >= (
content.height - visible.height - threshold
)
}),
distinctUntilChanged()
)
}

View File

@ -120,7 +120,7 @@ export function getElementContentSize(el: HTMLElement): ElementSize {
/** /**
* Watch element size * Watch element size
* *
* This function returns an observable that will subscribe to a single internal * This function returns an observable that subscribes to a single internal
* instance of `ResizeObserver` upon subscription, and emit resize events until * instance of `ResizeObserver` upon subscription, and emit resize events until
* termination. Note that this function should not be called with the same * termination. Note that this function should not be called with the same
* element twice, as the first unsubscription will terminate observation. * element twice, as the first unsubscription will terminate observation.

View File

@ -73,7 +73,7 @@ interface WatchOptions<T extends WorkerMessage> {
/** /**
* Watch a web worker * Watch a web worker
* *
* This function returns an observable that will send all values emitted by the * This function returns an observable that sends all values emitted by the
* message observable to the web worker. Web worker communication is expected * message observable to the web worker. Web worker communication is expected
* to be bidirectional (request-response) and synchronous. Messages that are * to be bidirectional (request-response) and synchronous. Messages that are
* emitted during a pending request are throttled, the last one is emitted. * emitted during a pending request are throttled, the last one is emitted.

View File

@ -20,7 +20,7 @@
* IN THE SOFTWARE. * IN THE SOFTWARE.
*/ */
import { Observable, merge } from "rxjs" import { Observable, ObservableInput, merge } from "rxjs"
import { filter, sample, take } from "rxjs/operators" import { filter, sample, take } from "rxjs/operators"
import { configuration } from "~/_" import { configuration } from "~/_"
@ -75,9 +75,9 @@ interface MountOptions {
* *
* @param url - Search index URL * @param url - Search index URL
* *
* @returns Promise resolving with search index * @returns Promise or observable
*/ */
function fetchSearchIndex(url: string) { function fetchSearchIndex(url: string): ObservableInput<SearchIndex> {
return __search?.index || requestJSON<SearchIndex>(url) return __search?.index || requestJSON<SearchIndex>(url)
} }
@ -104,7 +104,7 @@ export function mountSearch(
`${config.base}/search/search_index.json` `${config.base}/search/search_index.json`
)) ))
/* Retrieve elements */ /* Retrieve nested components */
const query = getComponentElement("search-query", el) const query = getComponentElement("search-query", el)
const result = getComponentElement("search-result", el) const result = getComponentElement("search-result", el)

View File

@ -20,14 +20,25 @@
* IN THE SOFTWARE. * IN THE SOFTWARE.
*/ */
import { Observable, Subject } from "rxjs"
import { import {
Observable,
Subject,
animationFrameScheduler,
merge,
of
} from "rxjs"
import {
bufferCount,
distinctUntilKeyChanged,
filter, filter,
finalize, finalize,
map, map,
observeOn,
startWith, startWith,
switchMap,
tap, tap,
withLatestFrom withLatestFrom,
zipWith
} from "rxjs/operators" } from "rxjs/operators"
import { import {
@ -36,7 +47,10 @@ import {
resetSearchResultMeta, resetSearchResultMeta,
setSearchResultMeta setSearchResultMeta
} from "~/actions" } from "~/actions"
import { getElementOrThrow } from "~/browser" import {
getElementOrThrow,
watchElementThreshold
} from "~/browser"
import { import {
SearchResult as SearchResultData, SearchResult as SearchResultData,
SearchWorker, SearchWorker,
@ -76,6 +90,9 @@ interface MountOptions {
/** /**
* Mount search result list * Mount search result list
* *
* This function will perform a lazy rendering of the search results, depending
* on the vertical offset of the search result container.
*
* @param el - Search result list element * @param el - Search result list element
* @param worker - Search worker * @param worker - Search worker
* @param options - Options * @param options - Options
@ -86,11 +103,16 @@ export function mountSearchResult(
el: HTMLElement, { rx$ }: SearchWorker, { query$ }: MountOptions el: HTMLElement, { rx$ }: SearchWorker, { query$ }: MountOptions
): Observable<Component<SearchResult>> { ): Observable<Component<SearchResult>> {
const internal$ = new Subject<SearchResult>() const internal$ = new Subject<SearchResult>()
const boundary$ = watchElementThreshold(el.parentElement!)
.pipe(
filter(Boolean)
)
/* Update search result metadata */ /* Update search result metadata */
const meta = getElementOrThrow(":scope > :first-child", el) const meta = getElementOrThrow(":scope > :first-child", el)
internal$ internal$
.pipe( .pipe(
observeOn(animationFrameScheduler),
withLatestFrom(query$) withLatestFrom(query$)
) )
.subscribe(([{ data }, { value }]) => { .subscribe(([{ data }, { value }]) => {
@ -103,15 +125,21 @@ export function mountSearchResult(
/* Update search result list */ /* Update search result list */
const list = getElementOrThrow(":scope > :last-child", el) const list = getElementOrThrow(":scope > :last-child", el)
internal$ internal$
.subscribe(({ data }) => { .pipe(
resetSearchResultList(list) observeOn(animationFrameScheduler),
tap(() => resetSearchResultList(list)),
/* Compute thresholds and search results */ switchMap(({ data }) => merge(
const thresholds = [...data.map(([best]) => best.score), 0] of(...data.slice(0, 10)),
for (let index = 0; index < data.length; index++) of(...data.slice(10))
addToSearchResultList(list, renderSearchResult( .pipe(
data[index++], thresholds[index] bufferCount(4),
zipWith(boundary$),
switchMap(([chunk]) => of(...chunk))
)
)) ))
)
.subscribe(result => {
addToSearchResultList(list, renderSearchResult(result))
}) })
/* Filter search result list */ /* Filter search result list */

View File

@ -54,7 +54,7 @@ const enum Flag {
*/ */
function renderSearchDocument( function renderSearchDocument(
document: SearchDocument & SearchMetadata, flag: Flag document: SearchDocument & SearchMetadata, flag: Flag
) { ): HTMLElement {
const parent = flag & Flag.PARENT const parent = flag & Flag.PARENT
const teaser = flag & Flag.TEASER const teaser = flag & Flag.TEASER
@ -101,13 +101,13 @@ function renderSearchDocument(
* Render a search result * Render a search result
* *
* @param result - Search result * @param result - Search result
* @param threshold - Score threshold
* *
* @returns Element * @returns Element
*/ */
export function renderSearchResult( export function renderSearchResult(
result: SearchResult, threshold = Infinity result: SearchResult
): HTMLElement { ): HTMLElement {
const threshold = result[0].score
const docs = [...result] const docs = [...result]
/* Find and extract parent article */ /* Find and extract parent article */

View File

@ -230,9 +230,11 @@
padding: 0 px2rem(44px) 0 px2rem(72px); padding: 0 px2rem(44px) 0 px2rem(72px);
text-overflow: ellipsis; text-overflow: ellipsis;
background-color: var(--md-default-bg-color); background-color: var(--md-default-bg-color);
box-shadow: 0 0 px2rem(12px) transparent;
transition: transition:
color 250ms, color 250ms,
background-color 250ms; background-color 250ms,
box-shadow 250ms;
// Adjust for right-to-left languages // Adjust for right-to-left languages
[dir="rtl"] & { [dir="rtl"] & {
@ -255,6 +257,11 @@
display: none; display: none;
} }
// Adjust appearance when search is active
[data-md-toggle="search"]:checked ~ .md-header & {
box-shadow: 0 0 px2rem(12px) var(--md-default-fg-color--lightest);
}
// [tablet portrait -]: Search modal // [tablet portrait -]: Search modal
@include break-to-device(tablet portrait) { @include break-to-device(tablet portrait) {
width: 100%; width: 100%;
@ -448,7 +455,9 @@
background-color: var(--md-default-bg-color); background-color: var(--md-default-bg-color);
// Hack: promote to own layer to reduce jitter // Hack: promote to own layer to reduce jitter
backface-visibility: hidden; backface-visibility: hidden;
scroll-snap-type: y mandatory; // Hack: Chrome 88+ has weird overscroll behavior. Overall, scroll snapping
// seems to be something that is not ready for prime time on some browsers.
// scroll-snap-type: y mandatory;
touch-action: pan-y; touch-action: pan-y;
// Mitigiate excessive repaints on non-retina devices // Mitigiate excessive repaints on non-retina devices

View File

@ -0,0 +1,85 @@
/*
* Copyright (c) 2016-2020 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import { getElementOrThrow, getElements } from "~/browser"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Component
*/
export type ComponentType =
| "icon-search" /* Icon search */
| "icon-search-query" /* Icon search input */
| "icon-search-result" /* Icon search results */
/**
* A component
*
* @template T - Component type
* @template U - Reference type
*/
export type Component<
T extends {} = {},
U extends HTMLElement = HTMLElement
> =
T & {
ref: U /* Component reference */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Retrieve the element for a given component or throw a reference error
*
* @template T - Element type
*
* @param type - Component type
* @param node - Node of reference
*
* @returns Element
*/
export function getComponentElement<T extends HTMLElement>(
type: ComponentType, node: ParentNode = document
): T {
return getElementOrThrow(`[data-mdx-component=${type}]`, node)
}
/**
* Retrieve all elements for a given component
*
* @template T - Element type
*
* @param type - Component type
* @param node - Node of reference
*
* @returns Elements
*/
export function getComponentElements<T extends HTMLElement>(
type: ComponentType, node: ParentNode = document
): T[] {
return getElements(`[data-mdx-component=${type}]`, node)
}

View File

@ -0,0 +1,83 @@
/*
* Copyright (c) 2016-2020 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import { Observable, merge } from "rxjs"
import { configuration } from "~/_"
import { requestJSON } from "~/browser"
import { Component, getComponentElement } from "../../_"
import {
IconSearchQuery,
mountIconSearchQuery
} from "../query"
import {
IconSearchResult,
mountIconSearchResult
} from "../result"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Icon search index
*/
export type IconSearchIndex = string[]
/**
* Icon search
*/
export type IconSearch =
| IconSearchQuery
| IconSearchResult
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Mount icon search
*
* @param el - Icon search element
*
* @returns Icon search component observable
*/
export function mountIconSearch(
el: HTMLElement
): Observable<Component<IconSearch>> {
const config = configuration()
const index$ = requestJSON<IconSearchIndex>(
`${config.base}/overrides/assets/javascripts/icons.json`
)
/* Retrieve nested components */
const query = getComponentElement("icon-search-query", el)
const result = getComponentElement("icon-search-result", el)
/* Create and return component */
const query$ = mountIconSearchQuery(query as HTMLInputElement)
return merge(
query$,
mountIconSearchResult(result, { index$, query$ })
)
}

View File

@ -0,0 +1,25 @@
/*
* Copyright (c) 2016-2020 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
export * from "./_"
export * from "./query"
export * from "./result"

View File

@ -20,50 +20,60 @@
* IN THE SOFTWARE. * IN THE SOFTWARE.
*/ */
/* eslint-disable */ import { Observable, combineLatest, fromEvent, merge } from "rxjs"
import {
delay,
distinctUntilChanged,
map,
startWith
} from "rxjs/operators"
import { wrap } from "fuzzaldrin-plus" import { watchElementFocus } from "~/browser"
import { h, round } from "utilities"
import { Component } from "../../_"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Icon search query
*/
export interface IconSearchQuery {
value: string /* Query value */
focus: boolean /* Query focus */
}
/* ---------------------------------------------------------------------------- /* ----------------------------------------------------------------------------
* Functions * Functions
* ------------------------------------------------------------------------- */ * ------------------------------------------------------------------------- */
function transform(value: string, query: string) { /**
return `:${wrap(value.replace(/\.svg$/, "").replace(/\//g, "-"), query, { * Mount icon search query
wrap: { *
tagOpen: "<b>", * @param el - Icon search query element
tagClose: "</b>" *
} * @returns Icon search query component observable
})}:` */
} export function mountIconSearchQuery(
el: HTMLInputElement
): Observable<Component<IconSearchQuery, HTMLInputElement>> {
const base = "https://raw.githubusercontent.com/squidfunk/mkdocs-material/master/material/.icons/" /* Intercept focus and input events */
const focus$ = watchElementFocus(el)
const value$ = merge(
fromEvent(el, "keyup"),
fromEvent(el, "focus").pipe(delay(1))
)
.pipe(
map(() => el.value),
startWith(el.value),
distinctUntilChanged()
)
export function renderIconSearch( /* Combine into single observable */
results: string[], query: string return combineLatest([value$, focus$])
) { .pipe(
if (!query.length) map(([value, focus]) => ({ ref: el, value, focus })),
return <div class=""></div>
return (
<div class="">
<span>{round(results.length)} results</span>
<ul class="tx-icon-search__list">
{results.slice(0, 10).map(result => (
<li class="tx-icon-search__item">
<span class="twemoji">
<img src={base + result} style="width: 18px; height: 18px" />
</span> <button
class="md-clipboard--inline"
data-clipboard-text={
":" + result.replace(/\.svg$/, "").replace(/\//g, "-") + ":"
}
>
<code>{transform(result, query)}</code>
</button>
</li>
))}
</ul>
</div>
) )
} }

View File

@ -0,0 +1,151 @@
/*
* Copyright (c) 2016-2020 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import { filter as search } from "fuzzaldrin-plus"
import {
Observable,
Subject,
animationFrameScheduler,
combineLatest,
merge,
of
} from "rxjs"
import {
bufferCount,
distinctUntilKeyChanged,
filter,
finalize,
map,
observeOn,
switchMap,
tap,
withLatestFrom,
zipWith
} from "rxjs/operators"
import {
addToSearchResultList,
resetSearchResultList,
resetSearchResultMeta,
setSearchResultMeta
} from "~/actions"
import {
getElementOrThrow,
watchElementThreshold
} from "~/browser"
import { renderIconSearchResult } from "../../../templates"
import { Component } from "../../_"
import { IconSearchIndex } from "../_"
import { IconSearchQuery } from "../query"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Icon search result
*/
export interface IconSearchResult {
data: string[] /* Search result data */
}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Mount options
*/
interface MountOptions {
index$: Observable<IconSearchIndex> /* Search index observable */
query$: Observable<IconSearchQuery> /* Search query observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Mount icon search result
*
* @param el - Icon search result element
* @param options - Options
*
* @returns Icon search result component observable
*/
export function mountIconSearchResult(
el: HTMLElement, { index$, query$ }: MountOptions
): Observable<Component<IconSearchResult, HTMLElement>> {
const internal$ = new Subject<IconSearchResult>()
const boundary$ = watchElementThreshold(el)
.pipe(
filter(Boolean)
)
/* Update search result metadata */
const meta = getElementOrThrow(":scope > :first-child", el)
internal$
.pipe(
observeOn(animationFrameScheduler),
withLatestFrom(query$)
)
.subscribe(([{ data }, { value }]) => {
if (value)
setSearchResultMeta(meta, data.length)
else
resetSearchResultMeta(meta)
})
/* Update icon search result list */
const list = getElementOrThrow(":scope > :last-child", el)
internal$
.pipe(
observeOn(animationFrameScheduler),
tap(() => resetSearchResultList(list)),
switchMap(({ data }) => merge(
of(...data.slice(0, 10)),
of(...data.slice(10))
.pipe(
bufferCount(10),
zipWith(boundary$),
switchMap(([chunk]) => of(...chunk))
)
)),
withLatestFrom(query$)
)
.subscribe(([result, { value }]) => {
addToSearchResultList(list, renderIconSearchResult(result, value))
})
/* Crate and return component */
return combineLatest([
index$,
query$.pipe(distinctUntilKeyChanged("value"))
])
.pipe(
map(([index, { value }]) => ({ data: search(index, value) })),
tap(internal$),
finalize(() => internal$.complete()),
map(state => ({ ref: el, ...state }))
)
}

View File

@ -0,0 +1,24 @@
/*
* Copyright (c) 2016-2020 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
export * from "./_"
export * from "./icon-search"

View File

@ -20,42 +20,30 @@
* IN THE SOFTWARE. * IN THE SOFTWARE.
*/ */
/* eslint-disable */ import { Observable, fromEvent, merge } from "rxjs"
import { filter } from "fuzzaldrin-plus" import { switchMap } from "rxjs/operators"
import { from, fromEvent } from "rxjs"
import { map, switchMap } from "rxjs/operators"
import { configuration } from "~/_" import {
import { getElement, getElementOrThrow } from "~/browser" getComponentElements,
mountIconSearch
} from "./components"
import { renderIconSearch } from "./templates/icon" /* ----------------------------------------------------------------------------
* Application
* ------------------------------------------------------------------------- */
// Obtain configuration /* Set up extra component observables */
const config = configuration() declare const document$: Observable<Document>
document$
// Now, load icons.json
const icons$ =
from(fetch(`${config.base}/overrides/assets/javascripts/icons.json`)
.then(res => res.json())
)
// Render icon search, if present
const search = getElement<HTMLInputElement>("#icon-search")
if (search) {
icons$
.pipe( .pipe(
switchMap(icons => fromEvent<InputEvent>(search, "keyup") switchMap(() => merge(
.pipe(
map(() => filter(icons, search.value)) /* Icon search */
...getComponentElements("icon-search")
.map(child => mountIconSearch(child))
))
) )
) .subscribe()
)
.subscribe((result: any[]) => {
const list = getElementOrThrow(".tx-icon-result")
list.innerHTML = ""
list.appendChild(renderIconSearch(result, search.value))
})
}
// Track click events // Track click events
fromEvent(document.body, "click") fromEvent(document.body, "click")
@ -63,8 +51,7 @@ fromEvent(document.body, "click")
if (ev.target instanceof HTMLElement) { if (ev.target instanceof HTMLElement) {
const el2 = ev.target.closest("a[href^=http]") const el2 = ev.target.closest("a[href^=http]")
if (el2 instanceof HTMLLinkElement) if (el2 instanceof HTMLLinkElement)
// @ts-ignore ga("send", "event", "outbound", "click", el2.href)
ga("send", "event", "outbound", "click", el.href)
} }
}) })

View File

@ -0,0 +1,23 @@
/*
* Copyright (c) 2016-2020 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
export * from "./analytics"

View File

@ -0,0 +1,101 @@
/*
* Copyright (c) 2016-2020 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import { wrap } from "fuzzaldrin-plus"
import { h } from "~/utilities"
import { translation } from "~/_"
/* ----------------------------------------------------------------------------
* Data
* ------------------------------------------------------------------------- */
/**
* Icon CDN URL
*/
const base =
"https://raw.githubusercontent.com/" +
"squidfunk/mkdocs-material/" +
"master/material/.icons/"
/* ----------------------------------------------------------------------------
* Helper functions
* ------------------------------------------------------------------------- */
/**
* Convert icon search result to shortcode
*
* @param value - Icon search result
*
* @returns Shortcode
*/
function shortcode(value: string): string {
return `:${value.replace(/\.svg$/, "").replace(/\//g, "-")}:`
}
/**
* Highlight an icon search result
*
* @param value - Icon search result
* @param query - Icon search query
*
* @returns Highlighted result
*/
function highlight(value: string, query: string) {
return wrap(shortcode(value), query, {
wrap: {
tagOpen: "<b>",
tagClose: "</b>"
}
})
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Render an icon search result
*
* @param value - Icon search result
* @param query - Icon search query
*
* @returns Element
*/
export function renderIconSearchResult(
value: string, query: string
): HTMLElement {
return (
<li class="mdx-icon-search-result__item">
<span class="twemoji">
<img src={base + value} />
</span>
<button
class="md-clipboard--inline"
title={translation("clipboard.copy")}
data-clipboard-text={shortcode(value)}
>
<code>{highlight(value, query)}</code>
</button>
</li>
)
}

View File

@ -0,0 +1,23 @@
/*
* Copyright (c) 2016-2020 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
export * from "./icon-search"

View File

@ -23,3 +23,97 @@
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Nothing to see here, move along // Nothing to see here, move along
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Scoped in typesetted content to match specificity of regular content
.md-typeset {
// Icon search
.mdx-icon-search {
position: relative;
background-color: var(--md-default-bg-color);
border-radius: px2rem(2px);
box-shadow:
0 px2rem(4px) px2rem(10px) hsla(0, 0%, 0%, 0.1),
0 px2rem(0.5px) px2rem(1px) hsla(0, 0%, 0%, 0.1);
transition: box-shadow 125ms;
// Icon search on focus/hover
&:focus-within,
&:hover {
box-shadow:
0 px2rem(8px) px2rem(20px) hsla(0, 0%, 0%, 0.15),
0 px2rem(0.5px) px2rem(1px) hsla(0, 0%, 0%, 0.15);
}
// Icon search input
.md-input {
box-shadow: 0 0 px2rem(12px) var(--md-default-fg-color--lightest);
}
}
// Icon search result
.mdx-icon-search-result {
max-height: 50vh;
overflow-y: auto;
// Hack: promote to own layer to reduce jitter
backface-visibility: hidden;
touch-action: pan-y;
scrollbar-width: thin;
scrollbar-color: var(--md-default-fg-color--lighter) transparent;
// Webkit scrollbar
&::-webkit-scrollbar {
width: px2rem(4px);
height: px2rem(4px);
}
// Webkit scrollbar thumb
&::-webkit-scrollbar-thumb {
background-color: var(--md-default-fg-color--lighter);
// Webkit scrollbar thumb on hover
&:hover {
background-color: var(--md-accent-fg-color);
}
}
// Icon search result metadata
&__meta {
position: absolute;
top: px2rem(8px);
right: px2rem(12px);
color: var(--md-default-fg-color--lighter);
font-size: px2rem(12.8px);
}
// Icon search result list
&__list {
margin: 0;
padding: 0;
list-style: none;
}
// Icon search result item
&__item {
margin: 0;
padding: px2rem(4px) px2rem(12px);
border-bottom: px2rem(1px) solid var(--md-default-fg-color--lightest);
// TODO:
&:last-child {
border-bottom: none;
}
// Item content
> * {
margin-right: px2rem(12px);
}
// Set icon dimensions to fit
img {
width: px2rem(18px);
height: px2rem(18px);
}
}
}
}

View File

@ -37,5 +37,14 @@ export interface GlobalSearchConfig {
/* ------------------------------------------------------------------------- */ /* ------------------------------------------------------------------------- */
declare global { declare global {
/**
* GLobal search configuration
*/
var __search: GlobalSearchConfig | undefined var __search: GlobalSearchConfig | undefined
/**
* Google Analytics
*/
function ga(...args: string[]): void
} }

View File

@ -29,7 +29,7 @@ import ImageminPlugin from "imagemin-webpack-plugin"
import MiniCssExtractPlugin = require("mini-css-extract-plugin") import MiniCssExtractPlugin = require("mini-css-extract-plugin")
import * as path from "path" import * as path from "path"
import { toPairs } from "ramda" import { toPairs } from "ramda"
import glob = require("tiny-glob") import glob from "tiny-glob"
import { minify as minjs } from "terser" import { minify as minjs } from "terser"
import { TsconfigPathsPlugin } from "tsconfig-paths-webpack-plugin" import { TsconfigPathsPlugin } from "tsconfig-paths-webpack-plugin"
import { Configuration } from "webpack" import { Configuration } from "webpack"