[Search] Lunr search engine support (#5290)

* add lunr package

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* add search translator type and search engine interface

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* (wip) add support for lunr search engine

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* lunr search engine support

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* clean up todo comments

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* typing and cleanups

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* move lunr type package from dev deps to deps

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* check if documents exist to index

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* test fixup

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* changeset

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* move LunrSearchEngine.ts to /engines and add tests

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* update imports

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* update error message

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* add comment to index rotation

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* Update plugins/search-backend-node/src/types.ts

Signed-off-by: Fredrik Adelöw freben@gmail.com

Co-authored-by: Fredrik Adelöw <freben@gmail.com>
Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* Update plugins/search-backend-node/src/engines/LunrSearchEngine.ts

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

Co-authored-by: Fredrik Adelöw <freben@gmail.com>

* Update plugins/search-backend-node/src/engines/LunrSearchEngine.ts

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

Co-authored-by: Fredrik Adelöw <freben@gmail.com>

* fix imports

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* use type assertion to specify more specific ConcreteLunrQuery type

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* fix imports

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* consistent naming

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* change search engine to be parameter of constructor in indexBuilder

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* make engine required in router options and pass it through in createRouter used in standalone server

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* fix tests

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* delete import

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* add types to SearchQuery interface to make it possible to scope to specific index + test

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* clean up tests

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* handle case when a filter is added on a field that does not exist on all documents + test

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

Co-authored-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Emma Indal
2021-04-22 15:03:10 +02:00
committed by GitHub
parent 47d511b976
commit b9b2b4b766
16 changed files with 582 additions and 16 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-search-backend': patch
'@backstage/plugin-search-backend-node': patch
---
Lunr Search Engine support
+7 -2
View File
@@ -15,7 +15,10 @@
*/
import { useHotCleanup } from '@backstage/backend-common';
import { createRouter } from '@backstage/plugin-search-backend';
import { IndexBuilder } from '@backstage/plugin-search-backend-node';
import {
IndexBuilder,
LunrSearchEngine,
} from '@backstage/plugin-search-backend-node';
import { PluginEnvironment } from '../types';
import { DefaultCatalogCollator } from '@backstage/plugin-catalog-backend';
@@ -23,7 +26,8 @@ export default async function createPlugin({
logger,
discovery,
}: PluginEnvironment) {
const indexBuilder = new IndexBuilder({ logger });
const searchEngine = new LunrSearchEngine({ logger });
const indexBuilder = new IndexBuilder({ logger, searchEngine });
indexBuilder.addCollator({
type: 'software-catalog',
@@ -38,6 +42,7 @@ export default async function createPlugin({
useHotCleanup(module, () => clearInterval(timerId));
return await createRouter({
engine: indexBuilder.getSearchEngine(),
logger,
});
}
+1
View File
@@ -18,6 +18,7 @@ import { JsonObject } from '@backstage/config';
export interface SearchQuery {
term: string;
filters?: JsonObject;
types?: string[];
pageCursor: string;
}
+3 -1
View File
@@ -20,7 +20,9 @@
},
"dependencies": {
"@backstage/search-common": "^0.1.1",
"winston": "^3.2.1"
"winston": "^3.2.1",
"lunr": "^2.3.9",
"@types/lunr": "^2.3.3"
},
"devDependencies": {
"@backstage/backend-common": "^0.6.0",
@@ -16,9 +16,11 @@
import { DocumentCollator, DocumentDecorator } from '@backstage/search-common';
import { Logger } from 'winston';
import {
RegisterCollatorParameters,
RegisterDecoratorParameters,
SearchEngine,
} from './types';
interface CollatorEnvelope {
@@ -27,18 +29,25 @@ interface CollatorEnvelope {
}
type IndexBuilderOptions = {
searchEngine: SearchEngine;
logger: Logger;
};
export class IndexBuilder {
private collators: Record<string, CollatorEnvelope>;
private decorators: Record<string, DocumentDecorator[]>;
private searchEngine: SearchEngine;
private logger: Logger;
constructor({ logger }: IndexBuilderOptions) {
constructor({ logger, searchEngine }: IndexBuilderOptions) {
this.collators = {};
this.decorators = {};
this.logger = logger;
this.searchEngine = searchEngine;
}
getSearchEngine(): SearchEngine {
return this.searchEngine;
}
/**
@@ -106,7 +115,13 @@ export class IndexBuilder {
documents = await decorators[i].execute(documents);
}
// TODO: push documents to a configured search engine.
if (!documents || documents.length === 0) {
this.logger.info(`No documents for type "${type}" to index`);
return;
}
// pushing documents to index to a configured search engine.
this.searchEngine.index(type, documents);
}),
);
}
@@ -0,0 +1,325 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { getVoidLogger } from '@backstage/backend-common';
import { LunrSearchEngine } from './LunrSearchEngine';
import { SearchEngine } from '../types';
describe('LunrSearchEngine', () => {
let testLunrSearchEngine: SearchEngine;
beforeEach(() => {
testLunrSearchEngine = new LunrSearchEngine({ logger: getVoidLogger() });
});
describe('translator', () => {
it('query translator invoked', async () => {
const translatorSpy = jest.spyOn(testLunrSearchEngine, 'translator');
// Translate query and ensure the translator was invoked.
await testLunrSearchEngine.translator({
term: 'testTerm',
filters: {},
pageCursor: '',
});
expect(translatorSpy).toHaveBeenCalled();
expect(translatorSpy).toHaveBeenCalledWith({
term: 'testTerm',
filters: {},
pageCursor: '',
});
});
it('should return translated query', async () => {
const mockedTranslatedQuery = await testLunrSearchEngine.translator({
term: 'testTerm',
filters: {},
pageCursor: '',
});
expect(mockedTranslatedQuery).toMatchObject({
documentTypes: ['*'],
lunrQueryString: 'testTerm',
});
});
it('should return translated query with 1 filter', async () => {
const mockedTranslatedQuery = await testLunrSearchEngine.translator({
term: 'testTerm',
filters: { kind: 'testKind' },
pageCursor: '',
});
expect(mockedTranslatedQuery).toMatchObject({
documentTypes: ['*'],
lunrQueryString: 'testTerm +kind:testKind',
});
});
it('should return translated query with multiple filters', async () => {
const mockedTranslatedQuery = await testLunrSearchEngine.translator({
term: 'testTerm',
filters: { kind: 'testKind', namespace: 'testNameSpace' },
pageCursor: '',
});
expect(mockedTranslatedQuery).toMatchObject({
documentTypes: ['*'],
lunrQueryString: 'testTerm +kind:testKind +namespace:testNameSpace',
});
});
});
describe('query', () => {
it('should perform search query', async () => {
const querySpy = jest.spyOn(testLunrSearchEngine, 'query');
// Perform search query and ensure the query func was invoked.
const mockedSearchResult = await testLunrSearchEngine.query({
term: 'testTerm',
filters: {},
pageCursor: '',
});
expect(querySpy).toHaveBeenCalled();
expect(querySpy).toHaveBeenCalledWith({
term: 'testTerm',
filters: {},
pageCursor: '',
});
// Should return 0 results as nothing is indexed here
expect(mockedSearchResult).toMatchObject({ results: [] });
});
it('should perform search query and return 0 results on no match', async () => {
const mockDocuments = [
{
title: 'testTitle',
text: 'testText',
location: 'test/location',
},
];
// Mock indexing of 1 document
testLunrSearchEngine.index('test-index', mockDocuments);
// Perform search query
const mockedSearchResult = await testLunrSearchEngine.query({
term: 'testTerm',
filters: {},
pageCursor: '',
});
// Should return 0 results as we are mocking the indexing of 1 document but with no match on the fields
expect(mockedSearchResult).toMatchObject({ results: [] });
});
it('should perform search query and return search results on match', async () => {
const mockDocuments = [
{
title: 'testTitle',
text: 'testText',
location: 'test/location',
},
];
// Mock indexing of 1 document
testLunrSearchEngine.index('test-index', mockDocuments);
// Perform search query
const mockedSearchResult = await testLunrSearchEngine.query({
term: 'testTitle',
filters: {},
pageCursor: '',
});
// Should return 1 result as we are mocking the indexing of 1 document with match on the title field
expect(mockedSearchResult).toMatchObject({
results: [
{
document: {
title: 'testTitle',
text: 'testText',
location: 'test/location',
},
},
],
});
});
it('should perform search query and return search results on match with filters', async () => {
const mockDocuments = [
{
title: 'testTitle',
text: 'testText',
location: 'test/location',
},
{
title: 'testTitle',
text: 'testText',
location: 'test/location2',
},
];
// Mock indexing of 2 documents
testLunrSearchEngine.index('test-index', mockDocuments);
// Perform search query
const mockedSearchResult = await testLunrSearchEngine.query({
term: 'testTitle',
filters: { location: 'test/location2' },
pageCursor: '',
});
// Should return 1 of 2 results as we are
// 1. Mocking the indexing of 2 documents
// 2. Matching on the location field with the filter { location: 'test/location2' }
expect(mockedSearchResult).toMatchObject({
results: [
{
document: {
title: 'testTitle',
text: 'testText',
location: 'test/location2',
},
},
],
});
});
it('should perform search query and return search results on match with filter and not fail on missing field', async () => {
const mockDocuments = [
{
title: 'testTitle',
text: 'testText',
location: 'test/location',
},
];
const mockDocuments2 = [
{
title: 'testTitle',
text: 'testText',
location: 'test/location2',
extraField: 'testExtraField',
},
];
// Mock 2 indices with 1 document each
testLunrSearchEngine.index('test-index', mockDocuments);
testLunrSearchEngine.index('test-index-2', mockDocuments2);
// Perform search query scoped to "test-index-2" with a filter on the field "extraField"
const mockedSearchResult = await testLunrSearchEngine.query({
term: 'testTitle',
pageCursor: '',
filters: { extraField: 'testExtraField' },
});
expect(mockedSearchResult).toMatchObject({
results: [
{
document: {
title: 'testTitle',
text: 'testText',
location: 'test/location2',
extraField: 'testExtraField',
},
},
],
});
});
it('should perform search query and return search results on match, scoped to specific index', async () => {
const mockDocuments = [
{
title: 'testTitle',
text: 'testText',
location: 'test/location',
},
{
title: 'testTitle',
text: 'testText',
location: 'test/location2',
},
];
const mockDocuments2 = [
{
title: 'testTitle',
text: 'testText',
location: 'test/location3',
},
{
title: 'testTitle',
text: 'testText',
location: 'test/location4',
},
];
// Mock 2 indices with 2 documents each
testLunrSearchEngine.index('test-index', mockDocuments);
testLunrSearchEngine.index('test-index-2', mockDocuments2);
// Perform search query scoped to "test-index-2"
const mockedSearchResult = await testLunrSearchEngine.query({
term: 'testTitle',
types: ['test-index-2'],
pageCursor: '',
});
expect(mockedSearchResult).toMatchObject({
results: [
{
document: {
location: 'test/location3',
text: 'testText',
title: 'testTitle',
},
},
{
document: {
location: 'test/location4',
text: 'testText',
title: 'testTitle',
},
},
],
});
});
});
describe('index', () => {
it('should index document', async () => {
const indexSpy = jest.spyOn(testLunrSearchEngine, 'index');
const mockDocuments = [
{
title: 'testTerm',
text: 'testText',
location: 'test/location',
},
];
// call index func and ensure the index func was invoked.
testLunrSearchEngine.index('test-index', mockDocuments);
expect(indexSpy).toHaveBeenCalled();
expect(indexSpy).toHaveBeenCalledWith('test-index', [
{ title: 'testTerm', text: 'testText', location: 'test/location' },
]);
});
});
});
@@ -0,0 +1,134 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
SearchQuery,
IndexableDocument,
SearchResultSet,
} from '@backstage/search-common';
import lunr from 'lunr';
import { Logger } from 'winston';
import { SearchEngine, QueryTranslator } from '../types';
type ConcreteLunrQuery = {
lunrQueryString: string;
documentTypes: string[];
};
export class LunrSearchEngine implements SearchEngine {
protected lunrIndices: Record<string, lunr.Index> = {};
protected docStore: Record<string, IndexableDocument>;
protected logger: Logger;
constructor({ logger }: { logger: Logger }) {
this.logger = logger;
this.docStore = {};
}
translator: QueryTranslator = ({
term,
filters,
types,
}: SearchQuery): ConcreteLunrQuery => {
let lunrQueryFilters;
if (filters) {
lunrQueryFilters = Object.entries(filters)
.map(([key, value]) => ` +${key}:${value}`)
.join('');
}
return {
lunrQueryString: `${term}${lunrQueryFilters || ''}`,
documentTypes: types || ['*'],
};
};
index(type: string, documents: IndexableDocument[]): void {
const lunrBuilder = new lunr.Builder();
// Make this lunr index aware of all relevant fields.
Object.keys(documents[0]).forEach(field => {
lunrBuilder.field(field);
});
// Set "location" field as reference field
lunrBuilder.ref('location');
documents.forEach((document: IndexableDocument) => {
// Add document to Lunar index
lunrBuilder.add(document);
// Store documents in memory to be able to look up document using the ref during query time
// This is not how you should implement your SearchEngine implementation! Do not copy!
this.docStore[document.location] = document;
});
// "Rotate" the index by simply overwriting any existing index of the same name.
this.lunrIndices[type] = lunrBuilder.build();
}
query(query: SearchQuery): Promise<SearchResultSet> {
const { lunrQueryString, documentTypes } = this.translator(
query,
) as ConcreteLunrQuery;
const results: lunr.Index.Result[] = [];
if (documentTypes.length === 1 && documentTypes[0] === '*') {
// Iterate over all this.lunrIndex values.
Object.values(this.lunrIndices).forEach(i => {
try {
results.push(...i.search(lunrQueryString));
} catch (err) {
// if a field does not exist on a index, we can see that as a no-match
if (
err instanceof lunr.QueryParseError &&
err.message.startsWith('unrecognised field')
)
return;
}
});
} else {
// Iterate over the filtered list of this.lunrIndex keys.
Object.keys(this.lunrIndices)
.filter(d => documentTypes.includes(d))
.forEach(d => {
try {
results.push(...this.lunrIndices[d].search(lunrQueryString));
} catch (err) {
// if a field does not exist on a index, we can see that as a no-match
if (
err instanceof lunr.QueryParseError &&
err.message.startsWith('unrecognised field')
)
return;
}
});
}
// Sort results.
results.sort((doc1, doc2) => {
return doc2.score - doc1.score;
});
// Translate results into SearchResultSet
const resultSet: SearchResultSet = {
results: results.map(d => {
return { document: this.docStore[d.ref] };
}),
};
return Promise.resolve(resultSet);
}
}
@@ -0,0 +1,17 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { LunrSearchEngine } from './LunrSearchEngine';
@@ -21,6 +21,7 @@ import {
IndexableDocument,
} from '@backstage/search-common';
import { IndexBuilder } from './IndexBuilder';
import { LunrSearchEngine, SearchEngine } from './index';
class TestDocumentCollator implements DocumentCollator {
async execute() {
@@ -35,12 +36,18 @@ class TestDocumentDecorator implements DocumentDecorator {
}
describe('IndexBuilder', () => {
let testSearchEngine: SearchEngine;
let testIndexBuilder: IndexBuilder;
let testCollator: DocumentCollator;
let testDecorator: DocumentDecorator;
beforeEach(() => {
testIndexBuilder = new IndexBuilder({ logger: getVoidLogger() });
const logger = getVoidLogger();
testSearchEngine = new LunrSearchEngine({ logger });
testIndexBuilder = new IndexBuilder({
logger,
searchEngine: testSearchEngine,
});
testCollator = new TestDocumentCollator();
testDecorator = new TestDocumentDecorator();
});
+2
View File
@@ -15,3 +15,5 @@
*/
export { IndexBuilder } from './IndexBuilder';
export { LunrSearchEngine } from './engines';
export type { SearchEngine } from './types';
+24 -1
View File
@@ -14,7 +14,13 @@
* limitations under the License.
*/
import { DocumentCollator, DocumentDecorator } from '@backstage/search-common';
import {
DocumentCollator,
DocumentDecorator,
IndexableDocument,
SearchQuery,
SearchResultSet,
} from '@backstage/search-common';
/**
* Parameters required to register a collator.
@@ -51,3 +57,20 @@ export interface RegisterDecoratorParameters {
*/
types?: string[];
}
/**
* A type of function responsible for translating an abstract search query into
* a concrete query relevant to a particular search engine.
*/
export type QueryTranslator = (query: SearchQuery) => unknown;
/**
* Interface that must be implemented by specific search engines, responsible
* for performing indexing and querying and translating abstract queries into
* concrete, search engine-specific queries.
*/
export interface SearchEngine {
translator: QueryTranslator;
index(type: string, documents: IndexableDocument[]): void;
query(query: SearchQuery): Promise<SearchResultSet>;
}
+1
View File
@@ -21,6 +21,7 @@
"dependencies": {
"@backstage/backend-common": "^0.6.0",
"@backstage/search-common": "^0.1.1",
"@backstage/plugin-search-backend-node": "^0.1.2",
"@types/express": "^4.17.6",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
@@ -15,6 +15,10 @@
*/
import { getVoidLogger } from '@backstage/backend-common';
import {
IndexBuilder,
LunrSearchEngine,
} from '@backstage/plugin-search-backend-node';
import express from 'express';
import request from 'supertest';
@@ -24,8 +28,12 @@ describe('createRouter', () => {
let app: express.Express;
beforeAll(async () => {
const logger = getVoidLogger();
const searchEngine = new LunrSearchEngine({ logger });
const indexBuilder = new IndexBuilder({ logger, searchEngine });
const router = await createRouter({
logger: getVoidLogger(),
engine: indexBuilder.getSearchEngine(),
logger,
});
app = express().use(router);
});
+8 -8
View File
@@ -18,23 +18,24 @@ import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import { SearchQuery, SearchResultSet } from '@backstage/search-common';
import { SearchEngine } from '@backstage/plugin-search-backend-node';
type RouterOptions = {
engine: SearchEngine;
logger: Logger;
};
export async function createRouter({
engine,
logger,
}: RouterOptions): Promise<express.Router> {
const router = Router();
router.get(
'/query',
async (
req: express.Request<any, unknown, unknown, SearchQuery>,
res: express.Response<SearchResultSet>,
) => {
// TODO: Actually transform req.params into search engine specific query.
const { term, filters = {}, pageCursor = '' } = req.query;
logger.info(
`Search request received: ${term}, ${JSON.stringify(
@@ -43,13 +44,12 @@ export async function createRouter({
);
try {
// TODO: Actually query search engine.
// TODO: And actually transform results into frontend-readable result
res.send({
results: [],
});
const results = await engine?.query(req.query);
res.send(results);
} catch (err) {
throw new Error(`There was a problem performing the search query.`);
throw new Error(
`There was a problem performing the search query. ${err}`,
);
}
},
);
@@ -18,6 +18,10 @@ import { createServiceBuilder } from '@backstage/backend-common';
import { Server } from 'http';
import { Logger } from 'winston';
import { createRouter } from './router';
import {
LunrSearchEngine,
IndexBuilder,
} from '@backstage/plugin-search-backend-node';
export interface ServerOptions {
port: number;
@@ -29,8 +33,14 @@ export async function startStandaloneServer(
options: ServerOptions,
): Promise<Server> {
const logger = options.logger.child({ service: 'search-backend' });
const searchEngine = new LunrSearchEngine({ logger });
const indexBuilder = new IndexBuilder({ logger, searchEngine });
logger.debug('Starting application server...');
// TODO: stub out some documents/indices?
const router = await createRouter({
engine: indexBuilder.getSearchEngine(),
logger,
});
+10
View File
@@ -6210,6 +6210,11 @@
resolved "https://registry.npmjs.org/@types/lru-cache/-/lru-cache-5.1.0.tgz#57f228f2b80c046b4a1bd5cac031f81f207f4f03"
integrity sha512-RaE0B+14ToE4l6UqdarKPnXwVDuigfFv+5j9Dze/Nqr23yyuqdNvzcZi3xB+3Agvi5R4EOgAksfv3lXX4vBt9w==
"@types/lunr@^2.3.3":
version "2.3.3"
resolved "https://registry.npmjs.org/@types/lunr/-/lunr-2.3.3.tgz#ec985618fd2712c010f8edab4f1ae7784ad7c583"
integrity sha512-09sXZZVsB3Ib41U0fC+O1O+4UOZT1bl/e+/QubPxpqDWHNEchvx/DEb1KJMOwq6K3MTNzZFoNSzVdR++o1DVnw==
"@types/luxon@^1.25.0":
version "1.26.0"
resolved "https://registry.npmjs.org/@types/luxon/-/luxon-1.26.0.tgz#8e783986370ad3bb9f885d93eb1a91caeecaed36"
@@ -17927,6 +17932,11 @@ lru-queue@^0.1.0:
dependencies:
es5-ext "~0.10.2"
lunr@^2.3.9:
version "2.3.9"
resolved "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz#18b123142832337dd6e964df1a5a7707b25d35e1"
integrity sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==
luxon@1.25.0, luxon@^1.25.0:
version "1.25.0"
resolved "https://registry.npmjs.org/luxon/-/luxon-1.25.0.tgz#d86219e90bc0102c0eb299d65b2f5e95efe1fe72"