Working on new Template

This commit is contained in:
JesseBrault0709 2023-01-16 20:05:10 -06:00
parent 2097e280e3
commit 8d37d8883d
26 changed files with 1268 additions and 63 deletions

View File

@ -1,4 +0,0 @@
subprojects {
group = 'com.jessebrault.ssg'
version = '0.0.1'
}

26
buildSrc/build.gradle Normal file
View File

@ -0,0 +1,26 @@
plugins {
id 'groovy-gradle-plugin'
}
repositories {
maven {
url 'https://archiva.jessebrault.com/repository/internal/'
credentials {
username System.getenv('JBARCHIVA_USERNAME')
password System.getenv('JBARCHIVA_PASSWORD')
}
}
maven {
url 'https://archiva.jessebrault.com/repository/snapshots/'
credentials {
username System.getenv('JBARCHIVA_USERNAME')
password System.getenv('JBARCHIVA_PASSWORD')
}
}
}
dependencies {
implementation 'com.jessebrault.jbarchiva:jbarchiva:0.0.2'
}

View File

@ -0,0 +1,29 @@
plugins {
id 'com.jessebrault.jbarchiva'
id 'groovy'
}
group 'com.jessebrault.ssg'
version '0.0.1'
repositories {
mavenCentral()
}
dependencies {
// https://mvnrepository.com/artifact/org.apache.groovy/groovy
implementation 'org.apache.groovy:groovy:4.0.7'
/**
* TESTING
*/
// https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter-api
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2'
// https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter-engine
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2'
}
test {
useJUnitPlatform()
}

View File

@ -0,0 +1,33 @@
plugins {
id 'java-library'
}
repositories {
mavenCentral()
}
dependencies {
/**
* Logging
*/
// https://mvnrepository.com/artifact/org.slf4j/slf4j-api
implementation 'org.slf4j:slf4j-api:1.7.36'
/**
* Test Logging
*/
// https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-slf4j-impl
testRuntimeOnly 'org.apache.logging.log4j:log4j-slf4j-impl:2.19.0'
// https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-core
testRuntimeOnly 'org.apache.logging.log4j:log4j-core:2.19.0'
/**
* Mockito
*/
// https://mvnrepository.com/artifact/org.mockito/mockito-core
testImplementation 'org.mockito:mockito-core:4.11.0'
// https://mvnrepository.com/artifact/org.mockito/mockito-junit-jupiter
testImplementation 'org.mockito:mockito-junit-jupiter:4.11.0'
}

View File

