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,
};