Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions app/assets/javascripts/beak/nlogo-file.coffee
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#
# NlogoFile class is a utility class to handle NetLogo files.
# This is not a full implementation. Expand as necessary.
#
class AbstractNlogoFile
constructor: (source) ->
@source = source

getSource: ->
return @source

getCode: ->
return 'dummy'

class NlogoFile extends AbstractNlogoFile
constructor: (source) ->
super(source)
@delimiter = "@#$#@#$#@"

getSource: ->
return @source

getCode: ->
# Extract the code from the source using the delimiter
parts = @source.split(@delimiter)
if parts.length > 1
return parts[0].trim()
else
return @source.trim()

class NlogoXFile extends AbstractNlogoFile
constructor: (source) ->
super(source)
parser = new DOMParser()
@doc = parser.parseFromString(source, "text/xml")
errorNode = @doc.querySelector("parsererror")
if errorNode
throw new Error("Invalid Nlogo XML: " + errorNode.textContent)

getSource: ->
return @source

getCode: ->
codeElement = @doc.querySelector("code")
codeText = codeElement.innerHTML
code = if not codeText.startsWith("<![CDATA[")
codeText
else
codeText.slice("<![CDATA[".length, -1 * ("]]>".length))

return code

export { NlogoXFile, NlogoFile }
177 changes: 177 additions & 0 deletions app/assets/javascripts/beak/nlw-extensions-loader.coffee
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@



# This is a singleton class for managing NetLogo Web (NLW) extensions.
# There is a few, unfortunately, global objects that we have to depend on:
# 1. Extensions. -- Managed by Tortoise Engine
# 2. URLExtensionsRepo -- Managed by Galapagos
#
# I tried to keep this file the main source of truth for NLW extensions as
# much as possible. This works hand-in-hand with the Tortoise Engine, particularly
# the NLWExtensionsManager class, which is responsible for managing the
# extensions in the Tortoise Engine.
#
# A lot of the functionality here is part of an API used by the Tortoise Engine.
# I could've implemented those in Scala, but I chose not to do so because
# I wanted to keep the NLW extensions management logic in JavaScript, where
# it is easier to work with URLs and dynamic imports.
# Also, because we cannot trigger asynchronous work in Scala and support in JavaScript
# easily––at least without the browser complaining about it––the `loadURLExtensions`
# had to be implemented in JavaScript anyways.
#
# - Omar Ibrahim, July 2025
#
class NLWExtensionsLoader
@instance: null
@allowedExtensions = ["js"]
constructor: (compiler) ->
if NLWExtensionsLoader.instance?
return NLWExtensionsLoader.instance

NLWExtensionsLoader.instance = this
window.NLWExtensionsLoader = NLWExtensionsLoader.instance

@compiler = compiler
@urlRepo = {}

loadURLExtensions: (source) ->
urlRepo = @urlRepo
extensions = @compiler.listExtensions(source)
url_extensions = Object.fromEntries(await Promise.all(extensions
.filter((ext) -> ext.url isnt null)
.map((ext) =>
{name, url} = ext
url = @normalizeURL(@removeURLProtocol(url))
baseName = NLWExtensionsLoader.getBaseNameFromURL(url)
primURL = NLWExtensionsLoader.getPrimitiveJSONSrc(url)
if urlRepo[url]?
# If the extension is already loaded, just return it
return [url, urlRepo[url]]
# We want to get a lazy loader for the extension,
# and fetch the primitives JSON file before we
# trigger the recompilation.
return Promise.resolve().then(() ->
extensionModule = await NLWExtensionsLoader.getModuleFromURL(url)
prims = await NLWExtensionsLoader.fetchPrimitives(primURL)
NLWExtensionsLoader.confirmNamesMatch(prims, name)
return [url, {
extensionModule,
prims
}]
)
)
))

Object.assign(@urlRepo, url_extensions)
NLWExtensionsLoader.updateGlobalExtensionsObject(url_extensions)
url_extensions

# Public API
getPrimitivesFromURL: (url) ->
# Get the primitives JSON file from the URL, if it exists
url = @normalizeURL(url)
if @urlRepo[url]?
return @urlRepo[url].prims
else
return null

getExtensionModuleFromURL: (url) ->
# Get the extension module from the URL, if it exists
url = @normalizeURL(url)
if @urlRepo[url]?
return @urlRepo[url].extensionModule
else
return null

