@mdsrs/fs 0.0.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.
package/README.md ADDED
@@ -0,0 +1,23 @@
1
+ # @mdsrs/fs
2
+
3
+ Node filesystem loader for Markdown card collections.
4
+
5
+ `@mdsrs/fs` turns a human-readable folder of Markdown files into the pure data
6
+ structures exposed by `@mdsrs/core`.
7
+
8
+ ## Example
9
+
10
+ ```ts
11
+ import { loadCollection, resolveContentAssetPath } from '@mdsrs/fs';
12
+
13
+ const collection = await loadCollection('./cards');
14
+ const assetPath = resolveContentAssetPath('biology/cells.md', './assets/cell.png');
15
+ ```
16
+
17
+ ## Folder conventions
18
+
19
+ - Every `*.md` file is parsed as a deck source.
20
+ - `index.md` contributes cards directly to its folder deck.
21
+ - Other Markdown files become child deck nodes below their folder.
22
+ - Frontmatter can set a human display name with `name = "..."`.
23
+ - Asset paths resolve relative to the Markdown file, while `@/` resolves from the collection root.
@@ -0,0 +1,28 @@
1
+ import { type Card, type DeckSource, type DeckTreeNode } from '@mdsrs/core';
2
+ export interface LoadCollectionOptions {
3
+ assetExtensions?: Iterable<string>;
4
+ includeHidden?: boolean;
5
+ }
6
+ export interface CollectionAsset {
7
+ path: string;
8
+ absolutePath: string;
9
+ }
10
+ export interface LoadedCollection {
11
+ rootPath: string;
12
+ sources: DeckSource[];
13
+ cards: Card[];
14
+ deckTree: DeckTreeNode[];
15
+ assets: CollectionAsset[];
16
+ }
17
+ export declare const sourceHierarchy: (filePath: string, frontmatterName: string | null) => {
18
+ folderPath: string;
19
+ nodePath: string;
20
+ displayName: string;
21
+ deckName: string;
22
+ };
23
+ export declare const resolveContentAssetPath: (filePath: string, assetPath: string) => string | null;
24
+ export declare const resolveCollectionAssetPath: (rootPath: string, contentAssetPath: string) => string | null;
25
+ export declare const loadDeckSources: (root: string | URL, options?: LoadCollectionOptions) => Promise<DeckSource[]>;
26
+ export declare const loadAssets: (root: string | URL, options?: LoadCollectionOptions) => Promise<CollectionAsset[]>;
27
+ export declare const loadCollection: (root: string | URL, options?: LoadCollectionOptions) => Promise<LoadedCollection>;
28
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAkC,KAAK,IAAI,EAAE,KAAK,UAAU,EAAE,KAAK,YAAY,EAAE,MAAM,aAAa,CAAC;AAuB5G,MAAM,WAAW,qBAAqB;IACrC,eAAe,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC;IACnC,aAAa,CAAC,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,eAAe;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,UAAU,EAAE,CAAC;IACtB,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,QAAQ,EAAE,YAAY,EAAE,CAAC;IACzB,MAAM,EAAE,eAAe,EAAE,CAAC;CAC1B;AA0BD,eAAO,MAAM,eAAe,GAAI,UAAU,MAAM,EAAE,iBAAiB,MAAM,GAAG,IAAI;;;;;CAiB/E,CAAC;AAkBF,eAAO,MAAM,uBAAuB,GAAI,UAAU,MAAM,EAAE,WAAW,MAAM,kBAM1E,CAAC;AAEF,eAAO,MAAM,0BAA0B,GAAI,UAAU,MAAM,EAAE,kBAAkB,MAAM,kBAIpF,CAAC;AAEF,eAAO,MAAM,eAAe,GAC3B,MAAM,MAAM,GAAG,GAAG,EAClB,UAAS,qBAA0B,KACjC,OAAO,CAAC,UAAU,EAAE,CAuBtB,CAAC;AAEF,eAAO,MAAM,UAAU,GACtB,MAAM,MAAM,GAAG,GAAG,EAClB,UAAS,qBAA0B,KACjC,OAAO,CAAC,eAAe,EAAE,CAgB3B,CAAC;AAEF,eAAO,MAAM,cAAc,GAC1B,MAAM,MAAM,GAAG,GAAG,EAClB,UAAS,qBAA0B,KACjC,OAAO,CAAC,gBAAgB,CAe1B,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,152 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { buildDeckTree, parseCollection } from '@mdsrs/core';
5
+ const frontmatterPattern = /^---\s*\n([\s\S]*?)\n---\s*\n?/;
6
+ const externalUrlPattern = /^(?:[a-z][a-z0-9+.-]*:|#|\/)/i;
7
+ const defaultAssetExtensions = new Set([
8
+ '.avif',
9
+ '.gif',
10
+ '.jpeg',
11
+ '.jpg',
12
+ '.m4a',
13
+ '.m4v',
14
+ '.mov',
15
+ '.mp3',
16
+ '.mp4',
17
+ '.ogg',
18
+ '.png',
19
+ '.svg',
20
+ '.wav',
21
+ '.webm',
22
+ '.webp'
23
+ ]);
24
+ const toRootPath = (root) => path.resolve(root instanceof URL ? fileURLToPath(root) : root);
25
+ const toPosixPath = (value) => value.split(path.sep).join('/');
26
+ const parseFrontmatterName = (text) => {
27
+ const match = text.match(frontmatterPattern);
28
+ const frontmatter = match?.[1] ?? '';
29
+ const name = frontmatter.match(/^name\s*=\s*"(.+)"\s*$/m)?.[1];
30
+ return name ?? null;
31
+ };
32
+ const stripFrontmatter = (text) => text.replace(frontmatterPattern, '');
33
+ const fileStem = (filePath) => filePath.split('/').at(-1)?.replace(/\.md$/, '') ?? 'Untitled';
34
+ const titleize = (value) => value
35
+ .split(/[-_\s]+/)
36
+ .filter(Boolean)
37
+ .map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
38
+ .join(' ');
39
+ export const sourceHierarchy = (filePath, frontmatterName) => {
40
+ const pathWithoutExtension = filePath.replace(/\.md$/, '');
41
+ const parts = pathWithoutExtension.split('/').filter(Boolean);
42
+ const isIndex = parts.at(-1) === 'index';
43
+ const nodeParts = isIndex ? parts.slice(0, -1) : parts;
44
+ const folderParts = isIndex ? nodeParts : nodeParts.slice(0, -1);
45
+ const nodePath = nodeParts.join('/');
46
+ const folderPath = folderParts.join('/');
47
+ const fallbackName = titleize(nodeParts.at(-1) ?? fileStem(filePath));
48
+ const displayName = frontmatterName ?? fallbackName;
49
+ return {
50
+ folderPath,
51
+ nodePath,
52
+ displayName,
53
+ deckName: nodePath || displayName
54
+ };
55
+ };
56
+ const normalizeContentPath = (contentPath) => {
57
+ const parts = [];
58
+ for (const part of contentPath.split('/')) {
59
+ if (!part || part === '.')
60
+ continue;
61
+ if (part === '..') {
62
+ if (parts.length === 0)
63
+ return null;
64
+ parts.pop();
65
+ continue;
66
+ }
67
+ parts.push(part);
68
+ }
69
+ return parts.join('/');
70
+ };
71
+ export const resolveContentAssetPath = (filePath, assetPath) => {
72
+ if (externalUrlPattern.test(assetPath))
73
+ return assetPath;
74
+ return assetPath.startsWith('@/')
75
+ ? normalizeContentPath(assetPath.slice(2))
76
+ : normalizeContentPath(`${filePath.split('/').slice(0, -1).join('/')}/${assetPath}`);
77
+ };
78
+ export const resolveCollectionAssetPath = (rootPath, contentAssetPath) => {
79
+ if (externalUrlPattern.test(contentAssetPath))
80
+ return contentAssetPath;
81
+ const normalized = normalizeContentPath(contentAssetPath);
82
+ return normalized ? path.join(rootPath, ...normalized.split('/')) : null;
83
+ };
84
+ export const loadDeckSources = async (root, options = {}) => {
85
+ const rootPath = toRootPath(root);
86
+ const files = await walkFiles(rootPath, options);
87
+ const markdownFiles = files
88
+ .filter((filePath) => filePath.toLowerCase().endsWith('.md'))
89
+ .sort((left, right) => left.localeCompare(right));
90
+ return Promise.all(markdownFiles.map(async (absolutePath) => {
91
+ const filePath = toPosixPath(path.relative(rootPath, absolutePath));
92
+ const text = await readFile(absolutePath, 'utf8');
93
+ const hierarchy = sourceHierarchy(filePath, parseFrontmatterName(text));
94
+ return {
95
+ deckName: hierarchy.deckName,
96
+ filePath,
97
+ folderPath: hierarchy.folderPath,
98
+ nodePath: hierarchy.nodePath,
99
+ displayName: hierarchy.displayName,
100
+ text: stripFrontmatter(text)
101
+ };
102
+ }));
103
+ };
104
+ export const loadAssets = async (root, options = {}) => {
105
+ const rootPath = toRootPath(root);
106
+ const assetExtensions = new Set([...(options.assetExtensions ?? defaultAssetExtensions)].map((extension) => extension.startsWith('.') ? extension.toLowerCase() : `.${extension.toLowerCase()}`));
107
+ const files = await walkFiles(rootPath, options);
108
+ return files
109
+ .filter((filePath) => assetExtensions.has(path.extname(filePath).toLowerCase()))
110
+ .sort((left, right) => left.localeCompare(right))
111
+ .map((absolutePath) => ({
112
+ path: toPosixPath(path.relative(rootPath, absolutePath)),
113
+ absolutePath
114
+ }));
115
+ };
116
+ export const loadCollection = async (root, options = {}) => {
117
+ const rootPath = toRootPath(root);
118
+ const [sources, assets] = await Promise.all([
119
+ loadDeckSources(rootPath, options),
120
+ loadAssets(rootPath, options)
121
+ ]);
122
+ const cards = parseCollection(sources);
123
+ return {
124
+ rootPath,
125
+ sources,
126
+ cards,
127
+ deckTree: buildDeckTree(cards),
128
+ assets
129
+ };
130
+ };
131
+ const walkFiles = async (rootPath, options) => {
132
+ const rootStats = await stat(rootPath);
133
+ if (!rootStats.isDirectory()) {
134
+ throw new Error(`Collection root must be a directory: ${rootPath}`);
135
+ }
136
+ const files = [];
137
+ const visit = async (directoryPath) => {
138
+ const entries = await readdir(directoryPath, { withFileTypes: true });
139
+ entries.sort((left, right) => left.name.localeCompare(right.name));
140
+ for (const entry of entries) {
141
+ if (!options.includeHidden && entry.name.startsWith('.'))
142
+ continue;
143
+ const entryPath = path.join(directoryPath, entry.name);
144
+ if (entry.isDirectory())
145
+ await visit(entryPath);
146
+ else if (entry.isFile())
147
+ files.push(entryPath);
148
+ }
149
+ };
150
+ await visit(rootPath);
151
+ return files;
152
+ };
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@mdsrs/fs",
3
+ "version": "0.0.0",
4
+ "type": "module",
5
+ "sideEffects": false,
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "default": "./dist/index.js"
10
+ }
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "dependencies": {
16
+ "@mdsrs/core": "0.0.0"
17
+ },
18
+ "devDependencies": {
19
+ "@types/node": "^24.0.0",
20
+ "typescript": "^6.0.3",
21
+ "vitest": "^4.0.0"
22
+ },
23
+ "scripts": {
24
+ "build": "tsc -p tsconfig.json",
25
+ "test": "vitest run",
26
+ "typecheck": "tsc -p tsconfig.json --noEmit"
27
+ }
28
+ }