feat(rechallenge): Retool challenge framework (#13666)

* feat(rechallenge): Retool challenge framework

* fix(code-storage): should use setContent not updateContent

* fix(rechallenge): fix context issue and temporal zone of death

* fix(rechallenge): Fix frame sources for user code

* fix(polyvinyl): Set should ignore source and transform should keep track of source

* fix(rechallenge): Missing return statement causing issues
pull/11800/merge
Berkeley Martinez 2017-04-28 18:30:23 -07:00 committed by Quincy Larson
parent da52116860
commit ee8ac7b453
11 changed files with 519 additions and 311 deletions

View File

@ -58,6 +58,11 @@ document.addEventListener('DOMContentLoaded', function() {
const newTest = { text, testString };
let test;
let __result;
// uncomment the following line to inspect
// the framerunner as it runs tests
// make sure the dev tools console is open
// debugger;
try {
/* eslint-disable no-eval */
// eval test string to actual JavaScript

View File

@ -0,0 +1,99 @@
import { Observable } from 'rx';
import cond from 'lodash/cond';
import flow from 'lodash/flow';
import identity from 'lodash/identity';
import matchesProperty from 'lodash/matchesProperty';
import partial from 'lodash/partial';
import stubTrue from 'lodash/stubTrue';
import {
compileHeadTail,
setExt,
transformContents
} from '../../common/utils/polyvinyl';
import {
fetchScript,
fetchLink
} from '../utils/fetch-and-cache.js';
const htmlCatch = '\n<!--fcc-->\n';
const jsCatch = '\n;/*fcc*/\n';
const wrapInScript = partial(transformContents, (content) => (
`${htmlCatch}<script>${content}${jsCatch}</script>`
));
const wrapInStyle = partial(transformContents, (content) => (
`${htmlCatch}<style>${content}</style>`
));
const setExtToHTML = partial(setExt, 'html');
const padContentWithJsCatch = partial(compileHeadTail, jsCatch);
const padContentWithHTMLCatch = partial(compileHeadTail, htmlCatch);
export const jsToHtml = cond([
[
matchesProperty('ext', 'js'),
flow(padContentWithJsCatch, wrapInScript, setExtToHTML)
],
[ stubTrue, identity ]
]);
export const cssToHtml = cond([
[
matchesProperty('ext', 'css'),
flow(padContentWithHTMLCatch, wrapInStyle, setExtToHTML)
],
[ stubTrue, identity ]
]);
// FileStream::concactHtml(
// required: [ ...Object ]
// ) => Observable[{ build: String, sources: Dictionary }]
export function concactHtml(required) {
const source = this.shareReplay();
const sourceMap = source
.flatMap(files => files.reduce((sources, file) => {
sources[file.name] = file.source || file.contents;
return sources;
}, {}));
const head = Observable.from(required)
.flatMap(required => {
if (required.src) {
return fetchScript(required);
}
if (required.link) {
return fetchLink(required);
}
return Observable.just('');
})
.reduce((head, required) => head + required, '')
.map(head => `<head>${head}</head>`);
const body = source
.flatMap(file => file.reduce((body, file) => {
return body + file.contents + htmlCatch;
}, ''))
.map(source => `
<body style='margin:8px;'>
<!-- fcc-start-source -->
${source}
<!-- fcc-end-source -->
</body>
`);
return Observable
.combineLatest(
head,
body,
fetchScript({
src: '/js/frame-runner.js',
crossDomain: false,
cacheBreaker: true
}),
sourceMap,
(head, body, frameRunner, sources) => ({
build: head + body + frameRunner,
sources
})
);
}

View File

@ -1,99 +1,124 @@
import { helpers, Observable } from 'rx';
import { Observable } from 'rx';
import cond from 'lodash/cond';
import identity from 'lodash/identity';
import stubTrue from 'lodash/stubTrue';
import conforms from 'lodash/conforms';
const throwForJsHtml = {
ext: /js|html/,
throwers: [
{
name: 'multiline-comment',
description: 'Detect if a JS multi-line comment is left open',
thrower: function checkForComments({ contents }) {
const openingComments = contents.match(/\/\*/gi);
const closingComments = contents.match(/\*\//gi);
if (
openingComments &&
(!closingComments || openingComments.length > closingComments.length)
) {
throw new Error('SyntaxError: Unfinished multi-line comment');
}
}
}, {
name: 'nested-jQuery',
description: 'Nested dollar sign calls breaks browsers',
detectUnsafeJQ: /\$\s*?\(\s*?\$\s*?\)/gi,
thrower: function checkForNestedJquery({ contents }) {
if (contents.match(this.detectUnsafeJQ)) {
throw new Error('Unsafe $($)');
}
}
}, {
name: 'unfinished-function',
description: 'lonely function keywords breaks browsers',
detectFunctionCall: /function\s*?\(|function\s+\w+\s*?\(/gi,
thrower: function checkForUnfinishedFunction({ contents }) {
if (
contents.match(/function/g) &&
!contents.match(this.detectFunctionCall)
) {
throw new Error(
'SyntaxError: Unsafe or unfinished function declaration'
);
}
}
}, {
name: 'unsafe console call',
description: 'console call stops tests scripts from running',
detectUnsafeConsoleCall: /if\s\(null\)\sconsole\.log\(1\);/gi,
thrower: function checkForUnsafeConsole({ contents }) {
if (contents.match(this.detectUnsafeConsoleCall)) {
throw new Error('Invalid if (null) console.log(1); detected');
}
}
}, {
name: 'glitch in code',
description: 'Code with the URL glitch.com or glitch.me' +
'should not be allowed to run',
detectGlitchInCode: /glitch\.(com|me)/gi,
thrower: function checkForGlitch({ contents }) {
if (contents.match(this.detectGlitchInCode)) {
throw new Error('Glitch.com or Glitch.me should not be in the code');
}
import castToObservable from '../../common/app/utils/cast-to-observable.js';
const HTML$JSReg = /html|js/;
const testHTMLJS = conforms({ ext: (ext) => HTML$JSReg.test(ext) });
// const testJS = matchesProperty('ext', 'js');
const passToNext = [ stubTrue, identity ];
// Detect if a JS multi-line comment is left open
const throwIfOpenComments = cond([
[
testHTMLJS,
function _checkForComments({ contents }) {
const openingComments = contents.match(/\/\*/gi);
const closingComments = contents.match(/\*\//gi);
if (
openingComments &&
(!closingComments || openingComments.length > closingComments.length)
) {
throw new SyntaxError('Unfinished multi-line comment');
}
}
]
};
],
passToNext
]);
export default function throwers() {
const source = this;
return source.map(file$ => file$.flatMap(file => {
if (!throwForJsHtml.ext.test(file.ext)) {
// Nested dollar sign calls breaks browsers
const nestedJQCallReg = /\$\s*?\(\s*?\$\s*?\)/gi;
const throwIfNestedJquery = cond([
[
testHTMLJS,
function({ contents }) {
if (nestedJQCallReg.test(contents)) {
throw new SyntaxError('Nested jQuery calls breaks browsers');
}
}
],
passToNext
]);
const functionReg = /function/g;
const functionCallReg = /function\s*?\(|function\s+\w+\s*?\(/gi;
// lonely function keywords breaks browsers
const ThrowIfUnfinishedFunction = cond([
[
testHTMLJS,
function({ contents }) {
if (
functionReg.test(contents) &&
!functionCallReg.test(contents)
) {
throw new SyntaxError(
'Unsafe or unfinished function declaration'
);
}
}
],
passToNext
]);
// console call stops tests scripts from running
const unsafeConsoleCallReg = /if\s\(null\)\sconsole\.log\(1\);/gi;
const throwIfUnsafeConsoleCall = cond([
[
testHTMLJS,
function({ contents }) {
if (unsafeConsoleCallReg.test(contents)) {
throw new SyntaxError(
'`if (null) console.log(1)` detected. This will break tests'
);
}
}
],
passToNext
]);
// Code with the URL hyperdev.com should not be allowed to run,
const goMixReg = /glitch\.(com|me)/gi;
const throwIfGomixDetected = cond([
[
testHTMLJS,
function({ contents }) {
if (goMixReg.test(contents)) {
throw new Error('Glitch.com or Glitch.me should not be in the code');
}
}
],
passToNext
]);
const validators = [
throwIfOpenComments,
throwIfGomixDetected,
throwIfNestedJquery,
ThrowIfUnfinishedFunction,
throwIfUnsafeConsoleCall
];
export default function validate(file) {
return validators.reduce((obs, validator) => obs.flatMap(file => {
try {
return castToObservable(validator(file));
} catch (err) {
return Observable.throw(err);
}
}), Observable.of(file))
// if no error has occured map to the original file
.map(() => file)
// if err add it to the file
// and return file
.catch(err => {
file.error = err;
return Observable.just(file);
}
return Observable.from(throwForJsHtml.throwers)
.flatMap(context => {
try {
let finalObs;
const maybeObservableOrPromise = context.thrower(file);
if (helpers.isPromise(maybeObservableOrPromise)) {
finalObs = Observable.fromPromise(maybeObservableOrPromise);
} else if (Observable.isObservable(maybeObservableOrPromise)) {
finalObs = maybeObservableOrPromise;
} else {
finalObs = Observable.just(maybeObservableOrPromise);
}
return finalObs;
} catch (err) {
return Observable.throw(err);
}
})
// if none of the throwers throw, wait for last one
.last({ defaultValue: null })
// then map to the original file
.map(file)
// if err add it to the file
// and return file
.catch(err => {
file.error = err;
return Observable.just(file);
});
}));
});
}

View File

@ -1,3 +1,9 @@
import cond from 'lodash/cond';
import identity from 'lodash/identity';
import matchesProperty from 'lodash/matchesProperty';
import stubTrue from 'lodash/stubTrue';
import conforms from 'lodash/conforms';
import * as babel from 'babel-core';
import presetEs2015 from 'babel-preset-es2015';
import presetReact from 'babel-preset-react';
@ -6,7 +12,11 @@ import { Observable } from 'rx';
import loopProtect from 'loop-protect';
/* eslint-enable import/no-unresolved */
import { updateContents } from '../../common/utils/polyvinyl';
import {
transformHeadTailAndContents,
setContent
} from '../../common/utils/polyvinyl.js';
import castToObservable from '../../common/app/utils/cast-to-observable.js';
const babelOptions = { presets: [ presetEs2015, presetReact ] };
loopProtect.hit = function hit(line) {
@ -18,67 +28,81 @@ loopProtect.hit = function hit(line) {
throw new Error(err);
};
const transformersForHtmlJS = {
ext: /html|js/,
transformers: [
{
name: 'add-loop-protect',
transformer: function addLoopProtect(file) {
const _contents = file.contents.toLowerCase();
if (file.ext === 'html' && _contents.indexOf('<script>') === -1) {
// No JavaScript in user code, so no need for loopProtect
return updateContents(file.contents, file);
}
return updateContents(loopProtect(file.contents), file);
}
},
{
name: 'replace-nbsp',
nbspRegExp: new RegExp(String.fromCharCode(160), 'g'),
transformer: function replaceNBSP(file) {
return updateContents(
file.contents.replace(this.nbspRegExp, ' '),
file
);
}
}
]
};
// const sourceReg =
// /(<!-- fcc-start-source -->)([\s\S]*?)(?=<!-- fcc-end-source -->)/g;
const HTML$JSReg = /html|js/;
const console$logReg = /(?:\b)console(\.log\S+)/g;
const NBSPReg = new RegExp(String.fromCharCode(160), 'g');
const transformersForJs = {
ext: /js/,
transformers: [
{
name: 'babel-transformer',
transformer: function babelTransformer(file) {
const result = babel.transform(file.contents, babelOptions);
return updateContents(
result.code,
file
);
}
}
]
};
const testHTMLJS = conforms({ ext: (ext) => HTML$JSReg.test(ext) });
const testJS = matchesProperty('ext', 'js');
// Observable[Observable[File]]::addLoopProtect() => Observable[String]
export default function transformers() {
const source = this;
return source.map(files$ => files$.flatMap(file => {
if (!transformersForHtmlJS.ext.test(file.ext)) {
return Observable.just(file);
}
if (
transformersForJs.ext.test(file.ext) &&
transformersForHtmlJS.ext.test(file.ext)
) {
return Observable.of(
...transformersForHtmlJS.transformers,
...transformersForJs.transformers
)
.reduce((file, context) => context.transformer(file), file);
}
return Observable.from(transformersForHtmlJS.transformers)
.reduce((file, context) => context.transformer(file), file);
}));
// if shouldProxyConsole then we change instances of console log
// to `window.__console.log`
// this let's us tap into logging into the console.
// currently we only do this to the main window and not the test window
export function proxyLoggerTransformer(file) {
return transformHeadTailAndContents(
(source) => (
source.replace(console$logReg, (match, methodCall) => {
return 'window.__console' + methodCall;
})),
file
);
}
export const addLoopProtect = cond([
[
testHTMLJS,
function(file) {
const _contents = file.contents.toLowerCase();
if (file.ext === 'html' && !_contents.indexOf('<script>') !== -1) {
// No JavaScript in user code, so no need for loopProtect
return file;
}
return setContent(loopProtect(file.contents), file);
}
],
[ stubTrue, identity ]
]);
export const replaceNBSP = cond([
[
testHTMLJS,
function(file) {
return setContent(
file.contents.replace(NBSPReg, ' '),
file
);
}
],
[ stubTrue, identity ]
]);
export const babelTransformer = cond([
[
testJS,
function(file) {
const result = babel.transform(file.contents, babelOptions);
return setContent(
result.code,
file
);
}
],
[ stubTrue, identity ]
]);
export const _transformers = [
addLoopProtect,
replaceNBSP,
babelTransformer
];
export function applyTransformers(file, transformers = _transformers) {
return transformers.reduce(
(obs, transformer) => {
return obs.flatMap(file => castToObservable(transformer(file)));
},
Observable.of(file)
);
}

View File

@ -3,7 +3,7 @@ import store from 'store';
import { removeCodeUri, getCodeUri } from '../utils/code-uri';
import { ofType } from '../../common/utils/get-actions-of-type';
import { updateContents } from '../../common/utils/polyvinyl';
import { setContent } from '../../common/utils/polyvinyl';
import combineSagas from '../../common/utils/combine-sagas';
import { userSelector } from '../../common/app/redux/selectors';
@ -51,7 +51,7 @@ function getLegacyCode(legacy) {
}
function legacyToFile(code, files, key) {
return { [key]: updateContents(code, files[key]) };
return { [key]: setContent(code, files[key]) };
}
export function clearCodeSaga(actions, getState) {

View File

@ -72,12 +72,14 @@ function frameMain({ build } = {}, document, proxyLogger) {
main.close();
}
function frameTests({ build, source, checkChallengePayload } = {}, document) {
function frameTests({ build, sources, checkChallengePayload } = {}, document) {
const { frame: tests } = getFrameDocument(document, testId);
refreshFrame(tests);
tests.Rx = Rx;
tests.__source = source;
tests.__getUserInput = key => source[key];
// default for classic challenges
// should not be used for modern
tests.__source = sources['index'] || '';
tests.__getUserInput = key => sources[key];
tests.__checkChallengePayload = checkChallengePayload;
tests.open();
tests.write(createHeader(testId) + build);

View File

@ -1,33 +1,32 @@
import { Observable } from 'rx';
import { getValues } from 'redux-form';
import identity from 'lodash/identity';
import { ajax$ } from '../../common/utils/ajax-stream';
import { fetchScript } from '../utils/fetch-and-cache.js';
import throwers from '../rechallenge/throwers';
import transformers from '../rechallenge/transformers';
import { setExt, updateContents } from '../../common/utils/polyvinyl';
import {
applyTransformers,
proxyLoggerTransformer
} from '../rechallenge/transformers';
import {
cssToHtml,
jsToHtml,
concactHtml
} from '../rechallenge/builders.js';
import {
createFileStream,
pipe
} from '../../common/utils/polyvinyl.js';
const consoleReg = /(?:\b)console(\.log\S+)/g;
// const sourceReg =
// /(<!-- fcc-start-source -->)([\s\S]*?)(?=<!-- fcc-end-source -->)/g;
// useConsoleLogProxy(source: String) => String
export function useConsoleLogProxy(source) {
return source.replace(consoleReg, (match, methodCall) => {
return 'window.__console' + methodCall;
});
}
// createFileStream(files: Dictionary[Path, PolyVinyl]) =>
// Observable[...Observable[...PolyVinyl]]
export function createFileStream(files = {}) {
return Observable.just(
Observable.from(Object.keys(files)).map(key => files[key])
);
}
const jQuery = {
src: 'https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js'
};
const frameRunner = {
src: '/js/frame-runner.js',
crossDomain: false,
cacheBreaker: true
};
const globalRequires = [
{
link: 'https://cdnjs.cloudflare.com/' +
@ -36,135 +35,23 @@ const globalRequires = [
jQuery
];
const scriptCache = new Map();
export function cacheScript({ src } = {}, crossDomain = true) {
if (!src) {
throw new Error('No source provided for script');
}
if (scriptCache.has(src)) {
return scriptCache.get(src);
}
const script$ = ajax$({ url: src, crossDomain })
.doOnNext(res => {
if (res.status !== 200) {
throw new Error('Request errror: ' + res.status);
}
})
.map(({ response }) => response)
.map(script => `<script>${script}</script>`)
.shareReplay();
scriptCache.set(src, script$);
return script$;
}
const linkCache = new Map();
export function cacheLink({ link } = {}, crossDomain = true) {
if (!link) {
return Observable.throw(new Error('No source provided for link'));
}
if (linkCache.has(link)) {
return linkCache.get(link);
}
const link$ = ajax$({ url: link, crossDomain })
.doOnNext(res => {
if (res.status !== 200) {
throw new Error('Request errror: ' + res.status);
}
})
.map(({ response }) => response)
.map(script => `<style>${script}</style>`)
.catch(() => Observable.just(''))
.shareReplay();
linkCache.set(link, link$);
return link$;
}
const htmlCatch = '\n<!--fcc-->';
const jsCatch = '\n;/*fcc*/\n';
// we add a cache breaker to prevent browser from caching ajax request
const frameRunner = cacheScript({
src: `/js/frame-runner.js?cacheBreaker=${Math.random()}` },
false
);
export function buildClassic(files, required, shouldProxyConsole) {
const finalRequires = [...globalRequires, ...required ];
return createFileStream(files)
::throwers()
::transformers()
// createbuild
.flatMap(file$ => file$.reduce((build, file) => {
let finalFile;
const finalContents = [
file.head,
file.contents,
file.tail
].map(
// if shouldProxyConsole then we change instances of console log
// to `window.__console.log`
// this let's us tap into logging into the console.
// currently we only do this to the main window and not the test window
source => shouldProxyConsole ? useConsoleLogProxy(source) : source
);
if (file.ext === 'js') {
finalFile = setExt('html', updateContents(
`<script>${finalContents.join(jsCatch)}${jsCatch}</script>`,
file
));
} else if (file.ext === 'css') {
finalFile = setExt('html', updateContents(
`<style>${finalContents.join(htmlCatch)}</style>`,
file
));
} else {
finalFile = file;
}
return build + finalFile.contents + htmlCatch;
}, ''))
// add required scripts and links here
.flatMap(source => {
const head$ = Observable.from(finalRequires)
.flatMap(required => {
if (required.src) {
return cacheScript(required, required.crossDomain);
}
// css files with `url(...` may not work in style tags
// so we put them in raw links
if (required.link && required.raw) {
return Observable.just(
`<link href=${required.link} rel='stylesheet' />`
);
}
if (required.link) {
return cacheLink(required, required.crossDomain);
}
return Observable.just('');
})
.reduce((head, required) => head + required, '')
.map(head => `<head>${head}</head>`);
return Observable.combineLatest(head$, frameRunner)
.map(([ head, frameRunner ]) => {
const body = `
<body style='margin:8px;'>
<!-- fcc-start-source -->
${source}
<!-- fcc-end-source -->
</body>`;
return {
build: head + body + frameRunner,
source,
head
};
});
});
::pipe(throwers)
::pipe(applyTransformers)
::pipe(shouldProxyConsole ? proxyLoggerTransformer : identity)
::pipe(jsToHtml)
::pipe(cssToHtml)
::concactHtml(finalRequires, frameRunner);
}
export function buildBackendChallenge(state) {
const { solution: url } = getValues(state.form.BackEndChallenge);
return Observable.combineLatest(frameRunner, cacheScript(jQuery))
return Observable.combineLatest(
fetchScript(frameRunner),
fetchScript(jQuery)
)
.map(([ frameRunner, jQuery ]) => ({
build: jQuery + frameRunner,
source: { url },

View File

@ -0,0 +1,74 @@
import { Observable } from 'rx';
import { ajax$ } from '../../common/utils/ajax-stream';
// value used to break browser ajax caching
const cacheBreakerValue = Math.random();
export function _fetchScript(
{
src,
cacheBreaker = false,
crossDomain = true
} = {},
) {
if (!src) {
throw new Error('No source provided for script');
}
if (this.cache.has(src)) {
return this.cache.get(src);
}
const url = cacheBreaker ?
`${src}?cacheBreaker=${cacheBreakerValue}` :
src;
const script = ajax$({ url, crossDomain })
.doOnNext(res => {
if (res.status !== 200) {
throw new Error('Request errror: ' + res.status);
}
})
.map(({ response }) => response)
.map(script => `<script>${script}</script>`)
.shareReplay();
this.cache.set(src, script);
return script;
}
export const fetchScript = _fetchScript.bind({ cache: new Map() });
export function _fetchLink(
{
link: href,
raw = false,
crossDomain = true
} = {},
) {
if (!href) {
return Observable.throw(new Error('No source provided for link'));
}
if (this.cache.has(href)) {
return this.cache.get(href);
}
// css files with `url(...` may not work in style tags
// so we put them in raw links
if (raw) {
const link = Observable.just(`<link href=${href} rel='stylesheet' />`)
.shareReplay();
this.cache.set(href, link);
return link;
}
const link = ajax$({ url: href, crossDomain })
.doOnNext(res => {
if (res.status !== 200) {
throw new Error('Request error: ' + res.status);
}
})
.map(({ response }) => response)
.map(script => `<style>${script}</style>`)
.catch(() => Observable.just(''))
.shareReplay();
this.cache.set(href, link);
return link;
}
export const fetchLink = _fetchLink.bind({ cache: new Map() });

View File

@ -1,5 +1,5 @@
import { createAction } from 'redux-actions';
import { updateContents } from '../../../../utils/polyvinyl';
import { setContent } from '../../../../utils/polyvinyl';
import { getMouse, loggerToStr } from '../utils';
import types from './types';
@ -65,7 +65,7 @@ export const clearFilter = createAction(types.clearFilter);
// files
export const updateFile = createAction(
types.updateFile,
(content, file) => updateContents(content, file)
(content, file) => setContent(content, file)
);
export const updateFiles = createAction(types.updateFiles);

View File

@ -0,0 +1,11 @@
import { Observable, helpers } from 'rx';
export default function castToObservable(maybe) {
if (Observable.isObservable(maybe)) {
return maybe;
}
if (helpers.isPromise(maybe)) {
return Observable.fromPromise(maybe);
}
return Observable.of(maybe);
}

View File

@ -1,7 +1,32 @@
// originally base off of https://github.com/gulpjs/vinyl
// originally based off of https://github.com/gulpjs/vinyl
import invariant from 'invariant';
import { Observable } from 'rx';
import castToObservable from '../app/utils/cast-to-observable.js';
// createFileStream(
// files: Dictionary[Path, PolyVinyl]
// ) => Observable[...Observable[...PolyVinyl]]
export function createFileStream(files = {}) {
return Observable.of(
Observable.from(Object.keys(files).map(key => files[key]))
);
}
// Observable::pipe(
// project(
// file: PolyVinyl
// ) => PolyVinyl|Observable[PolyVinyl]|Promise[PolyVinyl]
// ) => Observable[...Observable[...PolyVinyl]]
export function pipe(project) {
const source = this;
return source.map(
files => files.flatMap(file => castToObservable(project(file)))
);
}
// interface PolyVinyl {
// source: String,
// contents: String,
// name: String,
// ext: String,
@ -10,9 +35,9 @@ import invariant from 'invariant';
// head: String,
// tail: String,
// history: [...String],
// error: Null|Object
// error: Null|Object|Error
// }
//
// createPoly({
// name: String,
// ext: String,
@ -80,15 +105,18 @@ export function isEmpty(poly) {
return !!poly.contents;
}
// updateContents(contents: String, poly: PolyVinyl) => PolyVinyl
export function updateContents(contents, poly) {
// setContent(contents: String, poly: PolyVinyl) => PolyVinyl
// setContent will loose source if set
export function setContent(contents, poly) {
checkPoly(poly);
return {
...poly,
contents
contents,
source: null
};
}
// setExt(contents: String, poly: PolyVinyl) => PolyVinyl
export function setExt(ext, poly) {
checkPoly(poly);
const newPoly = {
@ -101,6 +129,7 @@ export function setExt(ext, poly) {
return newPoly;
}
// setName(contents: String, poly: PolyVinyl) => PolyVinyl
export function setName(name, poly) {
checkPoly(poly);
const newPoly = {
@ -113,6 +142,7 @@ export function setName(name, poly) {
return newPoly;
}
// setError(contents: String, poly: PolyVinyl) => PolyVinyl
export function setError(error, poly) {
invariant(
typeof error === 'object',
@ -125,3 +155,54 @@ export function setError(error, poly) {
error
};
}
// clearHeadTail(poly: PolyVinyl) => PolyVinyl
export function clearHeadTail(poly) {
checkPoly(poly);
return {
...poly,
head: '',
tail: ''
};
}
// compileHeadTail(contents: String, poly: PolyVinyl) => PolyVinyl
export function compileHeadTail(padding = '', poly) {
return clearHeadTail(setContent(
[ poly.head, poly.contents, poly.tail ].join(padding),
poly
));
}
// transformContents(
// wrap: (contents: String) => String,
// poly: PolyVinyl
// ) => PolyVinyl
// transformContents will keep a copy of the original
// code in the `source` property. If the original polyvinyl
// already contains a source, this version will continue as
// the source property
export function transformContents(wrap, poly) {
const newPoly = setContent(
wrap(poly.contents),
poly
);
// if no source exist, set the original contents as source
newPoly.source = poly.contents || poly.contents;
return newPoly;
}
// transformHeadTailAndContents(
// wrap: (source: String) => String,
// poly: PolyVinyl
// ) => PolyVinyl
export function transformHeadTailAndContents(wrap, poly) {
return {
...setContent(
wrap(poly.contents),
poly
),
head: wrap(poly.head),
tail: wrap(poly.tail)
};
}