Compare commits

..

2 Commits

Author SHA1 Message Date
325b747bd8 Merge pull request 'Rewrite in-progress' (#1) from rewrite into develop
Reviewed-on: #1
2025-04-21 21:51:39 -04:00
walcutt
850721934a Rewrite in-progress 2025-04-21 21:46:30 -04:00
21 changed files with 617 additions and 44 deletions

View File

@ -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}`);
}
});
renderer.renderAll();

View File

@ -0,0 +1 @@
test variable = <* test_var *>

View File

@ -0,0 +1,4 @@
{
"test_var": "Outer settings",
"template": "base-template"
}

View File

@ -0,0 +1,4 @@
{
"test_var": "Inner setting",
"template": ""
}

View File

@ -0,0 +1,3 @@
# Hello world from another directory!
<{ var-test }>

View File

@ -0,0 +1,3 @@
# Hello world!
<{ var-test }>

View File

@ -0,0 +1,6 @@
<div>
<h3>
Outer wrapper!
</h3>
<{ content }>
</div>

View File

@ -1 +0,0 @@
# Here's a test!

2
lib/constants.js Normal file
View File

@ -0,0 +1,2 @@
export const DEFAULT_SOURCE_BASE = './content';
export const DEFUALT_TARGET_BASE = './build';

24
lib/render/base-paths.js Normal file
View File

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

66
lib/render/file-helper.js Normal file
View File

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

View File

@ -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);
}
}

150
lib/render/renderer.js Normal file
View File

@ -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();
}
}

View File

@ -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);
}
};

View File

@ -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);
}
}

15
lib/render/token.js Normal file
View File

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

61
lib/render/tokenizer.js Normal file
View File

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

50
lib/struct/context.js Normal file
View File

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

34
lib/struct/fragment.js Normal file
View File

@ -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
);
}
}
}

9
lib/struct/variable.js Normal file
View File

@ -0,0 +1,9 @@
export class Variable {
key;
value;
constructor(k, v) {
this.key = k;
this.value = v;
}
}

View File

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