Skip to content

Commit

Permalink
refactor: custom watcher
Browse files Browse the repository at this point in the history
WIP custom watcher should handle create events from directories and
files.
  • Loading branch information
carlosallexandre committed Sep 18, 2024
1 parent e74930a commit cf502d1
Show file tree
Hide file tree
Showing 4 changed files with 241 additions and 74 deletions.
28 changes: 28 additions & 0 deletions scripts/__tests__/map-catalog-to-astro.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import path from 'node:path';
import { removeBasePath } from 'scripts/map-catalog-to-astro';

describe('MapCatalogToAstro', () => {
describe('removeBasePath', () => {
it('should remove the base path', () => {
const fullPath = path.join('/home/user/ec/events/index.md');
const basePath = path.join('/home/user/ec/');

expect(removeBasePath(fullPath, basePath)).toEqual('events/index.md');

Check failure on line 11 in scripts/__tests__/map-catalog-to-astro.spec.ts

View workflow job for this annotation

GitHub Actions / Verify Build (windows-latest)

scripts/__tests__/map-catalog-to-astro.spec.ts > MapCatalogToAstro > removeBasePath > should remove the base path

AssertionError: expected 'events\index.md' to deeply equal 'events/index.md' Expected: "events/index.md" Received: "events\index.md" ❯ scripts/__tests__/map-catalog-to-astro.spec.ts:11:50
});

it('should remove the base path', () => {
const fullPath = path.join('/events/index.md');
const basePath = path.join('/home/user/ec');

expect(removeBasePath(fullPath, basePath)).toEqual('/events/index.md');

Check failure on line 18 in scripts/__tests__/map-catalog-to-astro.spec.ts

View workflow job for this annotation

GitHub Actions / Verify Build (windows-latest)

scripts/__tests__/map-catalog-to-astro.spec.ts > MapCatalogToAstro > removeBasePath > should remove the base path

AssertionError: expected '\events\index.md' to deeply equal '/events/index.md' Expected: "/events/index.md" Received: "\events\index.md" ❯ scripts/__tests__/map-catalog-to-astro.spec.ts:18:50
});

it('should remove the base path', () => {
const fullPath = path.join('/ec/events/index.md');
const basePath = path.join('/ec');

expect(removeBasePath(fullPath, basePath)).toEqual('events/index.md');

Check failure on line 25 in scripts/__tests__/map-catalog-to-astro.spec.ts

View workflow job for this annotation

GitHub Actions / Verify Build (windows-latest)

scripts/__tests__/map-catalog-to-astro.spec.ts > MapCatalogToAstro > removeBasePath > should remove the base path

AssertionError: expected 'events\index.md' to deeply equal 'events/index.md' Expected: "events/index.md" Received: "events\index.md" ❯ scripts/__tests__/map-catalog-to-astro.spec.ts:25:50
});
});
});
35 changes: 27 additions & 8 deletions scripts/__tests__/watcher.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, beforeAll, afterAll, it, expect, test } from 'vitest';
import path from 'node:path';
import fs from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { watch } from '../watcher';

Expand All @@ -10,7 +11,7 @@ const PROJECT_DIR = path.join(__dirname, 'tmp-watcher', randomUUID());
const EC_CORE_DIR = path.join(PROJECT_DIR, '.eventcatalog-core');

