Merge pull request #162 from philomena-dev/ts-utils

Convert utilities to TypeScript
This commit is contained in:
Meow 2023-05-18 14:49:26 +02:00 committed by GitHub
commit ef64c6da41
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 1619 additions and 8924 deletions

View file

@ -16,7 +16,7 @@ export default {
functions: 0,
lines: 0,
},
'./js/utils/**/*.js': {
'./js/utils/**/*.ts': {
statements: 100,
branches: 100,
functions: 100,

View file

@ -2,12 +2,25 @@ import { $ } from './utils/dom';
import parseSearch from './match_query';
import store from './utils/store';
/* Store a tag locally, marking the retrieval time */
/**
* Store a tag locally, marking the retrieval time
* @param {TagData} tagData
*/
function persistTag(tagData) {
tagData.fetchedAt = new Date().getTime() / 1000;
store.set(`bor_tags_${tagData.id}`, tagData);
/**
* @type {TagData}
*/
const persistData = {
...tagData,
fetchedAt: new Date().getTime() / 1000,
};
store.set(`bor_tags_${tagData.id}`, persistData);
}
/**
* @param {TagData} tag
* @return {boolean}
*/
function isStale(tag) {
const now = new Date().getTime() / 1000;
return tag.fetchedAt === null || tag.fetchedAt < (now - 604800);
@ -21,11 +34,31 @@ function clearTags() {
});
}
/* Returns a single tag, or a dummy tag object if we don't know about it yet */
/**
* @param {unknown} value
* @returns {value is TagData}
*/
function isValidStoredTag(value) {
if ('id' in value && 'name' in value && 'images' in value && 'spoiler_image_uri' in value) {
return typeof value.id === 'number'
&& typeof value.name === 'string'
&& typeof value.images === 'number'
&& (value.spoiler_image_uri === null || typeof value.spoiler_image_uri === 'string')
&& (value.fetchedAt === null || typeof value.fetchedAt === 'number');
}
return false;
}
/**
* Returns a single tag, or a dummy tag object if we don't know about it yet
* @param {number} tagId
* @returns {TagData}
*/
function getTag(tagId) {
const stored = store.get(`bor_tags_${tagId}`);
if (stored) {
if (isValidStoredTag(stored)) {
return stored;
}
@ -34,10 +67,14 @@ function getTag(tagId) {
name: '(unknown tag)',
images: 0,
spoiler_image_uri: null,
fetchedAt: null,
};
}
/* Fetches lots of tags in batches and stores them locally */
/**
* Fetches lots of tags in batches and stores them locally
* @param {number[]} tagIds
*/
function fetchAndPersistTags(tagIds) {
if (!tagIds.length) return;
@ -50,7 +87,10 @@ function fetchAndPersistTags(tagIds) {
.then(() => fetchAndPersistTags(remaining));
}
/* Figure out which tags in the list we don't know about */
/**
* Figure out which tags in the list we don't know about
* @param {number[]} tagIds
*/
function fetchNewOrStaleTags(tagIds) {
const fetchIds = [];

View file

@ -874,7 +874,4 @@ SearchAST.prototype.dumpTree = function() {
return retStrArr.join('\n');
};
// Force module handling for Jest, can be removed after TypeScript migration
export {};
export default parseSearch;

View file

@ -4,7 +4,7 @@
import { $$ } from './utils/dom';
import store from './utils/store';
import { initTagDropdown} from './tags';
import { initTagDropdown } from './tags';
import { setupTagsInput, reloadTagsInput } from './tagsinput';
function tagInputButtons({target}) {

View file

@ -44,6 +44,7 @@ describe('Array Utilities', () => {
moveElement(input, 2, 1);
expect(input).toEqual(['a', 'c', 'b', 'd']);
});
it('should work with ascending index parameters', () => {
const input = ['a', 'b', 'c', 'd'];
moveElement(input, 1, 2);
@ -88,50 +89,48 @@ describe('Array Utilities', () => {
['', null, false, uniqueValue, mockObject, Infinity, undefined]
)).toBe(true);
});
});
it('should return true for matching up to the first array\'s length', () => {
describe('negative cases', () => {
it('should NOT return true for matching only up to the first array\'s length', () => {
// Numbers
expect(arraysEqual([0], [0, 1])).toBe(true);
expect(arraysEqual([0, 1], [0, 1, 2])).toBe(true);
expect(arraysEqual([0], [0, 1])).toBe(false);
expect(arraysEqual([0, 1], [0, 1, 2])).toBe(false);
// Strings
expect(arraysEqual(['a'], ['a', 'b'])).toBe(true);
expect(arraysEqual(['a', 'b'], ['a', 'b', 'c'])).toBe(true);
expect(arraysEqual(['a'], ['a', 'b'])).toBe(false);
expect(arraysEqual(['a', 'b'], ['a', 'b', 'c'])).toBe(false);
// Object by reference
const uniqueValue1 = Symbol('item1');
const uniqueValue2 = Symbol('item2');
expect(arraysEqual([uniqueValue1], [uniqueValue1, uniqueValue2])).toBe(true);
expect(arraysEqual([uniqueValue1], [uniqueValue1, uniqueValue2])).toBe(false);
// Mixed parameters
const mockObject = { value: Math.random() };
expect(arraysEqual(
[''],
['', null, false, mockObject, Infinity, undefined]
)).toBe(true);
)).toBe(false);
expect(arraysEqual(
['', null],
['', null, false, mockObject, Infinity, undefined]
)).toBe(true);
)).toBe(false);
expect(arraysEqual(
['', null, false],
['', null, false, mockObject, Infinity, undefined]
)).toBe(true);
)).toBe(false);
expect(arraysEqual(
['', null, false, mockObject],
['', null, false, mockObject, Infinity, undefined]
)).toBe(true);
)).toBe(false);
expect(arraysEqual(
['', null, false, mockObject, Infinity],
['', null, false, mockObject, Infinity, undefined]
)).toBe(true);
)).toBe(false);
});
});
describe('negative cases', () => {
// FIXME This case should be handled
// eslint-disable-next-line jest/no-disabled-tests
it.skip('should return false for arrays of different length', () => {
it('should return false for arrays of different length', () => {
// Numbers
expect(arraysEqual([], [0])).toBe(false);
expect(arraysEqual([0], [])).toBe(false);

View file

@ -5,7 +5,6 @@ import {
escapeCss,
escapeHtml,
hideEl,
insertBefore,
makeEl,
onLeftClick,
removeEl,
@ -245,9 +244,9 @@ describe('DOM Utilities', () => {
jest.restoreAllMocks();
});
it('should throw error if element has no parent', () => {
it('should NOT throw error if element has no parent', () => {
const detachedElement = document.createElement('div');
expect(() => removeEl(detachedElement)).toThrow(/propert(y|ies).*null/);
expect(() => removeEl(detachedElement)).not.toThrow();
});
it('should call the native removeElement method on parent', () => {
@ -297,50 +296,17 @@ describe('DOM Utilities', () => {
});
});
describe('insertBefore', () => {
it('should insert the new element before the existing element', () => {
const mockParent = document.createElement('p');
const mockExisingElement = document.createElement('span');
mockParent.appendChild(mockExisingElement);
const mockNewElement = document.createElement('strong');
insertBefore(mockExisingElement, mockNewElement);
expect(mockParent.children).toHaveLength(2);
expect(mockParent.children[0].tagName).toBe('STRONG');
expect(mockParent.children[1].tagName).toBe('SPAN');
});
it('should insert between two elements', () => {
const mockParent = document.createElement('p');
const mockFirstExisingElement = document.createElement('span');
const mockSecondExisingElement = document.createElement('em');
mockParent.appendChild(mockFirstExisingElement);
mockParent.appendChild(mockSecondExisingElement);
const mockNewElement = document.createElement('strong');
insertBefore(mockSecondExisingElement, mockNewElement);
expect(mockParent.children).toHaveLength(3);
expect(mockParent.children[0].tagName).toBe('SPAN');
expect(mockParent.children[1].tagName).toBe('STRONG');
expect(mockParent.children[2].tagName).toBe('EM');
});
it('should fail if there is no parent', () => {
const mockParent = document.createElement('p');
const mockNewElement = document.createElement('em');
expect(() => {
insertBefore(mockParent, mockNewElement);
}).toThrow(/propert(y|ies).*null/);
});
});
describe('onLeftClick', () => {
let cleanup: VoidFunction | undefined;
afterEach(() => {
if (cleanup) cleanup();
});
it('should call callback on left click', () => {
const mockCallback = jest.fn();
const element = document.createElement('div');
onLeftClick(mockCallback, element as unknown as Document);
cleanup = onLeftClick(mockCallback, element as unknown as Document);
fireEvent.click(element, { button: 0 });
@ -350,7 +316,7 @@ describe('DOM Utilities', () => {
it('should NOT call callback on non-left click', () => {
const mockCallback = jest.fn();
const element = document.createElement('div');
onLeftClick(mockCallback, element as unknown as Document);
cleanup = onLeftClick(mockCallback, element as unknown as Document);
const mockButton = getRandomArrayItem([1, 2, 3, 4, 5]);
fireEvent.click(element, { button: mockButton });
@ -360,12 +326,29 @@ describe('DOM Utilities', () => {
it('should add click event listener to the document by default', () => {
const mockCallback = jest.fn();
onLeftClick(mockCallback);
cleanup = onLeftClick(mockCallback);
fireEvent.click(document.body);
expect(mockCallback).toHaveBeenCalledTimes(1);
});
it('should return a cleanup function that removes the listener', () => {
const mockCallback = jest.fn();
const element = document.createElement('div');
const localCleanup = onLeftClick(mockCallback, element as unknown as Document);
fireEvent.click(element, { button: 0 });
expect(mockCallback).toHaveBeenCalledTimes(1);
// Remove the listener
localCleanup();
fireEvent.click(element, { button: 0 });
expect(mockCallback).toHaveBeenCalledTimes(1);
});
});
describe('whenReady', () => {

View file

@ -1,4 +1,4 @@
import { initDraggables } from '../draggable';
import { clearDragSource, initDraggables } from '../draggable';
import { fireEvent } from '@testing-library/dom';
import { getRandomArrayItem } from '../../../test/randomness';
@ -115,6 +115,14 @@ describe('Draggable Utilities', () => {
expect(mockEvent.dataTransfer?.effectAllowed).toEqual('move');
});
it('should not throw if the event has no dataTransfer property', () => {
initDraggables();
const mockEvent = createDragEvent('dragstart');
delete (mockEvent as Record<keyof typeof mockEvent, unknown>).dataTransfer;
expect(() => fireEvent(mockDraggable, mockEvent)).not.toThrow();
});
});
describe('dragOver', () => {
@ -228,6 +236,20 @@ describe('Draggable Utilities', () => {
boundingBoxSpy.mockRestore();
}
});
it('should not throw if drag source element is not set', () => {
clearDragSource();
initDraggables();
const mockSecondDraggable = createDraggableElement();
mockDragContainer.appendChild(mockSecondDraggable);
const mockDropEvent = createDragEvent('drop');
fireEvent(mockDraggable, mockDropEvent);
expect(() => fireEvent(mockDraggable, mockDropEvent)).not.toThrow();
expect(mockDropEvent.defaultPrevented).toBe(true);
});
});
describe('dragEnd', () => {
@ -269,7 +291,7 @@ describe('Draggable Utilities', () => {
initDraggables();
const mockEvent = createDragEvent('dragstart');
const documentClosestSpy = jest.spyOn(mockDraggable, 'closest').mockReturnValue(null);
const draggableClosestSpy = jest.spyOn(mockDraggable, 'closest').mockReturnValue(null);
try {
fireEvent(mockDraggable, mockEvent);
@ -277,7 +299,7 @@ describe('Draggable Utilities', () => {
expect(mockEvent.dataTransfer?.effectAllowed).toBeFalsy();
}
finally {
documentClosestSpy.mockRestore();
draggableClosestSpy.mockRestore();
}
});
});

View file

@ -1,9 +1,9 @@
import { delegate, fire, leftClick, on } from '../events';
import { delegate, fire, leftClick, on, PhilomenaAvailableEventsMap } from '../events';
import { getRandomArrayItem } from '../../../test/randomness';
import { fireEvent } from '@testing-library/dom';
describe('Event utils', () => {
const mockEvent = getRandomArrayItem(['click', 'blur', 'mouseleave']);
const mockEvent = getRandomArrayItem(['click', 'blur', 'mouseleave'] as (keyof PhilomenaAvailableEventsMap)[]);
describe('fire', () => {
it('should call the native dispatchEvent method on the element', () => {

View file

@ -54,6 +54,22 @@ describe('Image utils', () => {
mockSpoilerOverlay,
};
};
const imageFilteredClass = 'image-filtered';
const imageShowClass = 'image-show';
const spoilerPendingClass = 'spoiler-pending';
const createImageFilteredElement = (mockElement: HTMLDivElement) => {
const mockFilteredImageElement = document.createElement('div');
mockFilteredImageElement.classList.add(imageFilteredClass);
mockElement.appendChild(mockFilteredImageElement);
return { mockFilteredImageElement };
};
const createImageShowElement = (mockElement: HTMLDivElement) => {
const mockShowElement = document.createElement('div');
mockShowElement.classList.add(imageShowClass);
mockShowElement.classList.add(hiddenClass);
mockElement.appendChild(mockShowElement);
return { mockShowElement };
};
describe('showThumb', () => {
let mockServeHidpiValue: string | null = null;
@ -146,6 +162,26 @@ describe('Image utils', () => {
expect(result).toBe(true);
});
['data-size', 'data-uris'].forEach(missingAttributeName => {
it(`should return early if the ${missingAttributeName} attribute is missing`, () => {
const { mockElement } = createMockElements({
extension: 'webm',
});
const jsonParseSpy = jest.spyOn(JSON, 'parse');
mockElement.removeAttribute(missingAttributeName);
try {
const result = showThumb(mockElement);
expect(result).toBe(false);
expect(jsonParseSpy).not.toHaveBeenCalled();
}
finally {
jsonParseSpy.mockRestore();
}
});
});
it('should return early if there is no video element', () => {
const { mockElement, mockVideo, playSpy } = createMockElements({
extension: 'webm',
@ -304,24 +340,21 @@ describe('Image utils', () => {
const result = showThumb(mockElement);
expect(result).toBe(false);
});
it('should return false if overlay is missing', () => {
const { mockElement, mockSpoilerOverlay } = createMockElementWithPicture('jpg');
mockSpoilerOverlay.parentNode?.removeChild(mockSpoilerOverlay);
const result = showThumb(mockElement);
expect(result).toBe(false);
});
});
describe('showBlock', () => {
const imageFilteredClass = 'image-filtered';
const imageShowClass = 'image-show';
const spoilerPendingClass = 'spoiler-pending';
it('should hide the filtered image element and show the image', () => {
const mockElement = document.createElement('div');
const mockFilteredImageElement = document.createElement('div');
mockFilteredImageElement.classList.add(imageFilteredClass);
mockElement.appendChild(mockFilteredImageElement);
const mockShowElement = document.createElement('div');
mockShowElement.classList.add(imageShowClass);
mockShowElement.classList.add(hiddenClass);
mockElement.appendChild(mockShowElement);
const { mockFilteredImageElement } = createImageFilteredElement(mockElement);
const { mockShowElement } = createImageShowElement(mockElement);
showBlock(mockElement);
@ -329,6 +362,18 @@ describe('Image utils', () => {
expect(mockShowElement).not.toHaveClass(hiddenClass);
expect(mockShowElement).toHaveClass(spoilerPendingClass);
});
it('should not throw if image-filtered element is missing', () => {
const mockElement = document.createElement('div');
createImageShowElement(mockElement);
expect(() => showBlock(mockElement)).not.toThrow();
});
it('should not throw if image-show element is missing', () => {
const mockElement = document.createElement('div');
createImageFilteredElement(mockElement);
expect(() => showBlock(mockElement)).not.toThrow();
});
});
describe('hideThumb', () => {
@ -528,9 +573,7 @@ describe('Image utils', () => {
});
describe('spoilerBlock', () => {
const imageFilteredClass = 'image-filtered';
const filterExplanationClass = 'filter-explanation';
const imageShowClass = 'image-show';
const createFilteredImageElement = () => {
const mockImageFiltered = document.createElement('div');
mockImageFiltered.classList.add(imageFilteredClass, hiddenClass);
@ -541,22 +584,31 @@ describe('Image utils', () => {
return { mockImageFiltered, mockImage };
};
it('should do nothing if image element is missing', () => {
const createMockElement = (appendImageShow = true, appendImageFiltered = true) => {
const mockElement = document.createElement('div');
const { mockImageFiltered, mockImage } = createFilteredImageElement();
if (appendImageFiltered) mockElement.appendChild(mockImageFiltered);
const mockExplanation = document.createElement('span');
mockExplanation.classList.add(filterExplanationClass);
mockElement.appendChild(mockExplanation);
const mockImageShow = document.createElement('div');
mockImageShow.classList.add(imageShowClass);
if (appendImageShow) mockElement.appendChild(mockImageShow);
return { mockElement, mockImage, mockExplanation, mockImageShow, mockImageFiltered };
};
it('should not throw if image element is missing', () => {
const mockElement = document.createElement('div');
const { mockImageFiltered, mockImage } = createFilteredImageElement();
mockImage.parentNode?.removeChild(mockImage);
mockElement.appendChild(mockImageFiltered);
expect(() => spoilerBlock(mockElement, mockSpoilerUri, mockSpoilerReason)).not.toThrow();
});
it('should update the elements with the parameters and set classes if image element is found', () => {
const mockElement = document.createElement('div');
const { mockImageFiltered, mockImage } = createFilteredImageElement();
mockElement.appendChild(mockImageFiltered);
const mockExplanation = document.createElement('span');
mockExplanation.classList.add(filterExplanationClass);
mockElement.appendChild(mockExplanation);
const mockImageShow = document.createElement('div');
mockImageShow.classList.add(imageShowClass);
mockElement.appendChild(mockImageShow);
const { mockElement, mockImage, mockExplanation, mockImageShow, mockImageFiltered } = createMockElement();
spoilerBlock(mockElement, mockSpoilerUri, mockSpoilerReason);
@ -565,5 +617,15 @@ describe('Image utils', () => {
expect(mockImageShow).toHaveClass(hiddenClass);
expect(mockImageFiltered).not.toHaveClass(hiddenClass);
});
it('should not throw if image-filtered element is missing', () => {
const { mockElement } = createMockElement(true, false);
expect(() => spoilerBlock(mockElement, mockSpoilerUri, mockSpoilerReason)).not.toThrow();
});
it('should not throw if image-show element is missing', () => {
const { mockElement } = createMockElement(false, true);
expect(() => spoilerBlock(mockElement, mockSpoilerUri, mockSpoilerReason)).not.toThrow();
});
});
});

View file

@ -1,48 +1,45 @@
import { displayTags, getHiddenTags, getSpoileredTags, imageHitsComplex, imageHitsTags } from '../tag';
import { displayTags, getHiddenTags, getSpoileredTags, imageHitsComplex, imageHitsTags, TagData } from '../tag';
import { mockStorage } from '../../../test/mock-storage';
import { getRandomArrayItem } from '../../../test/randomness';
import parseSearch from '../../match_query';
// TODO Move to source file when rewriting in TypeScript
interface StorageTagInfo {
id: number;
name: string;
images: number;
spoiler_image_uri: string | null;
}
describe('Tag utilities', () => {
const tagStorageKeyPrefix = 'bor_tags_';
const mockTagInfo: Record<string, StorageTagInfo> = {
const mockTagInfo: Record<string, TagData> = {
1: {
id: 1,
name: 'safe',
images: 69,
spoiler_image_uri: null,
fetchedAt: null,
},
2: {
id: 2,
name: 'fox',
images: 1,
spoiler_image_uri: '/mock-fox-spoiler-image.svg',
fetchedAt: null,
},
3: {
id: 3,
name: 'paw pads',
images: 42,
spoiler_image_uri: '/mock-paw-pads-spoiler-image.svg',
fetchedAt: null,
},
4: {
id: 4,
name: 'whiskers',
images: 42,
spoiler_image_uri: null,
fetchedAt: null,
},
5: {
id: 5,
name: 'lilo & stitch',
images: 6,
spoiler_image_uri: null,
fetchedAt: null,
},
};
const getEnabledSpoilerType = () => getRandomArrayItem<SpoilerType>(['click', 'hover', 'static']);
@ -147,6 +144,12 @@ describe('Tag utilities', () => {
mockTagInfo[4],
]);
});
it('should return empty array if data attribute is missing', () => {
const mockImage = new Image();
const result = imageHitsTags(mockImage, []);
expect(result).toEqual([]);
});
});
describe('imageHitsComplex', () => {

View file

@ -1,11 +0,0 @@
// http://stackoverflow.com/a/5306832/1726690
export function moveElement(array, from, to) {
array.splice(to, 0, array.splice(from, 1)[0]);
}
export function arraysEqual(array1, array2) {
for (let i = 0; i < array1.length; ++i) {
if (array1[i] !== array2[i]) return false;
}
return true;
}

10
assets/js/utils/array.ts Normal file
View file

@ -0,0 +1,10 @@
// http://stackoverflow.com/a/5306832/1726690
export function moveElement<Items>(array: Items[], from: number, to: number): void {
array.splice(to, 0, array.splice(from, 1)[0]);
}
export function arraysEqual(array1: unknown[], array2: unknown[]): boolean {
if (array1.length !== array2.length) return false;
return array1.every((item, index) => item === array2[index]);
}

View file

@ -1,74 +0,0 @@
/**
* DOM Utils
*/
function $(selector, context = document) { // Get the first matching element
const element = context.querySelector(selector);
return element || null;
}
function $$(selector, context = document) { // Get every matching element as an array
const elements = context.querySelectorAll(selector);
return [].slice.call(elements);
}
function showEl(...elements) {
[].concat(...elements).forEach(el => el.classList.remove('hidden'));
}
function hideEl(...elements) {
[].concat(...elements).forEach(el => el.classList.add('hidden'));
}
function toggleEl(...elements) {
[].concat(...elements).forEach(el => el.classList.toggle('hidden'));
}
function clearEl(...elements) {
[].concat(...elements).forEach(el => { while (el.firstChild) el.removeChild(el.firstChild); });
}
function removeEl(...elements) {
[].concat(...elements).forEach(el => el.parentNode.removeChild(el));
}
function makeEl(tag, attr = {}) {
const el = document.createElement(tag);
for (const prop in attr) el[prop] = attr[prop];
return el;
}
function insertBefore(existingElement, newElement) {
existingElement.parentNode.insertBefore(newElement, existingElement);
}
function onLeftClick(callback, context = document) {
context.addEventListener('click', event => {
if (event.button === 0) callback(event);
});
}
function whenReady(callback) { // Execute a function when the DOM is ready
if (document.readyState !== 'loading') callback();
else document.addEventListener('DOMContentLoaded', callback);
}
function escapeHtml(html) {
return html.replace(/&/g, '&amp;')
.replace(/>/g, '&gt;')
.replace(/</g, '&lt;')
.replace(/"/g, '&quot;');
}
function escapeCss(css) {
return css.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"');
}
function findFirstTextNode(of) {
return Array.prototype.filter.call(of.childNodes, el => el.nodeType === Node.TEXT_NODE)[0];
}
export { $, $$, showEl, hideEl, toggleEl, clearEl, removeEl, makeEl, insertBefore, onLeftClick, whenReady, escapeHtml, escapeCss, findFirstTextNode };

89
assets/js/utils/dom.ts Normal file
View file

@ -0,0 +1,89 @@
// DOM Utils
/**
* Get the first matching element
*/
export function $<E extends Element = Element>(selector: string, context: Pick<Document, 'querySelector'> = document): E | null {
return context.querySelector<E>(selector);
}
/**
* Get every matching element as an array
*/
export function $$<E extends Element = Element>(selector: string, context: Pick<Document, 'querySelectorAll'> = document): E[] {
const elements = context.querySelectorAll<E>(selector);
return [...elements];
}
export function showEl<E extends HTMLElement>(...elements: E[] | ConcatArray<E>[]) {
([] as E[]).concat(...elements).forEach(el => el.classList.remove('hidden'));
}
export function hideEl<E extends HTMLElement>(...elements: E[] | ConcatArray<E>[]) {
([] as E[]).concat(...elements).forEach(el => el.classList.add('hidden'));
}
export function toggleEl<E extends HTMLElement>(...elements: E[] | ConcatArray<E>[]) {
([] as E[]).concat(...elements).forEach(el => el.classList.toggle('hidden'));
}
export function clearEl<E extends HTMLElement>(...elements: E[] | ConcatArray<E>[]) {
([] as E[]).concat(...elements).forEach(el => {
while (el.firstChild) el.removeChild(el.firstChild);
});
}
export function removeEl<E extends HTMLElement>(...elements: E[] | ConcatArray<E>[]) {
([] as E[]).concat(...elements).forEach(el => el.parentNode?.removeChild(el));
}
export function makeEl<Tag extends keyof HTMLElementTagNameMap>(tag: Tag, attr?: Partial<HTMLElementTagNameMap[Tag]>): HTMLElementTagNameMap[Tag] {
const el = document.createElement(tag);
if (attr) {
for (const prop in attr) {
const newValue = attr[prop];
if (typeof newValue !== 'undefined') {
el[prop] = newValue as Exclude<typeof newValue, undefined>;
}
}
}
return el;
}
export function onLeftClick(callback: (e: MouseEvent) => boolean | void, context: Pick<GlobalEventHandlers, 'addEventListener' | 'removeEventListener'> = document): VoidFunction {
const handler: typeof callback = event => {
if (event.button === 0) callback(event);
};
context.addEventListener('click', handler);
return () => context.removeEventListener('click', handler);
}
/**
* Execute a function when the DOM is ready
*/
export function whenReady(callback: VoidFunction): void {
if (document.readyState !== 'loading') {
callback();
}
else {
document.addEventListener('DOMContentLoaded', callback);
}
}
export function escapeHtml(html: string): string {
return html.replace(/&/g, '&amp;')
.replace(/>/g, '&gt;')
.replace(/</g, '&lt;')
.replace(/"/g, '&quot;');
}
export function escapeCss(css: string): string {
return css.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"');
}
export function findFirstTextNode<N extends Node>(of: Node): N {
return Array.prototype.filter.call(of.childNodes, el => el.nodeType === Node.TEXT_NODE)[0];
}

View file

@ -1,70 +0,0 @@
import { $$ } from './dom';
let dragSrcEl;
function dragStart(event, target) {
target.classList.add('dragging');
dragSrcEl = target;
if (event.dataTransfer.items.length === 0) {
event.dataTransfer.setData('text/plain', '');
}
event.dataTransfer.effectAllowed = 'move';
}
function dragOver(event) {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}
function dragEnter(event, target) {
target.classList.add('over');
}
function dragLeave(event, target) {
target.classList.remove('over');
}
function drop(event, target) {
event.preventDefault();
dragSrcEl.classList.remove('dragging');
if (dragSrcEl === target) return;
// divide the target element into two sets of coordinates
// and determine how to act based on the relative mouse positioin
const bbox = target.getBoundingClientRect();
const detX = bbox.left + (bbox.width / 2);
if (event.clientX < detX) {
target.insertAdjacentElement('beforebegin', dragSrcEl);
}
else {
target.insertAdjacentElement('afterend', dragSrcEl);
}
}
function dragEnd(event, target) {
dragSrcEl.classList.remove('dragging');
$$('.over', target.parentNode).forEach(t => t.classList.remove('over'));
}
function wrapper(func) {
return function(event) {
if (!event.target.closest) return;
const target = event.target.closest('.drag-container [draggable]');
if (target) func(event, target);
};
}
export function initDraggables() {
document.addEventListener('dragstart', wrapper(dragStart));
document.addEventListener('dragover', wrapper(dragOver));
document.addEventListener('dragenter', wrapper(dragEnter));
document.addEventListener('dragleave', wrapper(dragLeave));
document.addEventListener('dragend', wrapper(dragEnd));
document.addEventListener('drop', wrapper(drop));
}

View file

@ -0,0 +1,79 @@
import { $$ } from './dom';
import { delegate } from './events';
let dragSrcEl: HTMLElement | null = null;
function dragStart(event: DragEvent, target: HTMLElement) {
target.classList.add('dragging');
dragSrcEl = target;
if (!event.dataTransfer) return;
if (event.dataTransfer.items.length === 0) {
event.dataTransfer.setData('text/plain', '');
}
event.dataTransfer.effectAllowed = 'move';
}
function dragOver(event: DragEvent) {
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
}
function dragEnter(event: DragEvent, target: HTMLElement) {
target.classList.add('over');
}
function dragLeave(event: DragEvent, target: HTMLElement) {
target.classList.remove('over');
}
function drop(event: DragEvent, target: HTMLElement) {
event.preventDefault();
if (!dragSrcEl) return;
dragSrcEl.classList.remove('dragging');
if (dragSrcEl === target) return;
// divide the target element into two sets of coordinates
// and determine how to act based on the relative mouse position
const bbox = target.getBoundingClientRect();
const detX = bbox.left + bbox.width / 2;
if (event.clientX < detX) {
target.insertAdjacentElement('beforebegin', dragSrcEl);
}
else {
target.insertAdjacentElement('afterend', dragSrcEl);
}
}
function dragEnd(event: DragEvent, target: HTMLElement) {
clearDragSource();
if (target.parentNode) {
$$('.over', target.parentNode).forEach(t => t.classList.remove('over'));
}
}
export function initDraggables() {
const draggableSelector = '.drag-container [draggable]';
delegate(document, 'dragstart', { [draggableSelector]: dragStart});
delegate(document, 'dragover', { [draggableSelector]: dragOver});
delegate(document, 'dragenter', { [draggableSelector]: dragEnter});
delegate(document, 'dragleave', { [draggableSelector]: dragLeave});
delegate(document, 'dragend', { [draggableSelector]: dragEnd});
delegate(document, 'drop', { [draggableSelector]: drop});
}
export function clearDragSource() {
if (!dragSrcEl) return;
dragSrcEl.classList.remove('dragging');
dragSrcEl = null;
}

View file

@ -1,24 +0,0 @@
/**
* DOM events
*/
export function fire(el, event, detail) {
el.dispatchEvent(new CustomEvent(event, { detail, bubbles: true, cancelable: true }));
}
export function on(node, event, selector, func) {
delegate(node, event, { [selector]: func });
}
export function leftClick(func) {
return (event, target) => { if (event.button === 0) return func(event, target); };
}
export function delegate(node, event, selectors) {
node.addEventListener(event, e => {
for (const selector in selectors) {
const target = e.target.closest(selector);
if (target && selectors[selector](e, target) === false) break;
}
});
}

53
assets/js/utils/events.ts Normal file
View file

@ -0,0 +1,53 @@
// DOM events
export interface PhilomenaAvailableEventsMap {
dragstart: DragEvent,
dragover: DragEvent,
dragenter: DragEvent,
dragleave: DragEvent,
dragend: DragEvent,
drop: DragEvent,
click: MouseEvent,
submit: Event,
reset: Event
}
export interface PhilomenaEventElement {
addEventListener<K extends keyof PhilomenaAvailableEventsMap>(
type: K,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
listener: (this: Document | HTMLElement, ev: PhilomenaAvailableEventsMap[K]) => any,
options?: boolean | AddEventListenerOptions | undefined
): void;
}
export function fire<El extends Element, D>(el: El, event: string, detail: D) {
el.dispatchEvent(new CustomEvent<D>(event, { detail, bubbles: true, cancelable: true }));
}
export function on<K extends keyof PhilomenaAvailableEventsMap>(
node: PhilomenaEventElement,
event: K, selector: string, func: ((e: PhilomenaAvailableEventsMap[K], target: Element) => boolean)
) {
delegate(node, event, { [selector]: func });
}
export function leftClick<E extends MouseEvent, Target extends EventTarget>(func: (e: E, t: Target) => void) {
return (event: E, target: Target) => { if (event.button === 0) return func(event, target); };
}
export function delegate<K extends keyof PhilomenaAvailableEventsMap, Target extends Element>(
node: PhilomenaEventElement,
event: K,
selectors: Record<string, ((e: PhilomenaAvailableEventsMap[K], target: Target) => void | boolean)>
) {
node.addEventListener(event, e => {
for (const selector in selectors) {
const evtTarget = e.target as EventTarget | Target | null;
if (evtTarget && 'closest' in evtTarget && typeof evtTarget.closest === 'function') {
const target = evtTarget.closest(selector) as Target;
if (target && selectors[selector](e, target) === false) break;
}
}
});
}

View file

@ -1,9 +1,7 @@
import { clearEl } from './dom';
import store from './store';
function showVideoThumb(img) {
const size = img.dataset.size;
const uris = JSON.parse(img.dataset.uris);
function showVideoThumb(img: HTMLDivElement, size: string, uris: Record<string, string>) {
const thumbUri = uris[size];
const vidEl = img.querySelector('video');
@ -21,18 +19,22 @@ function showVideoThumb(img) {
vidEl.classList.remove('hidden');
vidEl.play();
img.querySelector('.js-spoiler-info-overlay').classList.add('hidden');
const overlay = img.querySelector('.js-spoiler-info-overlay');
if (overlay) overlay.classList.add('hidden');
return true;
}
export function showThumb(img) {
export function showThumb(img: HTMLDivElement) {
const size = img.dataset.size;
const uris = JSON.parse(img.dataset.uris);
const urisString = img.dataset.uris;
if (!size || !urisString) return false;
const uris: Record<string, string> = JSON.parse(urisString);
const thumbUri = uris[size].replace(/webm$/, 'gif');
const picEl = img.querySelector('picture');
if (!picEl) return showVideoThumb(img);
if (!picEl) return showVideoThumb(img, size, uris);
const imgEl = picEl.querySelector('img');
if (!imgEl || imgEl.src.indexOf(thumbUri) !== -1) return false;
@ -45,26 +47,30 @@ export function showThumb(img) {
}
imgEl.src = thumbUri;
const overlay = img.querySelector('.js-spoiler-info-overlay');
if (!overlay) return false;
if (uris[size].indexOf('.webm') !== -1) {
const overlay = img.querySelector('.js-spoiler-info-overlay');
overlay.classList.remove('hidden');
overlay.innerHTML = 'WebM';
}
else {
img.querySelector('.js-spoiler-info-overlay').classList.add('hidden');
overlay.classList.add('hidden');
}
return true;
}
export function showBlock(img) {
img.querySelector('.image-filtered').classList.add('hidden');
const imageShowClasses = img.querySelector('.image-show').classList;
imageShowClasses.remove('hidden');
imageShowClasses.add('spoiler-pending');
export function showBlock(img: HTMLDivElement) {
img.querySelector('.image-filtered')?.classList.add('hidden');
const imageShowClasses = img.querySelector('.image-show')?.classList;
if (imageShowClasses) {
imageShowClasses.remove('hidden');
imageShowClasses.add('spoiler-pending');
}
}
function hideVideoThumb(img, spoilerUri, reason) {
function hideVideoThumb(img: HTMLDivElement, spoilerUri: string, reason: string) {
const vidEl = img.querySelector('video');
if (!vidEl) return;
@ -74,15 +80,17 @@ function hideVideoThumb(img, spoilerUri, reason) {
imgEl.classList.remove('hidden');
imgEl.src = spoilerUri;
imgOverlay.innerHTML = reason;
imgOverlay.classList.remove('hidden');
if (imgOverlay) {
imgOverlay.innerHTML = reason;
imgOverlay.classList.remove('hidden');
}
clearEl(vidEl);
vidEl.classList.add('hidden');
vidEl.pause();
}
export function hideThumb(img, spoilerUri, reason) {
export function hideThumb(img: HTMLDivElement, spoilerUri: string, reason: string) {
const picEl = img.querySelector('picture');
if (!picEl) return hideVideoThumb(img, spoilerUri, reason);
@ -93,11 +101,13 @@ export function hideThumb(img, spoilerUri, reason) {
imgEl.srcset = '';
imgEl.src = spoilerUri;
imgOverlay.innerHTML = reason;
imgOverlay.classList.remove('hidden');
if (imgOverlay) {
imgOverlay.innerHTML = reason;
imgOverlay.classList.remove('hidden');
}
}
export function spoilerThumb(img, spoilerUri, reason) {
export function spoilerThumb(img: HTMLDivElement, spoilerUri: string, reason: string) {
hideThumb(img, spoilerUri, reason);
switch (window.booru.spoilerType) {
@ -114,15 +124,19 @@ export function spoilerThumb(img, spoilerUri, reason) {
}
}
export function spoilerBlock(img, spoilerUri, reason) {
const imgEl = img.querySelector('.image-filtered img');
const imgReason = img.querySelector('.filter-explanation');
export function spoilerBlock(img: HTMLDivElement, spoilerUri: string, reason: string) {
const imgFiltered = img.querySelector('.image-filtered');
const imgEl = imgFiltered?.querySelector<HTMLImageElement>('img');
if (!imgEl) return;
imgEl.src = spoilerUri;
imgReason.innerHTML = reason;
const imgReason = img.querySelector<HTMLElement>('.filter-explanation');
const imageShow = img.querySelector('.image-show');
img.querySelector('.image-show').classList.add('hidden');
img.querySelector('.image-filtered').classList.remove('hidden');
imgEl.src = spoilerUri;
if (imgReason) {
imgReason.innerHTML = reason;
}
imageShow?.classList.add('hidden');
if (imgFiltered) imgFiltered.classList.remove('hidden');
}

View file

@ -1,31 +1,23 @@
// Client-side tag completion.
import store from './store';
/**
* @typedef {object} Result
* @property {string} name
* @property {number} imageCount
* @property {number[]} associations
*/
interface Result {
name: string;
imageCount: number;
associations: number[];
}
/**
* Compare two strings, C-style.
*
* @param {string} a
* @param {string} b
* @returns {number}
*/
function strcmp(a, b) {
function strcmp(a: string, b: string): number {
return a < b ? -1 : Number(a > b);
}
/**
* Returns the name of a tag without any namespace component.
*
* @param {string} s
* @returns {string}
*/
function nameInNamespace(s) {
function nameInNamespace(s: string): string {
const v = s.split(':', 2);
if (v.length === 2) return v[1];
@ -39,25 +31,24 @@ function nameInNamespace(s) {
* the JS heap and speed up the execution of the search.
*/
export class LocalAutocompleter {
private data: Uint8Array;
private view: DataView;
private decoder: TextDecoder;
private numTags: number;
private referenceStart: number;
private secondaryStart: number;
private formatVersion: number;
/**
* Build a new local autocompleter.
*
* @param {ArrayBuffer} backingStore
*/
constructor(backingStore) {
/** @type {Uint8Array} */
constructor(backingStore: ArrayBuffer) {
this.data = new Uint8Array(backingStore);
/** @type {DataView} */
this.view = new DataView(backingStore);
/** @type {TextDecoder} */
this.decoder = new TextDecoder();
/** @type {number} */
this.numTags = this.view.getUint32(backingStore.byteLength - 4, true);
/** @type {number} */
this.referenceStart = this.view.getUint32(backingStore.byteLength - 8, true);
/** @type {number} */
this.secondaryStart = this.referenceStart + 8 * this.numTags;
/** @type {number} */
this.formatVersion = this.view.getUint32(backingStore.byteLength - 12, true);
if (this.formatVersion !== 2) {
@ -67,11 +58,8 @@ export class LocalAutocompleter {
/**
* Get a tag's name and its associations given a byte location inside the file.
*
* @param {number} location
* @returns {[string, number[]]}
*/
getTagFromLocation(location) {
getTagFromLocation(location: number): [string, number[]] {
const nameLength = this.view.getUint8(location);
const assnLength = this.view.getUint8(location + 1 + nameLength);
@ -88,11 +76,8 @@ export class LocalAutocompleter {
/**
* Get a Result object as the ith tag inside the file.
*
* @param {number} i
* @returns {[string, Result]}
*/
getResultAt(i) {
getResultAt(i: number): [string, Result] {
const nameLocation = this.view.getUint32(this.referenceStart + i * 8, true);
const imageCount = this.view.getInt32(this.referenceStart + i * 8 + 4, true);
const [ name, associations ] = this.getTagFromLocation(nameLocation);
@ -107,23 +92,16 @@ export class LocalAutocompleter {
/**
* Get a Result object as the ith tag inside the file, secondary ordering.
*
* @param {number} i
* @returns {[string, Result]}
*/
getSecondaryResultAt(i) {
getSecondaryResultAt(i: number): [string, Result] {
const referenceIndex = this.view.getUint32(this.secondaryStart + i * 4, true);
return this.getResultAt(referenceIndex);
}
/**
* Perform a binary search to fetch all results matching a condition.
*
* @param {(i: number) => [string, Result]} getResult
* @param {(name: string) => number} compare
* @param {{[key: string]: Result}} results
*/
scanResults(getResult, compare, results) {
scanResults(getResult: (i: number) => [string, Result], compare: (name: string) => number, results: Record<string, Result>) {
const unfilter = store.get('unfilter_tag_suggestions');
let min = 0;
@ -132,7 +110,7 @@ export class LocalAutocompleter {
const hiddenTags = window.booru.hiddenTagList;
while (min < max - 1) {
const med = (min + (max - min) / 2) | 0;
const med = min + (max - min) / 2 | 0;
const sortKey = getResult(med)[0];
if (compare(sortKey) >= 0) {
@ -161,25 +139,20 @@ export class LocalAutocompleter {
/**
* Find the top k results by image count which match the given string prefix.
*
* @param {string} prefix
* @param {number} k
* @returns {Result[]}
*/
topK(prefix, k) {
/** @type {{[key: string]: Result}} */
const results = {};
topK(prefix: string, k: number): Result[] {
const results: Record<string, Result> = {};
if (prefix === '') {
return [];
}
// Find normally, in full name-sorted order
const prefixMatch = (/** @type {string} */ name) => strcmp(name.slice(0, prefix.length), prefix);
const prefixMatch = (name: string) => strcmp(name.slice(0, prefix.length), prefix);
this.scanResults(this.getResultAt.bind(this), prefixMatch, results);
// Find in secondary order
const namespaceMatch = (/** @type {string} */ name) => strcmp(nameInNamespace(name).slice(0, prefix.length), prefix);
const namespaceMatch = (name: string) => strcmp(nameInNamespace(name).slice(0, prefix.length), prefix);
this.scanResults(this.getSecondaryResultAt.bind(this), namespaceMatch, results);
// Sort results by image count

View file

@ -1,7 +1,9 @@
// Request Utils
export function fetchJson(verb, endpoint, body) {
const data = {
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH';
export function fetchJson(verb: HttpMethod, endpoint: string, body?: Record<string, unknown>): Promise<Response> {
const data: RequestInit = {
method: verb,
credentials: 'same-origin',
headers: {
@ -19,7 +21,7 @@ export function fetchJson(verb, endpoint, body) {
return fetch(endpoint, data);
}
export function fetchHtml(endpoint) {
export function fetchHtml(endpoint: string): Promise<Response> {
return fetch(endpoint, {
credentials: 'same-origin',
headers: {
@ -29,7 +31,7 @@ export function fetchHtml(endpoint) {
});
}
export function handleError(response) {
export function handleError(response: Response): Response {
if (!response.ok) {
throw new Error('Received error from server');
}

View file

@ -6,7 +6,7 @@ export const lastUpdatedSuffix = '__lastUpdated';
export default {
set(key, value) {
set(key: string, value: unknown) {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
@ -16,17 +16,18 @@ export default {
}
},
get(key) {
get<Value = unknown>(key: string): Value | null {
const value = localStorage.getItem(key);
if (value === null) return null;
try {
return JSON.parse(value);
}
catch (err) {
return value;
return value as unknown as Value;
}
},
remove(key) {
remove(key: string) {
try {
localStorage.removeItem(key);
return true;
@ -37,8 +38,8 @@ export default {
},
// Watch changes to a specified key - returns value on change
watch(key, callback) {
const handler = event => {
watch(key: string, callback: (value: unknown) => void) {
const handler = (event: StorageEvent) => {
if (event.key === key) callback(this.get(key));
};
window.addEventListener('storage', handler);
@ -46,7 +47,7 @@ export default {
},
// set() with an additional key containing the current time + expiration time
setWithExpireTime(key, value, maxAge) {
setWithExpireTime(key: string, value: unknown, maxAge: number) {
const lastUpdatedKey = key + lastUpdatedSuffix;
const lastUpdatedTime = Date.now() + maxAge;
@ -54,11 +55,11 @@ export default {
},
// Whether the value of a key set with setWithExpireTime() has expired
hasExpired(key) {
hasExpired(key: string) {
const lastUpdatedKey = key + lastUpdatedSuffix;
const lastUpdatedTime = this.get(lastUpdatedKey);
const lastUpdatedTime = this.get<number>(lastUpdatedKey);
return Date.now() > lastUpdatedTime;
return lastUpdatedTime === null || Date.now() > lastUpdatedTime;
},
};

View file

@ -1,11 +1,19 @@
import { escapeHtml } from './dom';
import { getTag } from '../booru';
function unique(array) {
export interface TagData {
id: number;
name: string;
images: number;
spoiler_image_uri: string | null;
fetchedAt: null | number;
}
function unique<Item>(array: Item[]): Item[] {
return array.filter((a, b, c) => c.indexOf(a) === b);
}
function sortTags(hidden, a, b) {
function sortTags(hidden: boolean, a: TagData, b: TagData): number {
// If both tags have a spoiler image, sort by images count desc (hidden) or asc (spoilered)
if (a.spoiler_image_uri && b.spoiler_image_uri) {
return hidden ? b.images - a.images : a.images - b.images;
@ -34,16 +42,20 @@ export function getSpoileredTags() {
.sort(sortTags.bind(null, false));
}
export function imageHitsTags(img, matchTags) {
const imageTags = JSON.parse(img.dataset.imageTags);
export function imageHitsTags(img: HTMLImageElement, matchTags: TagData[]): TagData[] {
const imageTagsString = img.dataset.imageTags;
if (typeof imageTagsString === 'undefined') {
return [];
}
const imageTags = JSON.parse(imageTagsString);
return matchTags.filter(t => imageTags.indexOf(t.id) !== -1);
}
export function imageHitsComplex(img, matchComplex) {
export function imageHitsComplex(img: HTMLImageElement, matchComplex: { hitsImage: (img: HTMLImageElement) => boolean }) {
return matchComplex.hitsImage(img);
}
export function displayTags(tags) {
export function displayTags(tags: TagData[]): string {
const mainTag = tags[0], otherTags = tags.slice(1);
let list = escapeHtml(mainTag.name), extras;

9582
assets/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -9,7 +9,9 @@
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.3.0",
"@rollup/plugin-multi-entry": "^6.0.0",
"@rollup/plugin-typescript": "^11.0.0",
"@rollup/plugin-virtual": "^3.0.1",
"@types/web": "^0.0.91",
"@typescript-eslint/eslint-plugin": "^5.52.0",
"@typescript-eslint/parser": "^5.52.0",
@ -33,10 +35,7 @@
"postcss-scss": "^4.0.6",
"postcss-url": "^10.1.3",
"rollup": "^2.57.0",
"rollup-plugin-buble": "^0.19.8",
"rollup-plugin-includepaths": "^0.2.4",
"rollup-plugin-multi-entry": "^2.1.0",
"rollup-plugin-virtual": "^1.0.1",
"sass": "^1.58.3",
"sass-loader": "^13.2.0",
"source-map-support": "^0.5.21",
@ -56,6 +55,6 @@
"eslint-plugin-jest-dom": "^4.0.3",
"jest": "^29.4.3",
"jest-fetch-mock": "^3.0.3",
"ts-jest": "^29.0.5"
"ts-jest": "^29.1.0"
}
}

View file

@ -20,4 +20,5 @@ window.booru = {
watchedTagList: [],
hiddenFilter: blankFilter,
spoileredFilter: blankFilter,
tagsVersion: 5
};

View file

@ -6,6 +6,7 @@
"esModuleInterop": true,
"moduleResolution": "Node",
"allowJs": true,
"skipLibCheck": true,
"lib": [
"ES2018",
"DOM"

View file

@ -51,6 +51,7 @@ interface BooruObject {
* @type {import('../js/match_query.js').SearchAST}
*/
spoileredFilter: unknown;
tagsVersion: number;
}
interface Window {

View file

@ -9,8 +9,7 @@ import IgnoreEmitPlugin from 'ignore-emit-webpack-plugin';
import ESLintPlugin from 'eslint-webpack-plugin';
import autoprefixer from 'autoprefixer';
import rollupPluginIncludepaths from 'rollup-plugin-includepaths';
import rollupPluginMultiEntry from 'rollup-plugin-multi-entry';
import rollupPluginBuble from 'rollup-plugin-buble';
import rollupPluginMultiEntry from '@rollup/plugin-multi-entry';
import rollupPluginTypescript from '@rollup/plugin-typescript';
const isDevelopment = process.env.NODE_ENV !== 'production';
@ -18,7 +17,6 @@ const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
const includePaths = rollupPluginIncludepaths();
const multiEntry = rollupPluginMultiEntry();
const buble = rollupPluginBuble({ transforms: { dangerousForOf: true } });
const typescript = rollupPluginTypescript();
let plugins = [
@ -105,7 +103,6 @@ export default {
loader: 'webpack-rollup-loader',
options: {
plugins: [
buble,
includePaths,
multiEntry,
typescript,