Skip to content

Commit 54dc06d

Browse files
authored
feat: add hot-reload server to template development (#25)
* feat: add dev server to template debug * fix: remove unused npm command * revert: default config locale * feat: add hot-reload over sse * feat: add ability to watch any status page instead of configured * fix: linter * docs: readme update * 1.10.0 * fix: typo
1 parent 7247cf5 commit 54dc06d

File tree

9 files changed

+1007
-38
lines changed

9 files changed

+1007
-38
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ The main configuration is stored in the `config.json` file in a root directory a
9494
}
9595
```
9696

97+
There possible to run hot-reload server to develop your own theme with custom markup, styles, and scripts. To start dev-server just run command `npm run dev`. This command will start server on 8080 port ([http://localhost:8080](http://localhost:8080). By default, this address will be opened with a first status code, defined in `src` directory, which corresponds to configured `locale` value. You can choose any other code to continue specific page development. Don't be surprised with injected parts of code in a rendered page, because this is a part of hot-reload mode. Any change of the main configuration will require dev-server restart. The only configured theme and locale directories are watching during development.
98+
99+
97100
### Templates
98101

99102
All templates are located in the `themes` directory. You can change the existing `minimalistic` theme or add a new one. There are no special requirements to page templates: every template is a usual HTML document with injected variables for the text messages from locale files. The [mustache.js](https://www.npmjs.com/package/mustache) library was used to handle variables injection and compile templates. So if you want to have something specific around templates, you can refer to this library documentation to get more information about templating.

container.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Container } from "inversify";
2+
3+
import { Compiler, ICompiler } from "./lib/classes/Compiler";
4+
import { ChildProcessWrapper, IChildProcessWrapper } from "./lib/classes/ChildProcessWrapper";
5+
import { FileSystemHelper, IFileSystemHelper } from "./lib/classes/FileSystemHelper";
6+
import { IFileSystemWrapper, NodeFS } from "./lib/classes/FileSystemWrapper";
7+
import { ILogger, Logger } from "./lib/classes/Logger";
8+
import { PathRegistry } from "./lib/classes/PathRegistry";
9+
import { IStyler, Styler } from "./lib/classes/Styler";
10+
11+
import { pr } from "./path-registry";
12+
13+
import { DI_TOKENS } from "./lib/tokens";
14+
15+
// Register DI
16+
export function initContainer(): Container {
17+
const container = new Container({ defaultScope: "Singleton" });
18+
container.bind<ICompiler>(DI_TOKENS.COMPILER).to(Compiler);
19+
container.bind<IChildProcessWrapper>(DI_TOKENS.CHILD_PROCESS).to(ChildProcessWrapper);
20+
container.bind<IFileSystemHelper>(DI_TOKENS.FS_HELPER).to(FileSystemHelper);
21+
container.bind<IFileSystemWrapper>(DI_TOKENS.FS).to(NodeFS);
22+
container.bind<ILogger>(DI_TOKENS.LOGGER).to(Logger);
23+
container.bind<IStyler>(DI_TOKENS.STYLER).to(Styler);
24+
container.bind<PathRegistry>(DI_TOKENS.PATH).toConstantValue(pr);
25+
26+
return container;
27+
}

dev/server.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import "reflect-metadata";
2+
3+
import chokidar from "chokidar";
4+
import { readFileSync } from "fs";
5+
import Koa from "koa";
6+
import Stream from "stream";
7+
8+
import { ICompiler } from "../lib/classes/Compiler";
9+
import { IFileSystemHelper } from "../lib/classes/FileSystemHelper";
10+
import { Messages } from "../lib/classes/Messages";
11+
import { Renderer } from "../lib/classes/Renderer";
12+
13+
import { initContainer } from "../container";
14+
import { MessagesEnum } from "../messages";
15+
import { pr } from "../path-registry";
16+
17+
import { DEFAULTS } from "../lib/constants";
18+
import { Config, TemplateVariables } from "../lib/interfaces";
19+
import { DI_TOKENS } from "../lib/tokens";
20+
21+
const STATUS_PATH_REGEX = /^\/([0-9]{3})$/i;
22+
23+
const runContainer = initContainer();
24+
25+
const fsHelper = runContainer.get<IFileSystemHelper>(DI_TOKENS.FS_HELPER);
26+
27+
fsHelper.readConfig(pr.get("config")).then(async (config) => {
28+
runContainer.bind<Config>(DI_TOKENS.CONFIG).toConstantValue(config);
29+
30+
// Registry update with new paths, which depends on current config
31+
pr.update({
32+
src: `${DEFAULTS.SRC}/${config.locale}`,
33+
theme: `${DEFAULTS.THEMES}/${config.theme}`,
34+
themeConfig: `${DEFAULTS.THEMES}/${config.theme}/theme.tailwind.config.js`,
35+
themeCss: `${DEFAULTS.THEMES}/${config.theme}/@assets/css/main.twnd.css`,
36+
});
37+
38+
const compiler = runContainer.get<ICompiler>(DI_TOKENS.COMPILER);
39+
const statusList = await compiler.getStatusList();
40+
41+
// Server setup
42+
const app = new Koa();
43+
44+
const watcher = chokidar.watch([`${pr.get("src")}/**`, `${pr.get("theme")}/**`], {
45+
persistent: true,
46+
interval: 300,
47+
});
48+
49+
// Hot-reload feature over Server-sent events (SSE)
50+
app.use((ctx, next) => {
51+
console.log(`requested ${ctx.path}`);
52+
if ("/events" == ctx.path) {
53+
ctx.set({
54+
"Content-Type": "text/event-stream",
55+
"Cache-Control": "no-cache",
56+
Connection: "keep-alive",
57+
});
58+
ctx.status = 200;
59+
60+
const stream = new Stream.PassThrough();
61+
ctx.body = stream;
62+
63+
stream.write(`data: init\n\n`);
64+
65+
const sseHandler = (path) => {
66+
stream.write(`data: reload\n\n`);
67+
console.log(`hot-reload on ${path}`);
68+
};
69+
70+
watcher.on("add", sseHandler).on("change", sseHandler).on("unlink", sseHandler).on("addDir", sseHandler).on("unlinkDir", sseHandler);
71+
72+
ctx.req.on("close", () => {
73+
stream.end();
74+
});
75+
} else {
76+
return next();
77+
}
78+
});
79+
80+
// URL processor
81+
app.use(async (ctx, next) => {
82+
if (ctx.path === "/") {
83+
// Redirect to first status in a list
84+
ctx.redirect(`/${[...statusList][0]}`);
85+
} else if (STATUS_PATH_REGEX.test(ctx.path)) {
86+
// Read template if path looks like status path
87+
try {
88+
ctx.body = await fsHelper.readFile(pr.join("theme", "template.html"));
89+
return next();
90+
} catch (_) {
91+
ctx.status = 500;
92+
ctx.body = Messages.text(MessagesEnum.NO_TEMPLATE_CONTENT);
93+
}
94+
} else {
95+
// Overwise return status 301
96+
ctx.status = 301;
97+
}
98+
});
99+
100+
// Inject development variables
101+
app.use(async (ctx, next) => {
102+
if (ctx.body) {
103+
ctx.body = ctx.body.replace(/<\/(head|body)>/gi, "{{ $1-injection }}</$1>");
104+
await next();
105+
} else {
106+
ctx.status = 204;
107+
}
108+
});
109+
110+
// Render variables in template
111+
app.use(async (ctx, next) => {
112+
if (ctx.body) {
113+
try {
114+
const matches = ctx.path.match(STATUS_PATH_REGEX);
115+
const code = Number(matches[1]);
116+
if (!statusList.has(code)) {
117+
throw new Error(`No source file with status code #${code}`);
118+
}
119+
120+
const initVars = await compiler.initTemplateVariables();
121+
const commonVars = await fsHelper.readJson<TemplateVariables>(pr.join("src", "common.json"));
122+
const statusVars = await fsHelper.readJson<TemplateVariables>(pr.join("src", `${code}.json`));
123+
124+
const devVars = {
125+
"head-injection": "",
126+
"body-injection": readFileSync("./dev/sse.html").toString(),
127+
};
128+
129+
if (config.tailwind) {
130+
devVars["head-injection"] += `<script src="https://cdn.tailwindcss.com/3.2.4"></script>`;
131+
132+
if (await fsHelper.ensure(pr.get("themeConfig"))) {
133+
// eslint-disable-next-line @typescript-eslint/no-var-requires
134+
const tailwindConfig = require(pr.get("themeConfig"));
135+
devVars["head-injection"] += `<script>tailwind.config = ${JSON.stringify(tailwindConfig)};</script>`;
136+
}
137+
138+
if (await fsHelper.ensure(pr.get("themeCss"))) {
139+
const mainCss = await fsHelper.readFile(pr.get("themeCss"));
140+
devVars["head-injection"] += `<style type="text/tailwindcss">${mainCss}</style>`;
141+
}
142+
}
143+
144+
ctx.body = Renderer.renderTemplate(ctx.body, { ...initVars, ...commonVars, ...statusVars, ...devVars, code });
145+
146+
await next();
147+
} catch (err) {
148+
ctx.status = 500;
149+
ctx.body = err.message;
150+
}
151+
} else {
152+
ctx.status = 204;
153+
}
154+
});
155+
156+
const port = 8080;
157+
app.listen(port);
158+
console.log(`hot-reload server was started on port ${port}`);
159+
});

dev/sse.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<script type="text/javascript">
2+
const src = new EventSource("/events");
3+
4+
src.onmessage = (event) => {
5+
if (event.data.indexOf("reload") !== -1) {
6+
window.location.reload();
7+
}
8+
};
9+
</script>

index.ts

Lines changed: 6 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,20 @@
11
import "reflect-metadata";
22

3-
import { Container } from "inversify";
4-
5-
import { Compiler, ICompiler } from "./lib/classes/Compiler";
6-
import { ChildProcessWrapper, IChildProcessWrapper } from "./lib/classes/ChildProcessWrapper";
7-
import { FileSystemHelper, IFileSystemHelper } from "./lib/classes/FileSystemHelper";
8-
import { IFileSystemWrapper, NodeFS } from "./lib/classes/FileSystemWrapper";
9-
import { ILogger, Logger } from "./lib/classes/Logger";
3+
import { FileSystemHelper } from "./lib/classes/FileSystemHelper";
4+
import { ILogger } from "./lib/classes/Logger";
105
import { Main } from "./lib/classes/Main";
11-
import { IStyler, Styler } from "./lib/classes/Styler";
6+
7+
import { initContainer } from "./container";
8+
import { pr } from "./path-registry";
129

1310
import { Config } from "./lib/interfaces";
1411
import { Messages } from "./lib/classes/Messages";
1512
import { MessagesEnum } from "./messages";
16-
import { PathRegistry } from "./lib/classes/PathRegistry";
1713

1814
import { DEFAULTS } from "./lib/constants";
1915
import { DI_TOKENS } from "./lib/tokens";
2016

21-
// Resigstry of resolved paths to usage during the process
22-
const pr = new PathRegistry({
23-
assetsDist: `${DEFAULTS.DIST}/${DEFAULTS.ASSETS}`,
24-
config: DEFAULTS.CONFIG,
25-
dist: DEFAULTS.DIST,
26-
package: DEFAULTS.PACKAGE,
27-
snippets: DEFAULTS.SNIPPETS,
28-
twndDist: `${DEFAULTS.DIST}/${DEFAULTS.ASSETS}/css/${DEFAULTS.TAILWIND_OUT}`,
29-
});
30-
31-
// Register DI
32-
const runContainer = new Container({ defaultScope: "Singleton" });
33-
runContainer.bind<ICompiler>(DI_TOKENS.COMPILER).to(Compiler);
34-
runContainer.bind<IChildProcessWrapper>(DI_TOKENS.CHILD_PROCESS).to(ChildProcessWrapper);
35-
runContainer.bind<IFileSystemHelper>(DI_TOKENS.FS_HELPER).to(FileSystemHelper);
36-
runContainer.bind<IFileSystemWrapper>(DI_TOKENS.FS).to(NodeFS);
37-
runContainer.bind<ILogger>(DI_TOKENS.LOGGER).to(Logger);
38-
runContainer.bind<IStyler>(DI_TOKENS.STYLER).to(Styler);
39-
runContainer.bind<PathRegistry>(DI_TOKENS.PATH).toConstantValue(pr);
17+
const runContainer = initContainer();
4018

4119
runContainer
4220
.resolve(FileSystemHelper)

0 commit comments

Comments
 (0)