From 6a223226822aba3e1f4336fb15f1935962f900bd Mon Sep 17 00:00:00 2001 From: Tony Ketcham Date: Fri, 17 Jul 2026 14:45:14 -0700 Subject: [PATCH] feat(core,cli): unified watch with incremental reindex and live schema swap Implements the "Draft unified watch design" from docs/local-dev-loop.md and the ADR-0003 live-reindex dependency (closes the gap tracked in #65). - core: buildContentGraph/patchContentGraph build an indexed snapshot of the content graph and patch it from changed paths only (re-reading changed files plus ref-affected neighbors); createLiveSchemaReloader serializes reloads, publishes monotonic generation tokens, exposes waitForGeneration and a ReindexBarrier seam, and keeps the prior schema on any validation failure; generateSchema accepts a prebuilt content graph and a schema-cache bypass for live mode - source-filesystem: fetchPaths reads only requested files with capture metadata parity against the directory-walk path - codegen: watch-pattern derivation factored into exported deriveFlatbreadWatchPatterns/flattenFlatbreadWatchPatterns - flatbread: startGraphqlServer keeps one Express listener and atomically swaps a delegating Apollo middleware per committed generation, draining in-flight requests against the old schema; @parcel/watcher + picomatch drive content/config/document classification; `flatbread start --watch` threads through the CLI runner as FLATBREAD_WATCH - tests: reloader unit suite over a fake source, fetchPaths suite, watch-pattern suite, and liveServer integration tests including the two promised in docs/local-dev-loop.md (stale-until-restart without watch; edit-to-live-update with watch on one port) Co-authored-by: Cursor Change-Id: Ie2519e41d8f064eca3d05fa98209f09e4adf9e1b --- docs/local-dev-loop.md | 69 ++- .../src/__tests__/watch-patterns.test.ts | 94 ++++ packages/codegen/src/generator.ts | 58 +-- packages/codegen/src/index.ts | 6 + packages/codegen/src/watchPatterns.ts | 37 ++ packages/core/src/generators/contentGraph.ts | 222 +++++++++ packages/core/src/generators/schema.ts | 46 +- packages/core/src/index.ts | 5 + packages/core/src/reload/liveSchema.ts | 122 +++++ .../core/src/reload/test/liveSchema.test.ts | 426 ++++++++++++++++++ packages/core/src/types.ts | 50 +- packages/flatbread/package.json | 3 + packages/flatbread/src/cli/index.ts | 4 +- packages/flatbread/src/cli/runner.ts | 3 + .../flatbread/src/graphql/liveServer.test.ts | 341 ++++++++++++++ packages/flatbread/src/graphql/liveServer.ts | 271 +++++++++++ packages/flatbread/src/graphql/server.ts | 97 +--- packages/flatbread/src/utils/getSchema.ts | 15 +- packages/source-filesystem/src/index.ts | 48 +- .../src/utils/gatherFileNodes.ts | 22 + .../src/utils/tests/fetchPaths.test.ts | 87 ++++ .../tests/fixtures/captures/news/hello.md | 6 + .../tests/fixtures/captures/tech/notes.txt | 1 + .../tests/fixtures/captures/tech/world.md | 6 + pnpm-lock.yaml | 113 ++--- 25 files changed, 1865 insertions(+), 287 deletions(-) create mode 100644 packages/codegen/src/__tests__/watch-patterns.test.ts create mode 100644 packages/codegen/src/watchPatterns.ts create mode 100644 packages/core/src/generators/contentGraph.ts create mode 100644 packages/core/src/reload/liveSchema.ts create mode 100644 packages/core/src/reload/test/liveSchema.test.ts create mode 100644 packages/flatbread/src/graphql/liveServer.test.ts create mode 100644 packages/flatbread/src/graphql/liveServer.ts create mode 100644 packages/source-filesystem/src/utils/tests/fetchPaths.test.ts create mode 100644 packages/source-filesystem/src/utils/tests/fixtures/captures/news/hello.md create mode 100644 packages/source-filesystem/src/utils/tests/fixtures/captures/tech/notes.txt create mode 100644 packages/source-filesystem/src/utils/tests/fixtures/captures/tech/world.md diff --git a/docs/local-dev-loop.md b/docs/local-dev-loop.md index 894f6ca3..ec0003d6 100644 --- a/docs/local-dev-loop.md +++ b/docs/local-dev-loop.md @@ -11,11 +11,9 @@ Flatbread's local loop has four moving parts: 4. **Framework restart / refresh** — `flatbread start -- ` runs the GraphQL server beside your app command. -Today these pieces are partly automated. Codegen has a watch loop; the -GraphQL server started by `flatbread start` still builds its schema at process -startup. That means some edits update generated TypeScript automatically, while -runtime query behavior still needs a restart until the server grows a live -schema swap. +Today these pieces are automated by `flatbread start --watch`: content edits +incrementally reindex and hot-swap the GraphQL schema, config edits rebuild the +schema and watcher matchers, and document edits refresh codegen. ## Canonical Next.js happy path @@ -39,31 +37,28 @@ pnpm exec flatbread codegen --watch --verbose ```bash # terminal 2 — serve GraphQL + Next.js without HTTPS for headless/dev agents -pnpm exec flatbread start -- next dev --turbopack +pnpm exec flatbread start --watch -- next dev --turbopack ``` Expected behavior: -- Editing a `.graphql` document or a content/config file triggers the codegen - watcher and updates `generated/graphql.ts`. +- Editing a `.graphql` document or a content/config file refreshes + `generated/graphql.ts`. - The generated content-model types and prototype read API are refreshed by the same codegen command. -- The running GraphQL endpoint at `http://localhost:5057/graphql` continues to - use the schema it built at startup. -- Restart `pnpm exec flatbread start -- next dev --turbopack` after changing - content, refs, collection config, transformers, or validation-sensitive data - if you need the live endpoint/app render to reflect the new graph. +- The running GraphQL endpoint at `http://localhost:5057/graphql` hot-swaps + valid content and config generations without restarting the framework. ## Current reload matrix -| Change | Codegen watcher behavior | Running GraphQL server | Framework app | Action required today | -| --------------------------------------- | ------------------------------------------- | ------------------------------------------------ | -------------------------------------------------------- | -------------------------------------------------------- | -| Markdown/YAML field value | Regenerates if watched path matches | Keeps previous startup schema/data | Keeps rendering whatever the endpoint returns | Restart `flatbread start` to update live query results | -| New/removed content file | Regenerates if watched path matches | Keeps previous startup schema/data | Keeps rendering whatever the endpoint returns | Restart `flatbread start` to update live query results | -| `.graphql` document | Regenerates operation types | No restart unless query text used by app changed | Framework dev server normally recompiles importing files | No Flatbread restart unless app code needs it | -| `flatbread.config.*` content/ref change | Attempts config reload from the current cwd | Keeps previous startup schema/data | Keeps rendering whatever the endpoint returns | Restart `flatbread start`; run watcher from config dir | -| Transformer/source package code | Does not rebuild package code | Keeps previous imported package code | May keep previous imported package code | Rebuild/watch package separately, rerun codegen, restart | -| `generated/graphql.ts` | Output of codegen | No direct effect | Framework dev server recompiles imports | No Flatbread restart | +| Change | Codegen watcher behavior | Running GraphQL server | Framework app | Action required today | +| --------------------------------------- | --------------------------------------- | ------------------------------------------------ | -------------------------------------------------------- | -------------------------------------------------------- | +| Markdown/YAML field value | Refreshes types if watched path matches | Hot-swaps after validation | Keeps rendering whatever the endpoint returns | None; framework refresh remains explicit | +| New/removed content file | Refreshes types if watched path matches | Hot-swaps after validation | Keeps rendering whatever the endpoint returns | None; framework refresh remains explicit | +| `.graphql` document | Regenerates operation types | No restart unless query text used by app changed | Framework dev server normally recompiles importing files | No Flatbread restart unless app code needs it | +| `flatbread.config.*` content/ref change | Reloads config and refreshes types | Rebuilds and hot-swaps after validation | Keeps rendering whatever the endpoint returns | None; framework refresh remains explicit | +| Transformer/source package code | Does not rebuild package code | Keeps previous imported package code | May keep previous imported package code | Rebuild/watch package separately, rerun codegen, restart | +| `generated/graphql.ts` | Output of codegen | No direct effect | Framework dev server recompiles imports | No Flatbread restart | ## Failure semantics today @@ -75,10 +70,10 @@ Expected behavior: - If the running GraphQL server was started before the invalid edit, it keeps serving the schema/data it already loaded. Restarting it surfaces the validation error at startup. -- There is no partial hot-swap mode yet: generated TypeScript can refresh while - the live GraphQL server remains on the old content graph. +- In unified watch mode, invalid candidates are rejected atomically: generated + artifacts and the live GraphQL server remain on the previous committed graph. -## Draft unified watch design (not implemented) +## Draft unified watch design (implemented) The unified loop should eventually make this one command: @@ -107,29 +102,19 @@ Design contract: ## Known limitations -- `flatbread start` does **not** currently hot-swap schema or content. +- `flatbread start --watch` hot-swaps valid content/config generations; invalid + candidates leave the prior schema active. - `flatbread codegen --watch` is a long-running process; do not use it in CI or one-shot scripts. - The Next.js example `pnpm dev` includes `--https` for local convenience, but the Flatbread GraphQL endpoint remains documented as HTTP on `5057`. In headless environments prefer `pnpm exec flatbread start -- next dev --turbopack`. -- Generated TypeScript can update before the running GraphQL endpoint does. - Treat codegen success as a type artifact refresh, not proof that the live - server has reloaded. -- `flatbread.config.*` watching is relative to the process cwd today. Run - `flatbread codegen --watch` from the directory that contains the config. +- Codegen failures are logged and do not undo a committed schema generation. +- Watch mode requires a source plugin with `fetchPaths`; sources without it fail + fast at startup. +- `flatbread.config.*` watching is relative to the `flatbread start` cwd. - Port `5057` collisions are not resolved automatically; stop the old Flatbread process before starting another server. -## Follow-up implementation seams - -- Add a `flatbread start --watch` flag that composes schema reload and codegen - refresh. -- Factor codegen's watch-pattern derivation into a shared helper used by both - `@flatbread/codegen` and the CLI. -- Add an integration test that edits a fixture post and proves the GraphQL - endpoint returns the updated value without a manual restart once hot swap is - implemented. -- Add a current-behavior integration test that edits a fixture post and proves - the running server does **not** change until restart, so future hot-swap work - has a concrete test to flip. +Framework restarts remain explicit: Flatbread keeps the framework child process +running and does not attempt to restart or control its own refresh behavior. diff --git a/packages/codegen/src/__tests__/watch-patterns.test.ts b/packages/codegen/src/__tests__/watch-patterns.test.ts new file mode 100644 index 00000000..94704ec3 --- /dev/null +++ b/packages/codegen/src/__tests__/watch-patterns.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import type { LoadedFlatbreadConfig } from '@flatbread/core'; +import { + deriveFlatbreadWatchPatterns, + flattenFlatbreadWatchPatterns, +} from '../watchPatterns.js'; + +function makeConfig( + overrides: Partial<{ + content: Array>; + extensions: string[]; + }> = {} +): LoadedFlatbreadConfig { + return { + source: { fetch: async () => ({}) }, + transformer: [], + fieldNameTransform: (field: string) => field, + content: overrides.content ?? [ + { path: 'content/posts', collection: 'Post' }, + ], + loaded: { + extensions: overrides.extensions ?? ['.md'], + }, + } as unknown as LoadedFlatbreadConfig; +} + +describe('deriveFlatbreadWatchPatterns', () => { + it('always includes the flatbread config glob', () => { + const patterns = deriveFlatbreadWatchPatterns(makeConfig(), {}); + expect(patterns.config).toEqual(['flatbread.config.*']); + }); + + it('derives a content glob for a single extension', () => { + const patterns = deriveFlatbreadWatchPatterns( + makeConfig({ extensions: ['.md'] }), + {} + ); + expect(patterns.content).toEqual(['content/posts/**/*.md']); + }); + + it('derives a brace-expansion content glob for multiple extensions and normalizes leading dots', () => { + const patterns = deriveFlatbreadWatchPatterns( + makeConfig({ extensions: ['.md', 'mdx', '.markdown'] }), + {} + ); + expect(patterns.content).toEqual(['content/posts/**/*.{md,mdx,markdown}']); + }); + + it('derives one content glob per configured content entry and skips entries without a path', () => { + const patterns = deriveFlatbreadWatchPatterns( + makeConfig({ + content: [ + { path: 'content/posts', collection: 'Post' }, + { path: 'content/authors', collection: 'Author' }, + { collection: 'Virtual' }, + ], + extensions: ['.md'], + }), + {} + ); + expect(patterns.content).toEqual([ + 'content/posts/**/*.md', + 'content/authors/**/*.md', + ]); + }); + + it('passes documents through untouched and defaults to empty', () => { + const withDocuments = deriveFlatbreadWatchPatterns(makeConfig(), { + documents: ['src/**/*.graphql', 'queries/*.gql'], + }); + expect(withDocuments.documents).toEqual([ + 'src/**/*.graphql', + 'queries/*.gql', + ]); + + const withoutDocuments = deriveFlatbreadWatchPatterns(makeConfig(), {}); + expect(withoutDocuments.documents).toEqual([]); + }); +}); + +describe('flattenFlatbreadWatchPatterns', () => { + it('concatenates config, content, and documents patterns in order', () => { + const flattened = flattenFlatbreadWatchPatterns({ + config: ['flatbread.config.*'], + content: ['content/posts/**/*.md'], + documents: ['src/**/*.graphql'], + }); + expect(flattened).toEqual([ + 'flatbread.config.*', + 'content/posts/**/*.md', + 'src/**/*.graphql', + ]); + }); +}); diff --git a/packages/codegen/src/generator.ts b/packages/codegen/src/generator.ts index 77dd9f4e..8ee5c127 100644 --- a/packages/codegen/src/generator.ts +++ b/packages/codegen/src/generator.ts @@ -27,6 +27,10 @@ import { checkPluginDependencies, formatMissingDepsWarning, } from './dependencyCheck.js'; +import { + deriveFlatbreadWatchPatterns, + flattenFlatbreadWatchPatterns, +} from './watchPatterns.js'; function emitMissingDepsWarning(missingDeps: string[]) { if (missingDeps.length === 0) return; @@ -568,56 +572,6 @@ export async function generateTypesWithDocuments( return generateTypes(schema, config, mergedOptions); } -/** - * Get file patterns to watch based on the Flatbread configuration. - * - * Builds a list of glob patterns that includes: - * - Flatbread config files (flatbread.config.*) - * - Content directories with supported file extensions - * - GraphQL document files if specified in options - * - * @param config Loaded Flatbread configuration - * @param options Codegen options that may include document paths - * @returns Array of glob patterns to watch - */ -function getWatchPatterns( - config: LoadedFlatbreadConfig, - options: CodegenOptions -): string[] { - const patterns: string[] = []; - - // Watch Flatbread config files - patterns.push('flatbread.config.*'); - - // Watch content directories from the configuration - if (config.content) { - for (const contentType of config.content) { - if (contentType.path) { - // Watch for all supported file extensions in content directories - const rawExtensions = config.loaded?.extensions || [ - '.md', - '.mdx', - '.markdown', - ]; - // Remove dots from extensions since we'll add one in the pattern - const extensions = rawExtensions.map((ext) => - ext.startsWith('.') ? ext.slice(1) : ext - ); - const extensionPattern = - extensions.length > 1 ? `{${extensions.join(',')}}` : extensions[0]; - patterns.push(`${contentType.path}/**/*.${extensionPattern}`); - } - } - } - - // Watch GraphQL document files if provided - if (options.documents && options.documents.length > 0) { - patterns.push(...options.documents); - } - - return patterns; -} - /** * Watch for changes and regenerate types automatically. * @@ -673,7 +627,9 @@ export async function watchAndGenerate( await generateTypes(schema, config, currentOptions); // Set up file watchers - const patterns = getWatchPatterns(config, currentOptions); + const patterns = flattenFlatbreadWatchPatterns( + deriveFlatbreadWatchPatterns(config, currentOptions) + ); console.log(kleur.dim(`Watching patterns: ${patterns.join(', ')}`)); const ignored = [ diff --git a/packages/codegen/src/index.ts b/packages/codegen/src/index.ts index 3a75338d..97b374f5 100644 --- a/packages/codegen/src/index.ts +++ b/packages/codegen/src/index.ts @@ -29,3 +29,9 @@ export { export { loadCache, saveCache, isCacheValid, clearCache } from './cache.js'; export { createCodegenCommand } from './cli.js'; + +export { + deriveFlatbreadWatchPatterns, + flattenFlatbreadWatchPatterns, +} from './watchPatterns.js'; +export type { FlatbreadWatchPatterns } from './watchPatterns.js'; diff --git a/packages/codegen/src/watchPatterns.ts b/packages/codegen/src/watchPatterns.ts new file mode 100644 index 00000000..3c763990 --- /dev/null +++ b/packages/codegen/src/watchPatterns.ts @@ -0,0 +1,37 @@ +import type { LoadedFlatbreadConfig } from '@flatbread/core'; +import type { CodegenOptions } from './types.js'; + +export interface FlatbreadWatchPatterns { + config: readonly string[]; + content: readonly string[]; + documents: readonly string[]; +} + +export function deriveFlatbreadWatchPatterns( + config: LoadedFlatbreadConfig, + options: Pick +): FlatbreadWatchPatterns { + const rawExtensions = config.loaded?.extensions || [ + '.md', + '.mdx', + '.markdown', + ]; + const extensions = rawExtensions.map((ext) => + ext.startsWith('.') ? ext.slice(1) : ext + ); + const extensionPattern = + extensions.length > 1 ? `{${extensions.join(',')}}` : extensions[0]; + return { + config: ['flatbread.config.*'], + content: config.content + .filter((entry) => Boolean(entry.path)) + .map((entry) => `${entry.path}/**/*.${extensionPattern}`), + documents: options.documents ?? [], + }; +} + +export function flattenFlatbreadWatchPatterns( + patterns: FlatbreadWatchPatterns +): string[] { + return [...patterns.config, ...patterns.content, ...patterns.documents]; +} diff --git a/packages/core/src/generators/contentGraph.ts b/packages/core/src/generators/contentGraph.ts new file mode 100644 index 00000000..421d025e --- /dev/null +++ b/packages/core/src/generators/contentGraph.ts @@ -0,0 +1,222 @@ +import { extname, resolve } from 'node:path'; +import type { VFile } from 'vfile'; +import type { + ContentGraphSnapshot, + ContentNode, + EntryNode, + IndexedContentNode, + LoadedFlatbreadConfig, +} from '../types'; +import { getNodeIdentifier, normalizeIdentifier } from '../utils/ids'; +import { validateCollectionReferences } from '../utils/references'; +import { + optionallyTransformContentNodes, + validateCollectionIdentifiers, +} from './schema'; + +const keyFor = (collection: string, id: string) => `${collection}\u0000${id}`; + +export async function buildContentGraph( + config: LoadedFlatbreadConfig +): Promise { + config.source.initialize?.(config); + const fetched = await config.source.fetch(config.content); + const transformed = optionallyTransformContentNodes(fetched, config); + const nodesByCollection = validateCollectionIdentifiers(transformed); + validateCollectionReferences(transformed, config.content); + return makeSnapshot(config, nodesByCollection); +} + +export async function patchContentGraph( + previous: ContentGraphSnapshot, + changedPaths: readonly string[] +): Promise { + const fetchPaths = previous.config.source.fetchPaths; + if (!fetchPaths) { + throw new Error( + 'Flatbread watch mode requires the configured source to implement fetchPaths(paths).' + ); + } + const normalized = [...new Set(changedPaths.map((path) => resolve(path)))]; + const directFiles = await fetchPaths(normalized); + const directNodes = transformFiles(directFiles, previous.config); + // Reference keys (`collection\u0000id`) affected by this batch; resolved to + // paths below. Inbound referrers are already paths and are collected as-is. + const affectedTargets = new Set(); + const neighborPathSet = new Set(); + for (const path of normalized) { + const old = previous.nodeByPath.get(path); + if (old) { + affectedTargets.add(keyFor(old.collection, old.id)); + for (const target of previous.outboundReferences.get(path) ?? []) { + affectedTargets.add(target); + } + for (const referrer of previous.inboundReferences.get( + keyFor(old.collection, old.id) + ) ?? []) { + neighborPathSet.add(referrer); + } + } + const replacement = directNodes.get(path); + if (replacement) { + for (const target of referencesFor( + replacement.collection, + replacement.node, + previous.config + )) { + affectedTargets.add(target); + } + } + } + for (const target of affectedTargets) { + const targetPath = previous.pathByCollectionAndId.get(target); + if (targetPath !== undefined) neighborPathSet.add(targetPath); + } + const neighborPaths = [...neighborPathSet].filter( + (path) => !normalized.includes(path) + ); + const neighborFiles = neighborPaths.length + ? await fetchPaths(neighborPaths) + : []; + const files = new Map(); + for (const file of [...directFiles, ...neighborFiles]) { + files.set(resolve(file.path), file); + } + const replacements = transformFiles([...files.values()], previous.config); + const nodesByCollection: Record = Object.fromEntries( + Object.entries(previous.nodesByCollection).map(([collection, nodes]) => [ + collection, + [...nodes], + ]) + ); + for (const path of [...normalized, ...neighborPaths]) { + const old = previous.nodeByPath.get(path); + if (old) { + nodesByCollection[old.collection] = ( + nodesByCollection[old.collection] ?? [] + ).filter((node) => resolve(String(node._path)) !== path); + } + } + for (const [, indexed] of replacements) { + (nodesByCollection[indexed.collection] ??= []).push( + indexed.node as ContentNode + ); + } + validateCollectionIdentifiers(nodesByCollection); + validateCollectionReferences(nodesByCollection, previous.config.content); + return makeSnapshot(previous.config, nodesByCollection); +} + +function transformFiles( + files: readonly VFile[], + config: LoadedFlatbreadConfig +): Map { + const grouped: Record = {}; + for (const file of files) { + const collection = collectionForPath(resolve(file.path), config); + if (collection) (grouped[collection] ??= []).push(file); + } + const transformed = optionallyTransformContentNodes(grouped, config); + const result = new Map(); + for (const [collection, nodes] of Object.entries(transformed)) { + for (const node of nodes) { + const path = resolve(String(node._path)); + result.set(path, { + collection, + id: getNodeIdentifier(node, collection), + path, + node, + }); + } + } + return result; +} + +function makeSnapshot( + config: LoadedFlatbreadConfig, + nodesByCollection: Record +): ContentGraphSnapshot { + const nodeByPath = new Map(); + const pathByCollectionAndId = new Map(); + const inboundReferences = new Map>(); + const outboundReferences = new Map(); + for (const [collection, nodes] of Object.entries(nodesByCollection)) { + for (const node of nodes) { + const path = resolve(String(node._path ?? `${collection}:${node.id}`)); + const id = getNodeIdentifier(node, collection); + nodeByPath.set(path, { collection, id, path, node }); + pathByCollectionAndId.set(keyFor(collection, id), path); + const refs = referencesFor(collection, node, config); + outboundReferences.set(path, refs); + for (const target of refs) { + const referrers = inboundReferences.get(target) ?? new Set(); + referrers.add(path); + inboundReferences.set(target, referrers); + } + } + } + return { + config, + nodesByCollection, + nodeByPath, + pathByCollectionAndId, + inboundReferences, + outboundReferences, + }; +} + +function referencesFor( + collection: string, + node: EntryNode, + config: LoadedFlatbreadConfig +): string[] { + const refs = config.content.find( + (entry) => String(entry.collection) === collection + )?.refs; + if (!refs) return []; + const result: string[] = []; + for (const [field, targetCollection] of Object.entries(refs)) { + const value = node[field]; + for (const item of Array.isArray(value) ? value : [value]) { + if (item !== null && item !== undefined) { + result.push( + keyFor(String(targetCollection), normalizeIdentifier(item)) + ); + } + } + } + return result; +} + +function collectionForPath( + path: string, + config: LoadedFlatbreadConfig +): string | undefined { + const extension = extname(path).toLowerCase(); + if ( + !(config.loaded.extensions ?? []).some( + (item) => `.${item.replace(/^\./, '')}`.toLowerCase() === extension + ) + ) { + return undefined; + } + return config.content.find((entry) => { + if (!entry.path) return false; + const root = resolve(entry.path); + if (path === root || path.startsWith(`${root}/`)) return true; + return pathPatternMatches(path, root); + })?.collection; +} + +function pathPatternMatches(path: string, pattern: string): boolean { + const escaped = pattern + .split('/') + .map((segment) => { + if (/^\[[^\]]+\]/.test(segment)) { + return '[^/]+'; + } + return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + }) + .join('/'); + return new RegExp(`^${escaped}(?:/|$)`).test(path); +} diff --git a/packages/core/src/generators/schema.ts b/packages/core/src/generators/schema.ts index 01107308..57ea155d 100644 --- a/packages/core/src/generators/schema.ts +++ b/packages/core/src/generators/schema.ts @@ -12,6 +12,7 @@ import { import resolveQueryArgs from '../resolvers/arguments'; import { ConfigResult, + ContentGraphSnapshot, ContentNode, EntryNode, LoadedFlatbreadConfig, @@ -41,7 +42,10 @@ interface ResolverPayload { * @param configResult the result of the config file processing */ export async function generateSchema( - configResult: ConfigResult + configResult: ConfigResult & { + contentGraph?: ContentGraphSnapshot; + useSchemaCache?: boolean; + } ) { const { config } = configResult; if (!config) { @@ -49,23 +53,29 @@ export async function generateSchema( } // Invoke initialize function if it exists and provide loaded config - config.source.initialize?.(config); - - // Invoke the content source resolver to retrieve the content nodes - const allContentNodes = await config.source.fetch(config.content); - - // Transform the content nodes to the expected JSON format if needed - const allContentNodesJSON = optionallyTransformContentNodes( - allContentNodes, - config - ); - const contentNodesByCollection = - validateCollectionIdentifiers(allContentNodesJSON); - validateCollectionReferences(allContentNodesJSON, config.content); + let allContentNodesJSON: Record; + let contentNodesByCollection: Record; + if (configResult.contentGraph) { + allContentNodesJSON = configResult.contentGraph.nodesByCollection; + contentNodesByCollection = configResult.contentGraph.nodesByCollection; + } else { + config.source.initialize?.(config); + const allContentNodes = await config.source.fetch(config.content); + allContentNodesJSON = optionallyTransformContentNodes( + allContentNodes, + config + ); + contentNodesByCollection = + validateCollectionIdentifiers(allContentNodesJSON); + validateCollectionReferences(allContentNodesJSON, config.content); + } // Content validation must run before returning a cached schema because the // cache key is derived from config, while invalid IDs/refs live in content. - const cachedSchema = checkCacheForSchema(config); + const cachedSchema = + configResult.useSchemaCache === false + ? undefined + : checkCacheForSchema(config); if (cachedSchema) { return cachedSchema; @@ -258,7 +268,7 @@ export async function generateSchema( const schema = schemaComposer.buildSchema(); - cacheSchema(config, schema); + if (configResult.useSchemaCache !== false) cacheSchema(config, schema); return schema; } @@ -279,7 +289,7 @@ const fetchPreknownSchemaFragments = ( ); }; -function validateCollectionIdentifiers( +export function validateCollectionIdentifiers( allContentNodesJSON: Record ): Record { const errors: string[] = []; @@ -344,7 +354,7 @@ function getTransformerExtensionMap( * @param allContentNodes an object of keys and values - content type and content nodes to transform, respectively * @param config Flatbread config object */ -const optionallyTransformContentNodes = ( +export const optionallyTransformContentNodes = ( allContentNodes: Record, config: LoadedFlatbreadConfig ): Record => { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 284097db..ee549967 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,9 @@ export { generateSchema } from './generators/schema'; +export { + buildContentGraph, + patchContentGraph, +} from './generators/contentGraph'; +export { createLiveSchemaReloader } from './reload/liveSchema'; export { exportCollectionsAsCsv } from './export/csv'; export type { CsvExportOptions, CsvExportResult } from './export/csv'; export { exportCollectionsAsJson } from './export/json'; diff --git a/packages/core/src/reload/liveSchema.ts b/packages/core/src/reload/liveSchema.ts new file mode 100644 index 00000000..51e1f3a7 --- /dev/null +++ b/packages/core/src/reload/liveSchema.ts @@ -0,0 +1,122 @@ +import { generateSchema } from '../generators/schema'; +import { + buildContentGraph, + patchContentGraph, +} from '../generators/contentGraph'; +import type { + ChangedPaths, + ContentGraphSnapshot, + LoadedFlatbreadConfig, + LiveSchemaReloader, + ReindexBarrier, + ReindexResult, + SchemaSnapshot, +} from '../types'; + +export interface LiveSchemaReloaderOptions { + config: LoadedFlatbreadConfig; + commitSchema: ( + candidate: Omit + ) => Promise; + barrier?: ReindexBarrier; +} + +export async function createLiveSchemaReloader( + options: LiveSchemaReloaderOptions +): Promise { + let snapshot: SchemaSnapshot; + let queue = Promise.resolve(); + let generation = 0; + const waiters: Array<{ + generation: number; + resolve: (value: SchemaSnapshot) => void; + }> = []; + const barrier = options.barrier ?? { waitUntilReadable: async () => {} }; + + const initialGraph = await buildContentGraph(options.config); + const initialSchema = await generateSchema({ + config: options.config, + contentGraph: initialGraph, + useSchemaCache: false, + }); + await options.commitSchema({ schema: initialSchema, graph: initialGraph }); + snapshot = { schema: initialSchema, graph: initialGraph, generation: 0 }; + + const commit = async ( + graph: ContentGraphSnapshot, + config: LoadedFlatbreadConfig + ): Promise => { + try { + const schema = await generateSchema({ + config, + contentGraph: graph, + useSchemaCache: false, + }); + await options.commitSchema({ schema, graph }); + generation += 1; + snapshot = { schema, graph, generation }; + for (const waiter of waiters.splice(0)) { + if (waiter.generation <= generation) waiter.resolve(snapshot); + else waiters.push(waiter); + } + return { status: 'committed', generation }; + } catch (error) { + return { + status: 'rejected', + generation, + error: error instanceof Error ? error : new Error(String(error)), + }; + } + }; + + const enqueue = ( + operation: () => Promise + ): Promise => { + const result = queue.then(operation); + queue = result.then( + () => undefined, + () => undefined + ); + return result; + }; + + return { + get generation() { + return generation; + }, + getSnapshot: () => snapshot, + notifyChanged: (change: ChangedPaths) => + enqueue(async () => { + await barrier.waitUntilReadable(change.paths); + try { + const graph = await patchContentGraph(snapshot.graph, change.paths); + return await commit(graph, snapshot.graph.config); + } catch (error) { + return { + status: 'rejected', + generation, + error: error instanceof Error ? error : new Error(String(error)), + }; + } + }), + replaceConfig: (config: LoadedFlatbreadConfig) => + enqueue(async () => { + try { + const graph = await buildContentGraph(config); + return await commit(graph, config); + } catch (error) { + return { + status: 'rejected', + generation, + error: error instanceof Error ? error : new Error(String(error)), + }; + } + }), + waitForGeneration: (minimumGeneration: number) => + generation >= minimumGeneration + ? Promise.resolve(snapshot) + : new Promise((resolve) => + waiters.push({ generation: minimumGeneration, resolve }) + ), + }; +} diff --git a/packages/core/src/reload/test/liveSchema.test.ts b/packages/core/src/reload/test/liveSchema.test.ts new file mode 100644 index 00000000..ff52e5db --- /dev/null +++ b/packages/core/src/reload/test/liveSchema.test.ts @@ -0,0 +1,426 @@ +import test from 'ava'; +import { graphql, GraphQLSchema } from 'graphql'; +import { resolve } from 'node:path'; +import { VFile } from 'vfile'; +import { createLiveSchemaReloader } from '../liveSchema'; +import { initializeConfig } from '../../utils/initializeConfig'; +import type { + EntryNode, + LoadedFlatbreadConfig, + SchemaSnapshot, + Source, + Transformer, +} from '../../types'; + +const AUTHORS_ROOT = 'virtual/live/authors'; +const POSTS_ROOT = 'virtual/live/posts'; + +interface FakeProject { + config: LoadedFlatbreadConfig; + store: Map; + counters: { + fetch: number; + fetchPaths: string[][]; + }; + events: string[]; + path: (relative: string) => string; +} + +/** + * In-memory Source + trivial transformer so reloader tests never touch disk. + * Entries are keyed by absolute path; a missing key doubles as a deleted file. + */ +function makeProject(initialEntries: Record): FakeProject { + const store = new Map(); + for (const [relativePath, entry] of Object.entries(initialEntries)) { + store.set(resolve(relativePath), entry); + } + const counters = { fetch: 0, fetchPaths: [] as string[][] }; + const events: string[] = []; + + const toFile = (absolutePath: string, entry: EntryNode): VFile => { + const file = new VFile({ path: absolutePath }); + file.data.entry = entry; + return file; + }; + + const source: Source = { + fetch: async (content) => { + counters.fetch += 1; + events.push('fetch'); + const result: Record = {}; + for (const contentEntry of content) { + const root = resolve(String(contentEntry.path)); + result[String(contentEntry.collection)] = [...store.entries()] + .filter(([path]) => path.startsWith(`${root}/`)) + .map(([path, entry]) => toFile(path, entry)); + } + return result; + }, + fetchPaths: async (paths) => { + counters.fetchPaths.push([...paths]); + events.push('fetchPaths'); + return paths + .map((path) => resolve(path)) + .filter((path) => store.has(path)) + .map((path) => toFile(path, store.get(path)!)); + }, + }; + + const transformer: Transformer = { + extensions: ['.json'], + inspect: (input) => JSON.stringify(input), + parse: (input) => input.data.entry as EntryNode, + }; + + const config = initializeConfig({ + source, + transformer, + content: [ + { path: AUTHORS_ROOT, collection: 'LiveAuthor' }, + { + path: POSTS_ROOT, + collection: 'LivePost', + refs: { author: 'LiveAuthor' }, + }, + ], + }); + + return { + config, + store, + counters, + events, + path: (relative) => resolve(relative), + }; +} + +function basicEntries(): Record { + return { + [`${AUTHORS_ROOT}/a1.json`]: { id: 'a1', name: 'Original Author' }, + [`${AUTHORS_ROOT}/a2.json`]: { id: 'a2', name: 'Second Author' }, + [`${POSTS_ROOT}/p1.json`]: { id: 'p1', title: 'First Post', author: 'a1' }, + }; +} + +async function queryData( + schema: GraphQLSchema, + source: string +): Promise> { + const result = await graphql({ schema, source }); + if (result.errors?.length) { + throw new Error(result.errors.map((error) => error.message).join('\n')); + } + return result.data as Record; +} + +test.serial( + 'initial build commits generation 0 and calls commitSchema', + async (t) => { + const project = makeProject(basicEntries()); + const commits: Array> = []; + + const reloader = await createLiveSchemaReloader({ + config: project.config, + commitSchema: async (candidate) => { + commits.push(candidate); + }, + }); + + t.is(reloader.generation, 0); + t.is(reloader.getSnapshot().generation, 0); + t.is(commits.length, 1); + t.is(commits[0].schema, reloader.getSnapshot().schema); + t.is(project.counters.fetch, 1); + t.deepEqual(project.counters.fetchPaths, []); + + const data = await queryData( + reloader.getSnapshot().schema, + `query { allLiveAuthors { id name } }` + ); + t.deepEqual(data.allLiveAuthors, [ + { id: 'a1', name: 'Original Author' }, + { id: 'a2', name: 'Second Author' }, + ]); + } +); + +test.serial( + 'changed file reindexes through fetchPaths only, includes ref-affected neighbors, and advances generation 0→1 with a fresh schema', + async (t) => { + const project = makeProject(basicEntries()); + const reloader = await createLiveSchemaReloader({ + config: project.config, + commitSchema: async () => {}, + }); + const initialSchema = reloader.getSnapshot().schema; + const authorPath = project.path(`${AUTHORS_ROOT}/a1.json`); + const postPath = project.path(`${POSTS_ROOT}/p1.json`); + + project.store.set(authorPath, { id: 'a1', name: 'Renamed Author' }); + const result = await reloader.notifyChanged({ + paths: [authorPath], + source: 'watcher', + }); + + t.deepEqual(result, { status: 'committed', generation: 1 }); + t.is(reloader.generation, 1); + // Full fetch ran exactly once (initial build); the reindex used fetchPaths. + t.is(project.counters.fetch, 1); + t.true(project.counters.fetchPaths.length >= 1); + const requestedPaths = project.counters.fetchPaths.flat(); + t.true(requestedPaths.includes(authorPath)); + // p1 references a1, so it is a ref-affected neighbor and must be re-read. + t.true(requestedPaths.includes(postPath)); + + // useSchemaCache: false → a fresh schema object per committed generation + // even though the config is identical. + t.not(reloader.getSnapshot().schema, initialSchema); + + const data = await queryData( + reloader.getSnapshot().schema, + `query { LiveAuthor(id: "a1") { name } allLivePosts { id author { name } } }` + ); + t.deepEqual(data.LiveAuthor, { name: 'Renamed Author' }); + t.deepEqual(data.allLivePosts, [ + { id: 'p1', author: { name: 'Renamed Author' } }, + ]); + } +); + +test.serial( + 'duplicate ID edit is rejected and preserves the prior schema and generation', + async (t) => { + const project = makeProject(basicEntries()); + const reloader = await createLiveSchemaReloader({ + config: project.config, + commitSchema: async () => {}, + }); + const schemaBefore = reloader.getSnapshot().schema; + + const duplicatePath = project.path(`${AUTHORS_ROOT}/dupe.json`); + project.store.set(duplicatePath, { id: 'a1', name: 'Duplicate of a1' }); + const result = await reloader.notifyChanged({ paths: [duplicatePath] }); + + t.is(result.status, 'rejected'); + if (result.status === 'rejected') { + t.is(result.generation, 0); + t.regex(result.error.message, /duplicated/); + } + t.is(reloader.generation, 0); + t.is(reloader.getSnapshot().schema, schemaBefore); + + const data = await queryData( + reloader.getSnapshot().schema, + `query { allLiveAuthors { id } }` + ); + t.deepEqual(data.allLiveAuthors, [{ id: 'a1' }, { id: 'a2' }]); + } +); + +test.serial( + 'deleting a referenced target is rejected and keeps the prior snapshot', + async (t) => { + const project = makeProject(basicEntries()); + const reloader = await createLiveSchemaReloader({ + config: project.config, + commitSchema: async () => {}, + }); + const schemaBefore = reloader.getSnapshot().schema; + const authorPath = project.path(`${AUTHORS_ROOT}/a1.json`); + + project.store.delete(authorPath); + const result = await reloader.notifyChanged({ paths: [authorPath] }); + + t.is(result.status, 'rejected'); + if (result.status === 'rejected') { + t.regex(result.error.message, /broken reference/); + } + t.is(reloader.generation, 0); + t.is(reloader.getSnapshot().schema, schemaBefore); + + const data = await queryData( + reloader.getSnapshot().schema, + `query { allLiveAuthors { id } }` + ); + t.deepEqual(data.allLiveAuthors, [{ id: 'a1' }, { id: 'a2' }]); + } +); + +test.serial('deleting an unreferenced node commits', async (t) => { + const project = makeProject({ + ...basicEntries(), + [`${POSTS_ROOT}/p2.json`]: { + id: 'p2', + title: 'Unreferenced Post', + author: 'a2', + }, + }); + const reloader = await createLiveSchemaReloader({ + config: project.config, + commitSchema: async () => {}, + }); + const postPath = project.path(`${POSTS_ROOT}/p2.json`); + + project.store.delete(postPath); + const result = await reloader.notifyChanged({ paths: [postPath] }); + + t.deepEqual(result, { status: 'committed', generation: 1 }); + + const data = await queryData( + reloader.getSnapshot().schema, + `query { allLivePosts { id } allLiveAuthors { id } }` + ); + t.deepEqual(data.allLivePosts, [{ id: 'p1' }]); + t.deepEqual(data.allLiveAuthors, [{ id: 'a1' }, { id: 'a2' }]); +}); + +test.serial('rename (unlink + add in one batch) commits', async (t) => { + const project = makeProject(basicEntries()); + const reloader = await createLiveSchemaReloader({ + config: project.config, + commitSchema: async () => {}, + }); + const oldPath = project.path(`${AUTHORS_ROOT}/a2.json`); + const newPath = project.path(`${AUTHORS_ROOT}/a2-renamed.json`); + const entry = project.store.get(oldPath)!; + + project.store.delete(oldPath); + project.store.set(newPath, entry); + const result = await reloader.notifyChanged({ paths: [oldPath, newPath] }); + + t.deepEqual(result, { status: 'committed', generation: 1 }); + + const data = await queryData( + reloader.getSnapshot().schema, + `query { allLiveAuthors { id name } }` + ); + t.deepEqual(data.allLiveAuthors, [ + { id: 'a1', name: 'Original Author' }, + { id: 'a2', name: 'Second Author' }, + ]); +}); + +test.serial( + 'waitForGeneration(1) resolves only after commitSchema has completed', + async (t) => { + const project = makeProject(basicEntries()); + let releaseCommit: (() => void) | undefined; + let commitCalls = 0; + + const reloader = await createLiveSchemaReloader({ + config: project.config, + commitSchema: async () => { + commitCalls += 1; + // Initial build commits immediately; the second commit is gated so the + // test can observe the pre-commit state. + if (commitCalls > 1) { + await new Promise((resolveGate) => { + releaseCommit = resolveGate; + }); + } + }, + }); + + let waited: SchemaSnapshot | undefined; + const waiter = reloader.waitForGeneration(1).then((snapshot) => { + waited = snapshot; + return snapshot; + }); + + const postPath = project.path(`${POSTS_ROOT}/p1.json`); + project.store.set(postPath, { + id: 'p1', + title: 'Edited Post', + author: 'a1', + }); + const pendingChange = reloader.notifyChanged({ paths: [postPath] }); + + // Let the reindex reach the gated commitSchema call. + await new Promise((resolveTick) => setTimeout(resolveTick, 20)); + t.is(commitCalls, 2); + t.is(waited, undefined, 'waiter must not resolve before commit completes'); + t.is(reloader.generation, 0); + + releaseCommit!(); + const [changeResult, snapshot] = await Promise.all([pendingChange, waiter]); + t.deepEqual(changeResult, { status: 'committed', generation: 1 }); + t.is(snapshot.generation, 1); + t.is(waited, snapshot); + } +); + +test.serial( + 'commitSchema throwing rejects the candidate without advancing the generation', + async (t) => { + const project = makeProject(basicEntries()); + let commitCalls = 0; + + const reloader = await createLiveSchemaReloader({ + config: project.config, + commitSchema: async () => { + commitCalls += 1; + if (commitCalls > 1) { + throw new Error('apollo candidate failed to start'); + } + }, + }); + const schemaBefore = reloader.getSnapshot().schema; + + const postPath = project.path(`${POSTS_ROOT}/p1.json`); + project.store.set(postPath, { + id: 'p1', + title: 'Edited Post', + author: 'a1', + }); + const result = await reloader.notifyChanged({ paths: [postPath] }); + + t.is(result.status, 'rejected'); + if (result.status === 'rejected') { + t.is(result.generation, 0); + t.regex(result.error.message, /apollo candidate failed to start/); + } + t.is(reloader.generation, 0); + t.is(reloader.getSnapshot().schema, schemaBefore); + } +); + +test.serial( + 'barrier.waitUntilReadable is awaited before the source is read', + async (t) => { + const project = makeProject(basicEntries()); + const barrierPaths: string[][] = []; + + const reloader = await createLiveSchemaReloader({ + config: project.config, + commitSchema: async () => {}, + barrier: { + waitUntilReadable: async (paths) => { + barrierPaths.push([...paths]); + project.events.push('barrier'); + // Yield so an unawaited barrier would let fetchPaths run first. + await new Promise((resolveTick) => setTimeout(resolveTick, 10)); + }, + }, + }); + + const postPath = project.path(`${POSTS_ROOT}/p1.json`); + project.store.set(postPath, { + id: 'p1', + title: 'Edited Post', + author: 'a1', + }); + const result = await reloader.notifyChanged({ paths: [postPath] }); + + t.is(result.status, 'committed'); + t.deepEqual(barrierPaths, [[postPath]]); + const barrierIndex = project.events.indexOf('barrier'); + const firstFetchPathsIndex = project.events.indexOf('fetchPaths'); + t.true(barrierIndex >= 0); + t.true(firstFetchPathsIndex >= 0); + t.true( + barrierIndex < firstFetchPathsIndex, + 'barrier must complete before fetchPaths runs' + ); + } +); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index a8811041..adc4cc34 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,4 +1,8 @@ -import { GraphQLFieldConfigArgumentMap, GraphQLInputType } from 'graphql'; +import { + GraphQLFieldConfigArgumentMap, + GraphQLInputType, + GraphQLSchema, +} from 'graphql'; import { Maybe } from 'graphql/jsutils/Maybe'; import type { VFile } from 'vfile'; @@ -105,9 +109,53 @@ export interface ContentEntry< export interface Source { initialize?: (flatbreadConfig: LoadedFlatbreadConfig) => void; fetchByType?: (path: string) => Promise; + fetchPaths?: (paths: readonly string[]) => Promise; fetch: (allContentTypes: Content) => Promise>; } +export interface ContentGraphSnapshot { + readonly config: LoadedFlatbreadConfig; + readonly nodesByCollection: Readonly>; + readonly nodeByPath: ReadonlyMap; + readonly pathByCollectionAndId: ReadonlyMap; + readonly inboundReferences: ReadonlyMap>; + readonly outboundReferences: ReadonlyMap; +} + +export interface IndexedContentNode { + readonly collection: string; + readonly id: string; + readonly path: string; + readonly node: EntryNode; +} + +export interface ChangedPaths { + readonly paths: readonly string[]; + readonly source?: 'watcher' | 'writer'; +} + +export interface SchemaSnapshot { + readonly schema: GraphQLSchema; + readonly graph: ContentGraphSnapshot; + readonly generation: number; +} + +export type ReindexResult = + | { status: 'committed'; generation: number } + | { status: 'rejected'; generation: number; error: Error }; + +export interface ReindexBarrier { + waitUntilReadable(paths: readonly string[]): Promise; +} + +export interface LiveSchemaReloader { + readonly generation: number; + getSnapshot(): SchemaSnapshot; + notifyChanged(change: ChangedPaths): Promise; + replaceConfig(config: LoadedFlatbreadConfig): Promise; + waitForGeneration(minimumGeneration: number): Promise; +} + export type SourcePlugin< TConfig extends Record = Record > = (sourceConfig?: TConfig) => Source; diff --git a/packages/flatbread/package.json b/packages/flatbread/package.json index 2670d164..b19b0151 100644 --- a/packages/flatbread/package.json +++ b/packages/flatbread/package.json @@ -41,6 +41,7 @@ "@flatbread/transformer-markdown": "workspace:*", "@flatbread/transformer-yaml": "workspace:*", "@flatbread/utils": "workspace:*", + "@parcel/watcher": "^2.5.1", "apollo-server-core": "^3.13.0", "apollo-server-express": "^3.13.0", "cors": "^2.8.5", @@ -49,6 +50,7 @@ "gradient-string": "^2.0.2", "graphql": "16.5.0", "kleur": "^4.1.5", + "picomatch": "4.0.5", "remark-github": "^11.2.4", "sade": "^1.8.1", "serialize-javascript": "^6.0.2" @@ -58,6 +60,7 @@ "@types/express": "4.17.13", "@types/gradient-string": "1.1.2", "@types/node": "16.11.47", + "@types/picomatch": "4.0.3", "@types/sade": "1.7.4", "@types/serialize-javascript": "5.0.2", "tsup": "6.2.1", diff --git a/packages/flatbread/src/cli/index.ts b/packages/flatbread/src/cli/index.ts index 6090fad6..75879155 100644 --- a/packages/flatbread/src/cli/index.ts +++ b/packages/flatbread/src/cli/index.ts @@ -41,12 +41,13 @@ prog .option('--, _', 'Pass options to the corunning script') .option('-p, --port', 'Port to run the GraphQL server', 5057) .option('-H, --https', 'Use self-signed HTTPS certificate', false) + .option('-w, --watch', 'Hot-swap content and reload config', false) .option('-o, --open', 'Open the explorer in a browser tab', false) .option( '-X, --exec', 'The runner to execute the corunning script with. Defaults to your package manager (i.e. npm, pnpm, yarn)' ) - .action(async (corunner, { _, port, https, open, exec }) => { + .action(async (corunner, { _, port, https, watch, open, exec }) => { // Combine the corunning script & the options passed to it const secondaryScript = `${corunner} ${_.join(' ')}`; // Yeet it into the all seeing eye of the universe @@ -54,6 +55,7 @@ prog corunner: secondaryScript, flatbreadPort: port, https, + watch, packageManager: exec, }); // Say hi for good measure diff --git a/packages/flatbread/src/cli/runner.ts b/packages/flatbread/src/cli/runner.ts index fc3f1639..16092c31 100644 --- a/packages/flatbread/src/cli/runner.ts +++ b/packages/flatbread/src/cli/runner.ts @@ -12,6 +12,7 @@ export interface OrchestraOptions { corunner: string; flatbreadPort: number; https?: boolean; + watch?: boolean; packageManager: string | null; } @@ -26,6 +27,7 @@ export default function orchestrateProcesses({ corunner, flatbreadPort, https = false, + watch = false, packageManager = null, }: OrchestraOptions) { const pkgManager = packageManager || getRunCommand(process.cwd()); @@ -40,6 +42,7 @@ export default function orchestrateProcesses({ NODE_OPTIONS: '--experimental-vm-modules', FLATBREAD_PORT: String(flatbreadPort), FLATBREAD_HTTPS: String(https), + FLATBREAD_WATCH: watch ? '1' : '0', }, }); let runningScripts = [gql]; diff --git a/packages/flatbread/src/graphql/liveServer.test.ts b/packages/flatbread/src/graphql/liveServer.test.ts new file mode 100644 index 00000000..f6148c84 --- /dev/null +++ b/packages/flatbread/src/graphql/liveServer.test.ts @@ -0,0 +1,341 @@ +import test from 'ava'; +import { mkdtemp, rm, writeFile, mkdir, rename } from 'node:fs/promises'; +import { join, relative } from 'node:path'; +import filesystem from '@flatbread/source-filesystem'; +import markdownTransformer from '@flatbread/transformer-markdown'; +import { initializeConfig } from '@flatbread/core'; +import type { ConfigResult, LoadedFlatbreadConfig } from '@flatbread/core'; +import { startGraphqlServer } from './liveServer'; + +interface Fixture { + /** Absolute temp dir inside the repo (source-filesystem resolves content paths against cwd). */ + dir: string; + /** Content path relative to cwd, as a flatbread config would declare it. */ + postsPath: string; + postOne: string; + postTwo: string; + cleanup: () => Promise; +} + +const POST_ONE = (title: string) => `--- +id: post-1 +title: ${title} +--- + +Post one body. +`; + +const POST_TWO = (id = 'post-2') => `--- +id: ${id} +title: Second Post +--- + +Post two body. +`; + +async function makeFixture(): Promise { + // Keep the temp dir inside the repo: the filesystem source reads content + // paths relative to process.cwd(). + const dir = await mkdtemp(join(process.cwd(), '.tmp-live-server-test-')); + const postsDir = join(dir, 'posts'); + await mkdir(postsDir, { recursive: true }); + const postOne = join(postsDir, 'post-1.md'); + const postTwo = join(postsDir, 'post-2.md'); + await writeFile(postOne, POST_ONE('Original Title')); + await writeFile(postTwo, POST_TWO()); + return { + dir, + postsPath: join(relative(process.cwd(), dir), 'posts'), + postOne, + postTwo, + cleanup: () => rm(dir, { recursive: true, force: true }), + }; +} + +function makeConfig(fixture: Fixture): ConfigResult { + const config = initializeConfig({ + source: filesystem(), + transformer: markdownTransformer(), + content: [ + { + path: fixture.postsPath, + collection: 'LiveServerPost', + }, + ], + }); + return { config }; +} + +async function queryTitles(port: number): Promise { + const response = await fetch(`http://localhost:${port}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: `query { allLiveServerPosts { id title } }`, + }), + }); + const result = (await response.json()) as { + data?: { allLiveServerPosts: Array<{ id: string; title: string }> }; + errors?: Array<{ message: string }>; + }; + if (result.errors?.length) { + throw new Error(result.errors.map((error) => error.message).join('\n')); + } + return result + .data!.allLiveServerPosts.slice() + .sort((a, b) => a.id.localeCompare(b.id)) + .map((post) => post.title); +} + +test.serial( + 'watch:false keeps the startup schema until a fresh server is started', + async (t) => { + const fixture = await makeFixture(); + t.teardown(fixture.cleanup); + + const server = await startGraphqlServer({ + config: makeConfig(fixture), + port: 0, + watch: false, + }); + + try { + t.deepEqual(await queryTitles(server.port), [ + 'Original Title', + 'Second Post', + ]); + + await writeFile(fixture.postOne, POST_ONE('Edited Title')); + + // No watcher: the running endpoint still serves the startup snapshot. + t.deepEqual(await queryTitles(server.port), [ + 'Original Title', + 'Second Post', + ]); + t.is(server.reloader.generation, 0); + } finally { + await server.close(); + } + + const freshServer = await startGraphqlServer({ + config: makeConfig(fixture), + port: 0, + watch: false, + }); + try { + t.deepEqual(await queryTitles(freshServer.port), [ + 'Edited Title', + 'Second Post', + ]); + } finally { + await freshServer.close(); + } + } +); + +test.serial( + 'watch:true hot-swaps an edited fixture on the same port (real filesystem watcher)', + async (t) => { + const fixture = await makeFixture(); + t.teardown(fixture.cleanup); + + const server = await startGraphqlServer({ + config: makeConfig(fixture), + port: 0, + watch: true, + }); + + try { + t.deepEqual(await queryTitles(server.port), [ + 'Original Title', + 'Second Post', + ]); + + await writeFile(fixture.postOne, POST_ONE('Watched Title')); + + await Promise.race([ + server.reloader.waitForGeneration(1), + new Promise((_, reject) => + setTimeout( + () => reject(new Error('watcher did not commit within 20s')), + 20_000 + ) + ), + ]); + + t.deepEqual(await queryTitles(server.port), [ + 'Watched Title', + 'Second Post', + ]); + t.true(server.reloader.generation >= 1); + } finally { + await server.close(); + } + } +); + +test.serial( + 'notifyChanged hot-swaps an edited fixture on the same port', + async (t) => { + const fixture = await makeFixture(); + t.teardown(fixture.cleanup); + + const server = await startGraphqlServer({ + config: makeConfig(fixture), + port: 0, + watch: false, + }); + t.teardown(() => server.close()); + + t.deepEqual(await queryTitles(server.port), [ + 'Original Title', + 'Second Post', + ]); + + await writeFile(fixture.postOne, POST_ONE('Swapped Title')); + const result = await server.reloader.notifyChanged({ + paths: [fixture.postOne], + source: 'watcher', + }); + + t.deepEqual(result, { status: 'committed', generation: 1 }); + t.deepEqual(await queryTitles(server.port), [ + 'Swapped Title', + 'Second Post', + ]); + } +); + +test.serial( + 'invalid watched edit keeps the old response and generation', + async (t) => { + const fixture = await makeFixture(); + t.teardown(fixture.cleanup); + + const server = await startGraphqlServer({ + config: makeConfig(fixture), + port: 0, + watch: false, + }); + t.teardown(() => server.close()); + + t.deepEqual(await queryTitles(server.port), [ + 'Original Title', + 'Second Post', + ]); + + // Duplicate the first post's id: the candidate graph must be rejected. + await writeFile(fixture.postTwo, POST_TWO('post-1')); + const result = await server.reloader.notifyChanged({ + paths: [fixture.postTwo], + source: 'watcher', + }); + + t.is(result.status, 'rejected'); + if (result.status === 'rejected') { + t.regex(result.error.message, /duplicated/); + } + t.is(server.reloader.generation, 0); + t.deepEqual(await queryTitles(server.port), [ + 'Original Title', + 'Second Post', + ]); + } +); + +test.serial( + 'watched deletion of an unreferenced record updates the list result', + async (t) => { + const fixture = await makeFixture(); + t.teardown(fixture.cleanup); + + const server = await startGraphqlServer({ + config: makeConfig(fixture), + port: 0, + watch: false, + }); + t.teardown(() => server.close()); + + t.deepEqual(await queryTitles(server.port), [ + 'Original Title', + 'Second Post', + ]); + + await rm(fixture.postTwo); + const result = await server.reloader.notifyChanged({ + paths: [fixture.postTwo], + source: 'watcher', + }); + + t.deepEqual(result, { status: 'committed', generation: 1 }); + t.deepEqual(await queryTitles(server.port), ['Original Title']); + } +); + +test.serial( + 'rename coalesced into one batch keeps the record on the same endpoint', + async (t) => { + const fixture = await makeFixture(); + t.teardown(fixture.cleanup); + + const server = await startGraphqlServer({ + config: makeConfig(fixture), + port: 0, + watch: false, + }); + t.teardown(() => server.close()); + + const renamedPath = join(fixture.dir, 'posts', 'post-2-renamed.md'); + await rename(fixture.postTwo, renamedPath); + const result = await server.reloader.notifyChanged({ + paths: [fixture.postTwo, renamedPath], + source: 'watcher', + }); + + t.deepEqual(result, { status: 'committed', generation: 1 }); + t.deepEqual(await queryTitles(server.port), [ + 'Original Title', + 'Second Post', + ]); + } +); + +test.serial( + 'concurrent queries during a swap always see a complete old or new generation', + async (t) => { + const fixture = await makeFixture(); + t.teardown(fixture.cleanup); + + const server = await startGraphqlServer({ + config: makeConfig(fixture), + port: 0, + watch: false, + }); + t.teardown(() => server.close()); + + await writeFile(fixture.postOne, POST_ONE('After Swap Title')); + const pendingChange = server.reloader.notifyChanged({ + paths: [fixture.postOne], + source: 'watcher', + }); + + // Race queries against the in-progress swap. Every response must be a + // complete generation (old or new titles), never an error or a torn read. + const inFlight = Array.from({ length: 8 }, () => queryTitles(server.port)); + const responses = await Promise.all(inFlight); + for (const titles of responses) { + t.true( + (titles[0] === 'Original Title' || titles[0] === 'After Swap Title') && + titles[1] === 'Second Post', + `unexpected response during swap: ${JSON.stringify(titles)}` + ); + } + + const result = await pendingChange; + t.deepEqual(result, { status: 'committed', generation: 1 }); + t.deepEqual(await queryTitles(server.port), [ + 'After Swap Title', + 'Second Post', + ]); + } +); diff --git a/packages/flatbread/src/graphql/liveServer.ts b/packages/flatbread/src/graphql/liveServer.ts new file mode 100644 index 00000000..7e3b68ce --- /dev/null +++ b/packages/flatbread/src/graphql/liveServer.ts @@ -0,0 +1,271 @@ +import { subscribe } from '@parcel/watcher'; +import { + deriveFlatbreadWatchPatterns, + generateTypes, +} from '@flatbread/codegen'; +import type { + ConfigResult, + LoadedFlatbreadConfig, + LiveSchemaReloader, + SchemaSnapshot, +} from '@flatbread/core'; +import { createLiveSchemaReloader } from '@flatbread/core'; +import { ApolloServer } from '@apollo/server'; +import { expressMiddleware } from '@as-integrations/express5'; +import { InMemoryLRUCache } from '@apollo/utils.keyvaluecache'; +import cors from 'cors'; +import express, { type RequestHandler } from 'express'; +import http from 'http'; +import picomatch from 'picomatch'; +import { relative, resolve } from 'node:path'; +import { loadFlatbreadConfig } from '../utils/getSchema'; + +export interface GraphqlServerOptions { + port?: number; + config: ConfigResult; + watch?: boolean; + cwd?: string; +} +export interface RunningGraphqlServer { + readonly port: number; + readonly reloader: LiveSchemaReloader; + close(): Promise; +} + +export async function startGraphqlServer( + options: GraphqlServerOptions +): Promise { + const cwd = options.cwd ?? process.cwd(); + if (!options.config.config) throw new Error('Config is not defined'); + const config = options.config.config; + if (options.watch && !config.source.fetchPaths) { + throw new Error( + 'Flatbread watch mode requires the configured source to implement fetchPaths(paths).' + ); + } + const app = express(); + const httpServer = http.createServer(app); + let current: GenerationServer | undefined; + let currentMiddleware: RequestHandler = (_req, _res, next) => next(); + let closed = false; + app.use((req, res, next) => { + res.header('Access-Control-Allow-Private-Network', 'true'); + const origin = req.headers.origin || req.headers.referer || '*'; + res.header('Access-Control-Allow-Origin', origin); + res.header( + 'Access-Control-Allow-Methods', + 'GET, POST, PUT, DELETE, OPTIONS' + ); + res.header( + 'Access-Control-Allow-Headers', + 'Origin, X-Requested-With, Content-Type, Accept, Authorization, apollo-require-preflight' + ); + res.header('Access-Control-Allow-Credentials', 'true'); + if (req.method === 'OPTIONS') { + res.status(204).send(); + return; + } + next(); + }); + app.use( + cors({ + origin: true, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: [ + 'Origin', + 'X-Requested-With', + 'Content-Type', + 'Accept', + 'Authorization', + 'apollo-require-preflight', + ], + preflightContinue: false, + optionsSuccessStatus: 204, + }), + express.json() + ); + const reloader = await createLiveSchemaReloader({ + config, + commitSchema: async (candidate) => { + const next = await startGeneration(candidate.schema); + const old = current; + current = next; + currentMiddleware = next.middleware; + if (old) old.stopWhenDrained(); + }, + }); + app.use((req, res, next) => currentMiddleware(req, res, next)); + await new Promise((listenResolve) => + httpServer.listen({ port: options.port ?? 5050 }, listenResolve) + ); + const address = httpServer.address(); + const port = + typeof address === 'object' && address + ? address.port + : options.port ?? 5050; + let subscription: { unsubscribe(): Promise } | undefined; + let timer: ReturnType | undefined; + const pending = new Set(); + const pendingDeleted = new Set(); + let pendingConfig = false; + let pendingDocuments = false; + let matchers = compileMatchers(config); + const refreshCodegen = async ( + snapshot: SchemaSnapshot, + loadedConfig = snapshot.graph.config + ) => { + const cfg = loadedConfig.codegen; + // No codegen section in the config → nothing to refresh; avoids writing + // generated artifacts the user never asked for. + if (!cfg) return; + const result = await generateTypes(snapshot.schema, loadedConfig, { + ...cfg, + enabled: cfg.enabled ?? true, + documents: cfg.documents ?? [], + }); + if (!result.success) { + console.error('Flatbread codegen refresh failed:', result.error); + } + }; + if (options.watch) { + subscription = await subscribe( + cwd, + (error, events) => { + if (error) { + console.error('Flatbread watcher error:', error); + return; + } + for (const event of events) { + const path = resolve(event.path); + const kind = classifyPath(path, matchers, cwd); + if (kind === 'config') pendingConfig = true; + else if (kind === 'document') pendingDocuments = true; + else if (kind === 'content') { + if (event.type === 'delete') { + pendingDeleted.add(path); + pending.add(path); + } else if (!pendingDeleted.has(path)) { + pending.add(path); + } + } + } + if (!timer) { + timer = setTimeout(async () => { + timer = undefined; + const paths = [...pending]; + pending.clear(); + pendingDeleted.clear(); + if (pendingConfig) { + pendingConfig = false; + try { + const loaded = await loadFlatbreadConfig(cwd); + const result = await reloader.replaceConfig(loaded.config!); + if (result.status === 'committed') { + matchers = compileMatchers(loaded.config!); + await refreshCodegen(reloader.getSnapshot(), loaded.config); + } else console.error(result.error); + } catch (error) { + console.error('Flatbread config reload failed:', error); + } + } + if (paths.length > 0) { + const result = await reloader.notifyChanged({ + paths, + source: 'watcher', + }); + if (result.status === 'committed') { + await refreshCodegen(reloader.getSnapshot()); + } else console.error(result.error); + } + if (pendingDocuments) { + pendingDocuments = false; + await refreshCodegen(reloader.getSnapshot()); + } + }, 150); + } + }, + { ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**'] } + ); + } + return { + port, + reloader, + async close() { + if (closed) return; + closed = true; + if (timer) clearTimeout(timer); + await subscription?.unsubscribe(); + await current?.stop(); + await new Promise((closeResolve) => { + if (httpServer.listening) httpServer.close(() => closeResolve()); + else closeResolve(); + }); + }, + }; +} + +interface GenerationServer { + middleware: RequestHandler; + stopWhenDrained(): void; + stop(): Promise; +} +async function startGeneration( + schema: SchemaSnapshot['schema'] +): Promise { + const server = new ApolloServer({ + schema, + cache: new InMemoryLRUCache({ maxSize: Math.pow(2, 20) * 100 }), + }); + await server.start(); + let inFlight = 0; + let stopping = false; + let stopped = false; + const stop = async () => { + if (stopped) return; + stopped = true; + await server.stop(); + }; + const stopWhenDrained = () => { + stopping = true; + if (inFlight === 0) void stop(); + }; + const middleware = expressMiddleware(server); + const wrapped: RequestHandler = (req, res, next) => { + inFlight++; + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + inFlight--; + if (stopping && inFlight === 0) void stop(); + }; + res.once('finish', finish); + res.once('close', finish); + return middleware(req, res, next); + }; + return { middleware: wrapped, stopWhenDrained, stop }; +} +function compileMatchers(config: LoadedFlatbreadConfig) { + const patterns = deriveFlatbreadWatchPatterns(config, { + documents: config.codegen?.documents ?? [], + }); + return { + config: patterns.config.map((pattern) => picomatch(pattern)), + content: patterns.content.map((pattern) => picomatch(pattern)), + documents: patterns.documents.map((pattern) => picomatch(pattern)), + }; +} +function classifyPath( + path: string, + matchers: ReturnType, + cwd: string +): 'config' | 'content' | 'document' | undefined { + const relativePath = relative(cwd, path).split('\\').join('/'); + if (matchers.config.some((matcher) => matcher(relativePath))) return 'config'; + if (matchers.content.some((matcher) => matcher(relativePath))) + return 'content'; + if (matchers.documents.some((matcher) => matcher(relativePath))) + return 'document'; + return undefined; +} diff --git a/packages/flatbread/src/graphql/server.ts b/packages/flatbread/src/graphql/server.ts index b5195f0c..0c1c0d71 100644 --- a/packages/flatbread/src/graphql/server.ts +++ b/packages/flatbread/src/graphql/server.ts @@ -1,97 +1,18 @@ -import { ApolloServer } from '@apollo/server'; -import { expressMiddleware } from '@as-integrations/express5'; -import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer'; -import { InMemoryLRUCache } from '@apollo/utils.keyvaluecache'; -import express from 'express'; -import http from 'http'; -import cors from 'cors'; -import { generateSchema } from '@flatbread/core'; import { getConfig } from '../utils/getSchema'; -import { GraphQLSchema } from 'graphql'; +import { startGraphqlServer } from './liveServer'; const config = await getConfig(); -const schema = await generateSchema(config); +const running = await startGraphqlServer({ + config, + port: Number(process.env.FLATBREAD_PORT) || 5050, + watch: process.env.FLATBREAD_WATCH === '1', +}); -const port = Number(process.env.FLATBREAD_PORT) || 5050; +communicateReadiness(); -startApolloServer(schema, { port }); - -async function startApolloServer(schema: GraphQLSchema, { port = 5050 } = {}) { - const app = express(); - - const httpServer = http.createServer(app); - const server = new ApolloServer({ - schema, - // Prevents an unbounded cache from growing infinitely and causing memory issues. - cache: new InMemoryLRUCache({ - // ~100MiB - maxSize: Math.pow(2, 20) * 100, - }), - plugins: [ - // Apollo Server will drain your HTTP server when you call the stop() method (which is also called for you when the SIGTERM and SIGINT signals are received) - // @see https://www.apollographql.com/docs/apollo-server/api/plugin/drain-http-server/ - ApolloServerPluginDrainHttpServer({ httpServer }), - ], - }); - await server.start(); - - // Add headers for private network access and other CORS requirements BEFORE other middleware - app.use((req, res, next) => { - // Required for Apollo Studio and other public sites accessing localhost - res.header('Access-Control-Allow-Private-Network', 'true'); - // Set origin to the requesting origin (required when credentials are true) - const origin = req.headers.origin || req.headers.referer || '*'; - res.header('Access-Control-Allow-Origin', origin); - res.header( - 'Access-Control-Allow-Methods', - 'GET, POST, PUT, DELETE, OPTIONS' - ); - res.header( - 'Access-Control-Allow-Headers', - 'Origin, X-Requested-With, Content-Type, Accept, Authorization, apollo-require-preflight' - ); - res.header('Access-Control-Allow-Credentials', 'true'); - - // Handle preflight requests - if (req.method === 'OPTIONS') { - res.status(204).send(); - return; - } - - next(); - }); - - // Enable CORS for all routes - app.use( - cors({ - origin: true, // Allow all origins for development - credentials: true, - methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], - allowedHeaders: [ - 'Origin', - 'X-Requested-With', - 'Content-Type', - 'Accept', - 'Authorization', - 'apollo-require-preflight', - ], - preflightContinue: false, - optionsSuccessStatus: 204, - }), - express.json(), - expressMiddleware(server) - ); - - await new Promise((resolve) => httpServer.listen({ port }, resolve)); - - communicateReadiness(); -} +process.once('SIGINT', () => void running.close()); +process.once('SIGTERM', () => void running.close()); function communicateReadiness() { - /** - * If the process was spawned with an IPC channel, send a message to the parent process that the server is ready. - * This allows the parent process to wait for the server to be ready before continuing, like when you want the GraphQL server to be ready before starting the build process. - * @see https://nodejs.org/api/process.html#processsendmessage-sendhandle-options-callback - */ process.send && process.send('flatbread-gql-ready'); } diff --git a/packages/flatbread/src/utils/getSchema.ts b/packages/flatbread/src/utils/getSchema.ts index 4c760043..ee1c5bdf 100644 --- a/packages/flatbread/src/utils/getSchema.ts +++ b/packages/flatbread/src/utils/getSchema.ts @@ -10,12 +10,9 @@ import type { ConfigResult, LoadedFlatbreadConfig } from '../'; export async function getConfig(): Promise< ConfigResult > { - const { loadConfig } = await import('@flatbread/config'); - try { - return await loadConfig(); + return await loadFlatbreadConfig(); } catch (err) { - // Provide a helpful error message if the config file is not found console.error( colors.red('\nFlatbread was not supplied a valid') + colors.bold(' config') + @@ -25,3 +22,13 @@ export async function getConfig(): Promise< process.exit(1); } } + +export async function loadFlatbreadConfig( + cwd = process.cwd() +): Promise> { + const { loadConfig } = await import('@flatbread/config'); + const result = await loadConfig({ cwd }); + if (!result.config) throw new Error('Flatbread configuration was not found'); + const { initializeConfig } = await import('@flatbread/core'); + return { ...result, config: initializeConfig(result.config) }; +} diff --git a/packages/source-filesystem/src/index.ts b/packages/source-filesystem/src/index.ts index 461cf0d2..6ebbcfff 100644 --- a/packages/source-filesystem/src/index.ts +++ b/packages/source-filesystem/src/index.ts @@ -1,4 +1,5 @@ import { defaultsDeep } from 'lodash-es'; +import { resolve, extname } from 'node:path'; import { read } from 'to-vfile'; import type { LoadedFlatbreadConfig, SourcePlugin } from '@flatbread/core'; @@ -8,7 +9,7 @@ import type { InitializedSourceFilesystemConfig, sourceFilesystemConfig, } from './types'; -import gatherFileNodes from './utils/gatherFileNodes'; +import gatherFileNodes, { getCaptureData } from './utils/gatherFileNodes'; /** * Get nodes (files) from the directory @@ -62,6 +63,47 @@ async function getAllNodes( return nodes; } +async function getNodesFromPaths( + paths: readonly string[], + content: LoadedFlatbreadConfig['content'], + config: InitializedSourceFilesystemConfig +): Promise { + const extensions = config.extensions.map((extension) => + extension.startsWith('.') + ? extension.toLowerCase() + : `.${extension.toLowerCase()}` + ); + const files = await Promise.all( + paths + .filter((path) => extensions.includes(extname(path).toLowerCase())) + .map(async (path) => { + try { + const file = await read(path); + const entry = content.find((candidate) => { + if (!candidate.path) return false; + const patternParts = resolve(candidate.path) + .split('/') + .filter(Boolean); + const pathParts = resolve(path).split('/').filter(Boolean); + if (patternParts.length !== pathParts.length) return false; + return patternParts.every((part, index) => { + if (/^\[[^\]]+\].*$/.test(part) || part.includes('*')) + return true; + return part === pathParts[index]; + }); + }); + if (entry?.path) { + file.data = getCaptureData(resolve(path), resolve(entry.path)); + } + return file; + } catch { + return undefined; + } + }) + ); + return files.filter((file): file is VFile => Boolean(file)); +} + /** * Source filesystem plugin for fetching flat-file content nodes from directories on disk. * @@ -70,15 +112,19 @@ async function getAllNodes( */ export const source: SourcePlugin = (sourceConfig?: sourceFilesystemConfig) => { let config: InitializedSourceFilesystemConfig; + let content: LoadedFlatbreadConfig['content'] = []; return { initialize: (flatbreadConfig: LoadedFlatbreadConfig) => { const { extensions } = flatbreadConfig.loaded; config = defaultsDeep(sourceConfig ?? {}, { extensions }); + content = flatbreadConfig.content; }, fetchByType: (path: string) => getNodesFromDirectory(path, config), fetch: (allContentTypes: Record[]) => getAllNodes(allContentTypes, config), + fetchPaths: (paths: readonly string[]) => + getNodesFromPaths(paths, content, config), }; }; diff --git a/packages/source-filesystem/src/utils/gatherFileNodes.ts b/packages/source-filesystem/src/utils/gatherFileNodes.ts index 8f880636..f7c90092 100644 --- a/packages/source-filesystem/src/utils/gatherFileNodes.ts +++ b/packages/source-filesystem/src/utils/gatherFileNodes.ts @@ -18,6 +18,28 @@ function getSegmentData(node: { name: string }, segment: { remove: number }) { return node.name.slice(0, node.name.length - segment.remove); } +export function getCaptureData( + path: string, + pattern: string +): Record { + const patternParts = pattern.split('/').filter(Boolean); + const pathParts = path.split('/').filter(Boolean); + if (patternParts.length !== pathParts.length) return {}; + const data: Record = {}; + for (const [index, part] of patternParts.entries()) { + const value = pathParts[index]; + const capture = /^\[([^\]]+)\](.*)$/.exec(part); + if (capture) { + const suffix = capture[2]; + if (suffix && !value.endsWith(suffix)) return {}; + data[capture[1]] = suffix ? value.slice(0, -suffix.length) : value; + } else if (part !== value && !part.includes('*')) { + return {}; + } + } + return data; +} + function processFile(segment: Segment, node: FileNode) { return (file: FileNode) => { if (!segment) return file; diff --git a/packages/source-filesystem/src/utils/tests/fetchPaths.test.ts b/packages/source-filesystem/src/utils/tests/fetchPaths.test.ts new file mode 100644 index 00000000..95c0d3ce --- /dev/null +++ b/packages/source-filesystem/src/utils/tests/fetchPaths.test.ts @@ -0,0 +1,87 @@ +import test from 'ava'; +import { resolve } from 'node:path'; +import type { LoadedFlatbreadConfig } from '@flatbread/core'; +import source from '../../index'; + +const CAPTURE_PATTERN = + 'packages/source-filesystem/src/utils/tests/fixtures/captures/[category]/[slug].md'; + +const HELLO_PATH = + 'packages/source-filesystem/src/utils/tests/fixtures/captures/news/hello.md'; +const WORLD_PATH = + 'packages/source-filesystem/src/utils/tests/fixtures/captures/tech/world.md'; +const TXT_PATH = + 'packages/source-filesystem/src/utils/tests/fixtures/captures/tech/notes.txt'; +const MISSING_PATH = + 'packages/source-filesystem/src/utils/tests/fixtures/captures/news/missing.md'; + +function initializedSource() { + const plugin = source(); + plugin.initialize?.({ + content: [{ path: CAPTURE_PATTERN, collection: 'CaptureDoc' }], + loaded: { extensions: ['.md'] }, + } as unknown as LoadedFlatbreadConfig); + return plugin; +} + +test('fetchPaths reads only the requested existing paths', async (t) => { + const plugin = initializedSource(); + + const files = await plugin.fetchPaths!([HELLO_PATH, WORLD_PATH]); + + t.is(files.length, 2); + t.deepEqual( + files.map((file) => resolve(file.path)).sort(), + [resolve(HELLO_PATH), resolve(WORLD_PATH)].sort() + ); +}); + +test('fetchPaths silently omits missing paths', async (t) => { + const plugin = initializedSource(); + + const files = await plugin.fetchPaths!([HELLO_PATH, MISSING_PATH]); + + t.is(files.length, 1); + t.is(resolve(files[0].path), resolve(HELLO_PATH)); +}); + +test('fetchPaths filters unsupported extensions', async (t) => { + const plugin = initializedSource(); + + const files = await plugin.fetchPaths!([TXT_PATH, WORLD_PATH]); + + t.is(files.length, 1); + t.is(resolve(files[0].path), resolve(WORLD_PATH)); +}); + +test('fetchPaths produces the same capture metadata as fetch for a [category]/[slug].md pattern', async (t) => { + const plugin = initializedSource(); + + const fetched = await plugin.fetch([ + { path: CAPTURE_PATTERN, collection: 'CaptureDoc' }, + ]); + const fetchedFiles = fetched.CaptureDoc; + t.true(fetchedFiles.length >= 2); + + const byPath = new Map( + fetchedFiles.map((file) => [resolve(file.path), file]) + ); + const fetchedHello = byPath.get(resolve(HELLO_PATH)); + const fetchedWorld = byPath.get(resolve(WORLD_PATH)); + t.truthy(fetchedHello); + t.truthy(fetchedWorld); + t.deepEqual(fetchedHello!.data, { category: 'news', slug: 'hello' }); + t.deepEqual(fetchedWorld!.data, { category: 'tech', slug: 'world' }); + + const pathFiles = await plugin.fetchPaths!([HELLO_PATH, WORLD_PATH]); + const pathByPath = new Map( + pathFiles.map((file) => [resolve(file.path), file]) + ); + + t.deepEqual( + pathByPath.get(resolve(HELLO_PATH))!.data, + fetchedHello!.data, + 'fetchPaths must assign identical capture data to fetch' + ); + t.deepEqual(pathByPath.get(resolve(WORLD_PATH))!.data, fetchedWorld!.data); +}); diff --git a/packages/source-filesystem/src/utils/tests/fixtures/captures/news/hello.md b/packages/source-filesystem/src/utils/tests/fixtures/captures/news/hello.md new file mode 100644 index 00000000..f8d16ce8 --- /dev/null +++ b/packages/source-filesystem/src/utils/tests/fixtures/captures/news/hello.md @@ -0,0 +1,6 @@ +--- +id: hello +title: Hello +--- + +Hello body. diff --git a/packages/source-filesystem/src/utils/tests/fixtures/captures/tech/notes.txt b/packages/source-filesystem/src/utils/tests/fixtures/captures/tech/notes.txt new file mode 100644 index 00000000..1bca6df9 --- /dev/null +++ b/packages/source-filesystem/src/utils/tests/fixtures/captures/tech/notes.txt @@ -0,0 +1 @@ +Not markdown; should be filtered by extension. diff --git a/packages/source-filesystem/src/utils/tests/fixtures/captures/tech/world.md b/packages/source-filesystem/src/utils/tests/fixtures/captures/tech/world.md new file mode 100644 index 00000000..acf861bc --- /dev/null +++ b/packages/source-filesystem/src/utils/tests/fixtures/captures/tech/world.md @@ -0,0 +1,6 @@ +--- +id: world +title: World +--- + +World body. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3cfc9d9..67458dea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -163,7 +163,7 @@ importers: version: 9.6.1 autoprefixer: specifier: ^10.4.21 - version: 10.4.21(postcss@8.5.14) + version: 10.4.21(postcss@8.5.6) eslint: specifier: ^9.33.0 version: 9.33.0(jiti@2.5.1) @@ -193,7 +193,7 @@ importers: version: 5.38.0 svelte-check: specifier: ^4.3.1 - version: 4.3.1(picomatch@4.0.4)(svelte@5.38.0)(typescript@5.9.2) + version: 4.3.1(picomatch@4.0.5)(svelte@5.38.0)(typescript@5.9.2) tailwindcss: specifier: ^3.4.17 version: 3.4.17(ts-node@10.9.2(@swc/core@1.13.3)(@types/node@25.6.2)(typescript@5.9.2)) @@ -359,6 +359,9 @@ importers: '@flatbread/utils': specifier: workspace:* version: link:../utils + '@parcel/watcher': + specifier: ^2.5.1 + version: 2.5.1 apollo-server-core: specifier: ^3.13.0 version: 3.13.0(encoding@0.1.13)(graphql@16.5.0) @@ -383,6 +386,9 @@ importers: kleur: specifier: ^4.1.5 version: 4.1.5 + picomatch: + specifier: 4.0.5 + version: 4.0.5 remark-github: specifier: ^11.2.4 version: 11.2.4 @@ -405,6 +411,9 @@ importers: '@types/node': specifier: 16.11.47 version: 16.11.47 + '@types/picomatch': + specifier: 4.0.3 + version: 4.0.3 '@types/sade': specifier: 1.7.4 version: 1.7.4 @@ -2082,92 +2091,78 @@ packages: resolution: {integrity: sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.0': resolution: {integrity: sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.0': resolution: {integrity: sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.0': resolution: {integrity: sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.0': resolution: {integrity: sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.0': resolution: {integrity: sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.0': resolution: {integrity: sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.3': resolution: {integrity: sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.3': resolution: {integrity: sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.3': resolution: {integrity: sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.3': resolution: {integrity: sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.3': resolution: {integrity: sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.3': resolution: {integrity: sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.3': resolution: {integrity: sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.3': resolution: {integrity: sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==} @@ -2320,28 +2315,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@15.4.4': resolution: {integrity: sha512-LsGUCTvuZ0690fFWerA4lnQvjkYg9gHo12A3wiPUR4kCxbx/d+SlwmonuTH2SWZI+RVGA9VL3N0S03WTYv6bYg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@15.4.4': resolution: {integrity: sha512-aOy5yNRpLL3wNiJVkFYl6w22hdREERNjvegE6vvtix8LHRdsTHhWTpgvcYdCK7AIDCQW5ATmzr9XkPHvSoAnvg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@15.4.4': resolution: {integrity: sha512-FL7OAn4UkR8hKQRGBmlHiHinzOb07tsfARdGh7v0Z0jEJ3sz8/7L5bR23ble9E6DZMabSStqlATHlSxv1fuzAg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@15.4.4': resolution: {integrity: sha512-eEdNW/TXwjYhOulQh0pffTMMItWVwKCQpbziSBmgBNFZIIRn2GTXrhrewevs8wP8KXWYMx8Z+mNU0X+AfvtrRg==} @@ -2442,42 +2433,36 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.1': resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.1': resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.1': resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.1': resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.1': resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [musl] '@parcel/watcher-win32-arm64@2.5.1': resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} @@ -2585,42 +2570,36 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.18': resolution: {integrity: sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18': resolution: {integrity: sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18': resolution: {integrity: sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.18': resolution: {integrity: sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.18': resolution: {integrity: sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.18': resolution: {integrity: sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==} @@ -2694,67 +2673,56 @@ packages: resolution: {integrity: sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.46.2': resolution: {integrity: sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.46.2': resolution: {integrity: sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.46.2': resolution: {integrity: sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.46.2': resolution: {integrity: sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.46.2': resolution: {integrity: sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.46.2': resolution: {integrity: sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.46.2': resolution: {integrity: sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.46.2': resolution: {integrity: sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.46.2': resolution: {integrity: sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.46.2': resolution: {integrity: sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.46.2': resolution: {integrity: sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==} @@ -2860,28 +2828,24 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] - libc: [glibc] '@swc/core-linux-arm64-musl@1.13.3': resolution: {integrity: sha512-bc+CXYlFc1t8pv9yZJGus372ldzOVscBl7encUBlU1m/Sig0+NDJLz6cXXRcFyl6ABNOApWeR4Yl7iUWx6C8og==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - libc: [musl] '@swc/core-linux-x64-gnu@1.13.3': resolution: {integrity: sha512-dFXoa0TEhohrKcxn/54YKs1iwNeW6tUkHJgXW33H381SvjKFUV53WR231jh1sWVJETjA3vsAwxKwR23s7UCmUA==} engines: {node: '>=10'} cpu: [x64] os: [linux] - libc: [glibc] '@swc/core-linux-x64-musl@1.13.3': resolution: {integrity: sha512-ieyjisLB+ldexiE/yD8uomaZuZIbTc8tjquYln9Quh5ykOBY7LpJJYBWvWtm1g3pHv6AXlBI8Jay7Fffb6aLfA==} engines: {node: '>=10'} cpu: [x64] os: [linux] - libc: [musl] '@swc/core-win32-arm64-msvc@1.13.3': resolution: {integrity: sha512-elTQpnaX5vESSbhCEgcwXjpMsnUbqqHfEpB7ewpkAsLzKEXZaK67ihSRYAuAx6ewRQTo7DS5iTT6X5aQD3MzMw==} @@ -2957,28 +2921,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.11': resolution: {integrity: sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.11': resolution: {integrity: sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.11': resolution: {integrity: sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.11': resolution: {integrity: sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g==} @@ -3187,6 +3147,9 @@ packages: '@types/parse5@6.0.3': resolution: {integrity: sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==} + '@types/picomatch@4.0.3': + resolution: {integrity: sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==} + '@types/prettier@2.7.3': resolution: {integrity: sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==} @@ -3341,49 +3304,41 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -6405,56 +6360,48 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-gnu@1.32.0: resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.30.1: resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.30.1: resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.30.1: resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.30.1: resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} @@ -7330,14 +7277,14 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} - engines: {node: '>=12'} - picomatch@4.0.4: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + pify@2.3.0: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} @@ -11184,7 +11131,6 @@ snapshots: '@parcel/watcher-win32-arm64': 2.5.1 '@parcel/watcher-win32-ia32': 2.5.1 '@parcel/watcher-win32-x64': 2.5.1 - optional: true '@phenomnomnominal/tsquery@4.1.1(typescript@4.7.4)': dependencies: @@ -11768,6 +11714,8 @@ snapshots: '@types/parse5@6.0.3': {} + '@types/picomatch@4.0.3': {} + '@types/prettier@2.7.3': {} '@types/qs@6.14.0': {} @@ -12357,14 +12305,14 @@ snapshots: auto-bind@4.0.0: {} - autoprefixer@10.4.21(postcss@8.5.14): + autoprefixer@10.4.21(postcss@8.5.6): dependencies: browserslist: 4.25.2 caniuse-lite: 1.0.30001733 fraction.js: 4.3.7 normalize-range: 0.1.2 picocolors: 1.1.1 - postcss: 8.5.14 + postcss: 8.5.6 postcss-value-parser: 4.2.0 ava@4.3.1(@ava/typescript@3.0.1): @@ -13156,8 +13104,7 @@ snapshots: detect-indent@6.1.0: {} - detect-libc@1.0.3: - optional: true + detect-libc@1.0.3: {} detect-libc@2.0.4: {} @@ -14242,6 +14189,10 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fdir@6.4.6(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -16718,10 +16669,10 @@ snapshots: picomatch@2.3.1: {} - picomatch@4.0.3: {} - picomatch@4.0.4: {} + picomatch@4.0.5: {} + pify@2.3.0: {} pirates@4.0.7: {} @@ -17805,11 +17756,11 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svelte-check@4.3.1(picomatch@4.0.4)(svelte@5.38.0)(typescript@5.9.2): + svelte-check@4.3.1(picomatch@4.0.5)(svelte@5.38.0)(typescript@5.9.2): dependencies: '@jridgewell/trace-mapping': 0.3.29 chokidar: 4.0.3 - fdir: 6.4.6(picomatch@4.0.4) + fdir: 6.4.6(picomatch@4.0.5) picocolors: 1.1.1 sade: 1.8.1 svelte: 5.38.0 @@ -18644,7 +18595,7 @@ snapshots: magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.4 std-env: 4.1.0 tinybench: 2.9.0 tinyexec: 1.1.2