Add CSS framework

This commit is contained in:
2025-10-01 12:33:55 +02:00
parent 34127035c5
commit ff0fca203c
38 changed files with 3147 additions and 40 deletions

View File

@@ -0,0 +1,7 @@
CHange the handling of static files:
- The entire `static/` directory is now compiled into the binary, not just specific files.
- The server serves static files from an in-memory table instead of reading from disk.
- The static files are linked by their paths under `/static/` in the HTML template.
- This includes the `/static/` part of the path
- The intention is that static assets can reference each other using relative paths. (e.g. CSS referencing images)
- The `www/` directory remains for user uploads, served from disk.

View File

@@ -7,18 +7,19 @@ Luxtools is a minimal-but-complete scaffold for a retro-styled web application b
- **Nim**: Nim's ahead-of-time compilation and small standard library make it ideal for producing a single self-contained binary. Its async HTTP primitives provide great performance with minimal boilerplate.
- **HTMX**: HTMX keeps the frontend simple by embracing hypermedia. We can ship almost entirely server-rendered HTML snippets, allowing Nim to stay in control without a bulky SPA build step.
- **TUI.CSS**: This lightweight CSS framework offers a retro terminal aesthetic with zero JavaScript and a tiny footprint—perfect for a nostalgic interface while remaining easy to customize.
- **Single binary delivery**: HTML templates are embedded at compile time via `staticRead`, so the server ships with everything it needs. Drop the compiled executable onto any host with the correct architecture and you're ready to run.
- **Single binary delivery**: HTML templates and the entire `static/` tree of vendored assets are embedded at compile time via `staticRead`, so the server ships with everything it needs. The compiled binary serves those resources directly from an in-memory table, while a separate `www/` directory remains available for user-provided uploads.
## Project layout
```
luxtools/
├── bin/ # Output directory for release binaries
├── assets/ # Static files served directly by the HTTP handler
├── static/ # Vendored assets compiled into the binary static table
├── src/
│ └── luxtools.nim # Main server entrypoint
├── templates/
│ └── index.html # HTMX-powered landing page (compiled into the binary)
├── www/ # Runtime asset directory served from disk
├── tests/
│ └── test_rendering.nim# Lightweight regression tests for HTML helpers
├── luxtools.nimble # Nimble manifest with build/test tasks

BIN
luxtools Executable file

Binary file not shown.

BIN
src/luxtools Executable file

Binary file not shown.

View File

@@ -1,8 +1,66 @@
import std/[asynchttpserver, asyncdispatch, os, strformat, strutils, times]
import std/[asynchttpserver, asyncdispatch, os, strformat, strutils, tables, times]
const
indexPage = staticRead("../templates/index.html")
type
StaticFile = tuple[content: string, contentType: string]
const
defaultContentType = "application/octet-stream"
contentTypeByExt = block:
var table = initTable[string, string]()
table[".js"] = "application/javascript"
table[".css"] = "text/css"
table[".html"] = "text/html; charset=utf-8"
table[".json"] = "application/json"
table[".png"] = "image/png"
table[".jpg"] = "image/jpeg"
table[".jpeg"] = "image/jpeg"
table[".svg"] = "image/svg+xml"
table[".gif"] = "image/gif"
table[".ico"] = "image/x-icon"
table[".ttf"] = "font/ttf"
table[".woff"] = "font/woff"
table[".woff2"] = "font/woff2"
table[".txt"] = "text/plain; charset=utf-8"
table
proc guessContentType(path: string): string =
let ext = splitFile(path).ext.toLowerAscii()
if contentTypeByExt.hasKey(ext):
contentTypeByExt[ext]
else:
defaultContentType
const staticRootDir = parentDir(currentSourcePath()) / ".." / "static"
const staticFileEntries: seq[(string, StaticFile)] = block:
var entries: seq[(string, StaticFile)] = @[]
for path in walkDirRec(staticRootDir):
let fullPath = if isAbsolute(path): path else: staticRootDir / path
if not fileExists(fullPath):
continue
let relPath = relativePath(fullPath, staticRootDir)
if relPath.len == 0 or relPath.startsWith(".."):
continue
let normalizedRel = relPath.replace(DirSep, '/')
let urlPath = "/static/" & normalizedRel
let content = staticRead(fullPath)
let contentType = guessContentType(fullPath)
entries.add((urlPath, (content: content, contentType: contentType)))
entries
proc findStaticFile(path: string; asset: var StaticFile): bool =
for entry in staticFileEntries:
if entry[0] == path:
asset = entry[1]
return true
false
proc assetsRoot(): string =
absolutePath(joinPath(getAppDir(), "..", "www"))
var clickCount = 0
proc htmlHeaders(): HttpHeaders =
@@ -40,43 +98,42 @@ proc sendHtml(req: Request, code: HttpCode, body: string) {.async, gcsafe.} =
proc sendAsset(req: Request, body: string, contentType: string) {.async, gcsafe.} =
await req.respond(Http200, body, assetHeaders(contentType))
proc guessContentType(path: string): string =
let ext = splitFile(path).ext.toLowerAscii()
case ext
of ".js":
result = "application/javascript"
of ".css":
result = "text/css"
of ".html":
result = "text/html; charset=utf-8"
of ".json":
result = "application/json"
of ".png":
result = "image/png"
of ".jpg", ".jpeg":
result = "image/jpeg"
of ".svg":
result = "image/svg+xml"
of ".gif":
result = "image/gif"
of ".ico":
result = "image/x-icon"
else:
result = "application/octet-stream"
proc trySendAsset(req: Request): Future[bool] {.async, gcsafe.} =
proc trySendStatic(req: Request): Future[bool] {.async, gcsafe.} =
if req.reqMethod notin {HttpGet, HttpHead}:
return false
let path = req.url.path
if path.len <= 1 or path[0] != '/':
var asset: StaticFile
if not findStaticFile(path, asset):
return false
if req.reqMethod == HttpHead:
await req.respond(Http200, "", assetHeaders(asset.contentType))
else:
await sendAsset(req, asset.content, asset.contentType)
return true
proc trySendFileAsset(req: Request): Future[bool] {.async, gcsafe.} =
const assetPrefix = "/assets/"
if req.reqMethod notin {HttpGet, HttpHead}:
return false
let relative = path[1..^1]
let path = req.url.path
if not path.startsWith(assetPrefix):
return false
if path.len == assetPrefix.len:
return false
let relative = path[assetPrefix.len .. ^1]
if relative.len == 0 or relative.contains("..") or relative.contains('\\'):
return false
let assetPath = joinPath(absolutePath(joinPath(getAppDir(), "..", "assets")), relative)
let root = assetsRoot()
if not dirExists(root):
return false
let assetPath = joinPath(root, relative)
if not fileExists(assetPath):
return false
@@ -98,7 +155,9 @@ proc handleRequest(req: Request) {.async, gcsafe.} =
of "/time":
await sendHtml(req, renderTime())
else:
if await trySendAsset(req):
if await trySendStatic(req):
return
if await trySendFileAsset(req):
return
await sendHtml(req, Http404, """
<div class="tui-window">

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,72 @@
/
/(_____________ ____
\ /______)\ | |
:\ | / \:| |:::::::::: : .. . : .. . . :. .
\_____| / | \| |______
___ / ________ \... . . .
\______________ \ | | /.. . . . . .
\ |__| /
--x--x-----x----\______ |-/_____/-x--x-xx--x-- - -x -- - - -- - - -
. . . . . . . . . . . .\____|. . . . . .
-------------------------------------------------------------------------------
>> perfect dos vga 437 - general information >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
-------------------------------------------------------------------------------
"Perfect DOS VGA 437" and "Perfect DOS VGA 437 Win" are truetype fonts
designed to emulate the MS-DOS/Text mode standard font, used on VGA monitors,
with the 437 Codepage (standard US/International). This is a "bitmap" font,
meaning it emulates a bitmap font and can only be used at a given size (8 or
multiples of it like 16, 24, 32, etc). It's optimized for Flash too, so it
won't produce antialias if used at round positions.
There are two fonts available. "Perfect DOS VGA 437" uses the original DOS
codepage 437. It should be used, for example, if you're opening DOS ASCII
files on notepad or another windows-based editor. Since it's faithful to the
original DOS codes, it won't accent correctly in windows ("é" would produce
something different, not an "e" with an acute).
There's also "Perfect DOS VGA 437 Win" which is the exactly same font adapted
to a windows codepage. This should use accented characters correctly but won't
work if you're opening a DOS-based text file.
UPDATE: this is a new version, updated in august/2008. It has fixed leading
metrics for Mac systems.
-------------------------------------------------------------------------------
>> perfect dos vga 437 - creation process >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
-------------------------------------------------------------------------------
This font was created to be used on a Flash-based ANSi viewer I'm working. To
create it, I created a small Quick Basic program to write all characters on
screen,
CLS
FOR l = 0 TO 255
charWrite 1 + (l MOD 20), 1 + (l \ 20) * 6 + (l MOD 2), LTRIM$(RTRIM$(STR$(l))) + CHR$(l)
NEXT
SUB charWrite (lin, col, char$)
DEF SEG = &HB800
FOR i = 1 TO LEN(char$)
POKE ((lin - 1) * 160) + ((col - 2 + i) * 2), ASC(MID$(char$, i, 1))
IF (i = LEN(char$)) THEN POKE ((lin - 1) * 160) + ((col - 2 + i) * 2) + 1, 113
NEXT
END SUB
Then captured the text screen using SCREEN THIEF (a very, very old screen
capture TSR program which converts text screens to images accurately). I then
recreated the font polygon by polygon on Fontlab, while looking at the image
on Photoshop. No conversion took place.
-------------------------------------------------------------------------------
>> author >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
-------------------------------------------------------------------------------
zeh fernando remembers the old days. SMASH DAH FUCKING ENTAH.
http://www.fatorcaos.com.br
rorshack ^ maiden brazil
-------------------------------------------------------------------------------
^zehPULLSdahTRICK^kudosOUTtoWHOkeepsITreal^smashDAHfuckingENTAH!!!^lowres4ever^
-------------------------------------------------------------------------------

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 B

2704
static/lib/tuicss/tuicss.css Normal file

File diff suppressed because it is too large Load Diff

256
static/lib/tuicss/tuicss.js Normal file
View File

@@ -0,0 +1,256 @@
/**
* Replacement for jQuery's $(document).ready() function.
* This is handy in making sure stuff fires after the DOM is ready to be touched.
* Stolen from:https://stackoverflow.com/a/53601942/344028
*
* @param fn Callback.
*/
function domReady(fn) {
// If we're early to the party
document.addEventListener('DOMContentLoaded', fn);
// If late; I mean on time.
if (document.readyState === 'interactive' || document.readyState === 'complete') {
fn();
}
}
/**
* TuiTabs controller
*/
function tabsController() {
// Get all the tab elements (typically li tags).
const tabs = document.getElementsByClassName('tui-tab');
if (!tabs.length) {
// No tabs found, return early and save a couple CPU cycles.
return;
}
for (const tab of tabs) {
// Add click listeners to them.
tab.addEventListener('click', function (e) {
// Check if the clicked tab is disabled
if(e.target.classList.contains("disabled")) {
return;
}
// Remove the 'active' class from any and all tabs.
for (const otherTab of tabs) {
otherTab.classList.remove('active');
}
// Get the content element.
const tabContents = document.getElementsByClassName('tui-tab-content');
if (tabContents) {
for (const tabContent of tabContents) {
// Hide all tab contents.
tabContent.style.display = 'none';
}
} else {
throw 'No tab content elements found.'
}
// Get the id of the tab contents we want to show.
const tabContentId = e.target.getAttribute('data-tab-content');
if (tabContentId) {
const tabContent = document.getElementById(tabContentId);
if (tabContent) {
// Show the tab contents.
tabContent.style.display = 'block';
} else {
throw 'No tab content element with id "' + tabContentId + '" found.';
}
}
// We are not going to throw an error here, since we could make the tab do something else that a tab
// normally wouldn't do.
// Set the clicked tab to have the 'active' class so we can use it in the next part.
e.target.classList.add('active');
});
}
// Grab the first tab with the active class.
const activeTab = document.querySelector('.tui-tab.active');
if (activeTab) {
// Now click it 'click' it.
activeTab.click();
} else {
// Nothing found, just click the first tab foud.
tabs[0].click()
}
}
/**
* Date/time field controller
*/
function datetimeController() {
// Get date/time elements.
const clocks = document.getElementsByClassName('tui-datetime');
if (!clocks.length) {
// No date time elements found, return early and save a couple CPU cycles.
return;
}
// Kick off our clock interval stuff.
datetimeInterval();
// Synchronize time and set interval to control the clocks
setTimeout(() => {
setInterval(datetimeInterval, 1000);
}, 1000 - new Date().getMilliseconds());
function datetimeInterval() {
for (const clock of clocks) {
if (clock === null) {
continue;
}
// Get the format we want to display in the element.
let format = clock.getAttribute('data-format');
// parse out the date and time into constants.
const today = new Date();
const month = (today.getMonth() + 1).toString().padStart(2, '0');
const day = today.getDate().toString().padStart(2, '0');
const dayOfWeek = (today.getDay() + 1).toString().padStart(2, '0');
const year = today.getFullYear().toString();
const hour = today.getHours().toString().padStart(2, '0');
const hour12 = (parseInt(hour) + 24) % '12' || '12';
const minute = today.getMinutes().toString().padStart(2, '0');
const second = today.getSeconds().toString().padStart(2, '0');
const ampm = parseInt(hour) >= 12 ? 'PM' : 'AM';
// Replace based on the format.
format = format.replace('M', month);
format = format.replace('d', day);
format = format.replace('e', dayOfWeek);
format = format.replace('y', year);
format = format.replace('H', hour);
format = format.replace('h', hour12);
format = format.replace('m', minute);
format = format.replace('s', second);
format = format.replace('a', ampm);
// Show it in the element.
clock.innerHTML = format;
}
}
}
/**
* Sidenav Controller
* There should only side navigation element at the moment.
*/
function sidenavController() {
// Get the side navigation button (there should be only one, but if not, we are getting the first one).
const sideNavButton = document.querySelector('.tui-sidenav-button');
if (!sideNavButton) {
// No side navigation button found, return early and save a couple CPU cycles.
return;
}
// Add the click event listener to the buttons.
sideNavButton.addEventListener('click', () => {
// Get the side navigation element (there should be only one, but if not, we are getting the first one).
const sideNav = document.querySelector('.tui-sidenav');
if (sideNav) {
if (sideNav.classList.contains('active')) {
sideNav.classList.remove('active');
} else {
sideNav.classList.add('active');
}
} else {
throw 'No sidenav element found.'
}
});
}
/**
* Modal controller
*/
function modalController() {
// Get the overlap (overlay) element (there should be only one, but if not, we are getting the first one).
const tuiOverlap = document.querySelector('.tui-overlap');
if (!tuiOverlap) {
// No overlap found element, return early and save a couple CPU cycles.
return;
}
// Find modal buttons.
const modalButtons = document.getElementsByClassName('tui-modal-button');
for (const modalButton of modalButtons) {
// Add the click event listener to the buttons.
modalButton.addEventListener('click', (e) => {
// Show the overlap.
tuiOverlap.classList.add('active');
// Get the display element for the modal.
const modalId = e.target.getAttribute('data-modal');
if (modalId) {
const modal = document.getElementById(modalId);
if (modal) {
// Show it.
modal.classList.add('active');
} else {
throw 'No modal element with id of "' + modalId + '" found.';
}
} else {
throw 'Modal close button data-modal attribute is empty or not set.'
}
});
}
// Find the close modal buttons.
const modalCloseButtons = document.getElementsByClassName('tui-modal-close-button');
if (modalButtons.length > 0 && !modalCloseButtons.length) {
// A modal without a close button, is a bad modal.
throw 'No modal close buttons found.'
}
for (const modalCloseButton of modalCloseButtons) {
// Add the click event listener to the buttons.
modalCloseButton.addEventListener('click', (e) => {
// Hide the the overlap.
tuiOverlap.classList.remove('active');
// Get the display element id for the modal.
const modalId = e.target.getAttribute('data-modal');
if (modalId) {
// Get the modal element.
const modal = document.getElementById(modalId);
if (modal) {
// Hide it.
modal.classList.remove('active');
} else {
throw 'No modal element with id of "' + modalId + '" found.';
}
} else {
throw 'Modal close button data-modal attribute is empty or not set.'
}
});
}
}
/**
* Init: This is at the bottom to make sure it is fired correctly.
*/
domReady(function () {
tabsController();
datetimeController();
sidenavController();
modalController();
});

1
static/lib/tuicss/tuicss.min.css vendored Normal file

File diff suppressed because one or more lines are too long

1
static/lib/tuicss/tuicss.min.js vendored Normal file
View File

@@ -0,0 +1 @@
function domReady(t){document.addEventListener("DOMContentLoaded",t),"interactive"!==document.readyState&&"complete"!==document.readyState||t()}function tabsController(){const t=document.getElementsByClassName("tui-tab");if(!t.length)return;for(const e of t)e.addEventListener("click",function(e){if(e.target.classList.contains("disabled"))return;for(const e of t)e.classList.remove("active");const o=document.getElementsByClassName("tui-tab-content");if(!o)throw"No tab content elements found.";for(const t of o)t.style.display="none";const n=e.target.getAttribute("data-tab-content");if(n){const t=document.getElementById(n);if(!t)throw'No tab content element with id "'+n+'" found.';t.style.display="block"}e.target.classList.add("active")});const e=document.querySelector(".tui-tab.active");e?e.click():t[0].click()}function datetimeController(){const t=document.getElementsByClassName("tui-datetime");function e(){for(const e of t){if(null===e)continue;let t=e.getAttribute("data-format");const o=new Date,n=(o.getMonth()+1).toString().padStart(2,"0"),a=o.getDate().toString().padStart(2,"0"),c=(o.getDay()+1).toString().padStart(2,"0"),s=o.getFullYear().toString(),i=o.getHours().toString().padStart(2,"0"),l=(parseInt(i)+24)%"12"||"12",r=o.getMinutes().toString().padStart(2,"0"),d=o.getSeconds().toString().padStart(2,"0"),u=parseInt(i)>=12?"PM":"AM";t=(t=(t=(t=(t=(t=(t=(t=(t=t.replace("M",n)).replace("d",a)).replace("e",c)).replace("y",s)).replace("H",i)).replace("h",l)).replace("m",r)).replace("s",d)).replace("a",u),e.innerHTML=t}}t.length&&(e(),setTimeout(()=>{setInterval(e,1e3)},1e3-(new Date).getMilliseconds()))}function sidenavController(){const t=document.querySelector(".tui-sidenav-button");t&&t.addEventListener("click",()=>{const t=document.querySelector(".tui-sidenav");if(!t)throw"No sidenav element found.";t.classList.contains("active")?t.classList.remove("active"):t.classList.add("active")})}function modalController(){const t=document.querySelector(".tui-overlap");if(!t)return;const e=document.getElementsByClassName("tui-modal-button");for(const o of e)o.addEventListener("click",e=>{t.classList.add("active");const o=e.target.getAttribute("data-modal");if(!o)throw"Modal close button data-modal attribute is empty or not set.";{const t=document.getElementById(o);if(!t)throw'No modal element with id of "'+o+'" found.';t.classList.add("active")}});const o=document.getElementsByClassName("tui-modal-close-button");if(e.length>0&&!o.length)throw"No modal close buttons found.";for(const e of o)e.addEventListener("click",e=>{t.classList.remove("active");const o=e.target.getAttribute("data-modal");if(!o)throw"Modal close button data-modal attribute is empty or not set.";{const t=document.getElementById(o);if(!t)throw'No modal element with id of "'+o+'" found.';t.classList.remove("active")}})}domReady(function(){tabsController(),datetimeController(),sidenavController(),modalController()});

View File

@@ -6,9 +6,10 @@
<title>Luxtools · Nim + HTMX demo</title>
<link
rel="stylesheet"
href="https://unpkg.com/tui-css@2.3.1/dist/tui.css"
href="/static/lib/tuicss/tuicss.min.css"
/>
<script src="/lib/htmx.2.0.7.min.js" defer></script>
<script src="/static/lib/htmx.2.0.7.min.js" defer></script>
<script src="/static/lib/tuicss/tuicss.min.js" defer></script>
</head>
<body class="tui-bg-black">
<main class="tui-container tui-window" style="margin-top: 2rem;">

View File

@@ -10,11 +10,15 @@ suite "rendering helpers":
check html.contains("id=\"counter\"")
check html.contains("3")
test "index template uses vendored htmx build":
check indexTemplate.contains("/lib/htmx.2.0.7.min.js")
check indexTemplate.contains("<script src=\"/lib/htmx.2.0.7.min.js\" defer></script>")
test "index template uses embedded htmx build":
check indexTemplate.contains("/static/lib/htmx.2.0.7.min.js")
check indexTemplate.contains("<script src=\"/static/lib/htmx.2.0.7.min.js\" defer></script>")
check indexTemplate.contains("/static/lib/tuicss/tuicss.min.css")
check indexTemplate.contains("href=\"/static/lib/tuicss/tuicss.min.css\"")
check indexTemplate.contains("<script src=\"/static/lib/tuicss/tuicss.min.js\" defer></script>")
check not indexTemplate.contains("unpkg.com/htmx")
check not indexTemplate.contains("unpkg.com/tui-css")
test "vendored htmx asset lives in assets directory":
let assetPath = joinPath(getAppDir(), "..", "assets", "lib", "htmx.2.0.7.min.js")
test "demo asset lives in www directory":
let assetPath = joinPath(getAppDir(), "..", "www", "demo.txt")
check fileExists(assetPath)

1
www/demo.txt Normal file
View File

@@ -0,0 +1 @@
This is a demo asset served directly from the filesystem assets handler.