appendURLProtocol: (url) ->
if not url.startsWith("url://")
return "url://" + url
else
return url

removeURLProtocol: (name) ->
# Remove the "url://" protocol from the name
if name.startsWith("url://")
return name.slice("url://".length)
else
return name

isURL: (name) ->
return name.startsWith("url://")

validateURL: (url) ->
try
url = @normalizeURL(url)
fileExtension = url.split('.').pop().toLowerCase()
if NLWExtensionsLoader.allowedExtensions.includes(fileExtension)
return true
else
console.error("Invalid file extension: #{fileExtension}. " +
"Allowed extensions are: #{@allowedExtensions.join(', ')}"
)
return false
catch e
console.error("Invalid URL: #{url} - #{e.message}")
return false

normalizeURL: (url) ->
# Remove Search Params and Hash from the URL
try
url = @removeURLProtocol(url)
uri = new URL(url)
uri.search = ''
uri.hash = ''
return uri.toString()
catch e
console.error("Error normalizing URL: #{url} - #{e.message}")
return url

# Helpers
@getModuleFromURL: (url) ->
# Get the module from the URL, if it exists
extensionImport = await import(url)
extensionKeys = Object.keys(extensionImport)
if extensionKeys.length > 0
return extensionImport[extensionKeys[0]]
else
throw new Error("Extension module at #{url} does not export anything.")

@updateGlobalExtensionsObject: (url_extensions) ->
# Update the global Extensions object with the new URL extensions
if not window.URLExtensionsRepo?
window.URLExtensionsRepo = {}
Object.assign(window.URLExtensionsRepo, url_extensions)

@confirmNamesMatch: (primitives, name) ->
# Check if the primitives JSON file name matches the extension name
if primitives?.name.toLowerCase() isnt name.toLowerCase()
console.warn("Primitives JSON file name '#{primitives.name.toLowerCase()}' " +
"does not match extension import name '#{name.toLowerCase()}'")

@fetchPrimitives: (primURL) ->
try
response = await fetch(primURL)
if response.ok
return await response.json()
else
throw new Error("Failed to fetch primitives from #{primURL}: HTTP #{response.status}")
catch ex
console.error("Error fetching primitives from #{primURL}: #{ex.message}")
return null

@getBaseNameFromURL: (url) ->
# Remove the file extension from the URL
# to get the base name. By convention, the
# primitives JSON file is named after the extension
# base name, with a .json extension.
# e.g. "my-extension.js" becomes "my-extension.json"
url.split('.').slice(0, -1).join('.')

@getPrimitiveJSONSrc: (url) ->
# Get the base name from the URL and append '.json'
baseName = NLWExtensionsLoader.getBaseNameFromURL(url)
return "#{baseName}.json"


# Exports
export default NLWExtensionsLoader
8 changes: 6 additions & 2 deletions app/assets/javascripts/beak/session-lite.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ class SessionLite
# (Tortoise, Element|String, BrowserCompiler, Array[Rewriter], Array[Listener], Array[Widget],
# String, String, Boolean, String, String, NlogoSource, String, Boolean)
constructor: (@tortoise, container, @compiler, @rewriters, listeners, widgets,
code, info, isReadOnly, @locale, workInProgressState, @nlogoSource, modelJS, lastCompileFailed) ->
code, info, isReadOnly, @locale, workInProgressState, @nlogoSource, modelJS, lastCompileFailed,
@onBeforeRecompile) ->

@hnw = new HNWSession( (() => @widgetController)
, ((ps) => @compiler.compilePlots(ps)))
Expand Down Expand Up @@ -231,7 +232,6 @@ class SessionLite
if @widgetController.ractive.get('isEditing') and @hnw.isHNW()
parent.postMessage({ type: "recompile" }, "*")
else

code = @widgetController.code()
oldWidgets = @widgetController.widgets()
rewritten = @rewriteCode(code)
Expand All @@ -251,6 +251,10 @@ class SessionLite
@widgetController.ractive.fire('recompile-start', source, rewritten, code)

try
# # Execute the onBeforeRecompile callbacks
for callback in @onBeforeRecompile
await callback(source, rewritten, code)

res = @compiler.fromModel(compileParams)
if res.model.success

Expand Down
Loading