@ca-plant-list/ca-plant-list 0.4.4 → 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -1
- package/data/config.json +5 -0
- package/data/inattaxonphotos.csv +6059 -0
- package/data/taxa.csv +1 -0
- package/eslint.config.mjs +13 -0
- package/jekyll/index.md +3 -0
- package/lib/ebook/plantbook.js +1 -1
- package/lib/inat_photo.js +43 -0
- package/lib/photo.js +44 -0
- package/lib/taxa.js +37 -3
- package/lib/taxon.js +13 -0
- package/lib/util.js +20 -0
- package/lib/web/pagetaxon.js +28 -0
- package/package.json +5 -2
- package/scripts/build-site.js +1 -1
- package/scripts/inattaxonphotos.js +106 -0
- package/types/classes.d.ts +47 -1
- package/.vscode/settings.json +0 -12
package/data/taxa.csv
CHANGED
@@ -114,6 +114,7 @@ Antirrhinum kelloggii,,N,13568,401,165675
|
|
114
114
|
Antirrhinum majus,snapdragon,X,13557,404,48969
|
115
115
|
Antirrhinum thompsonii,Sierra snapdragon,N,108978,14286,168306,,pink,4,8
|
116
116
|
Antirrhinum vexillocalyculatum subsp. vexillocalyculatum,,N,88873,10499,840919,annual,purple,6,8
|
117
|
+
Aphanes occidentalis,western lady's mantle,N,13608,,1452907,annual,,3,5
|
117
118
|
Aphyllon californicum subsp. jepsonii,,N,103325,13438,802459
|
118
119
|
Aphyllon epigalium subsp. epigalium,,N,103327,13527,809377
|
119
120
|
Aphyllon fasciculatum,clustered broom-rape,N,100018,13441,802543
|
package/jekyll/index.md
CHANGED
package/lib/ebook/plantbook.js
CHANGED
@@ -167,7 +167,7 @@ class PlantBook extends EBook {
|
|
167
167
|
function getRequiredConfigValue(config, name) {
|
168
168
|
const value = config.getConfigValue("ebook", name);
|
169
169
|
if (value === undefined) {
|
170
|
-
throw new Error();
|
170
|
+
throw new Error(`Failed to find ebook config for ${name}`);
|
171
171
|
}
|
172
172
|
return value;
|
173
173
|
}
|
@@ -0,0 +1,43 @@
|
|
1
|
+
import {
|
2
|
+
CC0,
|
3
|
+
CC_BY,
|
4
|
+
CC_BY_NC,
|
5
|
+
COPYRIGHT,
|
6
|
+
Photo,
|
7
|
+
} from "./photo.js";
|
8
|
+
|
9
|
+
class InatPhoto extends Photo {
|
10
|
+
/** @type {number} */
|
11
|
+
inatPhotoId;
|
12
|
+
/** @type {string} */
|
13
|
+
ext;
|
14
|
+
|
15
|
+
/**
|
16
|
+
* @param {number} id
|
17
|
+
* @param {string} ext
|
18
|
+
* @param {InatLicenseCode} licenseCode
|
19
|
+
* @param {string} attrName
|
20
|
+
*/
|
21
|
+
constructor( id, ext, licenseCode, attrName ) {
|
22
|
+
/** @type {typeof COPYRIGHT | typeof CC_BY | typeof CC_BY_NC | typeof CC0} */
|
23
|
+
let rights = COPYRIGHT;
|
24
|
+
if ( licenseCode === "cc0" ) rights = CC0;
|
25
|
+
else if ( licenseCode === "cc-by" ) rights = CC_BY;
|
26
|
+
else if ( licenseCode === "cc-by-nc" ) rights = CC_BY_NC;
|
27
|
+
super( null, attrName, rights );
|
28
|
+
this.inatPhotoId = id;
|
29
|
+
this.ext = ext;
|
30
|
+
}
|
31
|
+
|
32
|
+
getUrl() {
|
33
|
+
return `https://inaturalist-open-data.s3.amazonaws.com/photos/${this.inatPhotoId}/medium.${this.ext}`;
|
34
|
+
}
|
35
|
+
|
36
|
+
getSourceUrl() {
|
37
|
+
return `https://www.inaturalist.org/photos/${this.inatPhotoId}`;
|
38
|
+
}
|
39
|
+
}
|
40
|
+
|
41
|
+
export {
|
42
|
+
InatPhoto
|
43
|
+
};
|
package/lib/photo.js
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
const CC0 = "CC0";
|
2
|
+
const CC_BY = "CC BY";
|
3
|
+
const CC_BY_NC = "CC BY-NC";
|
4
|
+
const COPYRIGHT = "C";
|
5
|
+
|
6
|
+
class Photo {
|
7
|
+
/** @type {string?} */
|
8
|
+
#url;
|
9
|
+
/** @type {string?} */
|
10
|
+
rightsHolder;
|
11
|
+
/** @type {null | typeof COPYRIGHT | typeof CC_BY | typeof CC_BY_NC | typeof CC0} */
|
12
|
+
rights;
|
13
|
+
|
14
|
+
/**
|
15
|
+
* @param {string?} url
|
16
|
+
* @param {string?} rightsHolder
|
17
|
+
* @param {null | typeof COPYRIGHT | typeof CC_BY | typeof CC_BY_NC | typeof CC0} rights
|
18
|
+
*/
|
19
|
+
constructor( url, rightsHolder, rights ) {
|
20
|
+
this.#url = url;
|
21
|
+
this.rightsHolder = rightsHolder;
|
22
|
+
this.rights = rights;
|
23
|
+
}
|
24
|
+
|
25
|
+
getUrl() {
|
26
|
+
return this.#url;
|
27
|
+
}
|
28
|
+
|
29
|
+
/**
|
30
|
+
* Return URL of page from whence this photo came
|
31
|
+
* @return {string?}
|
32
|
+
*/
|
33
|
+
getSourceUrl() {
|
34
|
+
return null;
|
35
|
+
}
|
36
|
+
}
|
37
|
+
|
38
|
+
export {
|
39
|
+
CC0,
|
40
|
+
CC_BY,
|
41
|
+
CC_BY_NC,
|
42
|
+
COPYRIGHT,
|
43
|
+
Photo
|
44
|
+
};
|
package/lib/taxa.js
CHANGED
@@ -1,9 +1,13 @@
|
|
1
|
+
import * as fs from "node:fs";
|
2
|
+
import path from "node:path";
|
3
|
+
|
1
4
|
import { Config } from "./config.js";
|
2
5
|
import { CSV } from "./csv.js";
|
3
6
|
import { Genera } from "./genera.js";
|
4
7
|
import { Taxon } from "./taxon.js";
|
5
8
|
import { Families } from "./families.js";
|
6
9
|
import { FlowerColor } from "./flowercolor.js";
|
10
|
+
import { InatPhoto } from "./inat_photo.js";
|
7
11
|
|
8
12
|
const FLOWER_COLORS = [
|
9
13
|
{ name: "white", color: "white" },
|
@@ -35,6 +39,7 @@ class Taxa {
|
|
35
39
|
* @param {function(TaxonData,Genera):Taxon} taxonFactory
|
36
40
|
* @param {TaxonData[]} [extraTaxa=[]]
|
37
41
|
* @param {SynonymData[]} [extraSynonyms=[]]
|
42
|
+
* @param {boolean} includePhotos
|
38
43
|
*/
|
39
44
|
constructor(
|
40
45
|
inclusionList,
|
@@ -42,7 +47,8 @@ class Taxa {
|
|
42
47
|
showFlowerErrors,
|
43
48
|
taxonFactory = (td, g) => new Taxon(td, g),
|
44
49
|
extraTaxa = [],
|
45
|
-
extraSynonyms = []
|
50
|
+
extraSynonyms = [],
|
51
|
+
includePhotos = true
|
46
52
|
) {
|
47
53
|
this.#isSubset = inclusionList !== true;
|
48
54
|
|
@@ -76,11 +82,39 @@ class Taxa {
|
|
76
82
|
a.getName().localeCompare(b.getName())
|
77
83
|
);
|
78
84
|
|
85
|
+
|
86
|
+
if ( includePhotos ) {
|
87
|
+
this.#loadInatPhotos( dataDir );
|
88
|
+
}
|
89
|
+
|
79
90
|
const synCSV = CSV.parseFile(dataDir, "synonyms.csv");
|
80
91
|
this.#loadSyns(synCSV, inclusionList);
|
81
92
|
this.#loadSyns(extraSynonyms, inclusionList);
|
82
93
|
}
|
83
94
|
|
95
|
+
/**
|
96
|
+
* @param {string} dataDir
|
97
|
+
*/
|
98
|
+
#loadInatPhotos( dataDir ) {
|
99
|
+
const photosFileName = "inattaxonphotos.csv";
|
100
|
+
if ( fs.existsSync( path.join( dataDir, photosFileName ) ) ) {
|
101
|
+
/** @type {InatCsvPhoto[]} */
|
102
|
+
const csvPhotos = CSV.parseFile( dataDir, photosFileName );
|
103
|
+
for ( const csvPhoto of csvPhotos ) {
|
104
|
+
const taxon = this.getTaxon(csvPhoto.name);
|
105
|
+
if(!taxon) {
|
106
|
+
continue;
|
107
|
+
}
|
108
|
+
taxon.addPhoto(new InatPhoto(
|
109
|
+
csvPhoto.id,
|
110
|
+
csvPhoto.ext,
|
111
|
+
csvPhoto.licenseCode,
|
112
|
+
csvPhoto.attrName
|
113
|
+
) );
|
114
|
+
}
|
115
|
+
}
|
116
|
+
}
|
117
|
+
|
84
118
|
getFamilies() {
|
85
119
|
return this.#families;
|
86
120
|
}
|
@@ -175,9 +209,9 @@ class Taxa {
|
|
175
209
|
const color = this.#flower_colors[colorName];
|
176
210
|
if (!color) {
|
177
211
|
throw new Error(
|
178
|
-
|
212
|
+
"flower color \"" +
|
179
213
|
colorName +
|
180
|
-
|
214
|
+
"\" not found for " +
|
181
215
|
name
|
182
216
|
);
|
183
217
|
}
|
package/lib/taxon.js
CHANGED
@@ -34,6 +34,8 @@ class Taxon {
|
|
34
34
|
#rankGlobal;
|
35
35
|
/** @type {string[]} */
|
36
36
|
#synonyms = [];
|
37
|
+
/** @type {Photo[]} */
|
38
|
+
#photos = [];
|
37
39
|
|
38
40
|
/**
|
39
41
|
* @param {TaxonData} data
|
@@ -92,6 +94,17 @@ class Taxon {
|
|
92
94
|
}
|
93
95
|
}
|
94
96
|
|
97
|
+
/**
|
98
|
+
* @param {InatPhoto} photo
|
99
|
+
*/
|
100
|
+
addPhoto(photo) {
|
101
|
+
this.#photos = this.#photos.concat( [photo] );
|
102
|
+
}
|
103
|
+
|
104
|
+
getPhotos() {
|
105
|
+
return this.#photos;
|
106
|
+
}
|
107
|
+
|
95
108
|
getBaseFileName() {
|
96
109
|
// Convert spaces to "-" and remove ".".
|
97
110
|
return this.#name.replaceAll(" ", "-").replaceAll(".", "");
|
package/lib/util.js
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
/**
|
2
|
+
* Break an array into chunks of a desired size
|
3
|
+
* https://github.com/you-dont-need/You-Dont-Need-Lodash-Underscore?tab=readme-ov-file#_chunk
|
4
|
+
* @param {any[]} input
|
5
|
+
* @param {number} size
|
6
|
+
*/
|
7
|
+
export function chunk( input, size ) {
|
8
|
+
return input.reduce((arr, item, idx) => {
|
9
|
+
return idx % size === 0
|
10
|
+
? [...arr, [item]]
|
11
|
+
: [...arr.slice(0, -1), [...arr.slice(-1)[0], item]];
|
12
|
+
}, []);
|
13
|
+
}
|
14
|
+
|
15
|
+
/**
|
16
|
+
* @param {number} time
|
17
|
+
*/
|
18
|
+
export async function sleep( time ) {
|
19
|
+
return new Promise( resolve => setTimeout( resolve, time ) );
|
20
|
+
}
|
package/lib/web/pagetaxon.js
CHANGED
@@ -194,6 +194,34 @@ class PageTaxon extends GenericPage {
|
|
194
194
|
);
|
195
195
|
html += "</div>";
|
196
196
|
|
197
|
+
const photos = this.#taxon.getPhotos( );
|
198
|
+
if ( photos.length > 0 ) {
|
199
|
+
let photosHtml = "";
|
200
|
+
for ( const photo of photos ) {
|
201
|
+
photosHtml += `
|
202
|
+
<figure class="col">
|
203
|
+
<a href="${photo.getSourceUrl()}">
|
204
|
+
<img
|
205
|
+
class="img-fluid"
|
206
|
+
src="${photo.getUrl()}"
|
207
|
+
/>
|
208
|
+
</a>
|
209
|
+
<figcaption>
|
210
|
+
${photo.rights === "CC0" ? "By" : "(c)"}
|
211
|
+
${photo.rightsHolder}
|
212
|
+
${photo.rights && `(${photo.rights})`}
|
213
|
+
</figcaption>
|
214
|
+
</figure>
|
215
|
+
`;
|
216
|
+
}
|
217
|
+
html += `
|
218
|
+
<h2>Photos</h2>
|
219
|
+
<div class="row">
|
220
|
+
${photosHtml}
|
221
|
+
</div>
|
222
|
+
`;
|
223
|
+
}
|
224
|
+
|
197
225
|
const footerTextPath =
|
198
226
|
Config.getPackageDir() +
|
199
227
|
"/data/text/" +
|
package/package.json
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
{
|
2
2
|
"name": "@ca-plant-list/ca-plant-list",
|
3
|
-
"version": "0.4.
|
3
|
+
"version": "0.4.6",
|
4
4
|
"description": "Tools to create Jekyll files for a website listing plants in an area of California.",
|
5
5
|
"license": "MIT",
|
6
6
|
"repository": {
|
@@ -19,16 +19,19 @@
|
|
19
19
|
},
|
20
20
|
"dependencies": {
|
21
21
|
"archiver": "^5.3.1",
|
22
|
+
"cli-progress": "^3.12.0",
|
22
23
|
"commander": "^12.1.0",
|
23
24
|
"csv-parse": "^5.3.1",
|
25
|
+
"csv-stringify": "^6.5.1",
|
24
26
|
"image-size": "^1.1.1",
|
25
27
|
"markdown-it": "^14.1.0",
|
26
28
|
"sharp": "^0.32.1",
|
27
|
-
"svgo-ll": "^5.
|
29
|
+
"svgo-ll": "^5.6.0",
|
28
30
|
"unzipper": "^0.10.11"
|
29
31
|
},
|
30
32
|
"devDependencies": {
|
31
33
|
"@types/archiver": "^6.0.2",
|
34
|
+
"@types/cli-progress": "^3.11.6",
|
32
35
|
"@types/markdown-it": "^14.1.2",
|
33
36
|
"@types/node": "^22.7.8",
|
34
37
|
"@types/unzipper": "^0.10.9",
|
package/scripts/build-site.js
CHANGED
@@ -42,7 +42,7 @@ class JekyllRenderer {
|
|
42
42
|
addConfigFile(configFiles, this.#srcDir, "_config.yml");
|
43
43
|
addConfigFile(configFiles, this.#srcDir, "_config-local.yml");
|
44
44
|
addConfigFile(configFiles, ".", "_config-dev.yml");
|
45
|
-
options.push("--config",
|
45
|
+
options.push("--config", `"${configFiles.join()}"`);
|
46
46
|
|
47
47
|
const result = child_process.execSync(
|
48
48
|
"bundle exec jekyll build " + options.join(" ")
|
@@ -0,0 +1,106 @@
|
|
1
|
+
#!/usr/bin/env node
|
2
|
+
|
3
|
+
import fs from "fs";
|
4
|
+
import cliProgress from "cli-progress";
|
5
|
+
import { stringify } from "csv-stringify";
|
6
|
+
import path from "path";
|
7
|
+
|
8
|
+
import { ErrorLog } from "../lib/errorlog.js";
|
9
|
+
import { Program } from "../lib/program.js";
|
10
|
+
import { Taxa } from "../lib/taxa.js";
|
11
|
+
import { chunk, sleep } from "../lib/util.js";
|
12
|
+
|
13
|
+
// While I'm guessing the products of this data will be non-commercial, it's
|
14
|
+
// not clear how they'll be licensed so the ShareAlike clause is out, and
|
15
|
+
// they'll probably be derivative works so the "No Derivatives" clause should
|
16
|
+
// be respected.
|
17
|
+
const ALLOWED_LICENSE_CODES = [
|
18
|
+
"cc0", "cc-by", "cc-by-nc"
|
19
|
+
];
|
20
|
+
|
21
|
+
/**
|
22
|
+
* @param {Taxon[]} taxa
|
23
|
+
* @return {Promise<InatApiTaxon[]>}
|
24
|
+
*/
|
25
|
+
async function fetchInatTaxa( taxa ) {
|
26
|
+
const inatTaxonIDs = taxa.map( taxon => taxon.getINatID( ) ).filter( Boolean );
|
27
|
+
const url = `https://api.inaturalist.org/v2/taxa/${inatTaxonIDs.join( "," )}?fields=(taxon_photos:(photo:(medium_url:!t,attribution:!t,license_code:!t)))`;
|
28
|
+
const resp = await fetch( url );
|
29
|
+
if (!resp.ok) {
|
30
|
+
const error = await resp.text();
|
31
|
+
throw new Error(`Failed to fetch taxa from iNat: ${error}`);
|
32
|
+
}
|
33
|
+
const json = await resp.json();
|
34
|
+
return json.results;
|
35
|
+
}
|
36
|
+
|
37
|
+
/**
|
38
|
+
* @param {CommandLineOptions} options
|
39
|
+
*/
|
40
|
+
async function getTaxonPhotos( options ) {
|
41
|
+
const errorLog = new ErrorLog(options.outputdir + "/errors.tsv");
|
42
|
+
const taxa = new Taxa(
|
43
|
+
Program.getIncludeList(options.datadir),
|
44
|
+
errorLog,
|
45
|
+
false
|
46
|
+
);
|
47
|
+
const targetTaxa = taxa.getTaxonList( );
|
48
|
+
|
49
|
+
const filename = path.join( "data", "inattaxonphotos.csv" );
|
50
|
+
const writableStream = fs.createWriteStream( filename );
|
51
|
+
const columns = [
|
52
|
+
"name",
|
53
|
+
"id",
|
54
|
+
"ext",
|
55
|
+
"licenseCode",
|
56
|
+
"attrName",
|
57
|
+
];
|
58
|
+
const stringifier = stringify( { header: true, columns: columns } );
|
59
|
+
stringifier.pipe(writableStream);
|
60
|
+
const prog = new cliProgress.SingleBar({
|
61
|
+
format: "Downloading [{bar}] {percentage}% | ETA: {eta}s | {value}/{total}",
|
62
|
+
etaBuffer: targetTaxa.length
|
63
|
+
});
|
64
|
+
prog.setMaxListeners( 100 );
|
65
|
+
prog.start( targetTaxa.length, 0 );
|
66
|
+
|
67
|
+
// Fetch endpoint can load multiple taxa, but it will created some long URLs so best to keep this smallish
|
68
|
+
for ( const batch of chunk( targetTaxa, 30 ) ) {
|
69
|
+
const inatTaxa = await fetchInatTaxa( batch );
|
70
|
+
for ( const taxon of batch ) {
|
71
|
+
prog.increment( );
|
72
|
+
const iNatTaxon = inatTaxa.find( it => it.id === Number( taxon.getINatID() ) );
|
73
|
+
if ( !iNatTaxon ) continue;
|
74
|
+
// Just get the CC-licensed ones, 5 per taxon should be fine (max is 20 on iNat). Whether or not
|
75
|
+
const taxonPhotos = iNatTaxon.taxon_photos
|
76
|
+
.filter( tp => ALLOWED_LICENSE_CODES.includes( tp.photo.license_code ) )
|
77
|
+
.slice( 0, 5 );
|
78
|
+
|
79
|
+
for ( const taxonPhoto of taxonPhotos ) {
|
80
|
+
const row = [
|
81
|
+
taxon.getName(),
|
82
|
+
taxonPhoto.photo.id,
|
83
|
+
taxonPhoto.photo.medium_url.split( "." ).at( -1 ),
|
84
|
+
// Need the license code to do attribution properly
|
85
|
+
taxonPhoto.photo.license_code,
|
86
|
+
// Photographers retain copyright for most CC licenses,
|
87
|
+
// except CC0, so attribution is a bit different
|
88
|
+
(
|
89
|
+
taxonPhoto.photo.attribution.match( /\(c\) (.*?),/ )?.[1]
|
90
|
+
|| taxonPhoto.photo.attribution.match( /uploaded by (.*)/ )?.[1]
|
91
|
+
)
|
92
|
+
];
|
93
|
+
stringifier.write( row );
|
94
|
+
}
|
95
|
+
}
|
96
|
+
// iNat will throttle you if you make more than 1 request a second.
|
97
|
+
// See https://www.inaturalist.org/pages/api+recommended+practices
|
98
|
+
await sleep( 1_100 );
|
99
|
+
}
|
100
|
+
prog.stop();
|
101
|
+
}
|
102
|
+
|
103
|
+
const program = Program.getProgram();
|
104
|
+
program.action(getTaxonPhotos).description( "Write a CSV to datadir with iNaturalist taxon photos" );
|
105
|
+
|
106
|
+
await program.parseAsync();
|
package/types/classes.d.ts
CHANGED
@@ -91,7 +91,7 @@ declare class Taxa {
|
|
91
91
|
|
92
92
|
declare class TaxaCol {
|
93
93
|
class?: string;
|
94
|
-
data:
|
94
|
+
data: (taxon:Taxon)=>string
|
95
95
|
title: string;
|
96
96
|
}
|
97
97
|
|
@@ -120,6 +120,7 @@ declare class Taxon {
|
|
120
120
|
getJepsonID(): string;
|
121
121
|
getLifeCycle(): string;
|
122
122
|
getName(): string;
|
123
|
+
getPhotos(): Photo[];
|
123
124
|
getRPIRank(): string;
|
124
125
|
getRPIRankAndThreat(): string;
|
125
126
|
getRPIRankAndThreatTooltip(): string;
|
@@ -152,3 +153,48 @@ declare class TaxonImage {
|
|
152
153
|
getCaption(): string | undefined;
|
153
154
|
getSrc(): string;
|
154
155
|
}
|
156
|
+
|
157
|
+
type PhotoRights = "CC0"| "CC BY"| "CC BY-NC"| "C"|null;
|
158
|
+
|
159
|
+
declare class Photo {
|
160
|
+
url?: string;
|
161
|
+
rightsHolder: null | string;
|
162
|
+
rights?: PhotoRights;
|
163
|
+
getUrl: ( ) => string;
|
164
|
+
getSourceUrl: ( ) => string;
|
165
|
+
}
|
166
|
+
|
167
|
+
declare class InatPhoto extends Photo {
|
168
|
+
inatPhotoId: number;
|
169
|
+
ext: string;
|
170
|
+
}
|
171
|
+
|
172
|
+
type InatLicenseCode = "cc-by-nc-sa"
|
173
|
+
| "cc-by-nc"
|
174
|
+
| "cc-by-nc-nd"
|
175
|
+
| "cc-by"
|
176
|
+
| "cc-by-sa"
|
177
|
+
| "cc-by-nd"
|
178
|
+
| "pd"
|
179
|
+
| "gdfl"
|
180
|
+
| "cc0";
|
181
|
+
|
182
|
+
declare class InatCsvPhoto {
|
183
|
+
name: string;
|
184
|
+
id: number;
|
185
|
+
ext: string;
|
186
|
+
licenseCode: InatLicenseCode;
|
187
|
+
attrName: string;
|
188
|
+
}
|
189
|
+
|
190
|
+
declare class InatApiTaxon {
|
191
|
+
id: number;
|
192
|
+
taxon_photos: {
|
193
|
+
photo: {
|
194
|
+
id: number;
|
195
|
+
attribution: string;
|
196
|
+
license_code: InatLicenseCode
|
197
|
+
medium_url: string;
|
198
|
+
}
|
199
|
+
}[]
|
200
|
+
}
|
package/.vscode/settings.json
DELETED
@@ -1,12 +0,0 @@
|
|
1
|
-
{
|
2
|
-
"editor.formatOnSave": true,
|
3
|
-
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
4
|
-
"prettier.tabWidth": 4,
|
5
|
-
"json.schemas": [
|
6
|
-
{
|
7
|
-
"fileMatch": ["**/exceptions.json"],
|
8
|
-
"url": "./schemas/exceptions.schema.json"
|
9
|
-
}
|
10
|
-
],
|
11
|
-
"editor.tabSize": 2
|
12
|
-
}
|