Skip to content
Permalink
Branch: master
Find file Copy path
Find file Copy path
Fetching contributors…
Cannot retrieve contributors at this time
562 lines (488 sloc) 17.2 KB
from webidl`@core` import Number, Function, String as NativeString
from webidl`github.com/serulian/virtualdom` import document, Node, Element, Text, HTMLDocument, HTMLIFrameElement
from "github.com/serulian/attachment" import Attachment
from "github.com/serulian/component" import RenderComponent, UpdateComponentState, StatefulComponent
from "github.com/serulian/debuglib" import Log
from "github.com/serulian/request" import Post, GetURLContents
from "github.com/serulian/virtualdom" import Context, Div, Button, If, Span, TextArea, IFrame, HideIf
from "github.com/serulian/virtualdom" import Nav, Ul, Li, A, DynamicAttributes, Pre, Img, Input
from webidl`history` import history
from webidl`location` import location
from webidl`timeout` import setTimeout, clearTimeout
from codeeditor import CodeEditor
from serulianmode import buildSerulianAceMode
/**
* BuildResult holds the result of calling the toolkit to perform a build.
*/
struct BuildResult {
/**
* Status is the status code from calling the toolkit executable.
*/
Status int
/**
* Output is the console output from the tookkit.
*/
Output string
/**
* GeneratedSourceFile is the generated source for the program, if `Status == 0`.
*/
GeneratedSourceFile string
/**
* GeneratedSourceMap is the generated sourcemap for the program, if `Status == 0`.
*/
GeneratedSourceMap string
}
/**
* playgroundState holds the state of the playground app or embeded editoor.
*/
struct playgroundState {
stateId string
currentCode string
initialCode string
buildResult BuildResult?
currentView string
consoleOutput string
gistInfo gistInfo?
}
/**
* gistFile represents a single file defined in a GitHub gist.
*/
struct gistFile {
content string
}
/**
* createGistArgs are the arguments to create a gist via a POST.
*/
struct createGistArgs {
files []{gistFile}
description string
public bool = true
}
/**
* gistInfo represents a small slice of the information found in a gist.
*/
struct gistInfo {
id string
html_url string
files []{gistFile}
}
/**
* playgroundBase is a base for the App and PlaygroundEditor components.
*/
agent playgroundBase for StatefulComponent {
var state playgroundState
var timerHandle Number?
var consoleOutputBuffer string?
function StateUpdated(state any) {
this.state = state.(playgroundState)
}
/**
* shareProject shares the current project by creating a gist of its source code on GitHub.
*/
function shareProject() {
UpdateComponentState(principal, this.state{stateId: 'sharing'})
// Create a public, anonymous gist containing the entered source code.
createArgs := createGistArgs{
files: []{gistFile}{"entrypoint.seru": gistFile{content: this.state.currentCode}},
description: "Serulian shared playground",
}
response, _ := Post('https://api.github.com/gists', createArgs.Stringify<json>())
if response is null {
UpdateComponentState(principal, this.state{stateId: 'couldnotshare'})
} else if response.StatusCode / 100 == 2 {
info := gistInfo.Parse<json>(response.Text)
UpdateComponentState(principal, this.state{stateId: 'editing', gistInfo: info})
// Update the page's URL to reflect the new state.
path := string(location.pathname)
if path.HasPrefix("/p/") {
history.replaceState({}, "Serulian Playground", `${string(location.origin)}/p/${info.id}`)
}
} else {
UpdateComponentState(principal, this.state{stateId: 'couldnotshare'})
}
}
/**
* runProject runs the project by sending it off for compilation and then runs it in an iframe.
*/
function runProject() {
// Set the project as running.
UpdateComponentState(principal, this.state{
stateId: 'building',
buildResult: null,
currentView: 'edit',
consoleOutput: '',
})
// Make an XHR request with the code over to the playground service.
response, _ := Post('/play/build', this.state.currentCode)
if response is null {
UpdateComponentState(principal, this.state{
stateId: 'servererror',
buildResult: null,
currentView: 'edit',
})
} else if response.StatusCode == 200 {
result := BuildResult.Parse<json>(response.Text)
UpdateComponentState(principal, this.state{
stateId: 'editing',
buildResult: result,
currentView: 'frame' if result.Status == 0 else 'output',
})
} else {
UpdateComponentState(principal, this.state{
stateId: 'servererror',
buildResult: null,
currentView: 'edit',
})
}
}
function codeChanged(value string) {
UpdateComponentState(principal, this.state{currentCode: value, gistInfo: null})
}
function updateConsole() {
buffer := this.consoleOutputBuffer ?? ''
this.consoleOutputBuffer = null
UpdateComponentState(principal, this.state{
consoleOutput: this.state.consoleOutput + buffer + '\n'.Trim(),
})
this.timerHandle = null
if this.consoleOutputBuffer is not null {
this.updateConsole()
}
}
function registerConsoleUpdater() {
if this.timerHandle is not null { return }
this.timerHandle = setTimeout(this.updateConsole, 100)
}
/**
* emitCode emits the code generated by the compiler, rendering it to the document inside
* the `iframeNode`.
*/
function emitCode(iframeNode Node) {
sourceCode := this.state.buildResult?.GeneratedSourceFile ?? ''
iframeElement := iframeNode.(HTMLIFrameElement)
iframeDoc := iframeElement.contentWindow.document
// Add the <script> tag for the compiled code.
scriptTag := iframeDoc.createElement('script')
scriptTag.setAttribute('type', 'text/javascript')
scriptTag.appendChild(iframeDoc.createTextNode(sourceCode))
iframeDoc.body.appendChild(scriptTag)
// Register a handler on the iframeElement that will be invoked from within the iframe for console.log.
iframeElement[&'handler'] = function(value any) {
// Since the value given to console.log can literally be anything,
// we access toString under it to get the toString function, then call it.
// Its ugly, but there is (currently) not a better way of dealing with
// *any* value (JS "types" for the win! /s).
var message string = 'null'
if value is not null {
rootValue := &value
message = string((rootValue->toString.(function<any>()))().(NativeString))
}
// Note: Since this method will be called without awaiting (since JS has no idea
// how to await a console.log), we buffer any console output given and have a timer update it
// batch, to prevent multiple instances of these calls running at the same time which causes
// DOM diffing issues.
this.consoleOutputBuffer = (this.consoleOutputBuffer ?? '') + message + '\n'
this.registerConsoleUpdater()
}
// Add the <script> tag for starting the program and override
// the console.log so it shows up in the playground.
startCode := `
var oldLog = window.console.log;
window.console.log = function(msg) {
oldLog.apply(this, arguments);
window.frameElement.handler(msg);
};
window.Serulian.then(function(global) {
global.playground.Run();
}).catch(function(e) {
console.error(e);
});
`
loadScriptTag := iframeDoc.createElement('script')
loadScriptTag.setAttribute('type', 'text/javascript')
loadScriptTag.appendChild(iframeDoc.createTextNode(startCode))
iframeDoc.body.appendChild(loadScriptTag)
}
}
struct playgroundAppProps {
InitialGistId string?
}
/**
* PlaygroundApp is the root component for the playground application.
*/
class PlaygroundApp with playgroundBase {
constructor Declare(props playgroundAppProps) {
initialGistId := props.InitialGistId
// Note: lack of indentation is important here so the initial code is properly
// indented in the code editor.
initialCode := `from "github.com/serulian/debuglib" import Log
function Run() any {
// Note: open the browser console to see Log outputs.
Log('hello world!')
return true
}`
app := PlaygroundApp{
playgroundBase: playgroundBase{
state: playgroundState{
stateId: 'loadinggist' if initialGistId is not null else 'editing',
initialCode: initialCode,
currentCode: '',
currentView: '',
consoleOutput: '',
},
},
}
// If there is an initial gist ID, download it from GitHub. Note that we use the setTimeout
// to ensure that the UI is rendered before we make the request.
if initialGistId is not null {
setTimeout(function() {
app.loadGist(initialGistId)
}, 50)
}
return app
}
function loadGist(initialGistId string) {
gistText, err := GetURLContents(`https://api.github.com/gists/${initialGistId}`)
if gistText is not null {
info := gistInfo.Parse<json>(gistText)
UpdateComponentState(this, this.state{
stateId: 'editing',
gistInfo: info,
initialCode: info.files['entrypoint.seru']?.content ?? this.state.initialCode,
})
return
}
UpdateComponentState(this, this.state{stateId: 'couldnotloadgist'})
}
function dismissModal() {
UpdateComponentState(this, this.state{stateId: 'editing'})
}
function Render(context Context) any {
return <Div id="rootElement">
<Div className="modal-overlay" @If={this.state.stateId == 'loadinggist'}>
<Div className="model-content">
<Div className="spinner" />
<Div className="model-overlay-message">
Please wait while we load the gist for this playground
</Div>
</Div>
</Div>
<Div className="modal-overlay"
@If={this.state.stateId == 'couldnotshare' || this.state.stateId == 'servererror' || this.state.stateId == 'couldnotloadgist'}>
<Div className="model-content">
<Div className="model-overlay-message"
@If={this.state.stateId == 'couldnotshare'}>
An error occurred while trying to share this playground
</Div>
<Div className="model-overlay-message" @If={this.state.stateId == 'servererror'}>
An error occurred while trying to build this playground
</Div>
<Div className="model-overlay-message"
@If={this.state.stateId == 'couldnotloadgist'}>
An error occurred while trying to load the gist for this playground
</Div>
<Div className="model-overlay-dismiss">
<Button className="btn btn-primary btn-large" onclick={this.dismissModal}>OKAY</Button>
</Div>
</Div>
</Div>
<Div>
<Nav className="navbar navbar-default">
<A className="navbar-brand" href="#">
<Img className="logo" src="serulian.png" />
<Span className="title">Serulian Playground</Span>
</A>
<Div className="navbar-form navbar-left" @If={this.state.stateId == 'editing'}>
<Button className="btn btn-primary" onclick={this.runProject}
@DynamicAttributes={{'disabled': this.state.currentCode.IsEmpty}}>
Run
</Button>
<Button className="btn btn-default" onclick={this.shareProject}
@DynamicAttributes={{'disabled': this.state.currentCode.IsEmpty}}
@If={this.state.gistInfo is null}>
Share (Public)
</Button>
<Span className="shared" @If={this.state.gistInfo is not null}>
<Button className="btn" disabled>Shared</Button>
<Span className="shared-url url">
<Input className="form-control" type="text"
value={'http://serulian.io/p/' + (this.state.gistInfo?.id ?? '')} readonly />
</Span>
<Span className="shared-url gist">
<Input className="form-control" type="text" value={this.state.gistInfo?.html_url}
readonly />
</Span>
</Span>
</Div>
<Div className="navbar-form navbar-left"
@If={this.state.stateId == 'building' || this.state.stateId == 'sharing'}>
<Div className="spinner" />
</Div>
</Nav>
<Div className="editor-and-viewer">
<Div className="left-col">
<Div className="code-editor-row">
<Div className="section-title">Serulian</Div>
<Div style="height: 100%;" @If={this.state.stateId != 'loadinggist'}>
<CodeEditor IsReadOnly={this.state.stateId != 'editing'}
OnChanged={this.codeChanged} Mode={buildSerulianAceMode()}
Theme="chrome">
{this.state.initialCode}
</CodeEditor>
</Div>
</Div>
<Div className="output-row">
<Div className="section-title">Build Output</Div>
<Pre className="build-result"
@If={!(this.state.buildResult?.Output?.IsEmpty ?? true)}>
{this.state.buildResult?.Output}
</Pre>
</Div>
</Div>
<Div className="right-col">
<Div className="iframe-row">
<Div className="section-title">Running Application</Div>
<Div className="enter-code"
@If={this.state.buildResult?.GeneratedSourceFile is null}
@If={this.state.stateId != 'building'}>
<Span className="glyphicon glyphicon-arrow-left" /> Enter code and hit "Run"
</Div>
<Div class="building" @If={this.state.stateId == 'building'}>
<Span className="glyphicon glyphicon-refresh" />
Building
</Div>
<IFrame ondomnodeinserted={this.emitCode}
sandbox="allow-forms allow-popups allow-scripts allow-same-origin allow-modals"
frameborder="0"
@If={!(this.state.buildResult?.GeneratedSourceFile?.IsEmpty ?? true)} />
</Div>
<Div className="console-row">
<Div className="section-title">Console Output</Div>
<Pre className="console-output" @HideIf={this.state.consoleOutput.IsEmpty}>
{this.state.consoleOutput}
</Pre>
</Div>
</Div>
</Div>
</Div>
</Div>
}
}
/**
* Start starts the playground user interface, attaching it to the given DOM element.
*/
function Start(element Element) {
var gistId string? = null
// Extract the gist ID from the URL, if applicable.
path := string(location.pathname)
if path.HasPrefix("/p/") {
gistId = path['/p/'.Length:]
if gistId?.IsEmpty ?? false {
gistId = null
}
}
RenderComponent(<PlaygroundApp InitialGistId={gistId} />, element)
}
/**
* PlaygroundEditor is a component displaying an inline playground element.
*/
class PlaygroundEditor with playgroundBase {
constructor Declare(attributes []{string}, code string) {
return PlaygroundEditor{
playgroundBase: playgroundBase{
state: playgroundState{
stateId: 'editing',
currentCode: code,
initialCode: code,
currentView: 'edit',
consoleOutput: '',
},
},
}
}
function showEditTab() {
UpdateComponentState(this, this.state{currentView: 'edit'})
}
function showOutputTab() {
UpdateComponentState(this, this.state{currentView: 'output'})
}
function showFrameTab() {
UpdateComponentState(this, this.state{currentView: 'frame'})
}
function Render(context Context) any {
// Make the height = # lines in the content * 16 + 50px, with a max of ~1000px
var height string = 'auto'
if !this.state.initialCode.IsEmpty {
lineCount := this.state.initialCode.Split('\n').Length
var heightPx int = lineCount * 16 + 50
if heightPx > 1000 {
heightPx = 1000
}
height = `${heightPx}px`
}
return <Div className="playgroundEditor" style={'height: ' + height}>
<Div className="pane" @HideIf={this.state.currentView != 'edit'}>
<CodeEditor IsReadOnly={this.state.stateId != 'editing'}
OnChanged={this.codeChanged} Theme="chrome"
Mode={buildSerulianAceMode()}>
{this.state.currentCode.Trim()}
</CodeEditor>
</Div>
<Div className="pane" @HideIf={this.state.currentView != 'output'}>
<Pre className="build-result" @If={this.state.buildResult is not null}>
{this.state.buildResult?.Output}
</Pre>
</Div>
<Div className="pane" @HideIf={this.state.currentView != 'frame'}>
<Pre className="console-output" @HideIf={this.state.consoleOutput.IsEmpty}>
{this.state.consoleOutput}
</Pre>
<IFrame ondomnodeinserted={this.emitCode}
sandbox="allow-forms allow-popups allow-scripts allow-same-origin allow-modals"
frameborder="0"
@If={!(this.state.buildResult?.GeneratedSourceFile?.IsEmpty ?? true)} />
</Div>
<Div className="toolbar">
<Ul className="tabs">
<Li className={'active' if this.state.currentView == 'edit' else ''}
onclick={this.showEditTab}>
Code
</Li>
<Li className={'active' if this.state.currentView == 'output' else ''}
onclick={this.showOutputTab} @If={this.state.buildResult is not null}>
Output
</Li>
<Li className={'active' if this.state.currentView == 'frame' else ''}
onclick={this.showFrameTab}
@If={!(this.state.buildResult?.GeneratedSourceFile?.IsEmpty ?? true)}>
Compiled
</Li>
</Ul>
<Button className="btn btn-primary" onclick={this.runProject}
@If={this.state.stateId == 'editing'}>
Run
</Button>
<Div className="spinner" @If={this.state.stateId == 'building'} />
<Div className="server-error" @If={this.state.stateId == 'servererror'}>
A server error occurred. Please try again shortly.
</Div>
</Div>
</Div>
}
}
/**
* DecorateEditors replaces all playgroundeditor tags in the DOM with an associated editor.
*/
function DecorateEditors() {
elements := document.getElementsByTagName('playgroundeditor')
for i in 0 .. int(elements.length) - 1 {
editorElement := elements[&i].(Element)
firstChild := editorElement.firstChild
var initialCode = ''
if firstChild is not null {
initialCode = string(firstChild.(Text).wholeText)
}
editorElement.setAttribute('className', '')
RenderComponent(<PlaygroundEditor>{initialCode}</PlaygroundEditor>, editorElement)
}
}
You can’t perform that action at this time.