@kntic/links 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Static HTML page generator for KNTIC Links.
3
+ *
4
+ * Produces a single, fully self-contained index.html with all CSS inlined.
5
+ * No JavaScript in the output — works with JS disabled.
6
+ */
7
+
8
+ import { readFileSync } from 'node:fs';
9
+ import { resolve, dirname, extname } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { isLinkActive } from './utils.js';
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Helpers
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /** HTML-escape a string to prevent XSS. */
20
+ function esc(str) {
21
+ if (!str) return '';
22
+ return String(str)
23
+ .replace(/&/g, '&')
24
+ .replace(/</g, '&lt;')
25
+ .replace(/>/g, '&gt;')
26
+ .replace(/"/g, '&quot;')
27
+ .replace(/'/g, '&#39;');
28
+ }
29
+
30
+ /** MIME type lookup for common image formats. */
31
+ function imageMime(filePath) {
32
+ const ext = extname(filePath).toLowerCase();
33
+ const map = {
34
+ '.png': 'image/png',
35
+ '.jpg': 'image/jpeg',
36
+ '.jpeg': 'image/jpeg',
37
+ '.gif': 'image/gif',
38
+ '.webp': 'image/webp',
39
+ '.svg': 'image/svg+xml',
40
+ '.ico': 'image/x-icon',
41
+ '.avif': 'image/avif',
42
+ };
43
+ return map[ext] || 'application/octet-stream';
44
+ }
45
+
46
+ /**
47
+ * Read a local image file and return a base64 data URI.
48
+ * @param {string} filePath — path to the image (absolute or relative to configDir)
49
+ * @param {string} configDir — directory that links.yaml lives in
50
+ * @returns {string} data URI string
51
+ */
52
+ export function inlineImage(filePath, configDir) {
53
+ const abs = resolve(configDir, filePath);
54
+ const buf = readFileSync(abs);
55
+ const mime = imageMime(abs);
56
+ return `data:${mime};base64,${buf.toString('base64')}`;
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Schedule filtering
61
+ // ---------------------------------------------------------------------------
62
+
63
+ /**
64
+ * Filter links to only those whose schedule window includes `now`.
65
+ * Links without scheduling fields are always included.
66
+ * Delegates to isLinkActive() from utils.js for per-link evaluation.
67
+ */
68
+ export function filterScheduled(links, now = new Date()) {
69
+ if (!links || !Array.isArray(links)) return [];
70
+ return links.filter((link) => isLinkActive(link, now));
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Theme CSS loading
75
+ // ---------------------------------------------------------------------------
76
+
77
+ /**
78
+ * Load theme CSS from the themes directory.
79
+ * @param {string} themeName — name without .css extension
80
+ * @returns {string} CSS content
81
+ */
82
+ export function loadThemeCSS(themeName) {
83
+ const themePath = resolve(__dirname, 'themes', `${themeName}.css`);
84
+ try {
85
+ return readFileSync(themePath, 'utf8');
86
+ } catch {
87
+ throw new Error(
88
+ `Theme "${themeName}" not found at ${themePath}. ` +
89
+ 'Available themes live in src/themes/.',
90
+ );
91
+ }
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // HTML generation
96
+ // ---------------------------------------------------------------------------
97
+
98
+ /**
99
+ * Generate a complete, self-contained HTML page from a links config.
100
+ *
101
+ * @param {object} config — parsed links.yaml config object
102
+ * @param {object} [options]
103
+ * @param {string} [options.configDir=process.cwd()] — directory links.yaml lives in (for resolving relative paths)
104
+ * @param {Date} [options.now=new Date()] — current time (for schedule filtering)
105
+ * @returns {string} Complete HTML document
106
+ */
107
+ export function generatePage(config, options = {}) {
108
+ const configDir = options.configDir || process.cwd();
109
+ const now = options.now || new Date();
110
+
111
+ const name = config.name || 'My Links';
112
+ const bio = config.bio || '';
113
+ const theme = config.theme || 'minimal-dark';
114
+
115
+ // Load and inline CSS
116
+ const css = loadThemeCSS(theme);
117
+
118
+ // Filter links by schedule
119
+ const allLinks = config.links || [];
120
+ const links = filterScheduled(allLinks, now);
121
+ const totalCount = allLinks.length;
122
+ const activeCount = links.length;
123
+ const buildDate = now.toISOString().slice(0, 10);
124
+
125
+ // Avatar handling
126
+ let avatarHTML = '';
127
+ if (config.avatar && config.avatar.trim().length > 0) {
128
+ try {
129
+ const dataUri = inlineImage(config.avatar, configDir);
130
+ avatarHTML = `<img class="profile__avatar" src="${dataUri}" alt="${esc(name)}" width="88" height="88">`;
131
+ } catch {
132
+ // Fallback: reference the file directly (it will be copied to output dir)
133
+ avatarHTML = `<img class="profile__avatar" src="${esc(config.avatar)}" alt="${esc(name)}" width="88" height="88">`;
134
+ }
135
+ }
136
+
137
+ // Build link list HTML
138
+ const linksHTML = links.map((link) => {
139
+ const icon = link.icon ? `<span class="links__icon">${esc(link.icon)}</span>` : '';
140
+ const description = link.description
141
+ ? `<span class="links__description">${esc(link.description)}</span>`
142
+ : '';
143
+
144
+ return ` <li class="links__item">
145
+ <a class="links__anchor" href="${esc(link.url)}" target="_blank" rel="noopener noreferrer">
146
+ <span class="links__label">${icon}${esc(link.label)}</span>${description}
147
+ </a>
148
+ </li>`;
149
+ }).join('\n');
150
+
151
+ // OG description falls back to bio, then a generic string
152
+ const ogDescription = bio || `${name} — link page`;
153
+
154
+ return `<!DOCTYPE html>
155
+ <!-- built at ${buildDate}, ${activeCount} of ${totalCount} links active -->
156
+ <html lang="en">
157
+ <head>
158
+ <meta charset="utf-8">
159
+ <meta name="viewport" content="width=device-width, initial-scale=1">
160
+ <title>${esc(name)}</title>
161
+ <meta name="description" content="${esc(ogDescription)}">
162
+ <meta property="og:title" content="${esc(name)}">
163
+ <meta property="og:description" content="${esc(ogDescription)}">
164
+ <meta property="og:type" content="website">
165
+ <style>
166
+ ${css}
167
+ </style>
168
+ </head>
169
+ <body>
170
+ <section class="profile">
171
+ ${avatarHTML}
172
+ <h1 class="profile__name">${esc(name)}</h1>
173
+ ${bio ? ` <p class="profile__bio">${esc(bio)}</p>\n` : ''} </section>
174
+
175
+ <ul class="links">
176
+ ${linksHTML}
177
+ </ul>
178
+
179
+ <footer class="footer">
180
+ <a class="footer__link" href="https://kntic.ai">Powered by KNTIC Links</a>
181
+ </footer>
182
+ </body>
183
+ </html>
184
+ `;
185
+ }
File without changes
@@ -0,0 +1,110 @@
1
+ # KNTIC Links — Theme System
2
+
3
+ Themes are **plain CSS files** that live in `src/themes/`. No build step, no
4
+ preprocessor. Each file defines a complete visual identity for a link page by
5
+ implementing a fixed set of **CSS custom properties** (design tokens) on `:root`
6
+ and using them throughout.
7
+
8
+ ---
9
+
10
+ ## Quick start — creating a new theme
11
+
12
+ 1. Copy an existing theme (e.g. `minimal-dark.css`) to a new file:
13
+
14
+ ```bash
15
+ cp src/themes/minimal-dark.css src/themes/my-theme.css
16
+ ```
17
+
18
+ 2. Edit the `:root` block to change colours, fonts, radii, etc.
19
+
20
+ 3. Set the theme in your project's `links.yaml`:
21
+
22
+ ```yaml
23
+ theme: my-theme
24
+ ```
25
+
26
+ 4. Run `links deploy --self` to see the result.
27
+
28
+ ---
29
+
30
+ ## Custom property contract
31
+
32
+ Every theme **must** declare all of the following custom properties inside a
33
+ `:root { … }` rule. The generator and the base HTML structure depend on these
34
+ tokens — omitting any of them will produce broken or inconsistent output.
35
+
36
+ | Token | Purpose | Example value |
37
+ |---|---|---|
38
+ | `--bg-color` | Page background colour | `#0d0d0d` |
39
+ | `--bg-secondary` | Card / surface background | `#1a1a1a` |
40
+ | `--text-primary` | Primary text colour | `#f0f0f0` |
41
+ | `--text-secondary` | Secondary text colour (descriptions) | `#cccccc` |
42
+ | `--text-muted` | Muted / de-emphasised text | `#999999` |
43
+ | `--accent-color` | Primary accent (link text, highlights) | `#e0e0e0` |
44
+ | `--accent-hover` | Accent on hover / focus | `#ffffff` |
45
+ | `--link-bg` | Link card background | `var(--bg-secondary)` |
46
+ | `--link-bg-hover` | Link card background on hover | `#252525` |
47
+ | `--link-border` | Link card border colour | `#333333` |
48
+ | `--link-radius` | Link card border-radius | `8px` |
49
+ | `--link-padding` | Link card inner padding | `0.875rem 1.25rem` |
50
+ | `--font-body` | Body font stack | `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif` |
51
+ | `--font-mono` | Monospace font stack (if needed) | `'SF Mono', 'Fira Code', monospace` |
52
+ | `--avatar-radius` | Avatar image border-radius | `50%` |
53
+ | `--page-max-width` | Max width of the page content column | `480px` |
54
+ | `--footer-opacity` | Footer link resting opacity | `0.6` |
55
+
56
+ ### Rules
57
+
58
+ * **No hardcoded colours outside `:root`** — every colour value used in
59
+ selectors must reference one of the tokens above.
60
+ * Themes may add *extra* custom properties for internal use but must **not**
61
+ remove or rename any of the standard tokens.
62
+ * The base reset (`box-sizing`, `margin`, `padding`) should be included in
63
+ every theme so each theme is fully self-contained.
64
+
65
+ ---
66
+
67
+ ## Loader API (`src/themes/loader.js`)
68
+
69
+ The loader is a pure ES-module with two exports:
70
+
71
+ ### `loadTheme(themeName)`
72
+
73
+ Resolves a theme name (e.g. `"minimal-dark"`) to the corresponding `.css` file
74
+ in the themes directory, reads it, and returns the raw CSS string.
75
+
76
+ * Throws a descriptive `Error` if the theme is not found (includes list of
77
+ available themes).
78
+ * Sanitises the input to prevent directory-traversal (uses `path.basename`).
79
+
80
+ ### `listThemes()`
81
+
82
+ Returns a sorted `string[]` of available theme names (filenames without the
83
+ `.css` extension).
84
+
85
+ ```js
86
+ import { loadTheme, listThemes } from './themes/loader.js';
87
+
88
+ console.log(listThemes()); // ['minimal-dark']
89
+ const css = loadTheme('minimal-dark');
90
+ ```
91
+
92
+ ---
93
+
94
+ ## File structure
95
+
96
+ ```
97
+ src/themes/
98
+ ├── loader.js # Theme loader module
99
+ ├── minimal-dark.css # Default theme
100
+ ├── README.md # This file
101
+ └── <your-theme>.css # Add your own here
102
+ ```
103
+
104
+ ---
105
+
106
+ ## Integration with the generator
107
+
108
+ The generator (`src/generator.js`) loads the theme specified in `links.yaml`
109
+ and inlines the full CSS into a `<style>` block in the generated HTML. The
110
+ output is a single self-contained file — no external stylesheets.
@@ -0,0 +1,210 @@
1
+ /* developer — hacker / IDE aesthetic for KNTIC Links
2
+ *
3
+ * Monospace everywhere. Links styled as code blocks.
4
+ * KNTIC orange accent. Left border bar on links (VSCode-style).
5
+ */
6
+
7
+ @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap');
8
+
9
+ *,
10
+ *::before,
11
+ *::after {
12
+ box-sizing: border-box;
13
+ margin: 0;
14
+ padding: 0;
15
+ }
16
+
17
+ :root {
18
+ /* Backgrounds */
19
+ --bg-color: #1e1e1e;
20
+ --bg-secondary: #252526;
21
+
22
+ /* Text */
23
+ --text-primary: #d4d4d4;
24
+ --text-secondary: #9cdcfe;
25
+ --text-muted: #6a9955;
26
+
27
+ /* Accent — KNTIC orange */
28
+ --accent-color: #FF4500;
29
+ --accent-hover: #ff6a33;
30
+
31
+ /* Link cards */
32
+ --link-bg: var(--bg-secondary);
33
+ --link-bg-hover: #2d2d30;
34
+ --link-border: #3e3e42;
35
+ --link-radius: 3px;
36
+ --link-padding: 0.75rem 1rem 0.75rem 1.15rem;
37
+
38
+ /* Typography */
39
+ --font-body: 'JetBrains Mono', Menlo, Monaco, 'Cascadia Code', 'Courier New', monospace;
40
+ --font-mono: 'JetBrains Mono', Menlo, Monaco, 'Cascadia Code', 'Courier New', monospace;
41
+
42
+ /* Avatar */
43
+ --avatar-radius: 4px;
44
+
45
+ /* Layout */
46
+ --page-max-width: 540px;
47
+
48
+ /* Footer */
49
+ --footer-opacity: 0.45;
50
+ }
51
+
52
+ /* ── Base ────────────────────────────────────────────────────────── */
53
+
54
+ html {
55
+ font-size: 16px;
56
+ -webkit-text-size-adjust: 100%;
57
+ }
58
+
59
+ body {
60
+ font-family: var(--font-body);
61
+ background-color: var(--bg-color);
62
+ color: var(--text-primary);
63
+ min-height: 100vh;
64
+ display: flex;
65
+ flex-direction: column;
66
+ align-items: center;
67
+ padding: 2.5rem 1rem 1rem;
68
+ line-height: 1.6;
69
+ }
70
+
71
+ /* ── Profile ─────────────────────────────────────────────────────── */
72
+
73
+ .profile {
74
+ text-align: left;
75
+ margin-bottom: 1.5rem;
76
+ max-width: var(--page-max-width);
77
+ width: 100%;
78
+ padding-bottom: 1rem;
79
+ border-bottom: 1px solid var(--link-border);
80
+ }
81
+
82
+ .profile__avatar {
83
+ width: 64px;
84
+ height: 64px;
85
+ border-radius: var(--avatar-radius);
86
+ object-fit: cover;
87
+ margin-bottom: 0.75rem;
88
+ border: 1px solid var(--link-border);
89
+ }
90
+
91
+ .profile__name {
92
+ font-size: 1.1rem;
93
+ font-weight: 700;
94
+ margin-bottom: 0.25rem;
95
+ color: var(--accent-color);
96
+ }
97
+
98
+ /* File-path style decoration */
99
+ .profile__name::before {
100
+ content: '~/';
101
+ color: var(--text-muted);
102
+ font-weight: 400;
103
+ }
104
+
105
+ .profile__bio {
106
+ font-size: 0.8rem;
107
+ color: var(--text-muted);
108
+ }
109
+
110
+ .profile__bio::before {
111
+ content: '/* ';
112
+ }
113
+
114
+ .profile__bio::after {
115
+ content: ' */';
116
+ }
117
+
118
+ /* ── Links ───────────────────────────────────────────────────────── */
119
+
120
+ .links {
121
+ list-style: none;
122
+ width: 100%;
123
+ max-width: var(--page-max-width);
124
+ display: flex;
125
+ flex-direction: column;
126
+ gap: 0.375rem;
127
+ }
128
+
129
+ .links__item {
130
+ display: block;
131
+ width: 100%;
132
+ }
133
+
134
+ .links__anchor {
135
+ display: block;
136
+ width: 100%;
137
+ padding: var(--link-padding);
138
+ background-color: var(--link-bg);
139
+ border: 1px solid var(--link-border);
140
+ border-left: 3px solid var(--accent-color);
141
+ border-radius: var(--link-radius);
142
+ color: var(--text-primary);
143
+ text-decoration: none;
144
+ text-align: left;
145
+ font-family: var(--font-mono);
146
+ transition: background-color 0.15s ease, border-left-color 0.15s ease;
147
+ }
148
+
149
+ .links__anchor:hover,
150
+ .links__anchor:focus {
151
+ background-color: var(--link-bg-hover);
152
+ color: var(--text-primary);
153
+ border-left-color: var(--accent-hover);
154
+ outline: none;
155
+ }
156
+
157
+ /* Filename-style label prefix */
158
+ .links__anchor::before {
159
+ content: '$ open ';
160
+ color: var(--text-muted);
161
+ font-size: 0.8rem;
162
+ }
163
+
164
+ .links__label {
165
+ font-size: 0.85rem;
166
+ font-weight: 500;
167
+ color: var(--text-secondary);
168
+ }
169
+
170
+ .links__icon {
171
+ margin-right: 0.4em;
172
+ }
173
+
174
+ .links__description {
175
+ display: block;
176
+ font-size: 0.75rem;
177
+ color: var(--text-muted);
178
+ margin-top: 0.15rem;
179
+ padding-left: 5.2em;
180
+ }
181
+
182
+ .links__description::before {
183
+ content: '# ';
184
+ }
185
+
186
+ /* ── Footer ──────────────────────────────────────────────────────── */
187
+
188
+ .footer {
189
+ margin-top: auto;
190
+ padding-top: 2rem;
191
+ padding-bottom: 1rem;
192
+ text-align: center;
193
+ max-width: var(--page-max-width);
194
+ width: 100%;
195
+ border-top: 1px solid var(--link-border);
196
+ }
197
+
198
+ .footer__link {
199
+ font-size: 0.65rem;
200
+ color: var(--text-muted);
201
+ text-decoration: none;
202
+ opacity: var(--footer-opacity);
203
+ font-family: var(--font-mono);
204
+ transition: opacity 0.2s ease;
205
+ }
206
+
207
+ .footer__link:hover {
208
+ opacity: 1;
209
+ color: var(--accent-color);
210
+ }