diff --git a/build.js b/build.js index 2163b71..583df93 100644 --- a/build.js +++ b/build.js @@ -1,46 +1,20 @@ import fs from 'fs'; -import Showdown from 'showdown'; -import 'dotenv/config'; -import { ENV } from './lib/env.js'; +import { BasePaths } from './lib/render/base-paths.js'; +import { Renderer } from './lib/render/renderer.js'; +import { FileHelper } from './lib/render/file-helper.js'; +import { SettingsReader } from './lib/render/settings-reader.js'; +import { Context } from './lib/struct/context.js'; -const converter = new Showdown.Converter(); - -let inputDir = 'content/'; -if(ENV.getIsSet('VS_INPUT_DIR')) { - inputDir = ENV.getString('VS_INPUT_DIR'); +// Delete prior output, if exists. +if(fs.existsSync(BasePaths.targetRoot())) { + fs.rmSync(BasePaths.targetRoot(), { recursive: true, force: true }); } -let outputDir = 'build/'; -if(ENV.getIsSet('VS_OUTPUT_DIR')) { - outputDir = ENV.getString('VS_OUTPUT_DIR'); -} +// fs.mkdirSync(BasePaths.targetRoot()); -const fileEntities = fs.readdirSync(inputDir, {recursive: true, withFileTypes: true, encoding: 'utf8'}); +const startPath = ''; +const rootContext = SettingsReader.readDirectorySettings(`${BasePaths.contentRoot()}/${startPath}`); -fileEntities.forEach((fileEnt) => { - const filenameTokens = fileEnt.name.split('.'); - const extension = filenameTokens[filenameTokens.length - 1]; +const renderer = new Renderer(startPath, rootContext); - if(extension === 'md') { - const fullPath = fileEnt.parentPath + fileEnt.name; - - const content = fs.readFileSync(fullPath, {encoding: 'utf8'}); - - const converted = converter.makeHtml(content); - - let outputFileName = ""; - for(let i = 0; i < filenameTokens.length - 1; i++) { - outputFileName += filenameTokens[i]; - } - - outputFileName += '.html'; - - const trimmedPath = fileEnt.parentPath.replace(inputDir, ""); - - const outputPath = outputDir + trimmedPath + outputFileName; - - fs.writeFileSync(outputPath, converted); - - console.log(`Converted ${fullPath} -> ${outputPath}`); - } -}); \ No newline at end of file +renderer.renderAll(); \ No newline at end of file diff --git a/content/fragments/var-test.vs.html b/content/fragments/var-test.vs.html new file mode 100644 index 0000000..290c39a --- /dev/null +++ b/content/fragments/var-test.vs.html @@ -0,0 +1 @@ +test variable = <* test_var *> \ No newline at end of file diff --git a/content/site/_settings.json b/content/site/_settings.json new file mode 100644 index 0000000..6f9ab7f --- /dev/null +++ b/content/site/_settings.json @@ -0,0 +1,4 @@ +{ + "test_var": "Outer settings", + "template": "base-template" +} \ No newline at end of file diff --git a/content/site/sub/_settings.json b/content/site/sub/_settings.json new file mode 100644 index 0000000..58bd7d0 --- /dev/null +++ b/content/site/sub/_settings.json @@ -0,0 +1,4 @@ +{ + "test_var": "Inner setting", + "template": "" +} \ No newline at end of file diff --git a/content/site/sub/test.vs.md b/content/site/sub/test.vs.md new file mode 100644 index 0000000..5a22c88 --- /dev/null +++ b/content/site/sub/test.vs.md @@ -0,0 +1,3 @@ +# Hello world from another directory! + +<{ var-test }> \ No newline at end of file diff --git a/content/site/test_root.vs.md b/content/site/test_root.vs.md new file mode 100644 index 0000000..6baf46a --- /dev/null +++ b/content/site/test_root.vs.md @@ -0,0 +1,3 @@ +# Hello world! + +<{ var-test }> \ No newline at end of file diff --git a/content/templates/base-template.vs.html b/content/templates/base-template.vs.html new file mode 100644 index 0000000..f76700c --- /dev/null +++ b/content/templates/base-template.vs.html @@ -0,0 +1,6 @@ +
+

+ Outer wrapper! +

+ <{ content }> +
\ No newline at end of file diff --git a/content/test.md b/content/test.md deleted file mode 100644 index 73e2de4..0000000 --- a/content/test.md +++ /dev/null @@ -1 +0,0 @@ -# Here's a test! \ No newline at end of file diff --git a/lib/constants.js b/lib/constants.js new file mode 100644 index 0000000..d83b66b --- /dev/null +++ b/lib/constants.js @@ -0,0 +1,2 @@ +export const DEFAULT_SOURCE_BASE = './content'; +export const DEFUALT_TARGET_BASE = './build'; \ No newline at end of file diff --git a/lib/render/base-paths.js b/lib/render/base-paths.js new file mode 100644 index 0000000..ffb25a2 --- /dev/null +++ b/lib/render/base-paths.js @@ -0,0 +1,24 @@ +import { DEFAULT_SOURCE_BASE, DEFUALT_TARGET_BASE } from "../constants.js"; +import { ENV } from "../env.js" + +export const BasePaths = { + sourceRoot() { + const overridePath = ENV.getString(`VS_INPUT_DIR`); + + return overridePath ?? DEFAULT_SOURCE_BASE; + }, + targetRoot() { + const overridePath = ENV.getString(`VS_OUTPUT_DIR`); + + return overridePath ?? DEFUALT_TARGET_BASE; + }, + templates() { + return `${this.sourceRoot()}/templates`; + }, + fragments() { + return `${this.sourceRoot()}/fragments`; + }, + contentRoot() { + return `${this.sourceRoot()}/site`; + }, +} \ No newline at end of file diff --git a/lib/render/file-helper.js b/lib/render/file-helper.js new file mode 100644 index 0000000..f572b12 --- /dev/null +++ b/lib/render/file-helper.js @@ -0,0 +1,66 @@ +import { fragmentFormats } from "../struct/fragment.js"; + +export const FileHelper = { + isContent(fileEnt) { + return this.isFragment(fileEnt); + }, + + isFragment(fileEnt) { + if(!fileEnt.isFile()) { + return false; + } + + const contentExtensions = [ + '.vs.md', + '.vs.html', + ]; + + const matchesAnyContentExtensions = contentExtensions.some( + (ext) => fileEnt.name.endsWith(ext) + ); + + return matchesAnyContentExtensions; + }, + + isSettingsFile(fileEnt) { + if(!fileEnt.isFile()) { + return false; + } + + return fileEnt.name === '_settings.json'; + }, + + getFragmentType(fileEnt) { + const filePath = fileEnt.name; + + if(filePath.endsWith('.vs.md')) { + return fragmentFormats.V_MARKDOWN; + } + + if(filePath.endsWith('.vs.html')) { + return fragmentFormats.V_HTML; + } + + return null; + }, + + getOutputFileName(fileEnt) { + if(this.isContent(fileEnt)) { + const stripped = this.getBaseName(fileEnt); + + return `${stripped}.html`; + } else { + return fileEnt.name; + } + }, + + getBaseName(fileEnt) { + const isMd = (this.getFragmentType(fileEnt) === fragmentFormats.V_MARKDOWN); + const ext = isMd ? '.vs.md' : '.vs.html'; + + const idx = fileEnt.name.lastIndexOf(ext); + const stripped = fileEnt.name.substring(0, idx); + + return stripped; + } +} \ No newline at end of file diff --git a/lib/render/fragment-manager.js b/lib/render/fragment-manager.js new file mode 100644 index 0000000..39bb58f --- /dev/null +++ b/lib/render/fragment-manager.js @@ -0,0 +1,69 @@ +import fs from 'fs'; +import { BasePaths } from "./base-paths.js"; +import { FileHelper } from './file-helper.js'; +import { Fragment } from '../struct/fragment.js'; + +export const SPECIAL_CONTENT_SYMBOL = `content`; + +const FragmentSuperManager = { + fragments: null, + initialized: false, + + initialize() { + const fragmentDir = BasePaths.fragments(); + const entries = fs.readdirSync(fragmentDir, { encoding: 'utf-8', withFileTypes: true }); + const fragmentEntries = entries.filter( + (e) => FileHelper.isFragment(e) + ); + + let fragments = []; + + for(let i = 0; i < fragmentEntries.length; i++) { + const type = FileHelper.getFragmentType(fragmentEntries[i]); + const path = fragmentEntries[i].parentPath + '/' + fragmentEntries[i].name; + const contents = fs.readFileSync(path, { encoding: 'utf-8' }); + + const fragment = new Fragment(type, contents); + + const name = FileHelper.getBaseName(fragmentEntries[i]); + + fragments.push({ + name: name, + fragment: fragment + }); + } + + this.fragments = fragments; + this.initialized = true; + }, + + get(key) { + const match = this.fragments?.find( + (e) => e.name === key + ); + + return match?.fragment; + }, +}; + +export class FragmentManager { + contentFragment; + _fragmentSuperManager; + + constructor(contentFragment) { + this.contentFragment = contentFragment; + this._fragmentSuperManager = FragmentSuperManager; + + if(!FragmentSuperManager.initialized) { + FragmentSuperManager.initialize(); + } + } + + get(key) { + if(key === SPECIAL_CONTENT_SYMBOL) { + return this.contentFragment; + } + + return this._fragmentSuperManager.get(key); + } +} \ No newline at end of file diff --git a/lib/render/renderer.js b/lib/render/renderer.js new file mode 100644 index 0000000..0f64f01 --- /dev/null +++ b/lib/render/renderer.js @@ -0,0 +1,150 @@ +import { Context } from "../struct/context.js"; +import { Fragment, fragmentFormats } from "../struct/fragment.js"; +import { BasePaths } from "./base-paths.js"; +import { FileHelper } from "./file-helper.js"; +import { FragmentManager } from "./fragment-manager.js"; +import { SettingsReader } from "./settings-reader.js"; +import { TemplateManager } from "./template-manager.js"; +import { tokenTypes } from "./token.js"; +import { Tokenizer } from "./tokenizer.js"; +import fs, { write } from 'fs'; + +export class Renderer { + path; + context; + + constructor(path, context) { + this.path = path; + this.context = context; + } + + readFolder() { + return `${BasePaths.contentRoot()}/${this.path}`; + } + + writeFolder() { + return `${BasePaths.targetRoot()}/${this.path}`; + } + + renderAll() { + // Create output folder + fs.mkdirSync(this.writeFolder()); + + // Get all files + const entries = fs.readdirSync(this.readFolder(), { encoding: 'utf-8', withFileTypes: true }); + const files = entries.filter( + (e) => e.isFile() + ); + + for(let i = 0; i < files.length; i++) { + // Render content files, copy non-content files. + if(FileHelper.isContent(files[i])) { + this.renderPage(files[i]); + } else if(FileHelper.isSettingsFile(files[i])) { + // pass + } else { + this.copyFile(files[i]); + } + } + + // Get all subdirectories. + const subdirs = entries.filter( + (e) => e.isDirectory() + ); + + for(let i = 0; i < subdirs.length; i++) { + this.renderSubdirectory(subdirs[i]); + } + } + + copyFile(fileEnt) { + const readPath = `${this.readFolder()}/${fileEnt.name}`; + const writePath = `${this.writeFolder()}/${fileEnt.name}`; + + fs.copyFileSync(readPath, writePath); + } + + renderPage(fileEnt) { + const type = FileHelper.getFragmentType(fileEnt); + + const readPath = `${this.readFolder()}/${fileEnt.name}`; + const content = fs.readFileSync(readPath, { encoding: 'utf-8' }); + + const vars = SettingsReader.readSettingsFromContent(content); + const strippedContent = SettingsReader.trimSettingsFromContent(content); + + const fileContext = new Context(vars); + const fullContext = this.context.mergeFrom(fileContext); + + const contentFragment = new Fragment(type, strippedContent); + + let root = contentFragment; + const templateKey = fullContext.get(`template`); + if(templateKey) { + const template = new TemplateManager().get(templateKey); + if(template) { + root = template; + } + } + + const pageOutput = this.renderFragment(root, fullContext, new FragmentManager(contentFragment)); + + const writePath = `${this.writeFolder()}/${FileHelper.getOutputFileName(fileEnt)}`; + fs.writeFileSync(writePath, pageOutput); + } + + renderFragment(fragment, localContext, fragmentManager) { + if(!fragment) { + return ''; + } + + const tokenizer = new Tokenizer(); + const fragmentAsHtml = fragment.toHtml().sourceContent; + + const tokensByVar = tokenizer.tokensByVariable(fragmentAsHtml); + const replacedVarTokens = tokensByVar.map( + (t) => { + if(t.type === tokenTypes.TEXT) { + return t.content; + } else { + const key = t.content.trim(); + const value = localContext.get(key); + return value; + } + } + ); + + const fragmentContentAfterVariableReplace = replacedVarTokens.reduce( + (a, b) => `${a}${b}` + ); + + const tokensByFragment = tokenizer.tokensByFragment(fragmentContentAfterVariableReplace); + const self = this; + const replacedFragmentTokens = tokensByFragment.map( + (t) => { + if(t.type === tokenTypes.TEXT) { + return t.content; + } else { + const key = t.content.trim(); + const value = fragmentManager.get(key); + return self.renderFragment(value, localContext, fragmentManager); + } + } + ); + + const final = replacedFragmentTokens.reduce( + (a, b) => `${a}${b}` + ); + + return final; + } + + renderSubdirectory(subDirEnt) { + const subPath = `${this.readFolder()}/${subDirEnt.name}`; + const subdirContext = SettingsReader.readDirectorySettings(subPath); + const nextContext = this.context.copy().mergeFrom(subdirContext); + + const subRenderer = new Renderer(`${this.path}/${subDirEnt.name}`, nextContext); + subRenderer.renderAll(); + } +} \ No newline at end of file diff --git a/lib/render/settings-reader.js b/lib/render/settings-reader.js new file mode 100644 index 0000000..3b2e3fb --- /dev/null +++ b/lib/render/settings-reader.js @@ -0,0 +1,35 @@ +import fs from 'fs'; +import { Variable } from '../struct/variable.js'; +import { Context } from '../struct/context.js'; + +export const SettingsReader = { + trimSettingsFromContent(rawContent) { + return rawContent; + }, + readSettingsFromContent(rawContent) { + return []; + }, + readDirectorySettings(directoryPath) { + if(!fs.existsSync(directoryPath)) { + return new Context(); + } + + if(!directoryPath.endsWith('/')) { + directoryPath += '/'; + } + + const settingsPath = directoryPath + '_settings.json'; + if(!fs.existsSync(settingsPath)) { + return new Context(); + } + + const settingsFileContent = fs.readFileSync(settingsPath, { encoding: 'utf-8' }); + const dict = JSON.parse(settingsFileContent); + + const vars = Object.keys(dict).map( + (k) => new Variable(k, dict[k]) + ); + + return new Context(vars); + } +}; \ No newline at end of file diff --git a/lib/render/template-manager.js b/lib/render/template-manager.js new file mode 100644 index 0000000..2692335 --- /dev/null +++ b/lib/render/template-manager.js @@ -0,0 +1,64 @@ +import fs from 'fs'; +import { Fragment, fragmentFormats } from "../struct/fragment.js"; +import { BasePaths } from "./base-paths.js"; +import { FileHelper } from './file-helper.js'; + +const TemplateSuperManager = { + templates: null, + initalized: false, + + initialize() { + const templateDir = BasePaths.templates(); + const entries = fs.readdirSync(templateDir, { encoding: 'utf-8', withFileTypes: true }); + const templateEntries = entries.filter( + (e) => FileHelper.isFragment(e) + ); + + let templates = []; + + for(let i = 0; i < templateEntries.length; i++) { + const type = FileHelper.getFragmentType(templateEntries[i]); + const path = templateEntries[i].parentPath + '/' + templateEntries[i].name; + const contents = fs.readFileSync(path, { encoding: 'utf-8' }); + + const template = new Fragment(type, contents); + + const name = FileHelper.getBaseName(templateEntries[i]); + + templates.push({ + name: name, + template: template + }); + } + + this.templates = templates; + this.initialized = true; + }, + + get(key) { + if(!this.initalized) { + this.initialize(); + } + + const match = this.templates.find( + (t) => t.name === key + ); + + return match?.template; + } +} + +export class TemplateManager { + _templateSuperManager; + + constructor() { + this._templateSuperManager = TemplateSuperManager; + if(!TemplateSuperManager.initalized) { + TemplateSuperManager.initialize(); + } + } + + get(key) { + return this._templateSuperManager.get(key); + } +} \ No newline at end of file diff --git a/lib/render/token.js b/lib/render/token.js new file mode 100644 index 0000000..7525cfc --- /dev/null +++ b/lib/render/token.js @@ -0,0 +1,15 @@ +export const tokenTypes = { + TEXT: 'text', + VARIABLE: 'variable', + FRAGMENT: 'fragment', +}; + +export class Token { + type; + content; + + constructor(type, token) { + this.type = type; + this.content = token; + } +} \ No newline at end of file diff --git a/lib/render/tokenizer.js b/lib/render/tokenizer.js new file mode 100644 index 0000000..2bcb609 --- /dev/null +++ b/lib/render/tokenizer.js @@ -0,0 +1,61 @@ +import { Token, tokenTypes } from "./token.js"; + +const VARIABLE_TOKEN_DEF = { + start: '<*', + end: '*>', +}; + +const FRAGMENT_TOKEN_DEF = { + start: '<{', + end: '}>', +}; + +export class Tokenizer { + constructor() { + + } + + tokensByVariable(fragmentText) { + const forward_split_tokens = fragmentText.split(VARIABLE_TOKEN_DEF.start); + + let tokens = [ + new Token(tokenTypes.TEXT, forward_split_tokens[0]) + ]; + + for(let i = 1; i < forward_split_tokens.length; i++) { + const back_split = forward_split_tokens[i].split(VARIABLE_TOKEN_DEF.end); + + if(back_split.length !== 2) { + console.error(`Difficulty parsing token: ${forward_split_tokens[i]}. Keeping as plain-text`); + tokens.push(new Token(tokenTypes.TEXT, forward_split_tokens[i])); + } else { + tokens.push(new Token(tokenTypes.VARIABLE, back_split[0])); + tokens.push(new Token(tokenTypes.TEXT, back_split[1])); + } + } + + return tokens; + } + + tokensByFragment(fragmentText) { + const forward_split_tokens = fragmentText.split(FRAGMENT_TOKEN_DEF.start); + + let tokens = [ + new Token(tokenTypes.TEXT, forward_split_tokens[0]) + ]; + + for(let i = 1; i < forward_split_tokens.length; i++) { + const back_split = forward_split_tokens[i].split(FRAGMENT_TOKEN_DEF.end); + + if(back_split.length !== 2) { + console.error(`Difficulty parsing token: ${forward_split_tokens[i]}. Keeping as plain-text`); + tokens.push(new Token(tokenTypes.TEXT, forward_split_tokens[i])); + } else { + tokens.push(new Token(tokenTypes.FRAGMENT, back_split[0])); + tokens.push(new Token(tokenTypes.TEXT, back_split[1])); + } + } + + return tokens; + } +} \ No newline at end of file diff --git a/lib/struct/context.js b/lib/struct/context.js new file mode 100644 index 0000000..78784dd --- /dev/null +++ b/lib/struct/context.js @@ -0,0 +1,50 @@ +import { Variable } from "./variable.js"; + +export class Context { + variables; + + constructor(vars) { + if(vars) { + this.variables = vars.map(v => new Variable(v.key, v.value)); + } else { + this.variables = []; + } + } + + copy() { + return new Context(this.variables); + } + + hasVariable(key) { + return this.variables.some( + (v) => v.key === key + ); + } + + get(key) { + const match = this.variables.find( + (v) => v.key === key + ); + + return match?.value ?? ""; + } + + set(key, value) { + const withoutOld = this.variables.filter( + (v) => v.key !== key + ); + + this.variables = [ + ...withoutOld, + new Variable(key, value) + ]; + } + + mergeFrom(other) { + for(let i = 0; i < other.variables.length; i++) { + this.set(other.variables[i].key, other.variables[i].value); + } + + return this; + } +} \ No newline at end of file diff --git a/lib/struct/fragment.js b/lib/struct/fragment.js new file mode 100644 index 0000000..7a9cb49 --- /dev/null +++ b/lib/struct/fragment.js @@ -0,0 +1,34 @@ +import Showdown from 'showdown'; + +export const fragmentFormats = { + V_HTML: ".vs.html", + V_MARKDOWN: ".vs.md", +}; + +export class Fragment { + format; + sourceContent; + + constructor(format, sourceContent) { + this.format = format; + this.sourceContent = sourceContent; + } + + toHtml() { + if(this.format === fragmentFormats.V_MARKDOWN) { + const converter = new Showdown.Converter(); + + const htmlContent = converter.makeHtml(this.sourceContent); + + return new Fragment( + fragmentFormats.V_HTML, + htmlContent + ); + } else { + return new Fragment( + this.format, + this.sourceContent + ); + } + } +} \ No newline at end of file diff --git a/lib/struct/variable.js b/lib/struct/variable.js new file mode 100644 index 0000000..cdfc6c6 --- /dev/null +++ b/lib/struct/variable.js @@ -0,0 +1,9 @@ +export class Variable { + key; + value; + + constructor(k, v) { + this.key = k; + this.value = v; + } +} \ No newline at end of file diff --git a/serve.js b/serve.js index 3ba020b..2928d8f 100644 --- a/serve.js +++ b/serve.js @@ -3,14 +3,14 @@ import handler from 'serve-handler'; import http from 'http'; import 'dotenv/config'; import { ENV } from './lib/env.js'; +import { BasePaths } from './lib/render/base-paths.js'; const isDebugEnabled = ENV.getBoolean('VS_DEBUG'); -const hasOverrideDirectory = ENV.getIsSet('VS_OUTPUT_DIR'); -const overrideDirectory = ENV.getString('VS_OUTPUT_DIR'); -const defaultDirectory = 'build'; + +const directory = BasePaths.targetRoot(); const options = { - public: hasOverrideDirectory ? overrideDirectory : defaultDirectory, + public: directory, directoryListing: isDebugEnabled, };