@ -1,5 +1,5 @@
plugins {
id 'groovy'
id 'ssg.common'
id 'application'
}
@ -10,9 +10,6 @@ repositories {
dependencies {
implementation project(':lib')
// https://mvnrepository.com/artifact/org.apache.groovy/groovy
implementation 'org.apache.groovy:groovy:4.0.7'
// https://mvnrepository.com/artifact/info.picocli/picocli
implementation 'info.picocli:picocli:4.7.0'
@ -27,15 +24,6 @@ dependencies {
// https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-core
implementation 'org.apache.logging.log4j:log4j-core:2.19.0'
/**
* TESTING
*/
// https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter-api
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2'
// https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter-engine
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2'
}
application {
@ -49,10 +37,7 @@ jar {
distributions {
main {
//noinspection GroovyAssignabilityCheck
distributionBaseName = 'ssg'
}
}
test {
useJUnitPlatform()
}

View File

@ -1,5 +1,6 @@
plugins {
id 'groovy'
id 'ssg.common'
id 'ssg.lib'
}
repositories {
@ -7,9 +8,6 @@ repositories {
}
dependencies {
// https://mvnrepository.com/artifact/org.apache.groovy/groovy
implementation 'org.apache.groovy:groovy:4.0.7'
// https://mvnrepository.com/artifact/org.apache.groovy/groovy-templates
implementation 'org.apache.groovy:groovy-templates:4.0.7'
@ -18,45 +16,8 @@ dependencies {
// https://mvnrepository.com/artifact/org.commonmark/commonmark-ext-yaml-front-matter
implementation 'org.commonmark:commonmark-ext-yaml-front-matter:0.21.0'
/**
* Logging
*/
// https://mvnrepository.com/artifact/org.slf4j/slf4j-api
implementation 'org.slf4j:slf4j-api:1.7.36'
/**
* TESTING
*/
// https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter-api
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2'
// https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter-engine
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2'
/**
* Mockito
*/
// https://mvnrepository.com/artifact/org.mockito/mockito-core
testImplementation 'org.mockito:mockito-core:4.11.0'
// https://mvnrepository.com/artifact/org.mockito/mockito-junit-jupiter
testImplementation 'org.mockito:mockito-junit-jupiter:4.11.0'
/**
* Test Logging
*/
// https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-slf4j-impl
testRuntimeOnly 'org.apache.logging.log4j:log4j-slf4j-impl:2.19.0'
// https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-core
testRuntimeOnly 'org.apache.logging.log4j:log4j-core:2.19.0'
}
jar {
archivesBaseName = 'ssg-lib'
}
test {
useJUnitPlatform()
}

View File

@ -1,2 +1,2 @@
rootProject.name = 'ssg'
include 'cli', 'lib'
include 'cli', 'lib', 'template'

26
template/build.gradle Normal file
View File

@ -0,0 +1,26 @@
plugins {
id 'ssg.common'
id 'ssg.lib'
}
repositories {
mavenCentral()
}
dependencies {
// https://mvnrepository.com/artifact/org.apache.groovy/groovy
implementation 'org.apache.groovy:groovy:4.0.7'
// https://mvnrepository.com/artifact/org.apache.groovy/groovy-templates
implementation 'org.apache.groovy:groovy-templates:4.0.7'
// https://archiva.jessebrault.com/#artifact/com.jessebrault.fsm/lib/0.1.0-SNAPSHOT
implementation 'com.jessebrault.fsm:lib:0.1.0-SNAPSHOT'
// https://archiva.jessebrault.com/#artifact/com.jessebrault.fsm/groovy-extension/0.1.0-SNAPSHOT
implementation 'com.jessebrault.fsm:groovy-extension:0.1.0-SNAPSHOT'
}
jar {
archivesBaseName = 'ssg-template'
}

View File

@ -0,0 +1,5 @@
package com.jessebrault.ssg.template.gspe
interface Component {
String render(Map<String, ?> attr, String body)
}

View File

@ -0,0 +1,5 @@
package com.jessebrault.ssg.template.gspe
interface ComponentFactory {
Component get()
}

View File

@ -0,0 +1,256 @@
package com.jessebrault.ssg.template.gspe
import groovy.transform.PackageScope
import static com.jessebrault.ssg.template.gspe.ComponentToken.Type.*
/**
* NOT thread safe
*/
@PackageScope
class ComponentParser {
private Queue<ComponentToken> tokens
private StringBuilder b
private String identifier
String parse(List<ComponentToken> tokens) {
this.b = new StringBuilder()
this.tokens = new LinkedList<>(tokens)
this.selfClosingComponent()
this.b.toString()
}
String parse(List<ComponentToken> openingTokens, String bodyClosure, List<ComponentToken> closingTokens) {
this.b = new StringBuilder()
this.tokens = new LinkedList<>(openingTokens)
this.openingComponent()
this.b << "bodyOut << ${ bodyClosure };"
this.tokens = new LinkedList<>(closingTokens)
this.closingComponent()
this.b.toString()
}
private static void error(Collection<ComponentToken.Type> expectedTypes, ComponentToken actual) {
throw new RuntimeException("expected ${ expectedTypes.join(' or ') } but got ${ actual ? "'${ actual }'" : 'null' }")
}
private void selfClosingComponent() {
this.startOfOpeningOrSelfClosingComponent()
this.keysAndValues()
def t0 = this.tokens.poll()
if (!t0 || t0.type != FORWARD_SLASH) {
error([FORWARD_SLASH], t0)
} else {
def t1 = tokens.poll()
if (!t1 || t1.type != GT) {
error([GT], t1)
} else {
this.b << '};'
}
}
}
private void openingComponent() {
this.startOfOpeningOrSelfClosingComponent()
this.keysAndValues()
def t0 = tokens.poll()
if (!t0 || t0.type != GT) {
error([GT], t0)
}
}
private void closingComponent() {
def t0 = this.tokens.poll()
if (!t0 || t0.type != LT) {
error([LT], t0)
} else {
def t1 = this.tokens.poll()
if (!t1 || t1.type != FORWARD_SLASH) {
error([FORWARD_SLASH], t1)
} else {
def t2 = this.tokens.poll()
if (!t2 || t2.type != IDENTIFIER) {
error([IDENTIFIER], t2)
} else if (t2.text != identifier) {
throw new RuntimeException("expected '${ this.identifier }' but got '${ t2.text }'")
} else {
def t3 = this.tokens.poll()
if (!t3 || t3.type != GT) {
error([GT], t3)
} else {
this.b << '};'
}
}
}
}
}
private void startOfOpeningOrSelfClosingComponent() {
def t0 = this.tokens.poll()
if (!t0 || t0.type != LT) {
error([LT], t0)
} else {
def t1 = this.tokens.poll()
if (!t1 || t1.type != IDENTIFIER) {
error([IDENTIFIER], t1)
} else {
this.identifier = t1.text
this.b << "renderComponent('${ this.identifier }') { attr, bodyOut ->\n"
}
}
}
private void keysAndValues() {
while (true) {
def t0 = this.tokens.peek()
if (!t0 || !t0.type.isAnyOf([KEY, FORWARD_SLASH])) {
error([KEY, FORWARD_SLASH], t0)
} else if (t0.type == KEY) {
this.keyAndValue()
} else if (t0.type == FORWARD_SLASH) {
break
}
}
}
@PeekBefore(KEY)
private void keyAndValue() {
def t0 = this.tokens.remove()
if (t0.type != KEY) {
throw new RuntimeException('programmer error')
} else {
def t1 = this.tokens.poll()
if (!t1 || t1.type != EQUALS) {
error([EQUALS], t1)
} else {
this.b << "attr['${ t0.text }'] = "
this.value()
}
}
}
private void value() {
def t0 = this.tokens.peek()
if (!t0 || !t0.type.isAnyOf([DOUBLE_QUOTE, SINGLE_QUOTE, DOLLAR, LT])) {
error([DOUBLE_QUOTE, SINGLE_QUOTE, DOLLAR, LT], t0)
} else if (t0.type == DOUBLE_QUOTE) {
this.doubleQuoteStringValue()
} else if (t0.type == SINGLE_QUOTE) {
this.singleQuoteStringValue()
} else if (t0.type == DOLLAR) {
this.dollarValue()
} else if (t0.type == LT) {
this.scriptletValue()
}
}
@PeekBefore(DOUBLE_QUOTE)
private void doubleQuoteStringValue() {
def t0 = this.tokens.remove()
if (t0.type != DOUBLE_QUOTE) {
throw new RuntimeException('programmer error')
} else {
def t1 = this.tokens.poll()
if (!t1 || t1.type != STRING) {
error([STRING], t1)
} else {
def t2 = this.tokens.poll()
if (!t2 || t2.type != DOUBLE_QUOTE) {
error([DOUBLE_QUOTE], t2)
} else {
this.b << /"${ t1.text }";/ + '\n'
}
}
}
}
@PeekBefore(SINGLE_QUOTE)
private void singleQuoteStringValue() {
def t0 = this.tokens.remove()
if (t0.type != SINGLE_QUOTE) {
throw new RuntimeException('programmer error')
} else {
def t1 = tokens.poll()
if (!t1 || t1.type != STRING) {
error([STRING], t1)
} else {
def t2 = this.tokens.poll()
if (!t2 || t2.type != SINGLE_QUOTE) {
error([SINGLE_QUOTE], t2)
} else {
this.b << /'${ t1.text }';/ + '\n'
}
}
}
}
@PeekBefore(DOLLAR)
private void dollarValue() {
def t0 = this.tokens.remove()
if (t0.type != DOLLAR) {
throw new RuntimeException('programmer error')
} else {
def t1 = this.tokens.poll()
if (!t1 || t1.type != CURLY_OPEN) {
error([CURLY_OPEN], t1)
} else {
def t2 = this.tokens.poll()
if (!t2 || t2.type != GROOVY) {
error([GROOVY], t2)
} else {
def t3 = this.tokens.poll()
if (!t3 || t3.type != CURLY_CLOSE) {
error([CURLY_CLOSE], t3)
} else {
this.b << "${ t2.text };\n"
}
}
}
}
}
@PeekBefore(LT)
private void scriptletValue() {
def t0 = this.tokens.remove()
if (t0.type != LT) {
throw new RuntimeException('programmer error')
} else {
def t1 = this.tokens.poll()
if (!t1 || t1.type != PERCENT) {
error([PERCENT], t1)
} else {
def t2 = this.tokens.poll()
if (!t2 || t2.type != EQUALS) {
error([EQUALS], t2)
} else {
def t3 = this.tokens.poll()
if (!t3 || t3.type != GROOVY) {
error([GROOVY], t3)
} else {
def t4 = this.tokens.poll()
if (!t4.type || t4.type != PERCENT) {
error([PERCENT], t4)
} else {
def t5 = this.tokens.poll()
if (!t5 || t5.type != GT) {
error([GT], t5)
} else {
this.b << "${ t3.text };\n"
}
}
}
}
}
}
}
}

View File

@ -0,0 +1,35 @@
package com.jessebrault.ssg.template.gspe
import groovy.transform.TupleConstructor
@TupleConstructor(defaults = false)
class ComponentToken {
enum Type {
LT,
GT,
IDENTIFIER,
KEY,
EQUALS,
DOUBLE_QUOTE,
SINGLE_QUOTE,
STRING,
DOLLAR,
CURLY_OPEN,
GROOVY,
CURLY_CLOSE,
GROOVY_IDENTIFIER,
PERCENT,
FORWARD_SLASH
;
boolean isAnyOf(Collection<Type> types) {
types.contains(this)
}
}
Type type
String text
}

View File

@ -0,0 +1,176 @@
package com.jessebrault.ssg.template.gspe
import com.jessebrault.fsm.function.FunctionFsmBuilder
import com.jessebrault.fsm.function.FunctionFsmBuilderImpl
import static ComponentToken.Type
class ComponentTokenizer {
private static final PatternFunction lessThan = new PatternFunction(~/^</)
private static final PatternFunction greaterThan = new PatternFunction(~/^>/)
private static final PatternFunction identifier = new PatternFunction(~/^\p{Lu}.*?(?=\s|\/)/)
private static final PatternFunction whitespace = new PatternFunction(~/^\s+/)
private static final PatternFunction key = new PatternFunction(~/^[\p{L}0-9_\-]+/)
private static final PatternFunction equals = new PatternFunction(~/^=/)
private static final PatternFunction doubleQuote = new PatternFunction(~/^"/)
private static final PatternFunction doubleQuoteStringContent = new PatternFunction(~/^(?:[\w\W&&[^\\"]]|\\")+/)
private static final PatternFunction singleQuote = new PatternFunction(~/^'/)
private static final PatternFunction singleQuoteStringContent = new PatternFunction(~/^(?:[\w\W&&[^\\']]|\\')+/)
// https://docs.groovy-lang.org/latest/html/documentation/#_identifiers
//'\u00C0' to '\u00D6'
//'\u00D8' to '\u00F6'
//'\u00F8' to '\u00FF'
//'\u0100' to '\uFFFE'
private static final PatternFunction dollarReference = new PatternFunction(~/^\$[a-zA-Z_$\u00c0-\u00d6\u00d8-\u00f6\u00f8-\u00ff\u0100-\ufff3][a-zA-Z_$0-9\u00c0-\u00d6\u00d8-\u00f6\u00f8-\u00ff\u0100-\ufff3]*(?=[\s\/>])/)
private static final PatternFunction dollarOpen = new PatternFunction(~/^\$\{/)
private static final PatternFunction percent = new PatternFunction(~/^%/)
private static final PatternFunction expressionScriptletGroovy = new PatternFunction(~/^.*?%>/)
private static final PatternFunction forwardSlash = new PatternFunction(~/^\//)
static enum State {
START,
DOUBLE_QUOTE_STRING,
SINGLE_QUOTE_STRING,
DOLLAR_GROOVY,
EXPRESSION_SCRIPTLET_GROOVY,
DONE
}
private static FunctionFsmBuilder<String, State, String> getFsmBuilder() {
new FunctionFsmBuilderImpl<>()
}
Queue<ComponentToken> tokenize(String src) {
Queue<ComponentToken> tokens = new LinkedList<>()
def fsm = getFsmBuilder().with {
initialState = State.START
whileIn(State.START) {
on lessThan exec {
tokens << new ComponentToken(Type.LT, it)
}
on greaterThan shiftTo State.DONE exec {
tokens << new ComponentToken(Type.GT, it)
}
on identifier exec {
tokens << new ComponentToken(Type.IDENTIFIER, it)
}
on whitespace exec { }
on key exec {
tokens << new ComponentToken(Type.KEY, it)
}
on equals exec {
tokens << new ComponentToken(Type.EQUALS, it)
}
on doubleQuote shiftTo State.DOUBLE_QUOTE_STRING exec {
tokens << new ComponentToken(Type.DOUBLE_QUOTE, it)
}
on singleQuote shiftTo State.SINGLE_QUOTE_STRING exec {
tokens << new ComponentToken(Type.SINGLE_QUOTE, it)
}
on dollarReference exec { String s ->
tokens << new ComponentToken(Type.DOLLAR, s[0])
tokens << new ComponentToken(Type.GROOVY_IDENTIFIER, s.substring(1))
}
on dollarOpen shiftTo State.DOLLAR_GROOVY exec { String s ->
tokens << new ComponentToken(Type.DOLLAR, s[0])
tokens << new ComponentToken(Type.CURLY_OPEN, s[1])
}
on percent shiftTo State.EXPRESSION_SCRIPTLET_GROOVY exec {
tokens << new ComponentToken(Type.PERCENT, it)
}
on forwardSlash exec {
tokens << new ComponentToken(Type.FORWARD_SLASH, it)
}
onNoMatch() exec {
throw new IllegalArgumentException()
}
}
whileIn(State.DOUBLE_QUOTE_STRING) {
on doubleQuoteStringContent exec {
tokens << new ComponentToken(Type.STRING, it)
}
on doubleQuote shiftTo State.START exec {
tokens << new ComponentToken(Type.DOUBLE_QUOTE, it)
}
onNoMatch() exec {
throw new IllegalArgumentException()
}
}
whileIn(State.SINGLE_QUOTE_STRING) {
on singleQuoteStringContent exec {
tokens << new ComponentToken(Type.STRING, it)
}
on singleQuote shiftTo State.START exec {
tokens << new ComponentToken(Type.SINGLE_QUOTE, it)
}
onNoMatch() exec {
throw new IllegalArgumentException()
}
}
whileIn(State.DOLLAR_GROOVY) {
on { String input ->
def b = new StringBuilder()
def openCurlyCount = 1
def iterator = input.iterator() as Iterator<String>
while (iterator.hasNext()) {
def c0 = iterator.next()
if (c0 == '{') {
b << c0
openCurlyCount++
} else if (c0 == '}') {
b << c0
openCurlyCount--
if (openCurlyCount == 0) {
break
}
} else {
b << c0
}
}
b.toString()
} shiftTo State.START exec { String s ->
tokens << new ComponentToken(Type.GROOVY, s.substring(0, s.length() - 1))
tokens << new ComponentToken(Type.CURLY_CLOSE, '}')
}
onNoMatch() exec {
throw new IllegalArgumentException()
}
}
whileIn(State.EXPRESSION_SCRIPTLET_GROOVY) {
on expressionScriptletGroovy shiftTo State.START exec { String s ->
tokens << new ComponentToken(Type.GROOVY, s.substring(0, s.length() - 2))
tokens << new ComponentToken(Type.PERCENT, '%')
tokens << new ComponentToken(Type.GT, '>')
}
onNoMatch() exec {
throw new IllegalArgumentException()
}
}
build()
}
def remaining = src
while (fsm.currentState != State.DONE) {
def output = fsm.apply(remaining)
if (!output) {
throw new IllegalStateException()
} else {
remaining = remaining.substring(output.length())
}
}
tokens
}
}

View File

@ -0,0 +1,43 @@
package com.jessebrault.ssg.template.gspe
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.slf4j.Marker
import org.slf4j.MarkerFactory
class ComponentsContainer {
private static final Logger logger = LoggerFactory.getLogger(ComponentsContainer)
private static final Marker enter = MarkerFactory.getMarker('ENTER')
private static final Marker exit = MarkerFactory.getMarker('EXIT')
private final Map<String, Component> componentCache = [:]
private final GroovyClassLoader loader
ComponentsContainer(Collection<URL> componentDirUrls, Collection<Component> components) {
logger.trace(enter, 'componentDirUrls: {}, components: {}', componentDirUrls, components)
this.loader = new GroovyClassLoader()
componentDirUrls.each { this.loader.addURL(it) }
components.each {
this.componentCache[it.class.simpleName] = it
}
logger.debug('this.loader: {}', this.loader)
logger.debug('this.componentCache: {}', this.componentCache)
logger.trace(exit, '')
}
Component get(String name) {
logger.trace('name: {}', name)
def component = this.componentCache.computeIfAbsent(name, {
def componentClass = (Class<? extends Component>) this.loader.loadClass(it)
componentClass.getDeclaredConstructor().newInstance() // must be a default constructor (for now)
})
logger.trace(exit, 'component: {}', component)
component
}
Component getAt(String name) {
this.get(name)
}
}

View File

@ -0,0 +1,23 @@
package com.jessebrault.ssg.template.gspe
import groovy.transform.PackageScope
@PackageScope
enum FsmState {
HTML,
SCRIPTLET,
EXPRESSION_SCRIPTLET,
DOLLAR,
COMPONENT,
COMPONENT_IDENTIFIER,
COMPONENT_ATTR_KEY,
COMPONENT_ATTR_VALUE_OPEN,
COMPONENT_ATTR_VALUE_STRING,
COMPONENT_ATTR_VALUE_STRING_CLOSE,
COMPONENT_CLOSE
}

View File

@ -0,0 +1,32 @@
package com.jessebrault.ssg.template.gspe
import groovy.text.Template
class GspeTemplate implements Template {
Closure templateClosure
ComponentsContainer components
String renderComponent(String componentName, Closure configureComponentInstance) {
Map<String, String> attr = [:]
def bodyOut = new StringBuilder()
configureComponentInstance(attr, bodyOut)
def component = this.components[componentName]
component.render(attr, bodyOut.toString())
}
@Override
final Writable make() {
this.make([:])
}
@Override
final Writable make(Map binding) {
def rehydrated = this.templateClosure.rehydrate(binding, this, this).asWritable()
rehydrated.setResolveStrategy(Closure.DELEGATE_FIRST)
rehydrated
}
}

View File

@ -0,0 +1,56 @@
package com.jessebrault.ssg.template.gspe
import groovy.text.Template
import groovy.text.TemplateEngine
import groovy.transform.TupleConstructor
import org.codehaus.groovy.control.CompilationFailedException
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.util.concurrent.atomic.AtomicInteger
import java.util.function.Supplier
final class GspeTemplateEngine extends TemplateEngine {
private static final Logger logger = LoggerFactory.getLogger(GspeTemplateEngine)
@TupleConstructor(defaults = false)
static class Configuration {
Supplier<GspeTemplate> ssgTemplateSupplier
Collection<URL> componentDirUrls
Collection<Component> components
}
private final Configuration configuration
private final File scriptsDir = File.createTempDir()
private final AtomicInteger templateCount = new AtomicInteger(0)
private final GroovyScriptEngine scriptEngine
GspeTemplateEngine(Configuration configuration) {
this.configuration = configuration
this.scriptEngine = new GroovyScriptEngine([this.scriptsDir.toURI().toURL()] as URL[])
}
@Override
Template createTemplate(Reader reader) throws CompilationFailedException, ClassNotFoundException, IOException {
def templateSrc = reader.text
def converter = new TemplateToScriptConverter()
def scriptSrc = converter.convert(templateSrc)
logger.debug('scriptSrc: {}', scriptSrc)
def scriptName = "SsgTemplate${ this.templateCount.getAndIncrement() }.groovy"
new File(this.scriptsDir, scriptName).write(scriptSrc)
def script = this.scriptEngine.createScript(scriptName, new Binding())
def templateClosure = (Closure) script.invokeMethod('getTemplate', null)
def template = this.configuration.ssgTemplateSupplier.get()
template.templateClosure = templateClosure
template.components = new ComponentsContainer(this.configuration.componentDirUrls, this.configuration.components)
template
}
}

View File

@ -0,0 +1,26 @@
package com.jessebrault.ssg.template.gspe
import groovy.transform.PackageScope
import groovy.transform.TupleConstructor
import java.util.function.Function
import java.util.regex.Pattern
@PackageScope
@TupleConstructor(includeFields = true, defaults = false)
class PatternFunction implements Function<String, String> {
protected final Pattern pattern
@Override
String apply(String s) {
def matcher = this.pattern.matcher(s)
matcher.find() ? matcher.group() : null
}
@Override
String toString() {
"PatternFunction(pattern: ${ this.pattern })"
}
}

View File

@ -0,0 +1,10 @@
package com.jessebrault.ssg.template.gspe
import java.lang.annotation.Retention
import java.lang.annotation.RetentionPolicy
@Retention(RetentionPolicy.SOURCE)
@interface PeekBefore {
ComponentToken.Type[] value()
}

View File

@ -0,0 +1,164 @@
package com.jessebrault.ssg.template.gspe
import com.jessebrault.fsm.stackfunction.StackFunctionFsmBuilder
import com.jessebrault.fsm.stackfunction.StackFunctionFsmBuilderImpl
import groovy.transform.PackageScope
import static FsmState.*
@PackageScope
class TemplateToScriptConverter {
private static final PatternFunction html = new PatternFunction(~/^(?:[\w\W&&[^<$]]|<(?!%|\p{Lu})|\$(?!\{))+/)
private static final PatternFunction scriptletOpen = new PatternFunction(~/^<%(?!=)/)
private static final PatternFunction expressionScriptletOpen = new PatternFunction(~/^<%=/)
private static final PatternFunction scriptletText = new PatternFunction(~/^.+(?=%>)/)
private static final PatternFunction scriptletClose = new PatternFunction(~/^%>/)
private static final PatternFunction componentOpen = new PatternFunction(~/^<(?=\p{Lu})/)
private static final PatternFunction componentIdentifier = new PatternFunction(~/^\p{Lu}.*?(?=\s|\\/)/)
private static final PatternFunction attrKeyWithValue = new PatternFunction(~/^\s*[\p{Ll}\p{Lu}0-9_\-]+=/)
private static final PatternFunction attrKeyBoolean = new PatternFunction(~/^\s*[\p{Ll}\p{Lu}0-9_\-]++(?!=)/)
private static final PatternFunction componentSelfClose = new PatternFunction(~/^\s*\/>/)
private static final PatternFunction attrValueStringOpen = new PatternFunction(~/^["']/)
private static final PatternFunction attrValueStringContents = new PatternFunction(~/^(?:[\w\W&&[^\\"]]|\\\\|\\")*(?=")/)
private static final PatternFunction attrValueStringClose = new PatternFunction(~/["']/)
private static StackFunctionFsmBuilder<String, FsmState, String> getFsmBuilder() {
new StackFunctionFsmBuilderImpl<>()
}
@SuppressWarnings('GrMethodMayBeStatic')
String convert(String src) {
def b = new StringBuilder()
def stringAcc = new StringBuilder()
b << 'def getTemplate() {\nreturn { out ->\n'
def fsm = getFsmBuilder().with {
initialState = HTML
whileIn(HTML) {
on html exec {
stringAcc << it
}
on scriptletOpen shiftTo SCRIPTLET exec {
if (stringAcc.length() > 0) {
b << 'out << """' << stringAcc.toString() << '""";\n'
stringAcc = new StringBuilder()
}
}
on expressionScriptletOpen shiftTo EXPRESSION_SCRIPTLET exec {
stringAcc << '${'
}
on componentOpen shiftTo COMPONENT_IDENTIFIER exec {
if (stringAcc.length() > 0) {
b << 'out << """' << stringAcc.toString() << '""";\n'
stringAcc = new StringBuilder()
}
}
}
whileIn(SCRIPTLET) {
on scriptletText exec {
b << it
}
on scriptletClose shiftTo HTML exec {
b << ';\n'
}
}
whileIn(EXPRESSION_SCRIPTLET) {
on scriptletText exec {
stringAcc << it
}
on scriptletClose shiftTo HTML exec {
stringAcc << '}'
}
}
whileIn(COMPONENT) {
// tokenize component, figure out body, and tokenize closing component
}
whileIn(COMPONENT_IDENTIFIER) {
on componentIdentifier shiftTo COMPONENT_ATTR_KEY exec {
b << "out << renderComponent('${ it }') { attr, bodyOut ->\n"
}
onNoMatch() exec {
throw new RuntimeException('expected a Component Identifier')
}
}
whileIn(COMPONENT_ATTR_KEY) {
on attrKeyWithValue shiftTo COMPONENT_ATTR_VALUE_OPEN exec { String s ->
def trimmed = s.trim()
def key = trimmed.substring(0, trimmed.length() - 1)
b << "attr['${ key }'] = "
}
on attrKeyBoolean exec { String s ->
def trimmed = s.trim()
def key = trimmed.substring(0, trimmed.length() - 1)
b << "attr['${ key }'] = true"
}
on componentSelfClose shiftTo HTML exec {
b << '};\n'
}
onNoMatch() exec {
throw new RuntimeException('expected either an attr key or a closing />')
}
}
whileIn(COMPONENT_ATTR_VALUE_OPEN) {
on attrValueStringOpen shiftTo COMPONENT_ATTR_VALUE_STRING exec {
b << '"'
}
onNoMatch() exec {
throw new RuntimeException('expected a string opening')
}
}
whileIn(COMPONENT_ATTR_VALUE_STRING) {
on attrValueStringContents shiftTo COMPONENT_ATTR_VALUE_STRING_CLOSE exec {
b << it
}
onNoMatch() exec {
throw new RuntimeException('expected string contents')
}
}
whileIn(COMPONENT_ATTR_VALUE_STRING_CLOSE) {
on attrValueStringClose shiftTo COMPONENT_ATTR_KEY exec {
b << '";\n'
}
onNoMatch() exec {
throw new RuntimeException('expected string close')
}
}
build()
}
def remaining = src
while (remaining.length() > 0) {
def output = fsm.apply(remaining)
if (output != null) {
remaining = remaining.substring(output.length())
} else if (output != null && output.length() == 0) {
throw new RuntimeException('output length is zero')
} else {
throw new RuntimeException('output is null')
}
}
if (fsm.currentState == HTML && stringAcc.length() > 0) {
b << 'out << """' << stringAcc.toString() << '""";\n'
stringAcc = new StringBuilder()
}
b << '}}\n'
b.toString()
}
}

View File

@ -0,0 +1,107 @@
package com.jessebrault.ssg.template.gspe
import org.junit.jupiter.api.Test
import static com.jessebrault.ssg.template.gspe.ComponentToken.Type.*
import static org.junit.jupiter.api.Assertions.assertEquals
class ComponentTokenizerTests {
private final ComponentTokenizer tokenizer = new ComponentTokenizer()
@Test
void selfClosingComponent() {
def r = this.tokenizer.tokenize('<Test />')
assertEquals(4, r.size())
assertEquals(LT, r[0].type)
assertEquals(IDENTIFIER, r[1].type)
assertEquals('Test', r[1].text)
assertEquals(FORWARD_SLASH, r[2].type)
assertEquals(GT, r[3].type)
}
@Test
void selfClosingComponentWithDoubleQuotedString() {
def r = this.tokenizer.tokenize('<Test key="value" />')
assertEquals(9, r.size())
assertEquals(LT, r[0].type)
assertEquals(IDENTIFIER, r[1].type)
assertEquals('Test', r[1].text)
assertEquals(KEY, r[2].type)
assertEquals('key', r[2].text)
assertEquals(EQUALS, r[3].type)
assertEquals(DOUBLE_QUOTE, r[4].type)
assertEquals(STRING, r[5].type)
assertEquals('value', r[5].text)
assertEquals(DOUBLE_QUOTE, r[6].type)
assertEquals(FORWARD_SLASH, r[7].type)
assertEquals(GT, r[8].type)
}
@Test
void selfClosingComponentWithSingleQuotedString() {
def r = this.tokenizer.tokenize("<Test key='value' />")
assertEquals(9, r.size())
assertEquals(LT, r[0].type)
assertEquals(IDENTIFIER, r[1].type)
assertEquals('Test', r[1].text)
assertEquals(KEY, r[2].type)
assertEquals('key', r[2].text)
assertEquals(EQUALS, r[3].type)
assertEquals(SINGLE_QUOTE, r[4].type)
assertEquals(STRING, r[5].type)
assertEquals('value', r[5].text)
assertEquals(SINGLE_QUOTE, r[6].type)
assertEquals(FORWARD_SLASH, r[7].type)
assertEquals(GT, r[8].type)
}
@Test
void componentWithSimpleDollarGroovy() {
def r = this.tokenizer.tokenize('<Test key=${ test } />')
assertEquals(10, r.size())
assertEquals(LT, r[0].type)
assertEquals(IDENTIFIER, r[1].type)
assertEquals('Test', r[1].text)
assertEquals(KEY, r[2].type)
assertEquals('key', r[2].text)
assertEquals(EQUALS, r[3].type)
assertEquals(DOLLAR, r[4].type)
assertEquals(CURLY_OPEN, r[5].type)
assertEquals(GROOVY, r[6].type)
assertEquals(' test ', r[6].text)
assertEquals(CURLY_CLOSE, r[7].type)
assertEquals(FORWARD_SLASH, r[8].type)
assertEquals(GT, r[9].type)
}
}

View File

@ -0,0 +1,159 @@
package com.jessebrault.ssg.template.gspe
import groovy.text.TemplateEngine
import org.junit.jupiter.api.Test
import static org.junit.jupiter.api.Assertions.assertEquals
class GspeTemplateEngineTests {
private final TemplateEngine engine = new GspeTemplateEngine(new GspeTemplateEngine.Configuration(
{ new GspeTemplate() },
[],
[]
))
@Test
void doctype() {
def src = '<!DOCTYPE html>'
def r = this.engine.createTemplate(src).make().toString()
assertEquals('<!DOCTYPE html>', r)
}
@Test
void handlesNewlines() {
def src = '<!DOCTYPE html>\n<html>'
def r = this.engine.createTemplate(src).make().toString()
assertEquals(src, r)
}
@Test
void emptyScriptlet() {
def src = '<%%>'
def r = this.engine.createTemplate(src).make().toString()
assertEquals('', r)
}
@Test
void simpleOut() {
def src = '<% out << "Hello, World!" %>'
def r = this.engine.createTemplate(src).make().toString()
assertEquals('Hello, World!', r)
}
@Test
void scriptletInString() {
def src = '<html lang="<% out << "en" %>">'
def r = this.engine.createTemplate(src).make().toString()
assertEquals('<html lang="en">', r)
}
@Test
void expressionScriptlet() {
def src = '<%= 13 %>'
def r = this.engine.createTemplate(src).make().toString()
assertEquals('13', r)
}
@Test
void bindingWorks() {
def src = '<%= greeting %>'
def r = this.engine.createTemplate(src).make([greeting: 'Hello, World!']).toString()
assertEquals('Hello, World!', r)
}
static class CustomBaseTemplate extends GspeTemplate {
def greeting = 'Greetings!'
def name = 'Jesse'
@SuppressWarnings('GrMethodMayBeStatic')
def greet() {
'Hello, World!'
}
}
@Test
void baseTemplateMethodsPresent() {
def src = '<%= greet() %>'
def configuration = new GspeTemplateEngine.Configuration({ new CustomBaseTemplate() }, [], [])
def engine = new GspeTemplateEngine(configuration)
def r = engine.createTemplate(src).make().toString()
assertEquals('Hello, World!', r)
}
@Test
void baseTemplatePropertiesPresent() {
def src = '<%= this.greeting %>'
def configuration = new GspeTemplateEngine.Configuration({ new CustomBaseTemplate() }, [], [])
def engine = new GspeTemplateEngine(configuration)
def r = engine.createTemplate(src).make().toString()
assertEquals('Greetings!', r)
}
@Test
void bindingOverridesCustomBaseTemplate() {
def src = '<%= greet() %>'
def configuration = new GspeTemplateEngine.Configuration({ new CustomBaseTemplate() }, [], [])
def engine = new GspeTemplateEngine(configuration)
def r = engine.createTemplate(src).make([greet: { "Hello, Person!" }]).toString()
assertEquals('Hello, Person!', r)
}
static class Greeter implements Component {
@Override
String render(Map<String, ?> attr, String body) {
"${ attr.greeting }, ${ attr.person }!"
}
}
@Test
void selfClosingComponent() {
def src = '<Greeter greeting="Hello" person="World" />'
def configuration = new GspeTemplateEngine.Configuration({ new GspeTemplate() }, [], [new Greeter()])
def engine = new GspeTemplateEngine(configuration)
def r = engine.createTemplate(src).make().toString()
assertEquals('Hello, World!', r)
}
@Test
void componentWithGStringAttrValue() {
def src = '<Greeter greeting="Hello" person="person number ${ 13 }" />'
def configuration = new GspeTemplateEngine.Configuration({ new GspeTemplate() }, [], [new Greeter()])
def engine = new GspeTemplateEngine(configuration)
def r = engine.createTemplate(src).make().toString()
assertEquals('Hello, person number 13!', r)
}
@Test
void componentWithGStringAttrValueCanAccessBinding() {
def src = '<Greeter greeting="Hello" person="person named ${ name }" />'
def configuration = new GspeTemplateEngine.Configuration({ new GspeTemplate() }, [], [new Greeter()])
def engine = new GspeTemplateEngine(configuration)
def r = engine.createTemplate(src).make([name: 'Jesse']).toString()
assertEquals('Hello, person named Jesse!', r)
}
@Test
void componentWithGStringAttrValueCanAccessBaseTemplateMethod() {
def src = '<Greeter greeting="Hello" person="person named ${ getName() }" />'
def configuration = new GspeTemplateEngine.Configuration({ new CustomBaseTemplate() }, [], [new Greeter()])
def engine = new GspeTemplateEngine(configuration)
def r = engine.createTemplate(src).make().toString()
assertEquals('Hello, person named Jesse!', r)
}
@Test
void componentWithGStringAttrValueCanAccessBaseTemplateProperty() {
def src = '<Greeter greeting="Hello" person="person named ${ this.name }" />'
def configuration = new GspeTemplateEngine.Configuration({ new CustomBaseTemplate() }, [], [new Greeter()])
def engine = new GspeTemplateEngine(configuration)
def r = engine.createTemplate(src).make().toString()
assertEquals('Hello, person named Jesse!', r)
}
}

View File

@ -0,0 +1,12 @@
package components
import com.jessebrault.ssg.template.gspe.Component
class Greeting implements Component {
@Override
String render(Map<String, ?> attr, String body) {
"<h1>${ attr.person }, ${ attr.person }!</h1>"
}
}

View File

@ -0,0 +1,16 @@
package components
import com.jessebrault.ssg.template.gspe.Component
class Head implements Component {
@Override
String render(Map<String, ?> attr, String body) {
def b = new StringBuilder()
b << '<head>\n'
b << " <title>${ attr.title }</title>\n"
b << '</head>\n'
b.toString()
}
}

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" ?>
<Configuration name="ssg" status="WARN">
<Appenders>
<Console name="standard" target="SYSTEM_OUT">
<PatternLayout>
<MarkerPatternSelector defaultPattern="%highlight{%-5level} %logger{1} %M %L: %msg%n%ex">
<PatternMatch key="FLOW" pattern="%highlight{%-5level} %logger{1} %M %L: %markerSimpleName %msg%n%ex" />
</MarkerPatternSelector>
</PatternLayout>
</Console>
</Appenders>
<Loggers>
<Root level="trace">
<AppenderRef ref="standard" />
</Root>
</Loggers>
</Configuration>

View File

@ -0,0 +1,7 @@
<!DOCTYPE html>
<html>
<Head title="Greeting Page" />
<body>
<Greeting person="World" />
</body>
</html>