Skip to content

Commit

Permalink
fix: update types on config added or removed
Browse files Browse the repository at this point in the history
  • Loading branch information
bholmesdev committed Dec 5, 2022
1 parent 4361f68 commit 1834bca
Showing 1 changed file with 174 additions and 163 deletions.
337 changes: 174 additions & 163 deletions packages/astro/src/content/vite-plugin-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
ContentConfig,
getEntryData,
getEntrySlug,
loadContentConfig,
NotFoundError,
parseFrontmatter,
} from './utils.js';
import * as devalue from 'devalue';
Expand All @@ -26,23 +28,29 @@ type Paths = {
config: URL;
};

type ChokidarEvent = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir';
type ContentEvent = { name: ChokidarEvent; entry: string };
type EntryInfo = {
id: string;
slug: string;
collection: string;
};

type GenerateContent = {
init(): Promise<void>;
queueEvent(event: ContentEvent): void;
};

type ContentTypes = Record<string, Record<string, string>>;

const CONTENT_BASE = 'types.generated';
const CONTENT_FILE = CONTENT_BASE + '.mjs';
const CONTENT_TYPES_FILE = CONTENT_BASE + '.d.ts';

function isContentFlagImport({ searchParams, pathname }: Pick<URL, 'searchParams' | 'pathname'>) {
return searchParams.has(CONTENT_FLAG) && contentFileExts.some((ext) => pathname.endsWith(ext));
}

export function getPaths({ srcDir }: { srcDir: URL }): Paths {
return {
// Output generated types in content directory. May change in the future!
cacheDir: new URL('./content/', srcDir),
contentDir: new URL('./content/', srcDir),
generatedInputDir: new URL('../../src/content/template/', import.meta.url),
config: new URL('./content/config', srcDir),
};
}
const msg = {
collectionAdded: (collection: string) => `${cyan(collection)} collection added`,
entryAdded: (entry: string, collection: string) => `${cyan(entry)} added to ${bold(collection)}.`,
};

