Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/blue-lands-joke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@getlang/parser": patch
"@getlang/walker": patch
"@getlang/utils": patch
"@getlang/ast": patch
"@getlang/get": patch
---

add custom modifiers
23 changes: 11 additions & 12 deletions packages/get/src/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { callModifier } from './modifiers.js'
import type { Execute } from './modules.js'
import { Modules } from './modules.js'
import type { RuntimeValue } from './value.js'
import { assert, toValue } from './value.js'
import { assert, materialize } from './value.js'

const {
NullInputError,
Expand Down Expand Up @@ -99,7 +99,7 @@ export async function execute(
return isRoot ? firstNull : ''
}
const els = node.elements.map(el => {
return isToken(el) ? el.value : toValue(el.data, el.typeInfo)
return isToken(el) ? el.value : materialize(el)
})
const data = els.join('')
return { data, typeInfo: node.typeInfo }
Expand All @@ -108,7 +108,7 @@ export async function execute(
async SliceExpr({ slice, typeInfo }) {
try {
const ctx = scope.context
const deps = ctx && toValue(ctx.data, ctx.typeInfo)
const deps = ctx && materialize(ctx)
const ret = await hooks.slice(slice.value, deps)
const data = ret === undefined ? new NullSelection('<slice>') : ret
return { data, typeInfo }
Expand Down Expand Up @@ -158,15 +158,14 @@ export async function execute(
return { data, typeInfo: node.typeInfo }
},

ModifierExpr(node) {
invariant(scope.context, 'Unresolved context')
async ModifierExpr(node) {
const mod = node.modifier.value
const args = node.args.data

return {
data: callModifier(mod, args, scope.context),
typeInfo: node.typeInfo,
}
const entry = await modules.importMod(mod)
const data = entry
? entry.mod(scope.context?.data, args)
: callModifier(mod, args, scope.context)
return { data, typeInfo: node.typeInfo }
},

ModuleExpr(node) {
Expand All @@ -178,7 +177,7 @@ export async function execute(
)
}
return {
data: toValue(node.args.data, node.args.typeInfo),
data: materialize(node.args),
typeInfo: node.typeInfo,
}
},
Expand Down Expand Up @@ -259,5 +258,5 @@ export async function execute(
const modules = new Modules(hooks, executeModule)
const rootEntry = await modules.import(rootModule)
const ex = await executeModule(rootEntry, rootInputs)
return ex && toValue(ex.data, ex.typeInfo)
return ex && materialize(ex)
}
7 changes: 3 additions & 4 deletions packages/get/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,11 @@ import { execute as exec } from './execute.js'
function buildHooks(hooks: Hooks): Required<Hooks> {
return {
import: (module: string) => {
invariant(
hooks.import,
new ImportError('Imports are not supported by the current runtime'),
)
const err = 'Imports are not supported by the current runtime'
invariant(hooks.import, new ImportError(err))
return hooks.import(module)
},
modifier: modifier => hooks.modifier?.(modifier),
call: hooks.call ?? (() => {}),
request: hooks.request ?? http.requestHook,
slice: hooks.slice ?? slice.runSlice,
Expand Down
12 changes: 7 additions & 5 deletions packages/get/src/modifiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,26 @@ import { cookies, html, js, json } from '@getlang/lib'
import { invariant } from '@getlang/utils'
import { ValueReferenceError } from '@getlang/utils/errors'
import type { RuntimeValue } from './value.js'
import { toValue } from './value.js'
import { materialize } from './value.js'

export function callModifier(
mod: string,
args: Record<string, unknown>,
context: RuntimeValue,
context?: RuntimeValue,
) {
let { data, typeInfo } = context
if (mod === 'link') {
let ctx = context
if (context && mod === 'link') {
let { data, typeInfo } = context
const tag = data.type === 'tag' ? data.name : undefined
if (tag === 'a') {
data = html.select(data, 'xpath:@href', false)
} else if (tag === 'img') {
data = html.select(data, 'xpath:@src', false)
}
ctx = { data, typeInfo }
}

const doc = toValue(data, typeInfo)
const doc = ctx && materialize(ctx)

switch (mod) {
case 'link':
Expand Down
35 changes: 30 additions & 5 deletions packages/get/src/modules.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import type { Program, TypeInfo } from '@getlang/ast'
import { Type } from '@getlang/ast'
import { analyze, desugar, inference, parse } from '@getlang/parser'
import type { Hooks, Inputs } from '@getlang/utils'
import type { Hooks, Inputs, Modifier } from '@getlang/utils'
import {
ImportError,
RecursiveCallError,
ValueTypeError,
} from '@getlang/utils/errors'
import { partition } from 'lodash-es'
import type { RuntimeValue } from './value.js'
import { toValue } from './value.js'
import { materialize } from './value.js'

type Info = {
ast: Program
Expand All @@ -24,6 +24,11 @@ type Entry = {
returnType: TypeInfo
}

type ModEntry = {
mod: Modifier
returnType: TypeInfo
}

export type Execute = (entry: Entry, inputs: Inputs) => Promise<any>

function repr(ti: TypeInfo): string {
Expand Down Expand Up @@ -57,6 +62,7 @@ function buildImportKey(module: string, typeInfo?: TypeInfo) {
export class Modules {
private info: Record<string, Promise<Info>> = {}
private entries: Record<string, Promise<Entry>> = {}
private modifiers: Record<string, Promise<ModEntry | null>> = {}

constructor(
private hooks: Required<Hooks>,
Expand Down Expand Up @@ -88,13 +94,19 @@ export class Modules {
macros.push(i)
}
}
const { program: simplified, calls } = desugar(ast, macros)
const { program: simplified, calls, modifiers } = desugar(ast, macros)

const returnTypes: Record<string, TypeInfo> = {}
for (const call of calls) {
const { returnType } = await this.import(call, stack)
returnTypes[call] = returnType
}
for (const mod of modifiers) {
const entry = await this.importMod(mod)
if (entry) {
returnTypes[mod] = entry.returnType
}
}

const { program, returnType } = inference(simplified, {
returnTypes,
Expand All @@ -114,6 +126,19 @@ export class Modules {
return this.entries[key]
}

async compileMod(mod: string): Promise<ModEntry | null> {
const compiled = await this.hooks.modifier(mod)
if (!compiled) {
return null
}
return { mod: compiled.modifier, returnType: { type: Type.Value } }
}

importMod(mod: string) {
this.modifiers[mod] ??= this.compileMod(mod)
return this.modifiers[mod]
}

async call(module: string, args: RuntimeValue, contextType?: TypeInfo) {
let entry: Entry
try {
Expand Down Expand Up @@ -153,8 +178,8 @@ export class Modules {
return extracted
}

const attrs = Object.fromEntries(attrArgs)
const raster = toValue(attrs, args.typeInfo)
const data = Object.fromEntries(attrArgs)
const raster = materialize({ data, typeInfo: args.typeInfo })
return { ...raster, ...extracted }
}
}
22 changes: 13 additions & 9 deletions packages/get/src/value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,28 @@ export type RuntimeValue = {
typeInfo: TypeInfo
}

export function toValue(value: any, typeInfo: TypeInfo): any {
export function materialize({ data, typeInfo }: RuntimeValue): any {
switch (typeInfo.type) {
case Type.Html:
return html.toValue(value)
return html.toValue(data)
case Type.Js:
return js.toValue(value)
return js.toValue(data)
case Type.Headers:
return headers.toValue(value)
return headers.toValue(data)
case Type.Cookies:
return cookies.toValue(value)
return cookies.toValue(data)
case Type.List:
return value.map((item: any) => toValue(item, typeInfo.of))
return data.map((item: any) =>
materialize({ data: item, typeInfo: typeInfo.of }),
)
case Type.Struct:
return mapValues(value, (v, k) => toValue(v, typeInfo.schema[k]!))
return mapValues(data, (v, k) =>
materialize({ data: v, typeInfo: typeInfo.schema[k]! }),
)
case Type.Maybe:
return toValue(value, typeInfo.option)
return materialize({ data, typeInfo: typeInfo.option })
case Type.Value:
return value
return data
default:
throw new ValueTypeError('Unsupported conversion type')
}
Expand Down
10 changes: 7 additions & 3 deletions packages/parser/src/passes/desugar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,16 @@ export type DesugarPass = (

function listCalls(ast: Program) {
const calls = new Set<string>()
const modifiers = new Set<string>()
transform(ast, {
ModuleExpr(node) {
node.call && calls.add(node.module.value)
},
ModifierExpr(node) {
modifiers.add(node.modifier.value)
},
})
return calls
return { calls, modifiers }
}

const visitors = [resolveContext, settleLinks, insertSliceDeps, dropDrills]
Expand All @@ -37,6 +41,6 @@ export function desugar(ast: Program, macros: string[] = []) {
// inference pass `registerCalls` is included in the desugar phase
// it produces the list of called modules required for type inference
program = registerCalls(program, macros)
const calls = listCalls(program)
return { program, calls }
const { calls, modifiers } = listCalls(program)
return { program, calls, modifiers }
}
3 changes: 2 additions & 1 deletion packages/parser/src/passes/inference/typeinfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,8 @@ export function resolveTypes(ast: Program, options: ResolveTypeOptions) {
headers: { type: Type.Headers },
cookies: { type: Type.Cookies },
}
const typeInfo = modTypeMap[node.modifier.value]
const mod = node.modifier.value
const typeInfo = modTypeMap[mod] || returnTypes[mod]
invariant(typeInfo, 'Modifier type lookup failed')
return { ...node, typeInfo }
},
Expand Down
6 changes: 6 additions & 0 deletions packages/utils/src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,18 @@ export type ExtractHook = (
value: any,
) => MaybePromise<any>

export type Modifier = (context: any, options: Record<string, unknown>) => any
export type ModifierHook = (
modifier: string,
) => MaybePromise<{ modifier: Modifier } | undefined>

export type Hooks = Partial<{
import: ImportHook
request: RequestHook
slice: SliceHook
call: CallHook
extract: ExtractHook
modifier: ModifierHook
}>

type RequestInit = {
Expand Down
31 changes: 22 additions & 9 deletions test/calls.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,9 @@ describe('calls', () => {
const result = await execute(
modules,
{},
() => new Response(`<!doctype html><div>x</div><span>y</span>`),
{
fetch: () => new Response(`<!doctype html><div>x</div><span>y</span>`),
},
)
expect(result).toEqual({ div: 'x', span: 'y' })
})
Expand All @@ -134,7 +136,9 @@ describe('calls', () => {
const result = await execute(
modules,
{},
() => new Response(`<!doctype html><div>x</div><span>y</span>`),
{
fetch: () => new Response(`<!doctype html><div>x</div><span>y</span>`),
},
)
expect(result).toEqual({ div: 'x', span: 'y' })
})
Expand Down Expand Up @@ -170,8 +174,9 @@ describe('calls', () => {
const result = await execute(
modules,
{ query: 'gifts' },
() =>
new Response(`
{
fetch: () =>
new Response(`
<!doctype html>
<ul>
<li class="result">
Expand All @@ -183,6 +188,7 @@ describe('calls', () => {
<a class="next" href="/?s=gifts&page=2">next</a>
</div>
`),
},
)
expect(result).toEqual({
items: [
Expand Down Expand Up @@ -217,8 +223,9 @@ describe('calls', () => {
const result = await execute(
modules,
{},
() =>
new Response(`
{
fetch: () =>
new Response(`
<!doctype html>
<body>
<div id="a">
Expand All @@ -229,6 +236,7 @@ describe('calls', () => {
</div>
</body>
`),
},
)

expect(result).toEqual({
Expand Down Expand Up @@ -258,12 +266,14 @@ describe('calls', () => {
const result = await execute(
modules,
{},
() =>
new Response(`
{
fetch: () =>
new Response(`
<!doctype html>
<div data-json='{"x": 1}'><p>first</p></li>
<div data-json='{"y": 2}'><p>second</p></li>
`),
},
)

expect(result).toEqual({
Expand Down Expand Up @@ -291,7 +301,10 @@ describe('calls', () => {
const result = await execute(
modules,
{},
() => new Response(`<div data-json='{"x": 1}'><p>first</p></li>`),
{
fetch: () =>
new Response(`<div data-json='{"x": 1}'><p>first</p></li>`),
},
)

expect(result).toEqual({ x: 1 })
Expand Down
Loading