describe('Watcher', () => {
let watcherSubscription: () => Promise<void>;
let watcherUnsubscribe: () => Promise<void>;

let callbacks: Array<{ resolve: (val: Events) => void; reject: (reason?: any) => void }> = [];

Expand All @@ -33,19 +34,20 @@ describe('Watcher', () => {

beforeAll(async () => {
await prepareProjectDir(PROJECT_DIR, EC_CORE_DIR);
watcherSubscription = await watch(PROJECT_DIR, EC_CORE_DIR, callbackFn);
watcherUnsubscribe = await watch(PROJECT_DIR, EC_CORE_DIR, callbackFn);
});

afterAll(async () => {
await watcherSubscription?.();
await watcherUnsubscribe?.();
await fs.rm(PROJECT_DIR, { recursive: true });
});

describe('Commands', () => {
describe('/commands directory', () => {
// TODO: handle create event in watcher
test.skip('when a command is created, it adds it to the correct location in astro', async () => {
test.todo('when a command is created, it adds it to the correct location in astro', async () => {
const filePath = path.join('commands/FakeCommand/index.md');
fs.mkdir(path.dirname(path.join(PROJECT_DIR, filePath)), { recursive: true });
await waitWatcher();

fs.writeFile(path.join(PROJECT_DIR, filePath), 'FAKE COMMAND TESTING');
await waitWatcher();
Expand All @@ -68,7 +70,7 @@ describe('Watcher', () => {
).resolves.toEqual(contentProjectDir);
});

// TODO: Verify what happens if /domains is deleted.
// TODO: Verify what happens if /commands is deleted.
test('when a command is deleted, it deletes the corresponding command from astro', async () => {
const filePath = path.join('commands/AddInventory/index.md');

Expand All @@ -88,6 +90,22 @@ describe('Watcher', () => {
).rejects.toThrow(/ENOENT: no such file or directory/);
});

// TODO: isolate each test case
test.skip('deletes', async () => {
if (!existsSync(path.join(PROJECT_DIR, 'commands'))) {
fs.mkdir(path.join(PROJECT_DIR, 'commands'), { recursive: true });
await waitWatcher();
}

fs.writeFile(path.join(PROJECT_DIR, 'commands/index.md'), 'TEST');
await waitWatcher();

fs.rm(path.join(PROJECT_DIR, 'commands'), { recursive: true });
await waitWatcher();

expect(existsSync(path.join(EC_CORE_DIR, 'src/content/commands'))).toBeFalsy();
});

test.skip('when a versioned command is created, it adds it to the correct location in astro', async () => {
const filePath = path.join('commands/FakeCommand/versioned/0.0.1/index.md');

Expand Down Expand Up @@ -135,9 +153,10 @@ describe('Watcher', () => {

describe('Domains', () => {
describe('/domains directory', () => {
// TODO: handle create event in watcher
test.skip('when a domain is created, it adds to the correct location in astro', async () => {
test.todo('when a domain is created, it adds to the correct location in astro', async () => {
const filePath = path.join('domains/FakeDomain/index.md');
fs.mkdir(path.dirname(path.join(PROJECT_DIR, filePath)));
await waitWatcher();

fs.writeFile(path.join(PROJECT_DIR, filePath), 'FAKE DOMAIN TESTING');
await waitWatcher();
Expand Down
145 changes: 145 additions & 0 deletions scripts/map-catalog-to-astro.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import path from 'node:path';

/**
* @typedef {Object} MapCatalogToAstroParams
* @prop {string} catalogFilePath The catalog file path
* @prop {string} astroDir The astro directory
* @prop {string} projectDir The user's project directory
*/

/**
*
* @param {MapCatalogToAstroParams} params
* @returns {string[]|null} The astro file paths
*/
export function mapCatalogToAstro({ catalogFilePath, astroDir, projectDir }) {
const relativeCatalogFilePath = removeBasePath(catalogFilePath, projectDir);

if (!isFileRelatedToCatalog(relativeCatalogFilePath)) {
return null;
}

const baseTargetPaths = getBaseTargetPath(relativeCatalogFilePath);
const relativeTargetPath = getRelativeTargetPath(relativeCatalogFilePath);

return baseTargetPaths.map((base) =>
path.join(astroDir, base, relativeTargetPath.replace('index.md', 'index.mdx').replace('changelog.md', 'changelog.mdx'))
);
}

/**
*
* @param {string} fullPath
* @param {string} basePath
* @returns {string} The fullPath without the basePath
*/
export function removeBasePath(fullPath, basePath) {
const relativePath = path.relative(basePath, fullPath);
return relativePath.startsWith('..') ? fullPath : relativePath;
}

/**
* Check if the key is an ASTRO COLLECTION KEY
* @param {string} key
* @returns {boolean}
*/
function isCollectionKey(key) {
const COLLECTION_KEYS = ['events', 'commands', 'services', 'users', 'teams', 'domains', 'flows', 'pages', 'changelogs'];
return COLLECTION_KEYS.includes(key);
}

function seemsDirectory(filePath) {
return path.parse(filePath).ext == '';
}

/**
* Checks whether the given file is a configuration file, styles file, public asset file or collection file.
* @param {string} filePath - The file path without the projectDir prefix.
* @returns {boolean}
*/
function isFileRelatedToCatalog(filePath) {
const filePathParsed = path.parse(filePath);

// Check if the file is a configuration or style file
if (filePathParsed.base == 'eventcatalog.config.js' || filePathParsed.base == 'eventcatalog.styles.css') {
return true;
}

const firstDir = filePathParsed.dir.split(path.sep).filter(Boolean)[0];

if (
isCollectionKey(firstDir) ||
firstDir == 'public' ||
firstDir == 'components' ||
(seemsDirectory(filePath) &&
(isCollectionKey(filePathParsed.base) || filePathParsed.base == 'public' || filePathParsed.base == 'components'))
) {
return true;
}

return false;
}

/**
* Generates the base target path accordingly to the file path.
* @param {string} filePath The path to the file without PROJECT_DIR prefix.
* @returns {Array.<'src/content'|'public/generated'|'src/catalog-files'|'/'>} The base target path.
*/
function getBaseTargetPath(filePath) {
const filePathParsed = path.parse(filePath);
const fileDir = filePathParsed.dir.split(path.sep).filter(Boolean);
const fileBase = filePathParsed.base;
const fileExt = filePathParsed.ext;

// Collection files (markdown or non-markdown)
if (isCollectionKey(fileDir[0]) || /* handle directory */ isCollectionKey(fileBase)) {
switch (true) {
case fileBase == 'changelog.md':
return [path.join('src', 'content', 'changelogs')];
case fileExt == '.md':
case fileExt == '': // Maybe a directory
return [path.join('src', 'content')];
default:
return [path.join('public', 'generated'), path.join('src', 'catalog-files')];
}
}

// Custom components
if (fileDir[0] == 'components' || /* handle directtory */ fileBase == 'components') {
return [path.join('src', 'custom-defined-components')];
}

// Public assets (public/*)
if (fileDir[0] == 'public' || /* handle directory */ fileBase == 'public') {
return [path.join('public')];
}

/**
* Config files:
* - eventcatalog.config.js
* - eventcatalog.styles.css
*/
return [path.join('/')];
}

/**
* Generates the path until the ASTRO_COLLECTION_KEY or the PROJECT_DIR root.
* @param {string} filePath The path to the file.
* @returns {string} The path until the COLLECTION_KEY or PROJECT_DIR root.
*/
function getRelativeTargetPath(filePath) {
const filePathParsed = path.parse(filePath);
const fileDir = filePathParsed.dir.split(path.sep).filter(Boolean);

if (fileDir[0] == 'public' || fileDir[0] == 'components') {
return path.join(...fileDir.slice(1), filePathParsed.base);
}

const relativePath = [];
for (let i = fileDir.length - 1; i >= 0; i--) {
relativePath.unshift(fileDir[i]);
if (isCollectionKey(fileDir[i])) break;
}

return path.join(...relativePath, filePathParsed.base);
}
107 changes: 41 additions & 66 deletions scripts/watcher.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import watcher from '@parcel/watcher';
import path from 'path';
import fs from 'fs';
import os from 'os';
import { mapCatalogToAstro } from './map-catalog-to-astro';

/**
* @typedef {Object} Event
Expand All @@ -16,73 +15,49 @@ import os from 'os';
* @param {(err: Error | null, events: Event[]) => void | undefined} callback
*/
export async function watch(projectDirectory, catalogDirectory, callback) {
const contentPath = path.join(catalogDirectory, 'src', 'content');

const watchList = ['domains', 'commands', 'events', 'services', 'teams', 'users', 'pages', 'components', 'flows'];
// const absoluteWatchList = watchList.map((item) => path.join(projectDirectory, item));

// confirm folders exist before watching them
const verifiedWatchList = watchList.filter((item) => fs.existsSync(path.join(projectDirectory, item)));

const extensionReplacer = (collection, file) => {
if (collection === 'teams' || collection == 'users') return file;
return file.replace('.md', '.mdx');
};

const subscriptions = await Promise.all(
verifiedWatchList.map((item) =>
watcher.subscribe(
path.join(projectDirectory, item),
compose((err, events) => {
if (err) {
return;
}

for (let event of events) {
const { path: eventPath, type } = event;
const file = eventPath.split(item)[1];
let newPath = path.join(contentPath, item, extensionReplacer(item, file));

// Check if changlogs, they need to go into their own content folder
if (file.includes('changelog.md')) {
newPath = newPath.replace('src/content', 'src/content/changelogs');
if (os.platform() == 'win32') {
newPath = newPath.replace('src\\content', 'src\\content\\changelogs');
}
}

// Check if its a component, need to move to the correct location
if (newPath.includes('components')) {
newPath = newPath.replace('src/content/components', 'src/custom-defined-components');
if (os.platform() == 'win32') {
newPath = newPath.replace('src\\content\\components', 'src\\custom-defined-components');
}
}

// If config files have changes
if (eventPath.includes('eventcatalog.config.js') || eventPath.includes('eventcatalog.styles.css')) {
fs.cpSync(eventPath, path.join(catalogDirectory, file));
return;
}

// If markdown files or astro files copy file over to the required location
if ((eventPath.endsWith('.md') || eventPath.endsWith('.astro')) && type === 'update') {
fs.cpSync(eventPath, newPath);
}

// IF directory remove it
if (type === 'delete') {
fs.rmSync(newPath);
const subscription = await watcher.subscribe(
projectDirectory,
compose(
// console.debug,
(err, events) => {
if (err) {
return;
}

for (let event of events) {
const { path: catalogFilePath, type } = event;

const astroPaths = mapCatalogToAstro({ astroDir: catalogDirectory, projectDir: projectDirectory, catalogFilePath });

if (!astroPaths) continue;

for (const astroPath of astroPaths) {
switch (type) {
case 'create':
case 'update':
fs.cpSync(catalogFilePath, astroPath);
break;
case 'delete':
try {
fs.rmSync(astroPath, { recursive: true });
} catch (e) {
if ('message' in e && e.message.match(/ENOENT: no such file or directory/)) {
// fail silently
} else throw e;
}
break;
}
}
}, callback)
)
)
}
},
callback
),
{
ignore: [catalogDirectory],
}
);

return async () => {
await Promise.allSettled(subscriptions.map((sub) => sub.unsubscribe()));
};
return () => subscription.unsubscribe();
}

/**
Expand All @@ -93,7 +68,7 @@ export async function watch(projectDirectory, catalogDirectory, callback) {
function compose(...fns) {
return function (_err, events) {
let error = _err;
fns.filter(Boolean).forEach((fn) => {
fns.filter(Boolean).forEach((fn, i) => {
try {
fn(error, events);
} catch (e) {
Expand Down

0 comments on commit cf502d1

Please sign in to comment.