scratchblocks
Version:
Make pictures of Scratch blocks from text.
893 lines (809 loc) • 20.8 kB
JavaScript
function assert(bool, message) {
if (!bool) {
throw new Error(`Assertion failed! ${message || ""}`)
}
}
import {
Label,
Icon,
Input,
Block,
Comment,
Glow,
Script,
Document,
} from "./model.js"
import {
allLanguages,
lookupDropdown,
hexColorPat,
minifyHash,
lookupHash,
hashSpec,
applyOverrides,
rtlLanguages,
iconPat,
blockName,
} from "./blocks.js"
function paintBlock(info, children, languages) {
let overrides = []
if (Array.isArray(children[children.length - 1])) {
overrides = children.pop()
}
// build hash
const words = []
for (const child of children) {
if (child.isLabel) {
words.push(child.value)
} else if (child.isIcon) {
words.push(`{child.name}`)
} else {
words.push("_")
}
}
const string = words.join(" ")
const shortHash = (info.hash = minifyHash(string))
// paint
const o = lookupHash(shortHash, info, children, languages)
let lang
let type
if (o) {
lang = o.lang
type = o.type
info.language = lang
info.isRTL = rtlLanguages.includes(lang.code)
if (
type.shape === "ring" ? info.shape === "reporter" : info.shape === "stack"
) {
info.shape = type.shape
}
info.category = type.category
info.categoryIsDefault = true
// store selector, used for translation among other things
if (type.selector) {
info.selector = type.selector
}
if (type.id) {
info.id = type.id
}
info.hasLoopArrow = type.hasLoopArrow
// ellipsis block
if (type.spec === ". . .") {
children = [new Label(". . .")]
}
} else {
// The block was not recognised, so we check if it's a define block.
//
// We check for built-in blocks first to avoid ambiguity, e.g. the
// `defina o tamanho como (100) %` block in pt_BR.
for (const lang of languages) {
if (!isDefineBlock(children, lang)) {
continue
}
// Setting the shape also triggers some logic in recogniseStuff.
info.shape = "define-hat"
info.category = "custom"
// Move the children of the define block into an "outline", transforming
// () and [] shapes as we go.
const outlineChildren = children
.splice(
lang.definePrefix.length,
children.length - lang.defineSuffix.length,
)
.map(child => {
if (child.isInput && child.isBoolean) {
// Convert empty boolean slot to empty boolean argument.
child = paintBlock(
{
shape: "boolean",
argument: "boolean",
category: "custom-arg",
},
[new Label("")],
languages,
)
} else if (
child.isInput &&
(child.shape === "string" || child.shape === "number")
) {
// Convert string inputs to string arguments, number inputs to number arguments.
const labels = child.value.split(/ +/g).map(word => new Label(word))
child = paintBlock(
{
shape: "reporter",
argument: child.shape === "string" ? "string" : "number",
category: "custom-arg",
},
labels,
languages,
)
} else if (child.isReporter || child.isBoolean) {
// Convert variables to number arguments, predicates to boolean arguments.
if (child.info.categoryIsDefault) {
child.info.category = "custom-arg"
child.info.argument = child.isBoolean ? "boolean" : "number"
}
}
return child
})
const outlineInfo = {
shape: "outline",
category: "custom",
categoryIsDefault: true,
hasLoopArrow: false,
}
const outline = new Block(outlineInfo, outlineChildren)
children.splice(lang.definePrefix.length, 0, outline)
break
}
}
// Apply overrides.
applyOverrides(info, overrides)
// loop arrows
if (info.hasLoopArrow) {
children.push(new Icon("loopArrow"))
}
const block = new Block(info, children)
// image replacement
if (type && iconPat.test(type.spec)) {
block.translate(lang, true)
}
// diffs
if (info.diff === "+") {
return new Glow(block)
}
block.diff = info.diff
return block
}
function isDefineBlock(children, lang) {
if (children.length < lang.definePrefix.length) {
return false
}
if (children.length < lang.defineSuffix.length) {
return false
}
for (let i = 0; i < lang.definePrefix.length; i++) {
const defineWord = lang.definePrefix[i]
const child = children[i]
if (!child.isLabel || minifyHash(child.value) !== minifyHash(defineWord)) {
return false
}
}
for (let i = 1; i <= lang.defineSuffix.length; i++) {
const defineWord = lang.defineSuffix[lang.defineSuffix.length - i]
const child = children[children.length - i]
if (!child.isLabel || minifyHash(child.value) !== minifyHash(defineWord)) {
return false
}
}
return true
}
function parseLines(code, languages) {
let tok = code[0]
let index = 0
function next() {
tok = code[++index]
}
function peek() {
return code[index + 1]
}
function peekNonWs() {
for (let i = index + 1; i < code.length; i++) {
if (code[i] !== " ") {
return code[i]
}
}
}
let sawNL
let define = []
languages.map(lang => {
define = define.concat(lang.define)
})
function makeBlock(shape, children) {
const hasInputs = children.filter(x => !x.isLabel).length
const info = {
shape: shape,
category: shape === "reporter" && !hasInputs ? "variables" : "obsolete",
categoryIsDefault: true,
hasLoopArrow: false,
}
return paintBlock(info, children, languages)
}
function makeMenu(shape, value) {
const menu = lookupDropdown(value, languages) || value
return new Input(shape, value, menu)
}
function pParts(end) {
const children = []
let label
while (tok && tok !== "\n") {
// So that comparison operators `<()<()>` and `<()>()>` don't need the
// central <> escaped, we interpret it as a label if particular
// conditions are met.
if (
(tok === "<" || tok === ">") &&
end === ">" && // We're parsing a predicate.
children.length === 1 && // There's exactly one AST node behind us.
!children[children.length - 1].isLabel // That node is not a label.
) {
const c = peekNonWs()
// The next token starts some kind of input.
if (c === "[" || c === "(" || c === "<" || c === "{") {
label = null
children.push(new Label(tok))
next()
continue
}
}
if (tok === end) {
break
}
if (tok === "/" && peek() === "/" && !end) {
break
}
switch (tok) {
case "[":
label = null
children.push(pString())
break
case "(":
label = null
children.push(pReporter())
break
case "<":
label = null
children.push(pPredicate())
break
case "{":
label = null
children.push(pEmbedded())
break
case " ":
case "\t":
next() // Skip over whitespace.
label = null
break
case "◂":
case "▸":
children.push(pIcon())
label = null
break
case "@": {
next()
let name = ""
while (tok && /[a-zA-Z]/.test(tok)) {
name += tok
next()
}
if (name === "cloud") {
children.push(new Label("☁"))
} else {
children.push(
Object.prototype.hasOwnProperty.call(Icon.icons, name)
? new Icon(name)
: new Label(`{name}`),
)
}
label = null
break
}
case "\\":
next() // escape character
// fallthrough
case ":":
if (tok === ":" && peek() === ":") {
children.push(pOverrides(end))
return children
}
// fallthrough
default:
if (!label) {
children.push((label = new Label("")))
}
label.value += tok
next()
}
}
return children
}
function pString() {
next() // '['
let s = ""
let escapeV = false
while (tok && tok !== "]" && tok !== "\n") {
if (tok === "\\") {
next()
if (tok === "v") {
escapeV = true
}
if (!tok) {
break
}
} else {
escapeV = false
}
s += tok
next()
}
if (tok === "]") {
next()
}
if (hexColorPat.test(s)) {
return new Input("color", s)
}
return !escapeV && / v$/.test(s)
? makeMenu("dropdown", s.slice(0, s.length - 2))
: new Input("string", s)
}
function pBlock(end) {
const children = pParts(end)
if (tok && tok === "\n") {
sawNL = true
next()
}
if (children.length === 0) {
return
}
// standalone reporters
if (children.length === 1) {
const child = children[0]
if (
child.isBlock &&
(child.isReporter || child.isBoolean || child.isRing)
) {
return child
}
}
return makeBlock("stack", children)
}
function pReporter() {
next() // '('
// empty number-dropdown
if (tok === " ") {
next()
if (tok === "v" && peek() === ")") {
next()
next()
return new Input("number-dropdown", "")
}
}
const children = pParts(")")
if (tok && tok === ")") {
next()
}
// empty numbers
if (children.length === 0) {
return new Input("number", "")
}
// number
if (children.length === 1 && children[0].isLabel) {
const value = children[0].value
if (/^[0-9e.-]*$/.test(value)) {
return new Input("number", value)
}
if (hexColorPat.test(value)) {
return new Input("color", value)
}
}
// number-dropdown
if (children.length > 1 && children.every(child => child.isLabel)) {
const last = children[children.length - 1]
if (last.value === "v") {
children.pop()
const value = children.map(l => l.value).join(" ")
return makeMenu("number-dropdown", value)
}
}
const block = makeBlock("reporter", children)
// rings
if (block.info && block.info.shape === "ring") {
const first = block.children[0]
if (
first &&
first.isInput &&
first.shape === "number" &&
first.value === ""
) {
block.children[0] = new Input("reporter")
} else if (
(first && first.isScript && first.isEmpty) ||
(first && first.isBlock && !first.children.length)
) {
block.children[0] = new Input("stack")
}
}
return block
}
function pPredicate() {
next() // '<'
const children = pParts(">")
if (tok && tok === ">") {
next()
}
if (children.length === 0) {
return new Input("boolean")
}
return makeBlock("boolean", children)
}
function pEmbedded() {
next() // '{'
sawNL = false
const f = function () {
while (tok && tok !== "}") {
const block = pBlock("}")
if (block) {
return block
}
}
}
const scripts = parseScripts(f)
let blocks = []
scripts.forEach(script => {
blocks = blocks.concat(script.blocks)
})
if (tok === "}") {
next()
}
if (!sawNL) {
assert(blocks.length <= 1)
return blocks.length ? blocks[0] : makeBlock("stack", [])
}
return new Script(blocks)
}
function pIcon() {
const c = tok
next()
switch (c) {
case "▸":
return new Icon("addInput")
case "◂":
return new Icon("delInput")
default:
return
}
}
function pOverrides(end) {
next()
next()
const overrides = []
let override = ""
while (tok && tok !== "\n" && tok !== end) {
if (tok === " ") {
if (override) {
overrides.push(override)
override = ""
}
} else if (tok === "/" && peek() === "/") {
break
} else {
override += tok
}
next()
}
if (override) {
overrides.push(override)
}
return overrides
}
function pComment(end) {
next()
next()
let comment = ""
while (tok && tok !== "\n" && tok !== end) {
comment += tok
next()
}
if (tok && tok === "\n") {
next()
}
return new Comment(comment, true)
}
function pLine() {
let diff
if (tok === "+" || tok === "-") {
diff = tok
next()
}
const block = pBlock()
if (tok === "/" && peek() === "/") {
const comment = pComment()
comment.hasBlock = block && block.children.length
if (!comment.hasBlock) {
return comment
}
block.comment = comment
}
if (block) {
block.diff = diff
}
return block
}
return () => {
if (!tok) {
return undefined
}
const line = pLine()
return line || "NL"
}
}
/* * */
function parseScripts(getLine) {
let line = getLine()
function next() {
line = getLine()
}
function pFile() {
while (line === "NL") {
next()
}
const scripts = []
while (line) {
let blocks = []
while (line && line !== "NL") {
let b = pLine()
const isGlow = b.diff === "+"
if (isGlow) {
b.diff = null
}
if (b.isElse || b.isEnd) {
b = new Block({ ...b.info, shape: "stack" }, b.children)
}
if (isGlow) {
const last = blocks[blocks.length - 1]
let children = []
if (last && last.isGlow) {
blocks.pop()
children = last.child.isScript ? last.child.blocks : [last.child]
}
children.push(b)
blocks.push(new Glow(new Script(children)))
} else if (b.isHat) {
if (blocks.length) {
scripts.push(new Script(blocks))
}
blocks = [b]
} else if (b.isFinal) {
blocks.push(b)
break
} else if (b.isCommand) {
blocks.push(b)
} else {
// reporter or predicate
if (blocks.length) {
scripts.push(new Script(blocks))
}
scripts.push(new Script([b]))
blocks = []
break
}
}
if (blocks.length) {
scripts.push(new Script(blocks))
}
while (line === "NL") {
next()
}
}
return scripts
}
function pLine() {
const b = line
next()
if (b.hasScript) {
while (true) {
const blocks = pMouth()
b.children.push(new Script(blocks))
if (line && line.isElse) {
for (const child of line.children) {
b.children.push(child)
}
next()
continue
}
if (line && line.isEnd) {
next()
}
break
}
}
return b
}
function pMouth() {
const blocks = []
while (line) {
if (line === "NL") {
next()
continue
}
if (!line.isCommand) {
return blocks
}
const b = pLine()
const isGlow = b.diff === "+"
if (isGlow) {
b.diff = null
}
if (isGlow) {
const last = blocks[blocks.length - 1]
let children = []
if (last && last.isGlow) {
blocks.pop()
children = last.child.isScript ? last.child.blocks : [last.child]
}
children.push(b)
blocks.push(new Glow(new Script(children)))
} else {
blocks.push(b)
}
}
return blocks
}
return pFile()
}
/* * */
function eachBlock(x, cb) {
if (x.isScript) {
x.blocks = x.blocks.map(block => {
eachBlock(block, cb)
return cb(block) || block
})
} else if (x.isBlock) {
x.children = x.children.map(child => {
eachBlock(child, cb)
return cb(child) || child
})
} else if (x.isGlow) {
eachBlock(x.child, cb)
}
}
const listBlocks = {
"append:toList:": 1,
"deleteLine:ofList:": 1,
"insert:at:ofList:": 2,
"setLine:ofList:to:": 1,
"showList:": 0,
"hideList:": 0,
}
function recogniseStuff(scripts) {
const customBlocksByHash = Object.create(null)
const listNames = new Set()
scripts.forEach(script => {
const customArgs = new Set()
eachBlock(script, block => {
if (!block.isBlock) {
return
}
// custom blocks
if (block.info.shape === "define-hat") {
// There should be exactly one `outline` child, added in paintBlock.
const outline = block.children.find(child => child.isOutline)
if (!outline) {
return
}
const names = []
const parts = []
for (const child of outline.children) {
if (child.isLabel) {
parts.push(child.value)
} else if (child.isBlock) {
if (!child.info.argument) {
return
}
parts.push(
{
number: "%n",
string: "%s",
boolean: "%b",
}[child.info.argument],
)
const name = blockName(child)
names.push(name)
customArgs.add(name)
}
}
const spec = parts.join(" ")
const hash = hashSpec(spec)
const info = {
spec: spec,
names: names,
}
if (!customBlocksByHash[hash]) {
customBlocksByHash[hash] = info
}
block.info.id = "PROCEDURES_DEFINITION"
block.info.selector = "procDef"
block.info.call = info.spec
block.info.names = info.names
block.info.category = "custom"
// custom arguments
} else if (
block.info.categoryIsDefault &&
(block.isReporter || block.isBoolean)
) {
const name = blockName(block)
if (customArgs.has(name)) {
block.info.category = "custom-arg"
block.info.categoryIsDefault = false
block.info.selector = "getParam"
}
// list names
} else if (
Object.prototype.hasOwnProperty.call(listBlocks, block.info.selector)
) {
const argIndex = listBlocks[block.info.selector]
const inputs = block.children.filter(child => !child.isLabel)
const input = inputs[argIndex]
if (input && input.isInput) {
listNames.add(input.value)
}
}
})
})
scripts.forEach(script => {
eachBlock(script, block => {
if (
block.info &&
block.info.categoryIsDefault &&
block.info.category === "obsolete"
) {
// custom blocks
const info = customBlocksByHash[block.info.hash]
if (info) {
block.info.selector = "call"
block.info.call = info.spec
block.info.names = info.names
block.info.category = "custom"
}
return
}
let name, info
if (
block.isReporter &&
block.info.category === "variables" &&
block.info.categoryIsDefault
) {
// We set the selector here for some reason
block.info.selector = "readVariable"
name = blockName(block)
info = block.info
}
if (!name) {
return
}
// list reporters
if (listNames.has(name)) {
info.category = "list"
info.categoryIsDefault = false
info.selector = "contentsOfList:"
}
return // already done
})
})
}
export function parse(code, options) {
options = {
inline: false,
languages: ["en"],
...options,
}
if (options.dialect) {
throw new Error("Option 'dialect' no longer supported")
}
code = code.replace(/</g, "<")
code = code.replace(/>/g, ">")
if (options.inline) {
code = code.replace(/\n/g, " ")
}
const languages = options.languages.map(code => {
const lang = allLanguages[code]
if (!lang) {
throw new Error(`Unknown language: '${code}'`)
}
return lang
})
/* * */
const f = parseLines(code, languages)
const scripts = parseScripts(f)
recogniseStuff(scripts)
return new Document(scripts)
}