export function astroContentVirtualModPlugin({ settings }: { settings: AstroSettings }): Plugin {
const paths = getPaths({ srcDir: settings.config.srcDir });
Expand Down Expand Up @@ -88,6 +96,126 @@ export function astroContentServerPlugin({
const paths: Paths = getPaths({ srcDir: settings.config.srcDir });
let contentDirExists = false;
let contentGenerator: GenerateContent;

async function createContentGenerator(): Promise<GenerateContent> {
const contentTypes: ContentTypes = {};

let events: Promise<void>[] = [];
let debounceTimeout: NodeJS.Timeout | undefined;
let eventsSettled: Promise<void> | undefined;

const contentTypesBase = await fs.readFile(
new URL(CONTENT_TYPES_FILE, paths.generatedInputDir),
'utf-8'
);

async function init() {
const pattern = new URL('./**/', paths.contentDir).pathname + '*.{md,mdx}';
const entries = await glob(pattern);
for (const entry of entries) {
queueEvent({ name: 'add', entry }, { shouldLog: false });
}
await eventsSettled;
}

async function onEvent(event: ContentEvent, opts?: { shouldLog: boolean }) {
const shouldLog = opts?.shouldLog ?? true;

if (event.name === 'addDir' || event.name === 'unlinkDir') {
const collection = path.relative(paths.contentDir.pathname, event.entry);
// If directory is multiple levels deep, it is not a collection!
const isCollectionEvent = collection.split(path.sep).length === 1;
if (!isCollectionEvent) return;
switch (event.name) {
case 'addDir':
addCollection(contentTypes, JSON.stringify(collection));
if (shouldLog) {
info(logging, 'content', msg.collectionAdded(collection));
}
break;
case 'unlinkDir':
removeCollection(contentTypes, JSON.stringify(collection));
break;
}
} else {
const fileType = getEntryType(event.entry, paths);
if (fileType === 'config') {
contentConfig = await loadContentConfig({ settings });
return;
}
if (fileType === 'unknown') {
warn(
logging,
'content',
`${cyan(
path.relative(paths.contentDir.pathname, event.entry)
)} is not a supported file type. Skipping.`
);
return;
}
const entryInfo = getEntryInfo({ entryPath: event.entry, contentDir: paths.contentDir });
// Not a valid `src/content/` entry. Silently return, but should be impossible?
if (entryInfo instanceof Error) return;

const { id, slug, collection } = entryInfo;
const collectionKey = JSON.stringify(collection);
const entryKey = JSON.stringify(id);
const collectionConfig =
contentConfig instanceof Error ? undefined : contentConfig.collections[collection];
switch (event.name) {
case 'add':
if (!(collectionKey in contentTypes)) {
addCollection(contentTypes, collectionKey);
}
if (!(entryKey in contentTypes[collectionKey])) {
addEntry(contentTypes, collectionKey, entryKey, slug, collectionConfig);
}
if (shouldLog) {
info(logging, 'content', msg.entryAdded(entryInfo.slug, entryInfo.collection));
}
break;
case 'unlink':
if (collectionKey in contentTypes && entryKey in contentTypes[collectionKey]) {
removeEntry(contentTypes, collectionKey, entryKey);
}
break;
case 'change':
// noop. Frontmatter types are inferred from collection schema import, so they won't change!
break;
}
}
}

function queueEvent(event: ContentEvent, eventOpts?: { shouldLog: boolean }) {
if (!event.entry.startsWith(paths.contentDir.pathname)) return;
if (event.entry.endsWith(CONTENT_TYPES_FILE)) return;

events.push(onEvent(event, eventOpts));
runEventsDebounced();
}

function runEventsDebounced() {
eventsSettled = new Promise((resolve, reject) => {
try {
debounceTimeout && clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(async () => {
await Promise.all(events);
await writeContentFiles({
contentTypes,
paths,
contentTypesBase,
hasContentConfig: !(contentConfig instanceof NotFoundError),
});
resolve();
}, 50 /* debounce 50 ms to batch chokidar events */);
} catch (e) {
reject(e);
}
});
}
return { init, queueEvent };
}

return [
{
name: 'content-flag-plugin',
Expand Down Expand Up @@ -151,7 +279,7 @@ export const _internal = {

info(logging, 'content', 'Generating entries...');

contentGenerator = await toGenerateContent({ logging, paths, contentConfig });
contentGenerator = await createContentGenerator();
await contentGenerator.init();
},
async configureServer(viteServer) {
Expand All @@ -175,18 +303,33 @@ export const _internal = {
}

function attachListeners() {
viteServer.watcher.on('add', (entry) =>
contentGenerator.queueEvent({ name: 'add', entry })
);
viteServer.watcher.on('all', async (event, entry) => {
if (
['add', 'unlink', 'change'].includes(event) &&
getEntryType(entry, paths) === 'config'
) {
for (const modUrl of viteServer.moduleGraph.urlToModuleMap.keys()) {
if (isContentFlagImport(new URL(modUrl, 'file://'))) {
const mod = await viteServer.moduleGraph.getModuleByUrl(modUrl);
if (mod) {
viteServer.moduleGraph.invalidateModule(mod);
}
}
}
}
});
viteServer.watcher.on('add', (entry) => {
contentGenerator.queueEvent({ name: 'add', entry });
});
viteServer.watcher.on('addDir', (entry) =>
contentGenerator.queueEvent({ name: 'addDir', entry })
);
viteServer.watcher.on('change', (entry) =>
contentGenerator.queueEvent({ name: 'change', entry })
);
viteServer.watcher.on('unlink', (entry) =>
contentGenerator.queueEvent({ name: 'unlink', entry })
);
viteServer.watcher.on('unlink', (entry) => {
contentGenerator.queueEvent({ name: 'unlink', entry });
});
viteServer.watcher.on('unlinkDir', (entry) =>
contentGenerator.queueEvent({ name: 'unlinkDir', entry })
);
Expand All @@ -196,150 +339,18 @@ export const _internal = {
];
}

type ChokidarEvent = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir';
type ContentEvent = { name: ChokidarEvent; entry: string };
type EntryInfo = {
id: string;
slug: string;
collection: string;
};

type GenerateContent = {
init(): Promise<void>;
queueEvent(event: ContentEvent): void;
};

type ContentTypes = Record<string, Record<string, string>>;

const msg = {
collectionAdded: (collection: string) => `${cyan(collection)} collection added`,
entryAdded: (entry: string, collection: string) => `${cyan(entry)} added to ${bold(collection)}.`,
};

async function toGenerateContent({
logging,
paths,
contentConfig,
}: {
logging: LogOptions;
paths: Paths;
contentConfig: ContentConfig | Error;
}): Promise<GenerateContent> {
const contentTypes: ContentTypes = {};

let events: Promise<void>[] = [];
let debounceTimeout: NodeJS.Timeout | undefined;
let eventsSettled: Promise<void> | undefined;

const contentTypesBase = await fs.readFile(
new URL(CONTENT_TYPES_FILE, paths.generatedInputDir),
'utf-8'
);

async function init() {
const pattern = new URL('./**/', paths.contentDir).pathname + '*.{md,mdx}';
const entries = await glob(pattern);
for (const entry of entries) {
queueEvent({ name: 'add', entry }, { shouldLog: false });
}
await eventsSettled;
}

async function onEvent(event: ContentEvent, opts?: { shouldLog: boolean }) {
const shouldLog = opts?.shouldLog ?? true;

if (event.name === 'addDir' || event.name === 'unlinkDir') {
const collection = path.relative(paths.contentDir.pathname, event.entry);
// If directory is multiple levels deep, it is not a collection!
const isCollectionEvent = collection.split(path.sep).length === 1;
if (!isCollectionEvent) return;
switch (event.name) {
case 'addDir':
addCollection(contentTypes, JSON.stringify(collection));
if (shouldLog) {
info(logging, 'content', msg.collectionAdded(collection));
}
break;
case 'unlinkDir':
removeCollection(contentTypes, JSON.stringify(collection));
break;
}
} else {
const fileType = getEntryType(event.entry, paths);
if (fileType === 'config') {
return;
}
if (fileType === 'unknown') {
warn(
logging,
'content',
`${cyan(
path.relative(paths.contentDir.pathname, event.entry)
)} is not a supported file type. Skipping.`
);
return;
}
const entryInfo = getEntryInfo({ entryPath: event.entry, contentDir: paths.contentDir });
// Not a valid `src/content/` entry. Silently return, but should be impossible?
if (entryInfo instanceof Error) return;

const { id, slug, collection } = entryInfo;
const collectionKey = JSON.stringify(collection);
const entryKey = JSON.stringify(id);
const collectionConfig =
contentConfig instanceof Error ? undefined : contentConfig.collections[collection];
switch (event.name) {
case 'add':
if (!(collectionKey in contentTypes)) {
addCollection(contentTypes, collectionKey);
}
if (!(entryKey in contentTypes[collectionKey])) {
addEntry(contentTypes, collectionKey, entryKey, slug, collectionConfig);
}
if (shouldLog) {
info(logging, 'content', msg.entryAdded(entryInfo.slug, entryInfo.collection));
}
break;
case 'unlink':
if (collectionKey in contentTypes && entryKey in contentTypes[collectionKey]) {
removeEntry(contentTypes, collectionKey, entryKey);
}
break;
case 'change':
// noop. Frontmatter types are inferred from collection schema import, so they won't change!
break;
}
}
}

function queueEvent(event: ContentEvent, eventOpts?: { shouldLog: boolean }) {
if (!event.entry.startsWith(paths.contentDir.pathname)) return;
if (event.entry.endsWith(CONTENT_TYPES_FILE)) return;

events.push(onEvent(event, eventOpts));
runEventsDebounced();
}
export function getPaths({ srcDir }: { srcDir: URL }): Paths {
return {
// Output generated types in content directory. May change in the future!
cacheDir: new URL('./content/', srcDir),
contentDir: new URL('./content/', srcDir),
generatedInputDir: new URL('../../src/content/template/', import.meta.url),
config: new URL('./content/config', srcDir),
};
}

function runEventsDebounced() {
eventsSettled = new Promise((resolve, reject) => {
try {
debounceTimeout && clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(async () => {
await Promise.all(events);
await writeContentFiles({
contentTypes,
paths,
contentTypesBase,
hasContentConfig: !(contentConfig instanceof Error),
});
resolve();
}, 50 /* debounce 50 ms to batch chokidar events */);
} catch (e) {
reject(e);
}
});
}
return { init, queueEvent };
function isContentFlagImport({ searchParams, pathname }: Pick<URL, 'searchParams' | 'pathname'>) {
return searchParams.has(CONTENT_FLAG) && contentFileExts.some((ext) => pathname.endsWith(ext));
}

function addCollection(contentMap: ContentTypes, collectionKey: string) {
Expand Down

0 comments on commit 1834bca

Please sign in to comment.