Skip to content

Commit c14a48e

Browse files
authored
Add extends field to JSON configs (#118)
You can now set the extends property of a JSON config to another config file URL, and they'll be fetched and merged recursively. Useful for when we have a base config for a site that we want to inherit across a bunch of playgrounds.
1 parent 3cd3767 commit c14a48e

4 files changed

Lines changed: 114 additions & 64 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
2626

2727
- Added support for JSON files with syntax highlighting.
2828

29+
- Added `extends` property to JSON config file, an optional URL of another JSON
30+
config file to extend from. Useful for setting side-wide defaults.
31+
2932
### Fixed
3033

3134
- Fixed bug where editor did not immediately switch to newly created files.

README.md

Lines changed: 19 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -163,22 +163,16 @@ There are 3 ways to specify the files of a playground project:
163163

164164
### Option 1: Inline scripts
165165

166-
Add one or more `<script type="sample/..." filename="...">` tags as children of
167-
your `<playground-ide>` or `<playground-project>`. The following attributes are
168-
available:
166+
Add one or more `<script>` tags as children of
167+
your `<playground-ide>` or `<playground-project>`, using the following attributes:
169168

170-
- `type`: Required filetype. Valid options: `sample/html`, `sample/css`,
171-
`sample/js`, `sample/ts`, `sample/json`, `sample/importmap`.
172-
173-
- `filename`: Required filename.
174-
175-
- `label`: Optional label for display in `playground-tab-bar`. If omitted, the
176-
filename is displayed.
177-
178-
- `hidden`: If present, the file won't be visible in `playground-tab-bar`.
179-
180-
- `preserve-whitespace`: Disable the default behavior where leading whitespace
181-
that is common to all lines is removed.
169+
| Attribute         |  Description |
170+
| --------------------- | --------------------------------------------------------------------------------------------------------------------- |
171+
| `type` | Required filetype. Options: `sample/html`, `sample/css`, `sample/js`, `sample/ts`, `sample/json`, `sample/importmap`. |
172+
| `filename` | Required filename. |
173+
| `label` | Optional label for display in `playground-tab-bar`. If omitted, the filename is displayed. |
174+
| `hidden` | If present, the file won't be visible in `playground-tab-bar`. |
175+
| `preserve-whitespace` | Disable the default behavior where leading whitespace that is common to all lines is removed. |
182176

183177
Be sure to escape closing `</script>` tags within your source as `<&lt;script>`.
184178

@@ -210,23 +204,18 @@ Be sure to escape closing `</script>` tags within your source as `<&lt;script>`.
210204
</playground-project>
211205
```
212206

213-
### Option 2: JSON manifest
214-
215-
Serve a JSON file containing a `files` object. Keys are filenames relative to
216-
the manifest URL. Values are objects with any of the following optional
217-
properties:
218-
219-
- `content`: Optional text content of the file. If omitted, a `fetch` is made to
220-
retrieve the file by filename, relative to the manifest URL.
221-
222-
- `contentType`: Optional MIME type of the file. If omitted, type is taken from
223-
either the `fetch` response `Content-Type` header, or inferred from the
224-
filename extension when `content` is set.
207+
### Option 2: JSON configuration
225208

226-
- `label`: Optional label for display in `playground-tab-bar`. If omitted, the
227-
filename is displayed.
209+
Set the `project-src` attribute or `projectSrc` property to a JSON file with format:
228210

229-
- `hidden`: If `true`, the file won't be visible in `playground-tab-bar`.
211+
| Property         |  Description |
212+
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
213+
| `files` | An object mapping filenames to file data. |
214+
| `files.content` | Optional text content of the file. If omitted, a `fetch` is made to retrieve the file by filename, relative to the manifest URL. |
215+
| `files.contentType` | Optional MIME type of the file. If omitted, type is taken from either the `fetch` response `Content-Type` header, or inferred from the filename extension when `content` is set. |
216+
| `files.label` | Optional label for display in `playground-tab-bar`. If omitted, the filename is displayed. |
217+
| `files.hidden` | If `true`, the file won't be visible in `playground-tab-bar`. |
218+
| `extends` | Optional URL to another JSON config file to extend from. Configs are deeply merged. URLs are interpreted relative to the URL of each extendee config. |
230219

231220
```html
232221
<playground-ide project-src="/path/to/my/project.json"> </playground-ide>

src/playground-project.ts

Lines changed: 90 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,9 @@ export class PlaygroundProject extends LitElement {
261261
this._importMap = source.importMap;
262262
break;
263263
case 'url':
264-
const {files, importMap} = await fetchProject(source.url);
264+
const {files, importMap} = await fetchProjectConfig(
265+
new URL(source.url, document.baseURI).href
266+
);
265267
// Note the source could have changed while fetching, hence the
266268
// double-check here.
267269
if (source !== this._source) {
@@ -519,39 +521,93 @@ export class PlaygroundProject extends LitElement {
519521
}
520522
}
521523

522-
const fetchProject = async (url: string) => {
523-
const projectUrl = new URL(url, document.baseURI);
524-
const manifestFetched = await fetch(url);
525-
const manifest = (await manifestFetched.json()) as ProjectManifest;
526-
527-
const files = await Promise.all(
528-
Object.entries(manifest.files ?? {}).map(
529-
async ([name, {content, contentType, label, hidden}]) => {
530-
if (content === undefined) {
531-
const fileUrl = new URL(name, projectUrl);
532-
const response = await fetch(fileUrl.href);
533-
if (response.status === 404) {
534-
throw new Error(`Could not find file ${name}`);
535-
}
536-
content = await response.text();
537-
if (!contentType) {
538-
contentType = response.headers.get('Content-Type') ?? undefined;
539-
}
540-
}
541-
if (!contentType) {
542-
contentType = typeFromFilename(name) ?? 'text/plain';
543-
}
544-
return {
545-
name,
546-
label,
547-
hidden,
548-
content,
549-
contentType,
550-
};
551-
}
552-
)
553-
);
554-
return {files, importMap: manifest.importMap ?? {}};
524+
const fetchProjectConfig = async (
525+
configUrl: string,
526+
alreadyFetchedFilenames = new Set<string>(),
527+
alreadyFetchedConfigUrls = new Set<string>()
528+
): Promise<{files: Array<SampleFile>; importMap: ModuleImportMap}> => {
529+
if (alreadyFetchedConfigUrls.has(configUrl)) {
530+
throw new Error(
531+
`Circular project config extends: ${[
532+
...alreadyFetchedConfigUrls.values(),
533+
configUrl,
534+
].join(' extends ')}`
535+
);
536+
}
537+
alreadyFetchedConfigUrls.add(configUrl);
538+
const resp = await fetch(configUrl);
539+
if (resp.status !== 200) {
540+
throw new Error(
541+
`Error ${
542+
resp.status
543+
} fetching project config from ${configUrl}: ${await resp.text()}`
544+
);
545+
}
546+
547+
let config;
548+
try {
549+
config = (await resp.json()) as ProjectManifest;
550+
} catch (e) {
551+
throw new Error(
552+
`Error parsing project config JSON from ${configUrl}: ${e.message}`
553+
);
554+
}
555+
556+
const filePromises: Array<Promise<SampleFile>> = [];
557+
for (const [filename, info] of Object.entries(config.files ?? {})) {
558+
// A higher precedence config is already handling this file.
559+
if (alreadyFetchedFilenames.has(filename)) {
560+
continue;
561+
}
562+
alreadyFetchedFilenames.add(filename);
563+
if (info.content === undefined) {
564+
filePromises.push(
565+
(async () => {
566+
const resp = await fetch(new URL(filename, configUrl).href);
567+
return {
568+
...info,
569+
name: filename,
570+
content: await resp.text(),
571+
contentType: resp.headers.get('Content-Type') ?? undefined,
572+
};
573+
})()
574+
);
575+
} else {
576+
filePromises.push(
577+
Promise.resolve({
578+
...info,
579+
name: filename,
580+
content: info.content ?? '',
581+
contentType: typeFromFilename(filename) ?? 'text/plain',
582+
})
583+
);
584+
}
585+
}
586+
587+
// Start extends config fetch before we block on file fetches.
588+
const extendsConfigPromise = config.extends
589+
? fetchProjectConfig(
590+
new URL(config.extends, configUrl).href,
591+
alreadyFetchedFilenames,
592+
alreadyFetchedConfigUrls
593+
)
594+
: undefined;
595+
596+
const files = await Promise.all(filePromises);
597+
const importMap = config.importMap ?? {};
598+
599+
if (extendsConfigPromise) {
600+
const extendsConfig = await extendsConfigPromise;
601+
// Parent files go after our own.
602+
files.push(...extendsConfig.files);
603+
importMap.imports = {
604+
...extendsConfig.importMap?.imports,
605+
// Our imports take precedence over our parents.
606+
...importMap.imports,
607+
};
608+
}
609+
610+
return {files, importMap};
555611
};
556612

557613
const typeFromFilename = (filename: string) => {

src/shared/worker-api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ export interface FileOptions {
9999
}
100100

101101
export interface ProjectManifest {
102+
/** Optional project manifest URL to extend from */
103+
extends?: string;
102104
files?: {[filename: string]: FileOptions};
103105
importMap?: ModuleImportMap;
104106
}

0 commit comments

Comments
 (0)