3 Commits

Author SHA1 Message Date
73a4f4e595 chore: convert everything to vue sfc 2018-09-08 17:39:46 -07:00
421ec9f0db chore: add and configure poi 2018-09-08 16:25:05 -07:00
49ef9a6155 chore: fix linting script 2018-09-08 16:21:20 -07:00
24 changed files with 275 additions and 458 deletions

View File

@@ -1,3 +0,0 @@
### Authors
- joe fleming ([w33ble](https://github.com/w33ble))

View File

@@ -1,29 +0,0 @@
### Changelog
#### [v1.1.0](https://git.w33ble.com/w33ble/strain-tools/compare/v1.0.2...v1.1.0) (18 September 2018)
- feat: basic routing and fav nav [`3b39345`](https://git.w33ble.com/w33ble/strain-tools/commit/3b39345fa66ac4748a400e9e80e032ec9ae9697b)
- feat: show favorite strain cards [`85efbd8`](https://git.w33ble.com/w33ble/strain-tools/commit/85efbd84d1704c55a3eefae7fa2d36e7fd7f3cc2)
- fix: weird bug in StrainList [`2c60ec4`](https://git.w33ble.com/w33ble/strain-tools/commit/2c60ec4afc16f81fd6b610443091d26d278d3f81)
#### [v1.0.2](https://git.w33ble.com/w33ble/strain-tools/compare/v1.0.1...v1.0.2) (13 September 2018)
#### [v1.0.1](https://git.w33ble.com/w33ble/strain-tools/compare/v1.0.0...v1.0.1) (13 September 2018)
- fix: correct localstorage fallback [`d5b0cd0`](https://git.w33ble.com/w33ble/strain-tools/commit/d5b0cd07307dbf06d8d4530803a00c2b2ab8c4c5)
- docs: update readme with usable scripts [`5bdd35b`](https://git.w33ble.com/w33ble/strain-tools/commit/5bdd35b74b417706c76bd50d7162567b60dbdcb2)
- fix: scrape strains alphabetically [`fa2db8d`](https://git.w33ble.com/w33ble/strain-tools/commit/fa2db8d4304b0b0d91221b4ef942daa74ab3df94)
#### v1.0.0 (9 September 2018)
- fix: assign key to strain cards [`e3e95e7`](https://git.w33ble.com/w33ble/strain-tools/commit/e3e95e7c2b390ca52633e84604cf6a4a9b239d54)
- fix: remove listener on form unmount [`4591a47`](https://git.w33ble.com/w33ble/strain-tools/commit/4591a47dbaa62cbd330bbc9b96ace2be1ace8bd6)
- feat: add fav/unfav control [`50a3b1d`](https://git.w33ble.com/w33ble/strain-tools/commit/50a3b1db92a64fcd7e10e35ba045a3970a1440ba)
- feat: adjust card tag label by screen size [`5665bde`](https://git.w33ble.com/w33ble/strain-tools/commit/5665bdeaa99bcbf4fc5a9d13d98803b17a42404b)
- feat: sort by adjusted rating [`712c5c0`](https://git.w33ble.com/w33ble/strain-tools/commit/712c5c095c52269d7ffbd7f763cdf325b11369a4)
- feat: show errors, sort tags [`943c091`](https://git.w33ble.com/w33ble/strain-tools/commit/943c0917c71db1612aefd9f2fc8bde098000364c)
- feat: functional search on site [`d3b2110`](https://git.w33ble.com/w33ble/strain-tools/commit/d3b21103eb5227101f581fcc853714bc78ea9950)
- feat: even more site functionality [`9a1f2f9`](https://git.w33ble.com/w33ble/strain-tools/commit/9a1f2f9785e36bd2de2a3a1fbf46bbc9cfdb90d3)
- feat: add tags to scraper output [`804fac1`](https://git.w33ble.com/w33ble/strain-tools/commit/804fac1afb68dc52f482cbfa393856b14b64400a)
- feat: mocked up search form on site [`22a95c3`](https://git.w33ble.com/w33ble/strain-tools/commit/22a95c3fe827ec0651c44164f58950dcc283354b)
- feat: simple site build and dev server [`699a03c`](https://git.w33ble.com/w33ble/strain-tools/commit/699a03c7c06da011b64bdc358287f5d653c96c92)
- feat: add a search site package [`37dcabd`](https://git.w33ble.com/w33ble/strain-tools/commit/37dcabde5e20a6ecea803f40798ae43c72e3afe8)
- feat: working scraper [`cc5e15d`](https://git.w33ble.com/w33ble/strain-tools/commit/cc5e15dbb11f388ba260da6c196fe6827e947d3c)
- initial commit [`c81b30b`](https://git.w33ble.com/w33ble/strain-tools/commit/c81b30be896009b48e66005007f88e55fb159c9c)

View File

@@ -1,16 +1,14 @@
{
"name": "strain-tools",
"version": "1.1.0",
"version": "0.0.0",
"description": "strain tools",
"main": "index",
"module": "index.mjs",
"private": true,
"scripts": {
"lint": "eslint \"packages/*/*.{js,mjs,vue}\" \"packages/*/src/**/*.{js,mjs,vue}\"",
"lint": "eslint \"packages/**/*.{js,mjs,vue}\" \"packages/**/src/**/*.{js,mjs,vue}\"",
"precommit": "lint-staged",
"prepush": "npm run lint",
"sync-versions": "node -r esm scripts/version-sync.mjs",
"version": "npm run sync-versions && auto-changelog -p && auto-authors && git add CHANGELOG.md AUTHORS.md packages",
"version": "auto-changelog -p && auto-authors && git add CHANGELOG.md AUTHORS.md",
"start": "node .",
"dev": "nodemon --ignore db.json ."
},
@@ -33,10 +31,10 @@
"commitLimit": false
},
"lint-staged": {
"*.{js,mjs,vue}": [
"*.{js,mjs}": [
"eslint --fix"
],
"*.{js,mjs,vue,json,css}": [
"*.{js,mjs,json,css}": [
"prettier --write"
]
},

View File

@@ -8,3 +8,5 @@ Clone repo and run the command. Resulting data can be found in `db.json`.
yarn install
yarn start
```
**NOTE**: You may need to run it multiple times (4 or 5 should do it), since some strains will get skipped the first few times. I don't know if it's leafly's endpoint or some weird race condition in the scraping code though.

View File

@@ -1,6 +1,6 @@
{
"name": "scraper",
"version": "1.1.0",
"name": "leafly-scraper",
"version": "0.0.0",
"private": true,
"description": "scrapes strain info, stores for later reference",
"main": "index",
@@ -29,7 +29,7 @@
},
"dependencies": {
"axios": "^0.18.0",
"esm": "^3.0.82",
"esm": "^3.0.81",
"lodash": "^4.17.10",
"lowdb": "^1.0.0"
},

View File

@@ -8,14 +8,14 @@ const adapter = new FileAsync('db.json');
const xhr = axios.create({
headers: {
Accept: 'application/json, text/plain, */*',
Referer: 'https://www.leafly.com/explore/sort-alpha',
Referer: 'https://www.leafly.com/explore',
},
});
const pSeries = tasks => tasks.reduce((c, task) => c.then(task), Promise.resolve());
const getPage = async num => {
const url = `https://www.leafly.com/explore/page-${num}/sort-alpha`;
const url = `https://www.leafly.com/explore/page-${num}`;
const response = await xhr.get(url, {
responseType: 'json',
});

View File

@@ -6,12 +6,6 @@ Strain search static website. Use it to search for strains by name, effects, med
Based on data from leafly. You'll need to run the scraper first since that's where the data comes from.
## Usage
- `yarn start`: starts the dev server, with HMR
- `yarn build`: builds the static site into the `dist` path
- `yarn deploy`: builds and deploys the app onto firebase
#### License
MIT © [w33ble](https://github.com/w33ble)

View File

@@ -1,6 +1,6 @@
{
"name": "search-site",
"version": "1.1.0",
"version": "0.0.0",
"private": true,
"main": "index",
"module": "index.mjs",
@@ -16,8 +16,10 @@
"cjs": true
},
"dependencies": {
"esm": "^3.0.82",
"ejs": "^2.6.1",
"esm": "^3.0.81",
"lunr": "^2.3.3",
"mitt": "^1.1.3",
"vue": "^2.5.17",
"vue-router": "^3.0.1",
"vue-template-compiler": "^2.5.17"
@@ -27,6 +29,7 @@
"babel-eslint": "^9.0.0",
"eslint-plugin-vue": "^4.7.1",
"firebase-tools": "^4.2.1",
"nodemon": "^1.18.4",
"poi": "^10.2.10"
}
}

View File

@@ -4,9 +4,5 @@ const path = require('path');
module.exports = {
entry: path.resolve(__dirname, 'src/index.mjs'),
outDir: 'dist',
plugins: [
require('@poi/plugin-vue-static')({
routes: ['/', '/favorites'],
}),
],
plugins: [require('@poi/plugin-vue-static')()],
};

View File

@@ -1,14 +0,0 @@
<template>
<div id="app-nav" class="tabs">
<ul>
<li class="is-active"><a>Search</a></li>
<li><a>Favorites</a></li>
</ul>
</div>
</template>
<script>
export default {
name: 'AppNav',
};
</script>

View File

@@ -1,5 +1,10 @@
<template>
<form @submit.prevent="handleSubmit">
<div class="container">
<h1 class="title">
Strain Search
</h1>
<!-- Name Search Input -->
<div class="field">
<label class="label">Search By Name</label>
@@ -72,10 +77,13 @@
<button type="button" class="button is-text" @click.prevent="resetForm">Clear</button>
</div>
</div>
</div>
</form>
</template>
<script>
import emitter from '../lib/emitter.mjs';
const getMultiValues = node => Array.from(node.selectedOptions).map(o => o.value);
export default {
@@ -84,10 +92,17 @@ export default {
uses: Array,
conditions: Array,
flavors: Array,
error: {
type: String,
default: '',
},
data() {
return {
error: '',
};
},
created() {
emitter.on('error', this.setError);
},
beforeDestroy() {
emitter.off('error', this.setError);
},
methods: {
handleSubmit() {
@@ -99,11 +114,15 @@ export default {
flavors: getMultiValues(this.$refs.flavors),
};
this.$emit('search', requirements);
this.error = '';
emitter.emit('search', requirements);
},
resetForm() {
this.$el.reset();
},
setError(msg) {
this.error = msg;
},
},
};
</script>

View File

@@ -31,6 +31,7 @@
<script>
import store from '../lib/store.mjs';
import emitter from '../lib/emitter.mjs';
import TagList from './TagList.vue';
export default {
@@ -49,22 +50,8 @@ export default {
},
methods: {
toggleFavorite() {
const isFav = !this.favorite;
const { id } = this.strain;
const favs = store.get('favorites') || [];
const idx = favs.indexOf(id);
// update component state
this.favorite = isFav;
// update the store
if (idx >= 0 && !isFav) {
// remove previously favorited strain
store.set('favorites', favs.filter(f => f !== id));
} else if (idx === -1 && isFav) {
// add new favorited strain
store.set('favorites', favs.concat(id));
}
this.favorite = !this.favorite;
emitter.emit('favorite', { id: this.strain.id, isFav: this.favorite });
},
},
};

View File

@@ -1,8 +1,8 @@
<template>
<div class="container">
<div>
<div>
<h3 v-show="strains.length === 0" class="title is-3">No Matching Strains :(</h3>
<h3 v-show="strains.length > 0" class="title is-3">Found {{strains.length}} Strains</h3>
<h3 v-if="strains.length === 0" class="title is-3">No Matching Strains :(</h3>
<h3 v-if="strains.length > 0" class="title is-3">Found {{strains.length}} Strains</h3>
</div>
<div class="cards">

View File

@@ -1,12 +0,0 @@
import Vue from 'vue';
Vue.filter('capitalize', value => {
if (!value) return '';
const v = value.toString();
return v.charAt(0).toUpperCase() + v.slice(1);
});
Vue.filter('round', (value, digits = 2) => {
const v = parseFloat(value, 10);
return Math.round(v * (10 * digits)) / (10 * digits);
});

View File

@@ -1,44 +1,26 @@
import Vue from 'vue';
import Router from 'vue-router';
import './filters.mjs';
import strainData from './plugins/strainData.mjs';
import lunr from './plugins/lunr.mjs';
Vue.use(strainData);
Vue.use(
lunr(function lunrSetup() {
// lunr search index setup
this.ref('id');
this.field('name');
this.field('effects');
this.field('uses');
this.field('conditions');
this.field('flavors');
Vue.prototype.$strainData.strains.forEach(doc => this.add(doc));
})
);
Vue.use(Router);
Vue.filter('capitalize', value => {
if (!value) return '';
const v = value.toString();
return v.charAt(0).toUpperCase() + v.slice(1);
});
Vue.filter('round', (value, digits = 2) => {
const v = parseFloat(value, 10);
return Math.round(v * (10 * digits)) / (10 * digits);
});
const router = new Router({
mode: 'history',
routes: [
{
path: '/',
component: () => import(/* webpackChunkName: "Home" */ './pages/Home.vue'),
children: [
{
path: '',
name: 'search',
component: () => import(/* webpackChunkName: "Search" */ './pages/Search.vue'),
},
{
path: 'favorites',
name: 'favorites',
component: () => import(/* webpackChunkName: "Favorites" */ './pages/Favorites.vue'),
},
],
name: 'home',
component: () => import(/* webpackChunkName: "homeapp" */ './pages/Home.vue'),
},
],
});

View File

@@ -0,0 +1,5 @@
import mitt from 'mitt';
const emitter = mitt();
export default emitter;

View File

@@ -1,15 +1,14 @@
/* eslint-env browser */
const storage = (() => {
try {
return window.localStorage;
} catch (err) {
// return localstorage in the browser env
if (this && this.localStorage) return this.localStorage;
// return a mock localstorage in the server env
return {
getItem: () => null,
setItem: () => null,
};
}
})();
export default {

View File

@@ -1,43 +0,0 @@
<template>
<div id="favorites-page">
<div class="container">
<h1 class="title">
Favorites
</h1>
<h3 class="title is-5">
Strains you have saved
</h3>
</div>
<section class="section">
<div class="container">
<StrainList :strains="favoriteStrains" no-save-control />
</div>
</section>
</div>
</template>
<script>
import store from '../lib/store.mjs';
import StrainList from '../components/StrainList.vue';
export default {
name: 'FavoritesPage',
components: {
StrainList,
},
data() {
const favorites = store.get('favorites') || [];
return {
favorites,
};
},
computed: {
favoriteStrains() {
return this.$strainData.strains.filter(strain => this.favorites.indexOf(strain.id) !== -1);
},
},
};
</script>

View File

@@ -1,31 +1,27 @@
<template>
<div id="home-page">
<div id="app-nav" class="tabs is-centered">
<ul>
<li :class="{'is-active': activeTab === 'search'}" @click="setActive('search')">
<router-link :to="{name: 'search'}">Search</router-link>
</li>
<li :class="{'is-active': activeTab === 'favorites'}" @click="setActive('favorites')">
<router-link :to="{name: 'favorites'}">Favorites</router-link>
</li>
</ul>
</div>
<router-view></router-view>
<div>
<section class="section">
<SearchForm :effects="effects" :uses="uses" :conditions="conditions" :flavors="flavors" />
</section>
<section class="section">
<StrainList :strains="matches" />
</section>
</div>
</template>
<script>
import lunr from 'lunr';
import SearchForm from '../components/SearchForm.vue';
import StrainList from '../components/StrainList.vue';
import data from '../../../scraper/db.json';
import emitter from '../lib/emitter.mjs';
import store from '../lib/store.mjs';
export default {
name: 'HomePage',
data() {
return {
activeTab: this.$route.name,
};
},
methods: {
setActive(name) {
this.activeTab = name;
},
name: 'Home',
components: {
SearchForm,
StrainList,
},
head: {
title: 'Strain Search',
@@ -36,5 +32,118 @@ export default {
},
],
},
data() {
return {
...data,
matches: [],
requirements: {
name: '',
effects: [],
uses: [],
conditions: [],
flavors: [],
},
};
},
computed: {
hasName() {
return this.requirements.name.length > 0;
},
hasFilters() {
return (
this.requirements.effects.length > 0 ||
this.requirements.uses.length > 0 ||
this.requirements.conditions.length > 0 ||
this.requirements.flavors.length > 0
);
},
searchParams() {
const searchParts = [this.requirements.name];
this.requirements.effects.forEach(t => searchParts.push(`+effects:${t}`));
this.requirements.uses.forEach(t => searchParts.push(`+uses:${t}`));
this.requirements.conditions.forEach(t => searchParts.push(`+conditions:${t}`));
this.requirements.flavors.forEach(t => searchParts.push(`+flavors:${t}`));
return searchParts.join(' ');
},
},
methods: {
showDefaults(limit = 40) {
// const favs = store.get('favorites') || [];
// const favStrains = this.strains.filter(({ id }) => favs.indexOf(id) !== -1);
// this.matches = favStrains.concat(this.strains.slice(0, limit - favStrains.length));
this.matches = this.strains.slice(0, limit);
},
updateMatches(limit = 40) {
if (!this.hasName && !this.hasFilters) {
this.showDefaults(limit);
return;
}
try {
const hits = this.idx.search(this.searchParams);
// .slice(0, limit);
const refs = hits.map(({ ref }) => parseInt(ref, 10));
this.matches = this.strains
.map(strain => {
const idx = refs.indexOf(strain.id);
if (idx < 0) return null;
return Object.assign({ score: hits[idx].score }, strain);
})
.filter(Boolean)
.sort((a, b) => {
if (a.score === b.score) {
// sort matching search score by adjusted rating
if (a.rating_adjusted === b.rating_adjusted) return 0;
return a.rating_adjusted > b.rating_adjusted ? -1 : 1;
}
return a.score > b.score ? -1 : 1;
});
} catch (err) {
this.matches = [];
emitter.emit('error', err.message);
}
},
},
created() {
// lunr setup
this.idx = lunr(function lunrSetup() {
this.ref('id');
this.field('name');
this.field('effects');
this.field('uses');
this.field('conditions');
this.field('flavors');
data.strains.forEach(doc => this.add(doc));
});
// function to handle search form submissions
this.searchListener = reqs => {
this.requirements = reqs;
this.updateMatches();
};
// listen for search form submissions
emitter.on('search', r => this.searchListener(r));
// listen for favorite changes
emitter.on('favorite', ({ id, isFav }) => {
const favs = store.get('favorites') || [];
const idx = favs.indexOf(id);
// remove previously favorited strain
if (idx >= 0 && !isFav) {
store.set('favorites', favs.filter(f => f !== id));
} else if (idx === -1 && isFav) {
store.set('favorites', favs.concat(id));
}
});
this.updateMatches(); // set initial match list
},
beforeDestroy() {
emitter.off('search', r => this.searchListener(r));
},
};
</script>

View File

@@ -1,140 +0,0 @@
<template>
<div id="search-page">
<div class="container">
<h1 class="title">
Strain Search
</h1>
</div>
<section class="section">
<div class="container">
<SearchForm
:effects="effects"
:uses="uses"
:conditions="conditions"
:flavors="flavors"
:error="error"
@search="onSearch"
/>
</div>
</section>
<section class="section">
<div class="container">
<StrainList :strains="matches" />
</div>
</section>
</div>
</template>
<script>
import SearchForm from '../components/SearchForm.vue';
import StrainList from '../components/StrainList.vue';
export default {
name: 'SearchPage',
components: {
SearchForm,
StrainList,
},
data() {
return {
error: '',
matches: [],
requirements: {
name: '',
effects: [],
uses: [],
conditions: [],
flavors: [],
},
};
},
computed: {
strains() {
return this.$strainData.strains;
},
effects() {
return this.$strainData.effects;
},
uses() {
return this.$strainData.uses;
},
conditions() {
return this.$strainData.conditions;
},
flavors() {
return this.$strainData.flavors;
},
hasName() {
return this.requirements.name.length > 0;
},
hasFilters() {
return (
this.requirements.effects.length > 0 ||
this.requirements.uses.length > 0 ||
this.requirements.conditions.length > 0 ||
this.requirements.flavors.length > 0
);
},
searchParams() {
const searchParts = [this.requirements.name];
this.requirements.effects.forEach(t => searchParts.push(`+effects:${t}`));
this.requirements.uses.forEach(t => searchParts.push(`+uses:${t}`));
this.requirements.conditions.forEach(t => searchParts.push(`+conditions:${t}`));
this.requirements.flavors.forEach(t => searchParts.push(`+flavors:${t}`));
return searchParts.join(' ');
},
},
methods: {
onSearch(reqs) {
this.requirements = reqs;
this.updateMatches();
},
showDefaults(limit = 40) {
// const favs = store.get('favorites') || [];
// const favStrains = this.strains.filter(({ id }) => favs.indexOf(id) !== -1);
// this.matches = favStrains.concat(this.strains.slice(0, limit - favStrains.length));
this.matches = this.strains.slice(0, limit);
},
updateMatches(limit = 40) {
this.error = '';
if (!this.hasName && !this.hasFilters) {
this.showDefaults(limit);
return;
}
try {
const hits = this.$lunr.search(this.searchParams);
// .slice(0, limit);
const refs = hits.map(({ ref }) => parseInt(ref, 10));
this.matches = this.strains
.map(strain => {
const idx = refs.indexOf(strain.id);
if (idx < 0) return null;
return Object.assign({ score: hits[idx].score }, strain);
})
.filter(Boolean)
.sort((a, b) => {
if (a.score === b.score) {
// sort matching search score by adjusted rating
if (a.rating_adjusted === b.rating_adjusted) return 0;
return a.rating_adjusted > b.rating_adjusted ? -1 : 1;
}
return a.score > b.score ? -1 : 1;
});
} catch (err) {
this.matches = [];
this.error = err.message;
}
},
},
created() {
this.updateMatches(); // set initial match list
},
};
</script>

View File

@@ -1,10 +0,0 @@
/* eslint no-param-reassign: 0 */
import lunr from 'lunr';
export default function(lunrSetup) {
return {
install(Vue) {
Vue.prototype.$lunr = lunr(lunrSetup);
},
};
}

View File

@@ -1,8 +0,0 @@
/* eslint no-param-reassign: 0 */
import data from '../../../scraper/db.json';
export default {
install(Vue) {
Vue.prototype.$strainData = data;
},
};

View File

@@ -1,26 +0,0 @@
import fs from 'fs';
import { promisify } from 'util';
import { join } from 'path';
import pkg from '../package.json';
const readDir = promisify(fs.readdir);
const readFile = promisify(fs.readFile);
const writeFile = promisify(fs.writeFile);
async function syncPackageVersions() {
const packagesPath = 'packages'; // path for all packages
const { version } = pkg;
const packages = await readDir(packagesPath);
packages.forEach(async pack => {
const packagePath = join(packagesPath, pack, 'package.json');
const p = JSON.parse(await readFile(packagePath, 'utf-8'));
p.version = version;
await writeFile(packagePath, `${JSON.stringify(p, null, 2)}\n`);
});
console.log(`Versions with root: ${version}`);
}
syncPackageVersions();

View File

@@ -2859,6 +2859,10 @@ ee-first@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
ejs@^2.6.1:
version "2.6.1"
resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.6.1.tgz#498ec0d495655abc6f23cd61868d926464071aa0"
electron-to-chromium@^1.2.7, electron-to-chromium@^1.3.62:
version "1.3.64"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.64.tgz#39f5a93bf84ab7e10cfbb7522ccfc3f1feb756cf"
@@ -3152,9 +3156,9 @@ eslint@^4.9.0:
table "4.0.2"
text-table "~0.2.0"
esm@^3.0.82:
version "3.0.82"
resolved "https://registry.yarnpkg.com/esm/-/esm-3.0.82.tgz#e8c4363a14e30936bedf49a44bf962e2c91866ff"
esm@^3.0.81:
version "3.0.81"
resolved "https://registry.yarnpkg.com/esm/-/esm-3.0.81.tgz#3d78df013960b6d30bb5a5adfafe5d9da654b9d2"
espree@^3.5.2, espree@^3.5.4:
version "3.5.4"
@@ -5692,6 +5696,10 @@ mississippi@^2.0.0:
stream-each "^1.1.0"
through2 "^2.0.0"
mitt@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/mitt/-/mitt-1.1.3.tgz#528c506238a05dce11cd914a741ea2cc332da9b8"
mixin-deep@^1.2.0:
version "1.3.1"
resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.1.tgz#a49e7268dce1a0d9698e45326c5626df3543d0fe"