UNPKG

polen

Version:

A framework for delightful GraphQL developer portals

836 lines 36.4 kB
/** * Tree-sitter GraphQL parsing with semantic analysis * * This module combines tree-sitter syntax parsing with GraphQL semantic * analysis to create unified tokens for interactive code blocks. */ import { getNamedType, GraphQLEnumType, GraphQLInputObjectType, GraphQLInterfaceType, GraphQLObjectType, GraphQLScalarType, GraphQLUnionType, isInterfaceType, isObjectType, } from 'graphql'; import graphqlWasmUrl from 'tree-sitter-graphql-grammar-wasm/grammar.wasm?url'; import * as WebTreeSitter from 'web-tree-sitter'; import treeSitterWasmUrl from 'web-tree-sitter/tree-sitter.wasm?url'; import { isKeywordNodeType, isLiteralNodeType, isPunctuationNodeType, } from './graphql-node-types.js'; import { isArgument, isFragment, isInputField, isInvalidField, isOperation, isOutputField, isVariable, } from './semantic-nodes.js'; /** * Implementation of unified token */ class UnifiedToken { treeSitterNode; semantic; polen; highlighter; codeHike; // Cache these values to avoid WASM access issues _text; _start; _end; _nodeType; constructor(treeSitterNode, semantic, annotations) { this.treeSitterNode = treeSitterNode; this.semantic = semantic; // Cache the values immediately to avoid WASM access issues later // This works for both real WebTreeSitter nodes and synthetic nodes this._text = treeSitterNode.text; this._start = treeSitterNode.startIndex; this._end = treeSitterNode.endIndex; this._nodeType = treeSitterNode.type; this.codeHike = { annotations }; // Polen namespace this.polen = { isInteractive: () => this._isInteractive(), getReferenceUrl: () => this._getReferenceUrl(), }; // Highlighter namespace this.highlighter = { getCssClass: () => this._getCssClass(), }; } get text() { return this._text; } get start() { return this._start; } get end() { return this._end; } _getCssClass() { const nodeType = this._nodeType; // Development-only validation if (process.env['NODE_ENV'] === 'development') { // Validate that the node type is actually a valid TreeSitterGraphQLNodeType const validTypes = new Set([ // Add a few common types for validation 'document', 'name', 'field', 'argument', 'variable', 'comment', 'error_hint', 'whitespace', 'string_value', 'int_value', 'float_value', 'query', 'mutation', 'subscription', ]); if (!validTypes.has(nodeType) && !nodeType.match(/^[a-z_]+$/)) { console.warn(`Unknown tree-sitter node type: "${nodeType}". Consider adding to TreeSitterGraphQLNodeType.`); } } // Error hints if (nodeType === 'error_hint') { return 'graphql-error-hint'; } // Comments if (nodeType === 'comment' || nodeType === 'description') { return 'graphql-comment'; } // Keywords if (isKeywordNodeType(nodeType)) { return 'graphql-keyword'; } // Literals if (nodeType === 'string_value') return 'graphql-string'; if (nodeType === 'int_value' || nodeType === 'float_value') return 'graphql-number'; // Punctuation if (isPunctuationNodeType(nodeType)) { return 'graphql-punctuation'; } // Names - use semantic info for better classification if (nodeType === 'name') { // Check if this is an invalid field (has invalidField semantic) if (this.semantic && 'kind' in this.semantic && this.semantic.kind === 'InvalidField') { return 'graphql-field-error'; } if (isOutputField(this.semantic) || isInputField(this.semantic)) { return 'graphql-field-interactive'; } if (this.semantic instanceof GraphQLObjectType || this.semantic instanceof GraphQLScalarType || this.semantic instanceof GraphQLInterfaceType) { return 'graphql-type-interactive'; } if (isVariable(this.semantic)) { return 'graphql-variable'; } if (isOperation(this.semantic)) { return 'graphql-operation'; } if (isFragment(this.semantic)) { return 'graphql-fragment'; } if (isArgument(this.semantic)) { return 'graphql-argument'; } } // Variables if (nodeType === 'variable') return 'graphql-variable'; return 'graphql-text'; } _isInteractive() { if (!this.semantic) return false; // Fields, type references, arguments, and invalid fields are interactive return isOutputField(this.semantic) || isInputField(this.semantic) || isArgument(this.semantic) || isInvalidField(this.semantic) // Invalid fields should show error popovers || this.semantic instanceof GraphQLObjectType || this.semantic instanceof GraphQLScalarType || this.semantic instanceof GraphQLInterfaceType || this.semantic instanceof GraphQLUnionType || this.semantic instanceof GraphQLEnumType || this.semantic instanceof GraphQLInputObjectType; } _getReferenceUrl() { if (!this.semantic) return null; // Arguments - use #<field>__<argument> pattern if (isArgument(this.semantic)) { return `/reference/${this.semantic.parentType.name}#${this.semantic.parentField.name}__${this.semantic.argumentDef.name}`; } // Output fields - use hash links since field routes aren't connected yet if (isOutputField(this.semantic)) { return `/reference/${this.semantic.parentType.name}#${this.semantic.fieldDef.name}`; } // Input fields - use hash links since field routes aren't connected yet if (isInputField(this.semantic)) { return `/reference/${this.semantic.parentType.name}#${this.semantic.fieldDef.name}`; } // Type references - use :type pattern if (this.semantic instanceof GraphQLObjectType) { return `/reference/${this.semantic.name}`; } if (this.semantic instanceof GraphQLScalarType) { return `/reference/${this.semantic.name}`; } if (this.semantic instanceof GraphQLInterfaceType) { return `/reference/${this.semantic.name}`; } if (this.semantic instanceof GraphQLUnionType) { return `/reference/${this.semantic.name}`; } if (this.semantic instanceof GraphQLEnumType) { return `/reference/${this.semantic.name}`; } if (this.semantic instanceof GraphQLInputObjectType) { return `/reference/${this.semantic.name}`; } return null; } } // Cache for the parser instance let parserPromise = null; /** * Minimal synthetic node that implements just enough of the WebTreeSitter.Node interface * Uses TreeSitterGraphQLNodeType for type safety * * IMPORTANT: This must be a class with getters to match WebTreeSitter.Node's WASM interface. * Plain objects with properties will cause "memory access out of bounds" errors when * tree-sitter tries to call the WASM getter functions. */ class SyntheticNode { type; _text; _startIndex; _endIndex; constructor(type, _text, _startIndex, _endIndex) { this.type = type; this._text = _text; this._startIndex = _startIndex; this._endIndex = _endIndex; } // These getters match WebTreeSitter.Node's interface get text() { return this._text; } get startIndex() { return this._startIndex; } get endIndex() { return this._endIndex; } get childCount() { return 0; } get parent() { return null; } } /** * Tracks semantic context while walking the tree-sitter AST * * This class maintains the current GraphQL execution context as we traverse * the syntax tree, allowing us to resolve field references to their schema * definitions and validate field access. * * ## Context Management Strategy * * The semantic context uses a stack-based approach to track the current type context * as we traverse nested GraphQL selections. This is essential for resolving field * references since field names are only meaningful within their parent type context. * * ### Type Stack Management: * - Each stack entry contains: { type: GraphQLType, field?: GraphQLField } * - The `field` property tracks the field that led us to this type level * - Stack depth corresponds to GraphQL selection nesting depth * - Root level: operation root type (Query/Mutation/Subscription) * - Nested levels: field return types that support sub-selections * * ### Context Transitions: * - `enterOperation()`: Sets root type based on operation type * - `enterField()`: Pushes field's return type if it's selectable (Object/Interface) * - `exitField()`: Pops from stack when leaving a field's selection set * - `enterFragment()`: Switches context to fragment's target type * * ### Argument Resolution Challenge: * Arguments appear in the AST before their parent field context is established, * requiring the complex lookup logic documented in the argument parsing section. * * @example * ```typescript * const context = new SemanticContext(schema) * context.enterOperation('query') // Stack: [Query] * context.enterField('user') // Stack: [Query, User] * const fieldInfo = context.getFieldInfo('name') // Gets User.name field * context.exitField() // Stack: [Query] * ``` */ class SemanticContext { /** * Stack of type contexts representing the current selection path. * Each entry tracks the type we're currently selecting from and optionally * the field that brought us to this type level. */ typeStack = []; /** Current operation type, set when entering an operation definition */ operationType = null; /** GraphQL schema used for type lookups and validation */ schema; constructor(schema) { this.schema = schema; } enterOperation(type) { this.operationType = type; const rootType = type === 'query' ? this.schema.getQueryType() : type === 'mutation' ? this.schema.getMutationType() : type === 'subscription' ? this.schema.getSubscriptionType() : null; if (rootType) { this.typeStack = [{ type: rootType }]; } } enterFragment(typeName) { const type = this.schema.getType(typeName); if (type && (isObjectType(type) || isInterfaceType(type))) { this.typeStack = [{ type }]; } } getFieldInfo(fieldName) { const current = this.typeStack[this.typeStack.length - 1]; if (!current) return null; const fields = current.type.getFields(); const fieldDef = fields[fieldName]; if (fieldDef) { return { parentType: current.type, fieldDef }; } return null; } enterField(fieldName) { const fieldInfo = this.getFieldInfo(fieldName); if (fieldInfo) { // Only push to stack if field type is object/interface const fieldType = getNamedType(fieldInfo.fieldDef.type); if (isObjectType(fieldType) || isInterfaceType(fieldType)) { // Push new context with the field that brought us here this.typeStack.push({ type: fieldType, field: fieldInfo.fieldDef }); } } } exitField() { // Only pop if we're not at root and the last entry has a field // (meaning it was pushed by enterField for an object/interface type) if (this.typeStack.length > 1) { const last = this.typeStack[this.typeStack.length - 1]; if (last && last.field) { this.typeStack.pop(); } } } getArgumentInfo(argName) { const current = this.typeStack[this.typeStack.length - 1]; if (!current?.field) return null; const arg = current.field.args.find(a => a.name === argName); return arg ? { field: current.field, arg, parentType: current.type } : null; } getCurrentType() { const current = this.typeStack[this.typeStack.length - 1]; return current?.type || null; } lookupType(typeName) { return this.schema.getType(typeName); } reset() { this.typeStack = []; this.operationType = null; } } /** * Parse GraphQL code into interactive tokens with semantic information * * @param code - The raw GraphQL code to parse * @param annotations - CodeHike annotations that might affect rendering * @param schema - Optional GraphQL schema for semantic analysis * @returns Array of tokens representing the parsed code */ export async function parseGraphQLWithTreeSitter(code, annotations = [], schema) { // Validate input if (!code || typeof code !== 'string') { throw new Error('Invalid GraphQL code: code must be a non-empty string'); } // Prevent parsing extremely large documents that could cause performance issues if (code.length > 100_000) { throw new Error('GraphQL document too large: maximum 100,000 characters allowed'); } // Step 1: Parse with tree-sitter const parser = await getParser(); const tree = parser.parse(code); if (!tree) { throw new Error('Tree-sitter failed to parse GraphQL code'); } // Check if tree-sitter found syntax errors (disabled for now as it may be too strict) // if (tree.rootNode.hasError) { // throw new Error('GraphQL syntax error detected by tree-sitter parser') // } try { // Step 2: Walk tree and attach semantics const tokens = collectTokensWithSemantics(tree, code, schema, annotations); // Step 3: Add error hint tokens after invalid fields const tokensWithHints = addErrorHintTokens(tokens, code, annotations); return tokensWithHints; } finally { // ## Tree-sitter Resource Lifecycle Management // // Tree-sitter creates native WASM objects that must be explicitly freed to prevent memory leaks. // The tree object holds references to parsed nodes and internal parser state that won't be // garbage collected automatically by JavaScript. // // Critical cleanup points: // 1. Always call tree.delete() in a finally block to ensure cleanup even on errors // 2. Do not access tree or any of its nodes after calling delete() // 3. The parser instance is cached globally and reused across multiple parsing calls // // Memory safety: Once tree.delete() is called, all WebTreeSitter.Node references become invalid. // Our tokens hold references to these nodes, but only use their text and position properties // which are copied during token creation, so the nodes can be safely deleted. tree.delete(); } } /** * Get or create the tree-sitter parser instance */ async function getParser() { if (!parserPromise) { parserPromise = initializeTreeSitter(); } return parserPromise; } /** * Initialize tree-sitter with the GraphQL grammar */ async function initializeTreeSitter() { try { // Handle different environments const isNode = typeof process !== 'undefined' && process.versions && process.versions.node; if (isNode) { // Node.js environment (tests) const fs = await import('node:fs/promises'); const path = await import('node:path'); // Find the actual WASM files in node_modules const treeSitterWasmPath = path.join(process.cwd(), 'node_modules/web-tree-sitter/tree-sitter.wasm'); const graphqlWasmPath = path.join(process.cwd(), 'node_modules/tree-sitter-graphql-grammar-wasm/grammar.wasm'); await WebTreeSitter.Parser.init({ locateFile: (filename) => { if (filename === 'tree-sitter.wasm') { return treeSitterWasmPath; } return filename; }, }); const parser = new WebTreeSitter.Parser(); const wasmBuffer = await fs.readFile(graphqlWasmPath); const GraphQL = await WebTreeSitter.Language.load(new Uint8Array(wasmBuffer)); parser.setLanguage(GraphQL); return parser; } else { // Browser/Vite environment await WebTreeSitter.Parser.init({ locateFile: (filename) => { if (filename === 'tree-sitter.wasm') { return treeSitterWasmUrl; } return filename; }, }); const parser = new WebTreeSitter.Parser(); // Fetch the WASM file as a buffer const response = await fetch(graphqlWasmUrl); if (!response.ok) { throw new Error(`Failed to load GraphQL grammar file: ${response.status} ${response.statusText}. ` + `This may indicate a network issue or missing grammar file.`); } const wasmBuffer = await response.arrayBuffer(); if (wasmBuffer.byteLength === 0) { throw new Error('GraphQL grammar file is empty or corrupted'); } const GraphQL = await WebTreeSitter.Language.load(new Uint8Array(wasmBuffer)); parser.setLanguage(GraphQL); return parser; } } catch (error) { // Enhance error messages for common issues if (error instanceof Error) { if (error.message.includes('fetch')) { throw new Error(`Tree-sitter initialization failed: ${error.message}. Check your network connection.`); } if (error.message.includes('Language.load')) { throw new Error(`Failed to load GraphQL grammar: ${error.message}. The grammar file may be corrupted.`); } } throw error; } } /** * Add error hint tokens after invalid fields */ function addErrorHintTokens(tokens, code, annotations) { const tokensWithHints = []; const processedIndices = new Set(); // Count invalid fields for debugging let invalidFieldCount = 0; tokens.forEach(t => { if (t.semantic && 'kind' in t.semantic && t.semantic.kind === 'InvalidField') { invalidFieldCount++; } }); if (invalidFieldCount > 10) { // Too many invalid fields - likely a schema mismatch // Return tokens without error hints to avoid corrupting display console.warn(`Polen: ${invalidFieldCount} invalid fields detected. Schema may not match queries.`); return tokens; } for (let i = 0; i < tokens.length; i++) { const token = tokens[i]; // Skip if we've already processed this token (due to lookahead for arguments) if (processedIndices.has(i)) { continue; } tokensWithHints.push(token); processedIndices.add(i); // Check if this is an invalid field if (token.semantic && 'kind' in token.semantic && token.semantic.kind === 'InvalidField') { // Look ahead to find where the field ends (after arguments if present) let fieldEndIndex = i; let j = i + 1; // Skip whitespace while (j < tokens.length && tokens[j].treeSitterNode.type === 'whitespace') { j++; } // Check if we have arguments starting with '(' if (j < tokens.length && tokens[j].text === '(') { // Find the matching closing ')' let parenDepth = 1; j++; // move past the opening '(' while (j < tokens.length && parenDepth > 0) { const t = tokens[j]; if (t.text === '(') parenDepth++; else if (t.text === ')') parenDepth--; j++; } // j is now past the closing ')' fieldEndIndex = j - 1; // Add all tokens that are part of the field's arguments for (let k = i + 1; k <= fieldEndIndex; k++) { if (k < tokens.length && !processedIndices.has(k)) { tokensWithHints.push(tokens[k]); processedIndices.add(k); } } } // Now add the error hint after the complete field (including arguments) const lastFieldToken = tokens[fieldEndIndex] || token; const hintText = ' ← No such field'; const hintToken = new UnifiedToken(createSyntheticNode('error_hint', hintText, lastFieldToken.end, lastFieldToken.end + hintText.length), undefined, annotations); tokensWithHints.push(hintToken); } } return tokensWithHints; } /** * Walk tree-sitter AST and collect tokens with semantic information */ function collectTokensWithSemantics(tree, code, schema, annotations) { const tokens = []; const cursor = tree.walk(); const context = schema ? new SemanticContext(schema) : null; let lastEnd = 0; function processNode() { const node = cursor.currentNode; if (!node) return; // Handle different node types for semantic context if (context) { if (node.type === 'operation_definition') { // Find the operation type child const operationType = findChildByType(cursor, 'operation_type'); if (operationType) { context.enterOperation(operationType.text); } } if (node.type === 'fragment_definition') { // Find the type condition const typeCondition = findChildByType(cursor, 'type_condition'); if (typeCondition) { const typeName = findChildByType(cursor, 'named_type', typeCondition); if (typeName) { const nameNode = findChildByType(cursor, 'name', typeName); if (nameNode) { context.enterFragment(nameNode.text); } } } } // We don't need special handling for selection_set anymore // Context is managed at the field level } // Collect leaf tokens with semantic info // Special case: string_value, int_value, float_value nodes should be collected as whole tokens // even though they have children (the quotes or signs) const isValueNode = node.type === 'string_value' || node.type === 'int_value' || node.type === 'float_value'; const shouldCollectToken = (node.childCount === 0 || isValueNode) && node.text.trim() !== ''; if (shouldCollectToken) { // Add whitespace before this token if needed if (node.startIndex > lastEnd) { const whitespace = code.slice(lastEnd, node.startIndex); tokens.push(new UnifiedToken(createWhitespaceNode(whitespace, lastEnd, node.startIndex), undefined, annotations)); } // Determine semantic info for this token let semantic; if (context && node.type === 'name') { const parent = cursor.currentNode.parent; if (parent?.type === 'field') { // This is a field name - get info from current context const fieldInfo = context.getFieldInfo(node.text); const currentType = context.getCurrentType(); if (fieldInfo) { semantic = { kind: 'OutputField', parentType: fieldInfo.parentType, fieldDef: fieldInfo.fieldDef, }; // Enter this field's context for processing its selection set context.enterField(node.text); } else if (currentType) { // Field doesn't exist - mark as invalid semantic = { kind: 'InvalidField', fieldName: node.text, parentType: currentType, }; } } else if (parent?.type === 'named_type') { // This is a type reference const type = context.lookupType(node.text); if (type) { // Check if it's one of the types we support as semantic nodes if (type instanceof GraphQLObjectType || type instanceof GraphQLScalarType || type instanceof GraphQLInterfaceType || type instanceof GraphQLUnionType || type instanceof GraphQLEnumType || type instanceof GraphQLInputObjectType) { semantic = type; } } } else if (parent?.type === 'operation_definition') { // This is an operation name semantic = { kind: 'Operation', type: context.operationType || 'query', name: node.text, }; } else if (parent?.type === 'fragment_definition') { // This is a fragment name - for now just mark it as a fragment semantic = { kind: 'Fragment', name: node.text, onType: context.getCurrentType(), // We'll have the type from enterFragment }; } else if (parent?.type === 'argument') { // This is an argument name // // ## Complex Argument Parsing Logic // // Arguments require complex tree traversal because they appear in the tree-sitter AST // before the semantic context has been updated for their parent field. This creates // a chicken-and-egg problem where we need the field to identify the argument, but // the field hasn't been processed yet. // // Tree structure: field > arguments > argument > name // Processing order: argument names are parsed before field context is established // // Our solution is to traverse up the AST to find the field node, then look for that // field in both the root operation type and the current type context. We check the // root type first because top-level fields (like Query.pokemon) are most common. let argumentsNode = parent.parent; if (argumentsNode && argumentsNode.type === 'arguments') { let fieldNode = argumentsNode.parent; if (fieldNode && fieldNode.type === 'field') { // Find the field name node within the field node for (let i = 0; i < fieldNode.childCount; i++) { const child = fieldNode.child(i); if (child && child.type === 'name') { // We need to find the parent type that contains this field // Start with the root type based on the operation type (query/mutation/subscription) const rootType = context.schema.getQueryType() || context.schema.getMutationType() || context.schema.getSubscriptionType(); if (rootType) { // First check if the field exists on the root type (most common case) let field = rootType.getFields()[child.text]; let parentType = rootType; // If not found on root, check the current type in our semantic context // This handles nested field arguments like User.posts(limit: 10) if (!field) { const currentType = context.getCurrentType(); if (currentType) { field = currentType.getFields()[child.text]; parentType = currentType; } } if (field && parentType) { const arg = field.args.find((a) => a.name === node.text); if (arg) { semantic = { kind: 'Argument', parentType: parentType, parentField: field, argumentDef: arg, }; } } } break; } } } } } else if (parent?.type === 'variable') { // This is a variable name (without the $) semantic = { kind: 'Variable', name: node.text, }; } else if (parent?.type === 'variable_definition') { // This is a variable definition in the operation header semantic = { kind: 'Variable', name: node.text, }; } } else if (context && node.type === 'variable' && node.text.startsWith('$')) { // This is the full variable including $ (usage in arguments or directives) semantic = { kind: 'Variable', name: node.text.slice(1), }; } const token = new UnifiedToken(node, semantic, annotations); tokens.push(token); lastEnd = node.endIndex; } // Traverse children (but skip children of value nodes since we collect them as whole tokens) if (!isValueNode && cursor.gotoFirstChild()) { do { processNode(); } while (cursor.gotoNextSibling()); cursor.gotoParent(); // Handle context exit if (context && node.type === 'field') { // Only exit field context if this field has a selection set // (meaning it's an object/interface type that pushed to the stack) const hasSelectionSet = node.childCount > 0 && node.children.some(child => child?.type === 'selection_set'); if (hasSelectionSet) { context.exitField(); } } else if (context && (node.type === 'operation_definition' || node.type === 'fragment_definition')) { // Reset context when exiting operation or fragment context.reset(); } } } processNode(); // Add final whitespace if needed if (lastEnd < code.length) { const remaining = code.slice(lastEnd); tokens.push(new UnifiedToken(createWhitespaceNode(remaining, lastEnd, code.length), undefined, annotations)); } return tokens; } /** * Helper to find a child node by type */ function findChildByType(cursor, type, node) { const targetNode = node || cursor.currentNode; if (!targetNode) return null; for (let i = 0; i < targetNode.childCount; i++) { const child = targetNode.child(i); if (child && child.type === type) { return child; } } return null; } /** * Create a pseudo tree-sitter node for whitespace */ function createWhitespaceNode(text, start, end) { // Create a synthetic node with proper getter interface const node = new SyntheticNode('whitespace', text, start, end); return node; } /** * Create a pseudo tree-sitter node for synthetic content * * ## Annotation Architecture * * Polen uses synthetic tree-sitter nodes to inject additional content into GraphQL code blocks. * This approach was chosen after considering several alternatives: * * ### Current Approach: Synthetic Nodes * We create fake tree-sitter nodes that implement just enough of the Node interface to flow * through our token rendering pipeline. This is used for error hints that appear after invalid fields. * * **When to use**: When you need to add new content to the code block (not just style existing content) * * ### Alternative Approaches Considered: * * 1. **CodeHike Annotations** * - Use CodeHike's built-in InlineAnnotation/BlockAnnotation system * - Pros: Works with CodeHike's architecture, composable with other handlers * - Cons: More complex, requires understanding CodeHike's annotation pipeline * - Best for: Complex features like collapsible sections, tabs * * 2. **Post-Processing During Render** * - Keep tokens unchanged, add content during the rendering phase * - Pros: Simpler, no fake nodes needed * - Cons: Rendering logic becomes more complex, harder to test * - Best for: Simple conditional content * * 3. **Token Metadata/Props** * - Add annotation data to token properties rather than creating new tokens * - Pros: Clean data model, easy to test * - Cons: Can't add new content, only modify existing tokens * - Best for: Styling annotations (highlights, emphasis, underlines) * * ### Guidelines for Future Annotations: * * - **Styling only** (highlights, emphasis): Use token metadata/props * - **Adding content** (error hints, tooltips): Use synthetic nodes (current approach) * - **Complex UI** (collapsible, tabs): Consider CodeHike annotation handlers * - **User-defined annotations**: Choose based on what the annotation does * * The synthetic node approach works well for Polen's error hints because we're actually * inserting new text content ("← No such field") that needs to be positioned and styled * like a regular token. */ function createSyntheticNode(type, text, start, end) { // Create a synthetic node with proper getter interface const node = new SyntheticNode(type, text, start, end); return node; } //# sourceMappingURL=parser.js.map