@shevky/core 0.0.2 → 0.0.3

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.
package/lib/project.js ADDED
@@ -0,0 +1,82 @@
1
+ import { io as _io } from "@shevky/base";
2
+
3
+ /** @typedef {import("../types/index.d.ts").ProjectPaths} ProjectPaths */
4
+
5
+ export class Project {
6
+ /**
7
+ * @type {string}
8
+ */
9
+ #_baseDir;
10
+
11
+ /**
12
+ * @param {string} [baseDir="."]
13
+ */
14
+ constructor(baseDir = ".") {
15
+ this.#_baseDir = baseDir;
16
+ }
17
+
18
+ get rootDir() {
19
+ return _io.path.combine(this.#_baseDir);
20
+ }
21
+
22
+ get srcDir() {
23
+ return _io.path.combine(this.rootDir, "src");
24
+ }
25
+
26
+ get distDir() {
27
+ return _io.path.combine(this.rootDir, "dist");
28
+ }
29
+
30
+ get tmpDir() {
31
+ return _io.path.combine(this.rootDir, "tmp");
32
+ }
33
+
34
+ get contentDir() {
35
+ return _io.path.combine(this.srcDir, "content");
36
+ }
37
+
38
+ get layoutsDir() {
39
+ return _io.path.combine(this.srcDir, "layouts");
40
+ }
41
+
42
+ get componentsDir() {
43
+ return _io.path.combine(this.srcDir, "components");
44
+ }
45
+
46
+ get templatesDir() {
47
+ return _io.path.combine(this.srcDir, "templates");
48
+ }
49
+
50
+ get assetsDir() {
51
+ return _io.path.combine(this.srcDir, "assets");
52
+ }
53
+
54
+ get siteConfig() {
55
+ return _io.path.combine(this.srcDir, "site.json");
56
+ }
57
+
58
+ get i18nConfig() {
59
+ return _io.path.combine(this.srcDir, "i18n.json");
60
+ }
61
+
62
+ /**
63
+ * @returns {ProjectPaths}
64
+ */
65
+ toObject() {
66
+ return {
67
+ root: this.rootDir,
68
+ src: this.srcDir,
69
+ dist: this.distDir,
70
+ tmp: this.tmpDir,
71
+ content: this.contentDir,
72
+ layouts: this.layoutsDir,
73
+ components: this.componentsDir,
74
+ templates: this.templatesDir,
75
+ assets: this.assetsDir,
76
+ siteConfig: this.siteConfig,
77
+ i18nConfig: this.i18nConfig,
78
+ };
79
+ }
80
+ }
81
+
82
+ export default new Project();
@@ -0,0 +1,50 @@
1
+ export class Template {
2
+ /**
3
+ * @type {string}
4
+ */
5
+ #_key;
6
+
7
+ /**
8
+ * @type {string}
9
+ */
10
+ #_path;
11
+
12
+ /**
13
+ * @type {string}
14
+ */
15
+ #_content;
16
+
17
+ /**
18
+ * @type {string}
19
+ */
20
+ #_type;
21
+
22
+ /**
23
+ * @param {string} key
24
+ * @param {string} type
25
+ * @param {string} path
26
+ * @param {string} content
27
+ */
28
+ constructor(key, type, path, content) {
29
+ this.#_key = key;
30
+ this.#_type = type;
31
+ this.#_path = path;
32
+ this.#_content = content;
33
+ }
34
+
35
+ get key() {
36
+ return this.#_key;
37
+ }
38
+
39
+ get type() {
40
+ return this.#_type;
41
+ }
42
+
43
+ get path() {
44
+ return this.#_path;
45
+ }
46
+
47
+ get content() {
48
+ return this.#_content;
49
+ }
50
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shevky/core",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "A minimal, dependency-light static site generator.",
5
5
  "type": "module",
6
6
  "main": "shevky.js",
@@ -9,7 +9,11 @@
9
9
  },
10
10
  "files": [
11
11
  "shevky.js",
12
+ "lib/",
13
+ "engines/",
14
+ "registries/",
12
15
  "scripts/",
16
+ "types/",
13
17
  "README.md",
14
18
  "LICENSE"
15
19
  ],
@@ -0,0 +1,286 @@
1
+ import { io as _io } from "@shevky/base";
2
+ import matter from "gray-matter";
3
+
4
+ import { ContentFile } from "../lib/contentFile.js";
5
+ import { ContentSummary } from "../lib/contentSummary.js";
6
+ import { MetaEngine } from "../engines/metaEngine.js";
7
+
8
+ export class ContentRegistry {
9
+ /**
10
+ * @type {ContentFile[]}
11
+ */
12
+ #_cache = [];
13
+ /** @type {import("../types/index.d.ts").CollectionsByLang | null} */
14
+ #_collectionsCache = null;
15
+ /** @type {ReturnType<ContentRegistry["buildFooterPolicies"]> | null} */
16
+ #_footerPoliciesCache = null;
17
+ /** @type {ReturnType<ContentRegistry["buildContentIndex"]> | null} */
18
+ #_contentIndexCache = null;
19
+ /** @type {MetaEngine} */
20
+ #_metaEngine;
21
+
22
+ /**
23
+ * @param {MetaEngine} metaEngine
24
+ */
25
+ constructor(metaEngine) {
26
+ this.#_metaEngine = metaEngine;
27
+ }
28
+
29
+ /**
30
+ * @param {string} path
31
+ * @returns
32
+ */
33
+ async load(path) {
34
+ const isExists = await _io.directory.exists(path);
35
+ if (!isExists) {
36
+ return;
37
+ }
38
+
39
+ const files = await _io.directory.read(path);
40
+ for (const entry of files) {
41
+ if (!entry.endsWith(".md")) {
42
+ continue;
43
+ }
44
+
45
+ const filePath = _io.path.combine(path, entry);
46
+ const isFileExists = await _io.file.exists(filePath);
47
+ if (!isFileExists) {
48
+ throw new Error(`Failed to read content file at ${filePath}`);
49
+ }
50
+
51
+ const contentFile = await this.#_loadFromFile(filePath);
52
+ this.#_cache.push(contentFile);
53
+ }
54
+
55
+ this.#_collectionsCache = null;
56
+ this.#_footerPoliciesCache = null;
57
+ this.#_contentIndexCache = null;
58
+ }
59
+
60
+ get count() {
61
+ return this.#_cache.length;
62
+ }
63
+
64
+ get files() {
65
+ return this.#_cache;
66
+ }
67
+
68
+ /**
69
+ * @returns {Record<string, Array<{ key: string, label: string, url: string, lang: string }>>}
70
+ */
71
+ buildFooterPolicies() {
72
+ if (this.#_footerPoliciesCache) {
73
+ return this.#_footerPoliciesCache;
74
+ }
75
+
76
+ if (this.count === 0) {
77
+ this.#_footerPoliciesCache = {};
78
+ return this.#_footerPoliciesCache;
79
+ }
80
+
81
+ /** @type {Record<string, Array<{ key: string, label: string, url: string, lang: string }>>} */
82
+ const policiesByLang = {};
83
+ const contentFiles = this.files;
84
+ for (const file of contentFiles) {
85
+ if (
86
+ !file.isValid ||
87
+ file.isDraft ||
88
+ !file.isPublished ||
89
+ file.category !== "policy"
90
+ ) {
91
+ continue;
92
+ }
93
+
94
+ const policy = {
95
+ lang: file.lang,
96
+ key: file.id,
97
+ label: file.menuLabel,
98
+ url: this.#_metaEngine.buildContentUrl(
99
+ file.canonical,
100
+ file.lang,
101
+ file.slug,
102
+ ),
103
+ };
104
+
105
+ if (!Array.isArray(policiesByLang[file.lang])) {
106
+ policiesByLang[file.lang] = [];
107
+ }
108
+
109
+ policiesByLang[file.lang].push(policy);
110
+ }
111
+
112
+ Object.keys(policiesByLang).forEach((lang) => {
113
+ policiesByLang[lang].sort((a, b) => a.label.localeCompare(b.label, lang));
114
+ });
115
+
116
+ this.#_footerPoliciesCache = policiesByLang;
117
+ return this.#_footerPoliciesCache;
118
+ }
119
+
120
+ /**
121
+ * @returns {Record<string, Record<string, { id: string, lang: string, title: string, canonical: string }>>}
122
+ */
123
+ buildContentIndex() {
124
+ if (this.#_contentIndexCache) {
125
+ return this.#_contentIndexCache;
126
+ }
127
+
128
+ if (this.count === 0) {
129
+ this.#_contentIndexCache = {};
130
+ return this.#_contentIndexCache;
131
+ }
132
+
133
+ /** @type {Record<string, Record<string, { id: string, lang: string, title: string, canonical: string }>>} */
134
+ const index = {};
135
+ const contentFiles = this.files;
136
+ for (const file of contentFiles) {
137
+ if (!file.isValid || file.isDraft || !file.isPublished || !file.id) {
138
+ continue;
139
+ }
140
+
141
+ if (!index[file.id]) {
142
+ index[file.id] = {};
143
+ }
144
+
145
+ index[file.id][file.lang] = {
146
+ id: file.id,
147
+ lang: file.lang,
148
+ title: file.title,
149
+ canonical: this.#_metaEngine.buildContentUrl(
150
+ file.canonical,
151
+ file.lang,
152
+ file.slug,
153
+ ),
154
+ };
155
+ }
156
+
157
+ this.#_contentIndexCache = index;
158
+ return this.#_contentIndexCache;
159
+ }
160
+
161
+ /**
162
+ * @returns {import("../types/index.d.ts").CollectionsByLang}
163
+ */
164
+ buildCategoryTagCollections() {
165
+ if (this.#_collectionsCache) {
166
+ return this.#_collectionsCache;
167
+ }
168
+
169
+ if (this.count === 0) {
170
+ this.#_collectionsCache = {};
171
+ return this.#_collectionsCache;
172
+ }
173
+
174
+ /** @type {import("../types/index.d.ts").CollectionsByLang} */
175
+ const pagesByLang = {};
176
+ const contentFiles = this.files;
177
+ for (const file of contentFiles) {
178
+ if (!file.isValid || file.isDraft || !file.isPublished) {
179
+ continue;
180
+ }
181
+
182
+ const contentSummary = new ContentSummary(file);
183
+ const summary = /** @type {import("../types/index.d.ts").CollectionEntry} */ ({
184
+ ...contentSummary.toObject(),
185
+ canonical: this.#_metaEngine.buildContentUrl(
186
+ file.canonical,
187
+ file.lang,
188
+ file.slug,
189
+ ),
190
+ });
191
+ const langStore = pagesByLang[file.lang] ?? (pagesByLang[file.lang] = {});
192
+
193
+ if (file.isPostTemplate && file.isFeatured) {
194
+ this.#_addCollectionEntry(langStore, "home", summary, "home");
195
+ }
196
+
197
+ if (file.category) {
198
+ this.#_addCollectionEntry(
199
+ langStore,
200
+ file.category,
201
+ summary,
202
+ "category",
203
+ );
204
+ }
205
+
206
+ for (const tag of file.tags) {
207
+ this.#_addCollectionEntry(langStore, tag, summary, "tag");
208
+ }
209
+
210
+ if (file.series) {
211
+ this.#_addCollectionEntry(
212
+ langStore,
213
+ file.series,
214
+ {
215
+ ...contentSummary.toObject(),
216
+ seriesTitle: file.seriesTitle,
217
+ },
218
+ "series",
219
+ );
220
+ }
221
+ }
222
+
223
+ this.#_collectionsCache = this.#_sortCollectionEntries(pagesByLang);
224
+ return this.#_collectionsCache;
225
+ }
226
+
227
+ /**
228
+ * @param {string} filePath
229
+ * @returns {Promise<ContentFile>}
230
+ */
231
+ async #_loadFromFile(filePath) {
232
+ const raw = await _io.file.read(filePath);
233
+ let isValid = false;
234
+
235
+ /**
236
+ * @type {{data: Record<string, unknown>, content: string}}
237
+ */
238
+ let matterResponse = { data: {}, content: "" };
239
+
240
+ try {
241
+ matterResponse = matter(raw);
242
+ isValid = true;
243
+ } catch {}
244
+
245
+ const { data, content } = matterResponse;
246
+ return new ContentFile(data, content, filePath, isValid);
247
+ }
248
+
249
+ /**
250
+ * @param {Record<string, import("../types/index.d.ts").CollectionEntry[]>} store
251
+ * @param {string} key
252
+ * @param {import("../types/index.d.ts").CollectionEntry} entry
253
+ * @param {string} type
254
+ */
255
+ #_addCollectionEntry(store, key, entry, type) {
256
+ if (!store[key]) {
257
+ store[key] = [];
258
+ }
259
+ store[key].push({
260
+ ...entry,
261
+ type,
262
+ });
263
+ }
264
+
265
+ /**
266
+ * @param {import("../types/index.d.ts").CollectionsByLang} collections
267
+ */
268
+ #_sortCollectionEntries(collections) {
269
+ /** @type {import("../types/index.d.ts").CollectionsByLang} */
270
+ const sorted = {};
271
+ Object.keys(collections).forEach((lang) => {
272
+ sorted[lang] = {};
273
+ Object.keys(collections[lang]).forEach((key) => {
274
+ sorted[lang][key] = collections[lang][key].slice().sort((a, b) => {
275
+ const aDate = Date.parse(String(a.date ?? "")) || 0;
276
+ const bDate = Date.parse(String(b.date ?? "")) || 0;
277
+ if (aDate === bDate) {
278
+ return (a.title ?? "").localeCompare(b.title ?? "", lang);
279
+ }
280
+ return bDate - aDate;
281
+ });
282
+ });
283
+ });
284
+ return sorted;
285
+ }
286
+ }
@@ -0,0 +1,29 @@
1
+ import { Page } from "../lib/page.js";
2
+
3
+ export class PageRegistry {
4
+ /**
5
+ * @type {Page[]}
6
+ */
7
+ #_cache = [];
8
+
9
+ /**
10
+ * @param {Page} page
11
+ */
12
+ add(page) {
13
+ if (page instanceof Page) {
14
+ this.#_cache.push(page);
15
+ }
16
+ }
17
+
18
+ list() {
19
+ return [...this.#_cache];
20
+ }
21
+
22
+ get count() {
23
+ return this.#_cache.length;
24
+ }
25
+
26
+ clear() {
27
+ this.#_cache = [];
28
+ }
29
+ }
@@ -0,0 +1,94 @@
1
+ import { exec as _exec, log as _log, config as _cfg } from "@shevky/base";
2
+ import { Project } from "../lib/project.js";
3
+
4
+ /** @typedef {import("../types/index.d.ts").PluginInstance} PluginInstance */
5
+ /** @typedef {import("../types/index.d.ts").PluginLoadContext} PluginLoadContext */
6
+
7
+ export class PluginRegistry {
8
+ /**
9
+ * @type {Map<string, PluginInstance>}
10
+ */
11
+ #_cache = new Map();
12
+
13
+ /**
14
+ * @type {Project}
15
+ */
16
+ #_project = new Project(process.cwd());
17
+
18
+ /**
19
+ * @param {string[]|null|undefined} names
20
+ * @returns {Promise<void>}
21
+ */
22
+ async load(names) {
23
+ const pluginNames = Array.isArray(names) ? names.filter(Boolean) : [];
24
+ if (!pluginNames.length) {
25
+ return;
26
+ }
27
+
28
+ const resolveBase = this.#_project.rootDir;
29
+ for (const pluginName of pluginNames) {
30
+ try {
31
+ const fromCwd = _exec.resolve(pluginName, resolveBase);
32
+ const loaded = fromCwd
33
+ ? await import(fromCwd)
34
+ : await import(pluginName);
35
+
36
+ /**
37
+ * @type {PluginInstance}
38
+ */
39
+ const instance = loaded?.default ?? loaded;
40
+
41
+ if (!instance || typeof instance !== "object") {
42
+ _log.warn(`Plugin cannot load correctly. Plugin name: ${pluginName}`);
43
+ continue;
44
+ }
45
+
46
+ /**
47
+ * @type {string}
48
+ */
49
+ const name =
50
+ typeof instance.name === "string" ? instance.name.trim() : "";
51
+ if (!name) {
52
+ _log.warn(
53
+ `Plugin cannot load correctly. Missing name: ${pluginName}`,
54
+ );
55
+ continue;
56
+ }
57
+
58
+ if (this.#_cache.has(name)) {
59
+ _log.warn(`Duplicate plugin name detected: ${name}`);
60
+ continue;
61
+ }
62
+
63
+ this.#_cache.set(name, instance);
64
+
65
+ if (instance.load) {
66
+ const loadContext = this.#_createContext();
67
+ await instance.load(loadContext);
68
+ }
69
+
70
+ _log.debug(`The plugin '${pluginName}' has been loaded.`);
71
+ } catch (error) {
72
+ _log.err(`Failed to load plugin '${pluginName}':`, error);
73
+ }
74
+ }
75
+ }
76
+
77
+ get count() {
78
+ return this.#_cache.size;
79
+ }
80
+
81
+ get plugins() {
82
+ return this.#_cache;
83
+ }
84
+
85
+ /**
86
+ * @returns {PluginLoadContext}
87
+ */
88
+ #_createContext() {
89
+ return {
90
+ config: _cfg,
91
+ paths: this.#_project.toObject(),
92
+ };
93
+ }
94
+ }
@@ -0,0 +1,152 @@
1
+ import { io as _io } from "@shevky/base";
2
+
3
+ import { Template } from "../lib/template.js";
4
+
5
+ export const TYPE_PARTIAL = "partial";
6
+ export const TYPE_COMPONENT = "component";
7
+ export const TYPE_LAYOUT = "layout";
8
+ export const TYPE_TEMPLATE = "template";
9
+
10
+ const MUSTACHE_EXT = ".mustache";
11
+
12
+ export class TemplateRegistry {
13
+ /**
14
+ * @type {Map<string, Map<string, Template>>}
15
+ */
16
+ #_cache = new Map();
17
+
18
+ /**
19
+ * @param {string} directoryPath
20
+ */
21
+ async loadPartials(directoryPath) {
22
+ await this.#_loadDirectory(directoryPath, {
23
+ type: TYPE_PARTIAL,
24
+ keyPrefix: "partials/",
25
+ accept: (entry) => entry.startsWith("_") && entry.endsWith(MUSTACHE_EXT),
26
+ });
27
+ }
28
+
29
+ /**
30
+ * @param {string} directoryPath
31
+ */
32
+ async loadComponents(directoryPath) {
33
+ await this.#_loadDirectory(directoryPath, {
34
+ type: TYPE_COMPONENT,
35
+ keyPrefix: "components/",
36
+ accept: (entry) => entry.endsWith(MUSTACHE_EXT),
37
+ });
38
+ }
39
+
40
+ /**
41
+ * @param {string} directoryPath
42
+ */
43
+ async loadLayouts(directoryPath) {
44
+ await this.#_loadDirectory(directoryPath, {
45
+ type: TYPE_LAYOUT,
46
+ accept: (entry) => !entry.startsWith("_") && entry.endsWith(MUSTACHE_EXT),
47
+ });
48
+ }
49
+
50
+ /**
51
+ * @param {string} directoryPath
52
+ */
53
+ async loadTemplates(directoryPath) {
54
+ await this.#_loadDirectory(directoryPath, {
55
+ type: TYPE_TEMPLATE,
56
+ accept: (entry) => entry.endsWith(MUSTACHE_EXT),
57
+ });
58
+ }
59
+
60
+ /**
61
+ * @param {string} type
62
+ */
63
+ list(type) {
64
+ return Array.from(this.#_ensure(type).keys());
65
+ }
66
+
67
+ /**
68
+ * @param {string} type
69
+ * @param {string} key
70
+ */
71
+ get(type, key) {
72
+ const template = this.#_ensure(type).get(key);
73
+ if (!template) {
74
+ throw new Error(`Template not found: ${key}`);
75
+ }
76
+
77
+ return template.content;
78
+ }
79
+
80
+ /**
81
+ * @param {string} type
82
+ * @param {string} key
83
+ */
84
+ getTemplate(type, key) {
85
+ const template = this.#_ensure(type).get(key);
86
+ if (!template) {
87
+ throw new Error(`Template not found: ${key}`);
88
+ }
89
+
90
+ return template;
91
+ }
92
+
93
+ /**
94
+ * @param {string} type
95
+ */
96
+ getFiles(type) {
97
+ /** @type {Record<string, string>} */
98
+ const result = {};
99
+ for (const [key, template] of this.#_ensure(type).entries()) {
100
+ result[key] = template.content;
101
+ }
102
+
103
+ return result;
104
+ }
105
+
106
+ /**
107
+ * @param {string} type
108
+ */
109
+ getCount(type) {
110
+ return this.#_ensure(type).size;
111
+ }
112
+
113
+ /**
114
+ * @param {string} directoryPath
115
+ * @param {{ type: string, keyPrefix?: string, accept: (entry: string) => boolean }} options
116
+ */
117
+ async #_loadDirectory(directoryPath, options) {
118
+ const isExists = await _io.directory.exists(directoryPath);
119
+ if (!isExists) {
120
+ return;
121
+ }
122
+
123
+ const { type, keyPrefix = "", accept } = options;
124
+ const entries = await _io.directory.read(directoryPath);
125
+ for (const entry of entries) {
126
+ if (!accept(entry)) {
127
+ continue;
128
+ }
129
+
130
+ const key = `${keyPrefix}${entry.replace(`${MUSTACHE_EXT}`, "")}`;
131
+ const path = _io.path.combine(directoryPath, entry);
132
+ const raw = await _io.file.read(path);
133
+
134
+ const template = new Template(key, type, path, raw);
135
+ this.#_ensure(type).set(key, template);
136
+ }
137
+ }
138
+
139
+ /**
140
+ * @param {string} type
141
+ * @returns {Map<string, Template>}
142
+ */
143
+ #_ensure(type) {
144
+ let bucket = this.#_cache.get(type);
145
+ if (!bucket) {
146
+ bucket = new Map();
147
+ this.#_cache.set(type, bucket);
148
+ }
149
+
150
+ return bucket;
151
+ }
152
+ }