From 67c24716bdbf389636e8c889e87aafabc94061cc Mon Sep 17 00:00:00 2001 From: Maciej Krajowski-Kukiel Date: Fri, 20 Mar 2026 09:57:21 +0100 Subject: [PATCH] add validation for frontmatter --- .../src/checks/index.ts | 2 + .../checks/valid-frontmatter/index.spec.ts | 666 ++++++++++++++++++ .../src/checks/valid-frontmatter/index.ts | 344 +++++++++ .../src/frontmatter/index.ts | 353 +--------- .../platformos-check-node/configs/all.yml | 3 + .../configs/recommended.yml | 3 + .../src/documents-locator/DocumentsLocator.ts | 31 +- packages/platformos-common/src/frontmatter.ts | 533 ++++++++++++++ packages/platformos-common/src/index.ts | 1 + packages/platformos-common/src/path-utils.ts | 55 ++ .../src/route-table/RouteTable.ts | 21 +- .../src/route-table/index.ts | 2 +- .../TranslationProvider.ts | 52 +- .../src/completions/CompletionsProvider.ts | 122 +++- .../FrontmatterKeyCompletionProvider.spec.ts | 196 ++++++ .../FrontmatterKeyCompletionProvider.ts | 182 +++++ .../src/completions/providers/index.ts | 5 + .../src/definitions/DefinitionProvider.ts | 2 + .../FrontmatterDefinitionProvider.spec.ts | 463 ++++++++++++ .../FrontmatterDefinitionProvider.ts | 258 +++++++ 20 files changed, 2909 insertions(+), 385 deletions(-) create mode 100644 packages/platformos-check-common/src/checks/valid-frontmatter/index.spec.ts create mode 100644 packages/platformos-check-common/src/checks/valid-frontmatter/index.ts create mode 100644 packages/platformos-common/src/frontmatter.ts create mode 100644 packages/platformos-language-server-common/src/completions/providers/FrontmatterKeyCompletionProvider.spec.ts create mode 100644 packages/platformos-language-server-common/src/completions/providers/FrontmatterKeyCompletionProvider.ts create mode 100644 packages/platformos-language-server-common/src/definitions/providers/FrontmatterDefinitionProvider.spec.ts create mode 100644 packages/platformos-language-server-common/src/definitions/providers/FrontmatterDefinitionProvider.ts diff --git a/packages/platformos-check-common/src/checks/index.ts b/packages/platformos-check-common/src/checks/index.ts index 2b2751d1..17e2343b 100644 --- a/packages/platformos-check-common/src/checks/index.ts +++ b/packages/platformos-check-common/src/checks/index.ts @@ -39,6 +39,7 @@ import { DuplicateFunctionArguments } from './duplicate-function-arguments'; import { MissingRenderPartialArguments } from './missing-render-partial-arguments'; import { NestedGraphQLQuery } from './nested-graphql-query'; import { MissingPage } from './missing-page'; +import { ValidFrontmatter } from './valid-frontmatter'; export const allChecks: ( | LiquidCheckDefinition @@ -79,6 +80,7 @@ export const allChecks: ( MissingRenderPartialArguments, NestedGraphQLQuery, MissingPage, + ValidFrontmatter, ]; /** diff --git a/packages/platformos-check-common/src/checks/valid-frontmatter/index.spec.ts b/packages/platformos-check-common/src/checks/valid-frontmatter/index.spec.ts new file mode 100644 index 00000000..a2f3fef3 --- /dev/null +++ b/packages/platformos-check-common/src/checks/valid-frontmatter/index.spec.ts @@ -0,0 +1,666 @@ +import { expect, describe, it } from 'vitest'; +import { ValidFrontmatter } from '.'; +import { check } from '../../test'; + +const PAGE = 'app/views/pages/test.html.liquid'; +const FORM = 'app/forms/test.liquid'; +const AUTH = 'app/authorization_policies/test.liquid'; +const EMAIL = 'app/emails/test.liquid'; +const SMS = 'app/smses/test.liquid'; +const API_CALL = 'app/api_calls/test.liquid'; +const LAYOUT = 'app/views/layouts/application.liquid'; +const PARTIAL = 'app/views/partials/card.liquid'; +const MIGRATION = 'app/migrations/20240101_seed.liquid'; + +describe('ValidFrontmatter', () => { + // ── Required fields ─────────────────────────────────────────────────────── + + describe('no required fields (name derived from file path)', () => { + it('does not report on Page with no frontmatter fields', async () => { + const offenses = await check({ [PAGE]: `---\nslug: /test\n---\n{{ content }}` }, [ + ValidFrontmatter, + ]); + expect(offenses).to.have.length(0); + }); + + it('does not report on FormConfiguration with no name field', async () => { + const offenses = await check({ [FORM]: `---\nresource: User\n---\n` }, [ValidFrontmatter]); + expect(offenses.some((o) => o.message.includes('Missing required'))).toBe(false); + }); + + it('does not report on AuthorizationPolicy with no name field', async () => { + const offenses = await check({ [AUTH]: `---\nhttp_status: 403\n---\n` }, [ValidFrontmatter]); + expect(offenses.some((o) => o.message.includes('Missing required'))).toBe(false); + }); + + it('does not report on Email with only from field', async () => { + const offenses = await check({ [EMAIL]: `---\nfrom: sender@example.com\n---\nHi` }, [ + ValidFrontmatter, + ]); + expect(offenses.some((o) => o.message.includes('Missing required'))).toBe(false); + }); + + it('does not report on ApiCall with only to and request_type', async () => { + const offenses = await check( + { [API_CALL]: `---\nto: https://example.com\nrequest_type: GET\n---\n` }, + [ValidFrontmatter], + ); + expect(offenses.some((o) => o.message.includes('Missing required'))).toBe(false); + }); + + it('does not report on SMS with only to field', async () => { + const offenses = await check({ [SMS]: `---\nto: "+15550001234"\n---\n` }, [ValidFrontmatter]); + expect(offenses.some((o) => o.message.includes('Missing required'))).toBe(false); + }); + + it('does not report when there is no frontmatter', async () => { + const offenses = await check({ [FORM]: `{{ content }}` }, [ValidFrontmatter]); + expect(offenses).to.have.length(0); + }); + + it('does not report on empty frontmatter block', async () => { + const offenses = await check({ [FORM]: `---\n---\n` }, [ValidFrontmatter]); + expect(offenses.some((o) => o.message.includes('Missing required'))).toBe(false); + }); + }); + + // ── Deprecated fields ───────────────────────────────────────────────────── + + describe('deprecated fields', () => { + it('warns on layout_name on Page', async () => { + const files = { + 'app/views/layouts/application.liquid': `{{ content }}`, + [PAGE]: `---\nlayout_name: application\n---\n{{ content }}`, + }; + const offenses = await check(files, [ValidFrontmatter]); + expect(offenses).to.containOffense('Use `layout` instead of `layout_name`.'); + }); + + it('warns on deprecated redirect_url on Page', async () => { + const offenses = await check({ [PAGE]: `---\nredirect_url: /home\n---\n{{ content }}` }, [ + ValidFrontmatter, + ]); + expect(offenses).to.containOffense('Use `redirect_to` instead of `redirect_url`.'); + }); + + it('does not warn on redirect_to (non-deprecated)', async () => { + const offenses = await check({ [PAGE]: `---\nredirect_to: /home\n---\n{{ content }}` }, [ + ValidFrontmatter, + ]); + expect(offenses).to.have.length(0); + }); + + it('warns on deprecated return_to in FormConfiguration', async () => { + const offenses = await check({ [FORM]: `---\nname: my_form\nreturn_to: /home\n---\n` }, [ + ValidFrontmatter, + ]); + expect(offenses).to.containOffense('Use `redirect_to` instead of `return_to`.'); + }); + + it('does not warn on redirect_to in FormConfiguration (non-deprecated)', async () => { + const offenses = await check({ [FORM]: `---\nname: my_form\nredirect_to: /home\n---\n` }, [ + ValidFrontmatter, + ]); + expect(offenses).to.have.length(0); + }); + + it('warns on deprecated layout_path in Email', async () => { + const offenses = await check({ [EMAIL]: `---\nlayout_path: email_base\n---\nHi` }, [ + ValidFrontmatter, + ]); + expect(offenses).to.containOffense('Use `layout` instead of `layout_path`.'); + }); + + it('does not warn on layout in Email (non-deprecated)', async () => { + const files = { + 'app/views/layouts/email_base.liquid': `{{ content }}`, + [EMAIL]: `---\nlayout: email_base\n---\nHi`, + }; + const offenses = await check(files, [ValidFrontmatter]); + expect(offenses).to.have.length(0); + }); + + it('warns on deprecated headers field in ApiCall', async () => { + const offenses = await check( + { [API_CALL]: `---\nto: https://example.com\nrequest_type: GET\nheaders: "{}"\n---\n` }, + [ValidFrontmatter], + ); + expect(offenses).to.containOffense('Use `request_headers` instead of `headers`.'); + }); + + it('does not warn on non-deprecated fields', async () => { + const files = { + 'app/views/layouts/application.liquid': `{{ content }}`, + [PAGE]: `---\nlayout: application\n---\n{{ content }}`, + }; + const offenses = await check(files, [ValidFrontmatter]); + expect(offenses).to.have.length(0); + }); + }); + + // ── Enum validation ─────────────────────────────────────────────────────── + + describe('enum validation', () => { + // Page method + it('reports invalid method on Page', async () => { + const offenses = await check({ [PAGE]: `---\nmethod: invalid\n---\n{{ content }}` }, [ + ValidFrontmatter, + ]); + expect(offenses).to.containOffense( + "Invalid value 'invalid' for 'method'. Must be one of: delete, get, patch, post, put, options", + ); + }); + + it('accepts all valid method values on Page', async () => { + for (const method of ['get', 'post', 'put', 'patch', 'delete', 'options']) { + const offenses = await check({ [PAGE]: `---\nmethod: ${method}\n---\n{{ content }}` }, [ + ValidFrontmatter, + ]); + expect(offenses).to.have.length(0); + } + }); + + it('is case-insensitive for method values', async () => { + const offenses = await check({ [PAGE]: `---\nmethod: GET\n---\n{{ content }}` }, [ + ValidFrontmatter, + ]); + expect(offenses).to.have.length(0); + }); + + // Page redirect_code + it('reports invalid redirect_code on Page', async () => { + const offenses = await check( + { [PAGE]: `---\nredirect_to: /home\nredirect_code: 200\n---\n` }, + [ValidFrontmatter], + ); + expect(offenses).to.containOffense( + "Invalid value '200' for 'redirect_code'. Must be one of: 301, 302, 307", + ); + }); + + it('accepts valid redirect_code values', async () => { + for (const code of [301, 302, 307]) { + const offenses = await check( + { [PAGE]: `---\nredirect_to: /home\nredirect_code: ${code}\n---\n` }, + [ValidFrontmatter], + ); + expect(offenses).to.have.length(0); + } + }); + + // AuthorizationPolicy http_status + it('reports invalid http_status on AuthorizationPolicy', async () => { + const offenses = await check({ [AUTH]: `---\nname: my_policy\nhttp_status: 500\n---\n` }, [ + ValidFrontmatter, + ]); + expect(offenses).to.containOffense( + "Invalid value '500' for 'http_status'. Must be one of: 403, 404", + ); + }); + + it('accepts valid http_status values', async () => { + for (const status of [403, 404]) { + const offenses = await check( + { [AUTH]: `---\nname: my_policy\nhttp_status: ${status}\n---\n` }, + [ValidFrontmatter], + ); + expect(offenses).to.have.length(0); + } + }); + + // FormConfiguration spam_protection + it('reports invalid spam_protection in FormConfiguration', async () => { + const offenses = await check( + { [FORM]: `---\nname: my_form\nspam_protection: invalid_type\n---\n` }, + [ValidFrontmatter], + ); + expect(offenses).to.containOffense( + "Invalid value 'invalid_type' for 'spam_protection'. Must be one of: recaptcha, recaptcha_v2, recaptcha_v3, hcaptcha", + ); + }); + + it('accepts all valid spam_protection values', async () => { + for (const val of ['recaptcha', 'recaptcha_v2', 'recaptcha_v3', 'hcaptcha']) { + const offenses = await check( + { [FORM]: `---\nname: my_form\nspam_protection: ${val}\n---\n` }, + [ValidFrontmatter], + ); + expect(offenses).to.have.length(0); + } + }); + + // ApiCall request_type + it('reports invalid request_type in ApiCall', async () => { + const offenses = await check( + { [API_CALL]: `---\nname: my_call\nto: https://example.com\nrequest_type: INVALID\n---\n` }, + [ValidFrontmatter], + ); + expect(offenses).to.containOffense( + "Invalid value 'INVALID' for 'request_type'. Must be one of: GET, POST, PUT, PATCH, DELETE", + ); + }); + + it('accepts all valid request_type values', async () => { + for (const method of ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']) { + const offenses = await check( + { + [API_CALL]: `---\nname: my_call\nto: https://example.com\nrequest_type: ${method}\n---\n`, + }, + [ValidFrontmatter], + ); + expect(offenses).to.have.length(0); + } + }); + + it('is case-insensitive for request_type values', async () => { + const offenses = await check( + { [API_CALL]: `---\nname: my_call\nto: https://example.com\nrequest_type: get\n---\n` }, + [ValidFrontmatter], + ); + expect(offenses).to.have.length(0); + }); + + it('does not validate method on non-Page files', async () => { + const offenses = await check({ [FORM]: `---\nname: my_form\nmethod: invalid\n---\n` }, [ + ValidFrontmatter, + ]); + expect(offenses.some((o) => o.message.includes("for 'method'"))).toBe(false); + }); + }); + + // ── Layout association ──────────────────────────────────────────────────── + + describe('layout association', () => { + // Page + it('reports missing layout file on Page', async () => { + const offenses = await check({ [PAGE]: `---\nlayout: nonexistent\n---\n{{ content }}` }, [ + ValidFrontmatter, + ]); + expect(offenses).to.containOffense("Layout 'nonexistent' does not exist"); + }); + + it('does not report when layout file exists on Page', async () => { + const files = { + 'app/views/layouts/application.liquid': `{{ content }}`, + [PAGE]: `---\nlayout: application\n---\n{{ content }}`, + }; + const offenses = await check(files, [ValidFrontmatter]); + expect(offenses).to.have.length(0); + }); + + it('reports missing module layout (public path)', async () => { + const offenses = await check( + { [PAGE]: `---\nlayout: modules/my-module/layouts/email\n---\n{{ content }}` }, + [ValidFrontmatter], + ); + expect(offenses).to.containOffense("Layout 'modules/my-module/layouts/email' does not exist"); + }); + + it('does not report when module layout exists at public path', async () => { + const files = { + 'modules/my-module/public/views/layouts/layouts/email.liquid': `{{ content }}`, + [PAGE]: `---\nlayout: modules/my-module/layouts/email\n---\n{{ content }}`, + }; + const offenses = await check(files, [ValidFrontmatter]); + expect(offenses).to.have.length(0); + }); + + it('does not report when module layout exists at private path', async () => { + const files = { + 'modules/my-module/private/views/layouts/layouts/email.liquid': `{{ content }}`, + [PAGE]: `---\nlayout: modules/my-module/layouts/email\n---\n{{ content }}`, + }; + const offenses = await check(files, [ValidFrontmatter]); + expect(offenses).to.have.length(0); + }); + + it('reports layout: false (boolean) and suggests empty string on Page', async () => { + const offenses = await check({ [PAGE]: `---\nlayout: false\n---\n{{ content }}` }, [ + ValidFrontmatter, + ]); + expect(offenses).to.containOffense( + "`layout: false` falls back to the default layout. Use `layout: ''` to disable layout rendering.", + ); + }); + + it('does not warn for layout: empty string on Page (valid disable)', async () => { + const offenses = await check({ [PAGE]: `---\nlayout: ''\n---\n{{ content }}` }, [ + ValidFrontmatter, + ]); + expect(offenses.some((o) => o.message.includes('does not exist'))).toBe(false); + expect(offenses.some((o) => o.message.includes('falls back'))).toBe(false); + }); + + // Email layout + it('reports missing layout file on Email', async () => { + const offenses = await check({ [EMAIL]: `---\nlayout: nonexistent_email_layout\n---\nHi` }, [ + ValidFrontmatter, + ]); + expect(offenses).to.containOffense("Layout 'nonexistent_email_layout' does not exist"); + }); + + it('does not report when layout file exists on Email', async () => { + const files = { + 'app/views/layouts/email_base.liquid': `{{ content }}`, + [EMAIL]: `---\nlayout: email_base\n---\nHi`, + }; + const offenses = await check(files, [ValidFrontmatter]); + expect(offenses).to.have.length(0); + }); + + it('reports layout: false (boolean) on Email', async () => { + const offenses = await check({ [EMAIL]: `---\nlayout: false\n---\nHi` }, [ValidFrontmatter]); + expect(offenses).to.containOffense( + "`layout: false` falls back to the default layout. Use `layout: ''` to disable layout rendering.", + ); + }); + }); + + // ── Authorization policy association ───────────────────────────────────── + + describe('authorization_policies association', () => { + it('reports missing authorization policy file', async () => { + const offenses = await check( + { [PAGE]: `---\nauthorization_policies:\n - missing_policy\n---\n{{ content }}` }, + [ValidFrontmatter], + ); + expect(offenses).to.containOffense("Authorization policy 'missing_policy' does not exist"); + }); + + it('does not report when authorization policy file exists', async () => { + const files = { + 'app/authorization_policies/require_login.liquid': `---\nname: require_login\n---\n`, + [PAGE]: `---\nauthorization_policies:\n - require_login\n---\n{{ content }}`, + }; + const offenses = await check(files, [ValidFrontmatter]); + expect(offenses.some((o) => o.message.includes('Authorization policy'))).toBe(false); + }); + + it('reports each missing policy in the list', async () => { + const offenses = await check( + { [PAGE]: `---\nauthorization_policies:\n - policy_a\n - policy_b\n---\n{{ content }}` }, + [ValidFrontmatter], + ); + expect(offenses).to.containOffense("Authorization policy 'policy_a' does not exist"); + expect(offenses).to.containOffense("Authorization policy 'policy_b' does not exist"); + }); + }); + + // ── Form notification associations ─────────────────────────────────────── + + describe('form notification associations', () => { + it('reports missing email notification', async () => { + const offenses = await check( + { [FORM]: `---\nname: my_form\nemail_notifications:\n - missing_email\n---\n` }, + [ValidFrontmatter], + ); + expect(offenses).to.containOffense("Email notification 'missing_email' does not exist"); + }); + + it('reports missing SMS notification', async () => { + const offenses = await check( + { [FORM]: `---\nname: my_form\nsms_notifications:\n - missing_sms\n---\n` }, + [ValidFrontmatter], + ); + expect(offenses).to.containOffense("SMS notification 'missing_sms' does not exist"); + }); + + it('reports missing API call notification', async () => { + const offenses = await check( + { [FORM]: `---\nname: my_form\napi_call_notifications:\n - missing_api_call\n---\n` }, + [ValidFrontmatter], + ); + expect(offenses).to.containOffense("API call notification 'missing_api_call' does not exist"); + }); + + it('does not report when email notification file exists', async () => { + const files = { + 'app/emails/welcome.liquid': `---\nto: user@example.com\nsubject: Welcome\n---\n`, + [FORM]: `---\nemail_notifications:\n - welcome\n---\n`, + }; + const offenses = await check(files, [ValidFrontmatter]); + expect(offenses.some((o) => o.message.includes('Email notification'))).toBe(false); + }); + + it('does not report when SMS notification file exists', async () => { + const files = { + 'app/smses/alert.liquid': `---\nto: "+15550001234"\n---\n`, + [FORM]: `---\nsms_notifications:\n - alert\n---\n`, + }; + const offenses = await check(files, [ValidFrontmatter]); + expect(offenses.some((o) => o.message.includes('SMS notification'))).toBe(false); + }); + + it('does not report when API call notification file exists', async () => { + const files = { + 'app/api_calls/webhook.liquid': `---\nto: https://example.com\nrequest_type: POST\n---\n`, + [FORM]: `---\napi_call_notifications:\n - webhook\n---\n`, + }; + const offenses = await check(files, [ValidFrontmatter]); + expect(offenses.some((o) => o.message.includes('API call notification'))).toBe(false); + }); + + it('reports each missing notification individually in a list', async () => { + const offenses = await check( + { [FORM]: `---\nname: my_form\nemail_notifications:\n - email_a\n - email_b\n---\n` }, + [ValidFrontmatter], + ); + expect(offenses).to.containOffense("Email notification 'email_a' does not exist"); + expect(offenses).to.containOffense("Email notification 'email_b' does not exist"); + }); + + it('only reports missing items when some exist and some do not', async () => { + const files = { + 'app/emails/welcome.liquid': `---\nto: u@e.com\nsubject: Hi\n---\n`, + [FORM]: `---\nemail_notifications:\n - welcome\n - missing_one\n---\n`, + }; + const offenses = await check(files, [ValidFrontmatter]); + expect(offenses).to.containOffense("Email notification 'missing_one' does not exist"); + expect(offenses.some((o) => o.message.includes("'welcome'"))).toBe(false); + }); + }); + + // ── home.html.liquid deprecation ───────────────────────────────────────── + + describe('home.html.liquid deprecation', () => { + it('warns when home.html.liquid is used', async () => { + const offenses = await check( + { 'app/views/pages/home.html.liquid': `---\nslug: /\n---\n{{ content }}` }, + [ValidFrontmatter], + ); + expect(offenses).to.containOffense( + "'home.html.liquid' is deprecated. Rename to 'index.html.liquid' to serve as the root page.", + ); + }); + + it('does not warn for index.html.liquid', async () => { + const offenses = await check( + { 'app/views/pages/index.html.liquid': `---\nslug: /\n---\n{{ content }}` }, + [ValidFrontmatter], + ); + expect(offenses.some((o) => o.message.includes('home.html.liquid'))).toBe(false); + }); + + it('does not warn for files whose name contains home but is not home.html.liquid', async () => { + const offenses = await check({ 'app/views/pages/homepage.html.liquid': `{{ content }}` }, [ + ValidFrontmatter, + ]); + expect(offenses.some((o) => o.message.includes('home.html.liquid'))).toBe(false); + }); + }); + + // ── Unknown key validation ──────────────────────────────────────────────── + + describe('unknown key validation', () => { + it('warns on unknown keys in Page', async () => { + const offenses = await check({ [PAGE]: `---\nmy_custom_field: value\n---\n{{ content }}` }, [ + ValidFrontmatter, + ]); + expect(offenses).to.containOffense( + "Unknown frontmatter field 'my_custom_field' in Page file", + ); + }); + + it('warns on unknown keys in FormConfiguration', async () => { + const offenses = await check({ [FORM]: `---\nname: my_form\nunknown_field: value\n---\n` }, [ + ValidFrontmatter, + ]); + expect(offenses).to.containOffense( + "Unknown frontmatter field 'unknown_field' in FormConfiguration file", + ); + }); + + it('warns on unknown keys in AuthorizationPolicy', async () => { + const offenses = await check( + { [AUTH]: `---\nname: my_policy\nunknown_field: value\n---\n` }, + [ValidFrontmatter], + ); + expect(offenses).to.containOffense( + "Unknown frontmatter field 'unknown_field' in AuthorizationPolicy file", + ); + }); + + it('flash_notice is not valid in AuthorizationPolicy (only flash_alert is)', async () => { + const offenses = await check( + { [AUTH]: `---\nname: my_policy\nflash_notice: Denied\n---\n` }, + [ValidFrontmatter], + ); + expect(offenses).to.containOffense( + "Unknown frontmatter field 'flash_notice' in AuthorizationPolicy file", + ); + }); + + it('warns on unknown keys in Email', async () => { + const offenses = await check( + { [EMAIL]: `---\nname: my_email\nto: u@e.com\nsubject: Hi\nunknown_field: value\n---\n` }, + [ValidFrontmatter], + ); + expect(offenses).to.containOffense("Unknown frontmatter field 'unknown_field' in Email file"); + }); + + it('accepts unique_args in Email (valid server-side field)', async () => { + const offenses = await check( + { + [EMAIL]: `---\nname: my_email\nto: u@e.com\nsubject: Hi\nunique_args:\n campaign: welcome\n---\n`, + }, + [ValidFrontmatter], + ); + expect(offenses.some((o) => o.message.includes("'unique_args'"))).toBe(false); + }); + + it('warns on unknown keys in SMS', async () => { + const offenses = await check( + { + [SMS]: `---\nname: my_sms\nto: "+15550001234"\ncontent: Hello\nunknown_field: value\n---\n`, + }, + [ValidFrontmatter], + ); + expect(offenses).to.containOffense("Unknown frontmatter field 'unknown_field' in SMS file"); + }); + + it('warns on unknown keys in ApiCall', async () => { + const offenses = await check( + { + [API_CALL]: `---\nname: my_call\nto: https://example.com\nrequest_type: GET\nunknown_field: value\n---\n`, + }, + [ValidFrontmatter], + ); + expect(offenses).to.containOffense( + "Unknown frontmatter field 'unknown_field' in ApiCall file", + ); + }); + + it('warns on unknown keys in Layout', async () => { + const offenses = await check({ [LAYOUT]: `---\nunknown_field: value\n---\n{{ content }}` }, [ + ValidFrontmatter, + ]); + expect(offenses).to.containOffense( + "Unknown frontmatter field 'unknown_field' in Layout file", + ); + }); + + it('name is not a valid Layout frontmatter field (derived from file path)', async () => { + const offenses = await check({ [LAYOUT]: `---\nname: my_layout\n---\n{{ content }}` }, [ + ValidFrontmatter, + ]); + expect(offenses).to.containOffense("Unknown frontmatter field 'name' in Layout file"); + }); + + it('warns on unknown keys in Partial', async () => { + const offenses = await check({ [PARTIAL]: `---\nunknown_field: value\n---\n{{ content }}` }, [ + ValidFrontmatter, + ]); + expect(offenses).to.containOffense( + "Unknown frontmatter field 'unknown_field' in Partial file", + ); + }); + + it('does not validate Migration files (no schema — arbitrary frontmatter allowed)', async () => { + const offenses = await check( + { [MIGRATION]: `---\ncustom_key: value\nanother_key: 123\n---\n{{ content }}` }, + [ValidFrontmatter], + ); + expect(offenses.some((o) => o.message.includes('Unknown frontmatter'))).toBe(false); + }); + }); + + // ── Union-type field validation ─────────────────────────────────────────── + + describe('union-type fields', () => { + it('accepts trigger_condition as boolean in Email', async () => { + const offenses = await check({ [EMAIL]: `---\ntrigger_condition: true\n---\n` }, [ + ValidFrontmatter, + ]); + expect(offenses.some((o) => o.message.includes("'trigger_condition'"))).toBe(false); + }); + + it('accepts trigger_condition as string in Email', async () => { + const offenses = await check( + { [EMAIL]: `---\ntrigger_condition: "{{ context.current_user != blank }}"\n---\n` }, + [ValidFrontmatter], + ); + expect(offenses.some((o) => o.message.includes("'trigger_condition'"))).toBe(false); + }); + + it('accepts trigger_condition as boolean in SMS', async () => { + const offenses = await check({ [SMS]: `---\ntrigger_condition: false\n---\n` }, [ + ValidFrontmatter, + ]); + expect(offenses.some((o) => o.message.includes("'trigger_condition'"))).toBe(false); + }); + + it('accepts trigger_condition as boolean in ApiCall', async () => { + const offenses = await check( + { + [API_CALL]: `---\nto: https://example.com\nrequest_type: POST\ntrigger_condition: true\n---\n`, + }, + [ValidFrontmatter], + ); + expect(offenses.some((o) => o.message.includes("'trigger_condition'"))).toBe(false); + }); + + it('accepts default_payload as string in FormConfiguration', async () => { + const offenses = await check({ [FORM]: `---\ndefault_payload: "{}"\n---\n` }, [ + ValidFrontmatter, + ]); + expect(offenses.some((o) => o.message.includes("'default_payload'"))).toBe(false); + }); + + it('accepts resource as string in FormConfiguration', async () => { + const offenses = await check({ [FORM]: `---\nresource: User\n---\n` }, [ValidFrontmatter]); + expect(offenses.some((o) => o.message.includes("'resource'"))).toBe(false); + }); + }); + + // ── Unrecognized file type ──────────────────────────────────────────────── + + describe('unknown file type', () => { + it('skips files in unknown directories', async () => { + const offenses = await check( + { 'some/random/path/file.liquid': `---\nfoo: bar\n---\n{{ content }}` }, + [ValidFrontmatter], + ); + expect(offenses).to.have.length(0); + }); + }); +}); diff --git a/packages/platformos-check-common/src/checks/valid-frontmatter/index.ts b/packages/platformos-check-common/src/checks/valid-frontmatter/index.ts new file mode 100644 index 00000000..f0526043 --- /dev/null +++ b/packages/platformos-check-common/src/checks/valid-frontmatter/index.ts @@ -0,0 +1,344 @@ +import { isMap, isScalar, isSeq, parseDocument } from 'yaml'; +import { LiquidCheckDefinition, RelativePath, Severity, SourceCodeType } from '../../types'; +import { + containsLiquid, + FRONTMATTER_ASSOCIATION_DIRS, + getFrontmatterSchema, + getFileType, + PlatformOSFileType, +} from '@platformos/platformos-common'; +import { doesFileExist } from '../../utils/file-utils'; + +export const ValidFrontmatter: LiquidCheckDefinition = { + meta: { + code: 'ValidFrontmatter', + name: 'Valid Frontmatter', + docs: { + description: + 'Validates YAML frontmatter properties (required fields, allowed values, deprecated keys) for known platformOS file types.', + recommended: true, + url: undefined, + }, + type: SourceCodeType.LiquidHtml, + severity: Severity.WARNING, + schema: {}, + targets: [], + }, + + create(context) { + return { + async onCodePathStart(file) { + const source = file.source; + + if (/(?:^|\/)home\.html\.liquid$/.test(file.uri)) { + context.report({ + message: + "'home.html.liquid' is deprecated. Rename to 'index.html.liquid' to serve as the root page.", + startIndex: 0, + endIndex: 0, + }); + } + + // Locate the frontmatter block — may be preceded by whitespace + const trimmed = source.trimStart(); + if (!trimmed.startsWith('---')) return; + + const leadingLen = source.length - trimmed.length; + const firstNewline = trimmed.indexOf('\n'); + if (firstNewline === -1) return; + + const afterOpening = trimmed.slice(firstNewline + 1); + + // The closing `---` may be the very first line of afterOpening (empty frontmatter) + // or may follow a newline (normal frontmatter with content). + let yamlBody: string; + if (afterOpening.startsWith('---')) { + yamlBody = ''; + } else { + const closeIdx = afterOpening.indexOf('\n---'); + if (closeIdx === -1) return; + yamlBody = afterOpening.slice(0, closeIdx); + } + // Absolute offset of the first character of yamlBody in source + const bodyOffset = leadingLen + firstNewline + 1; + + const fileType = getFileType(file.uri); + const schema = getFrontmatterSchema(fileType); + if (!schema) return; + + // Parse YAML with position tracking (yaml v2 provides range arrays). + // Continue even when the document has parse errors — parseDocument is + // lenient and still builds a partial map for the valid pairs it finds. + // Normalize CRLF → LF so YAML values don't contain stray \r characters. + const doc = parseDocument(yamlBody.replace(/\r\n/g, '\n').replace(/\r/g, '\n')); + + // Build lookup: key → { jsValue, absStart, absEnd, valueAbsStart, valueAbsEnd } + type Entry = { + jsValue: unknown; + absStart: number; + absEnd: number; + valueAbsStart: number; + valueAbsEnd: number; + }; + const entries = new Map(); + + // Only populate entries when the document parsed to a map (non-empty frontmatter). + // When frontmatter is empty (`---\n---\n`) doc.contents is null — entries stays empty + // and required-field validation below will still fire correctly. + if (isMap(doc.contents)) { + for (const pair of doc.contents.items) { + const keyNode = pair.key; + if (!isScalar(keyNode) || typeof keyNode.value !== 'string') continue; + const [ks = 0, ke = 0] = keyNode.range ?? []; + const valNode = isScalar(pair.value) ? pair.value : undefined; + const jsValue = valNode?.value; + const [vs = 0, ve = 0] = valNode?.range ?? []; + entries.set(keyNode.value, { + jsValue, + absStart: bodyOffset + ks, + absEnd: bodyOffset + ke, + valueAbsStart: bodyOffset + vs, + valueAbsEnd: bodyOffset + ve, + }); + } + } + + const frontmatterStart = leadingLen; // position of opening `---` + + // 1. Required field validation + for (const [fieldName, fieldSchema] of Object.entries(schema.fields)) { + if (fieldSchema.required && !entries.has(fieldName)) { + context.report({ + message: `Missing required frontmatter field '${fieldName}' in ${schema.name} file`, + startIndex: frontmatterStart, + endIndex: frontmatterStart + 3, + }); + } + } + + // 2. Unrecognized key warnings + for (const [key, entry] of entries) { + if (!(key in schema.fields)) { + context.report({ + message: `Unknown frontmatter field '${key}' in ${schema.name} file`, + startIndex: entry.absStart, + endIndex: entry.absEnd, + }); + } + } + + // 3. Deprecated field warnings + for (const [key, entry] of entries) { + const fieldSchema = schema.fields[key]; + if (fieldSchema?.deprecated) { + context.report({ + message: fieldSchema.deprecatedMessage ?? `'${key}' is deprecated`, + startIndex: entry.absStart, + endIndex: entry.absEnd, + }); + } + } + + // 4. Enum validation — allowed values are defined in the schema. + // Comparison is case-insensitive for string values: both the field value and + // each enum entry are lowercased before comparing, so `GET` matches `get` etc. + for (const [key, entry] of entries) { + const fieldSchema = schema.fields[key]; + if (!fieldSchema?.enumValues) continue; + const { jsValue, absStart, absEnd } = entry; + // Skip enum validation for Liquid expressions — they're dynamic and can't be statically checked. + if (typeof jsValue === 'string' && containsLiquid(jsValue)) continue; + const normalizedValue = typeof jsValue === 'string' ? jsValue.toLowerCase() : jsValue; + const matches = fieldSchema.enumValues.some((allowed) => + typeof allowed === 'string' + ? allowed.toLowerCase() === normalizedValue + : allowed === normalizedValue, + ); + if (!matches) { + context.report({ + message: `Invalid value '${jsValue}' for '${key}'. Must be one of: ${fieldSchema.enumValues.join(', ')}`, + startIndex: absStart, + endIndex: absEnd, + }); + } + } + + // 5. Layout association validation (Page and Email). + // Both types share the primary `layout` key; deprecated aliases differ per type. + if (fileType === PlatformOSFileType.Page || fileType === PlatformOSFileType.Email) { + const deprecatedAlias = + fileType === PlatformOSFileType.Page ? 'layout_name' : 'layout_path'; + const layoutEntry = entries.get('layout') ?? entries.get(deprecatedAlias); + if (layoutEntry) { + if (layoutEntry.jsValue === false) { + // `layout: false` (YAML boolean) does NOT disable the layout — it falls back to the + // instance default. Use `layout: ''` to explicitly disable layout rendering. + context.report({ + message: + "`layout: false` falls back to the default layout. Use `layout: ''` to disable layout rendering.", + startIndex: layoutEntry.valueAbsStart, + endIndex: layoutEntry.valueAbsEnd, + suggest: [ + { + message: "Replace with `layout: ''`", + fix: (corrector) => { + corrector.replace(layoutEntry.valueAbsStart, layoutEntry.valueAbsEnd, "''"); + }, + }, + ], + }); + } else if ( + typeof layoutEntry.jsValue === 'string' && + layoutEntry.jsValue !== '' && + !containsLiquid(layoutEntry.jsValue) + ) { + await checkLayoutExists(layoutEntry.jsValue, layoutEntry, context); + } + } + } + + // 6. Authorization policy association validation (Page) + if (fileType === PlatformOSFileType.Page) { + await checkNotificationArray( + doc, + bodyOffset, + 'authorization_policies', + `app/${FRONTMATTER_ASSOCIATION_DIRS['authorization_policies']}`, + 'Authorization policy', + context, + ); + } + + // 7. Notification association validation (FormConfiguration) + if (fileType === PlatformOSFileType.FormConfiguration) { + for (const [field, dir] of Object.entries(FRONTMATTER_ASSOCIATION_DIRS)) { + if (field === 'authorization_policies') continue; // only on Page, handled above + await checkNotificationArray( + doc, + bodyOffset, + field, + `app/${dir}`, + fieldLabel(field), + context, + ); + } + } + }, + }; + }, +}; + +/** + * Checks each string item of a YAML sequence field against the filesystem. + * + * App-level items (e.g. `require_login`) are looked up at `{dir}/{name}.liquid`. + * Module-prefixed items (e.g. `modules/community/require_login`) are looked up + * at modules/{mod}/{public|private}/{moduleDir}/{name}.liquid where moduleDir + * is derived from dir by stripping the leading `app/` segment. + */ +async function checkNotificationArray( + doc: ReturnType, + bodyOffset: number, + fieldName: string, + dir: string, + label: string, + context: Parameters[0], +) { + if (!isMap(doc.contents)) return; + const pair = doc.contents.items.find((p) => isScalar(p.key) && p.key.value === fieldName); + if (!pair || !isSeq(pair.value)) return; + + // Module-relative dir: strip leading 'app/' (e.g. 'app/authorization_policies' → 'authorization_policies') + const moduleDir = dir.slice('app/'.length); + + for (const item of pair.value.items) { + if (!isScalar(item) || typeof item.value !== 'string') continue; + const name = item.value; + if (containsLiquid(name)) continue; + const [is = 0, ie = 0] = item.range ?? []; + + let exists: boolean; + if (name.startsWith('modules/')) { + const match = name.match(/^modules\/([^/]+)\/(.+)$/); + if (!match) { + exists = false; + } else { + const [, mod, rest] = match; + exists = + (await doesFileExist( + context, + `modules/${mod}/public/${moduleDir}/${rest}.liquid` as RelativePath, + )) || + (await doesFileExist( + context, + `modules/${mod}/private/${moduleDir}/${rest}.liquid` as RelativePath, + )); + } + } else { + exists = await doesFileExist(context, `${dir}/${name}.liquid` as RelativePath); + } + + if (!exists) { + context.report({ + message: `${label} '${name}' does not exist`, + startIndex: bodyOffset + is, + endIndex: bodyOffset + ie, + }); + } + } +} + +/** + * Tries both `{base}.liquid` and `{base}.html.liquid` since layout files may + * carry a format extension (e.g. `application.html.liquid`). + */ +async function layoutFileExists( + context: Parameters[0], + base: string, +): Promise { + return ( + (await doesFileExist(context, `${base}.liquid` as RelativePath)) || + (await doesFileExist(context, `${base}.html.liquid` as RelativePath)) + ); +} + +async function checkLayoutExists( + layoutName: string, + entry: { absStart: number; absEnd: number }, + context: Parameters[0], +) { + let exists: boolean; + + if (layoutName.startsWith('modules/')) { + // modules/{mod}/rest → modules/{mod}/{public,private}/views/layouts/{rest}.{html.}liquid + const match = layoutName.match(/^modules\/([^/]+)\/(.+)$/); + if (!match) return; + const [, mod, rest] = match; + exists = + (await layoutFileExists(context, `modules/${mod}/public/views/layouts/${rest}`)) || + (await layoutFileExists(context, `modules/${mod}/private/views/layouts/${rest}`)); + } else { + exists = await layoutFileExists(context, `app/views/layouts/${layoutName}`); + } + + if (!exists) { + context.report({ + message: `Layout '${layoutName}' does not exist`, + startIndex: entry.absStart, + endIndex: entry.absEnd, + }); + } +} + +function fieldLabel(field: string): string { + switch (field) { + case 'email_notifications': + return 'Email notification'; + case 'sms_notifications': + return 'SMS notification'; + case 'api_call_notifications': + return 'API call notification'; + default: + return field; + } +} diff --git a/packages/platformos-check-common/src/frontmatter/index.ts b/packages/platformos-check-common/src/frontmatter/index.ts index 1088750f..d8731285 100644 --- a/packages/platformos-check-common/src/frontmatter/index.ts +++ b/packages/platformos-check-common/src/frontmatter/index.ts @@ -1,344 +1,9 @@ -/** - * Frontmatter schema definitions for platformOS Liquid file types. - * - * Each Liquid file type in platformOS has a YAML frontmatter section at the - * top of the file that configures server-side behaviour. The schema for each - * type is different — Pages have slug/layout, Emails have to/from/subject, etc. - * - * This module provides: - * - FrontmatterFieldSchema — type definition for a single field - * - FrontmatterSchema — type definition for a complete schema - * - FRONTMATTER_SCHEMAS — per-type schemas keyed by PlatformOSFileType - * - getFrontmatterSchema() — convenience lookup that returns undefined for - * types without a frontmatter schema - */ - -import { PlatformOSFileType } from '@platformos/platformos-common'; - -// ─── Types ──────────────────────────────────────────────────────────────────── - -export type FrontmatterFieldType = 'string' | 'boolean' | 'integer' | 'number' | 'array' | 'object'; - -export interface FrontmatterFieldSchema { - /** The expected YAML type(s) for this field's value. */ - type: FrontmatterFieldType | FrontmatterFieldType[]; - /** Whether this field must be present. */ - required?: boolean; - /** Human-readable description of this field. */ - description?: string; - /** Whether this field name is deprecated in favour of a newer one. */ - deprecated?: boolean; - /** Message shown when this deprecated field is used. */ - deprecatedMessage?: string; -} - -export interface FrontmatterSchema { - /** Human-readable name of the file type, used in diagnostics. */ - name: string; - /** - * Known frontmatter fields. - * Checks can use this to surface unknown keys or missing required keys. - */ - fields: Record; - /** - * Whether fields not listed in `fields` are allowed without a warning. - * Defaults to true — schemas are additive and may not be exhaustive. - */ - allowAdditionalFields?: boolean; -} - -// ─── Schemas ────────────────────────────────────────────────────────────────── - -/** - * Per-type frontmatter schemas. - * - * Only Liquid file types are present here — GraphQL, YAML, and Asset types - * do not use frontmatter. - * - * Field lists are based on real-world usage in platformOS apps. Set - * `allowAdditionalFields: true` (the default) everywhere so that apps using - * custom/undocumented keys don't get false-positive warnings until the schemas - * are finalised. - */ -export const FRONTMATTER_SCHEMAS: Partial> = { - // ── Page ───────────────────────────────────────────────────────────────────── - [PlatformOSFileType.Page]: { - name: 'Page', - fields: { - slug: { - type: 'string', - description: 'URL slug for this page. Supports dynamic segments (e.g. users/:id).', - }, - layout: { - type: 'string', - description: 'Layout template to wrap this page (path relative to app root, no extension).', - }, - layout_name: { - type: 'string', - description: 'Alias for layout.', - deprecated: true, - deprecatedMessage: 'Use `layout` instead of `layout_name`.', - }, - method: { - type: 'string', - description: 'HTTP method this page responds to (get, post, put, patch, delete).', - }, - authorization_policies: { - type: 'array', - description: 'List of authorization policy names that must pass before rendering.', - }, - response_headers: { - type: 'string', - description: 'Liquid template that renders a JSON object of HTTP response headers.', - }, - metadata: { - type: 'object', - description: 'Arbitrary metadata object (e.g. SEO title/description, robots directives).', - }, - max_deep_level: { - type: 'integer', - description: 'Maximum number of dynamic URL segments to capture.', - }, - searchable: { - type: 'boolean', - description: 'Whether this page is included in platformOS search indexes.', - }, - format: { - type: 'string', - description: 'Response format (html, json, xml, csv, …). Often encoded in the filename.', - }, - }, - allowAdditionalFields: true, - }, - - // ── Layout ─────────────────────────────────────────────────────────────────── - [PlatformOSFileType.Layout]: { - name: 'Layout', - fields: { - name: { - type: 'string', - description: 'Identifier used to reference this layout from pages.', - }, - }, - allowAdditionalFields: true, - }, - - // ── Partial ────────────────────────────────────────────────────────────────── - [PlatformOSFileType.Partial]: { - name: 'Partial', - fields: { - metadata: { - type: 'object', - description: - 'Partial metadata. `metadata.params` declares accepted parameters; `metadata.name` is a human-readable label for the style guide.', - }, - }, - allowAdditionalFields: true, - }, - - // ── AuthorizationPolicy ────────────────────────────────────────────────────── - [PlatformOSFileType.Authorization]: { - name: 'AuthorizationPolicy', - fields: { - name: { - type: 'string', - required: true, - description: 'Unique identifier for this authorization policy.', - }, - redirect_to: { - type: 'string', - description: 'URL to redirect the user to when the policy fails.', - }, - flash_alert: { - type: 'string', - description: 'Flash alert message shown after a failed authorization redirect.', - }, - flash_notice: { - type: 'string', - description: 'Flash notice message shown after a failed authorization redirect.', - }, - }, - allowAdditionalFields: true, - }, - - // ── Email ──────────────────────────────────────────────────────────────────── - [PlatformOSFileType.Email]: { - name: 'Email', - fields: { - to: { - type: 'string', - required: true, - description: 'Recipient email address (may use Liquid).', - }, - from: { - type: 'string', - description: 'Sender email address.', - }, - reply_to: { - type: 'string', - description: 'Reply-to email address.', - }, - cc: { - type: 'string', - description: 'Carbon-copy recipients.', - }, - bcc: { - type: 'string', - description: 'Blind carbon-copy recipients.', - }, - subject: { - type: 'string', - required: true, - description: 'Email subject line (may use Liquid).', - }, - layout_path: { - type: 'string', - description: 'Layout partial to wrap the email body.', - }, - delay: { - type: 'integer', - description: 'Seconds to delay delivery after being triggered.', - }, - enabled: { - type: 'boolean', - description: 'When false, this email is never sent. Defaults to true.', - }, - trigger_condition: { - type: ['boolean', 'string'], - description: - 'Liquid expression or boolean; email is only sent when this evaluates to true.', - }, - }, - allowAdditionalFields: true, - }, - - // ── ApiCall ────────────────────────────────────────────────────────────────── - [PlatformOSFileType.ApiCall]: { - name: 'ApiCall', - fields: { - to: { - type: 'string', - required: true, - description: 'Target URL for the HTTP request (may use Liquid).', - }, - request_type: { - type: 'string', - required: true, - description: 'HTTP method: GET, POST, PUT, PATCH, or DELETE.', - }, - request_headers: { - type: 'string', - description: 'Liquid template rendering a JSON object of request headers.', - }, - headers: { - type: 'string', - description: 'Alias for request_headers.', - deprecated: true, - deprecatedMessage: 'Use `request_headers` instead of `headers`.', - }, - callback: { - type: 'string', - description: 'Liquid template executed after the HTTP response is received.', - }, - delay: { - type: 'integer', - description: 'Seconds to delay the request after being triggered.', - }, - enabled: { - type: 'boolean', - description: 'When false, this API call is never executed. Defaults to true.', - }, - trigger_condition: { - type: ['boolean', 'string'], - description: 'Liquid expression or boolean; call is only made when this evaluates to true.', - }, - format: { - type: 'string', - description: 'Request body encoding format (http, json, …).', - }, - }, - allowAdditionalFields: true, - }, - - // ── Sms ────────────────────────────────────────────────────────────────────── - [PlatformOSFileType.Sms]: { - name: 'SMS', - fields: { - to: { - type: 'string', - required: true, - description: 'Recipient phone number in E.164 format (may use Liquid).', - }, - delay: { - type: 'integer', - description: 'Seconds to delay sending after being triggered.', - }, - enabled: { - type: 'boolean', - description: 'When false, this SMS is never sent. Defaults to true.', - }, - trigger_condition: { - type: ['boolean', 'string'], - description: 'Liquid expression or boolean; SMS is only sent when this evaluates to true.', - }, - }, - allowAdditionalFields: true, - }, - - // ── Migration ──────────────────────────────────────────────────────────────── - [PlatformOSFileType.Migration]: { - name: 'Migration', - fields: {}, - allowAdditionalFields: true, - }, - - // ── FormConfiguration ──────────────────────────────────────────────────────── - [PlatformOSFileType.FormConfiguration]: { - name: 'FormConfiguration', - fields: { - name: { - type: 'string', - required: true, - description: 'Unique identifier for this form, used in include_form / function calls.', - }, - resource: { - type: ['string', 'object'], - description: 'Model or resource type this form operates on.', - }, - resource_owner: { - type: 'string', - description: 'Who owns the resource being created/updated.', - }, - fields: { - type: 'object', - description: 'Field definitions — what data this form accepts and validates.', - }, - redirect_to: { - type: 'string', - description: 'URL to redirect to after a successful form submission.', - }, - flash_notice: { - type: 'string', - description: 'Flash notice message shown after a successful submission.', - }, - flash_alert: { - type: 'string', - description: 'Flash alert message shown after a failed submission.', - }, - }, - allowAdditionalFields: true, - }, -}; - -// ─── Lookup helper ──────────────────────────────────────────────────────────── - -/** - * Returns the frontmatter schema for a given file type, or undefined if no - * schema is defined for that type (e.g. GraphQL, YAML, Asset types). - */ -export function getFrontmatterSchema( - fileType: PlatformOSFileType | undefined, -): FrontmatterSchema | undefined { - if (fileType === undefined) return undefined; - return FRONTMATTER_SCHEMAS[fileType]; -} +// Frontmatter schemas and types live in platformos-common so they can be used +// by other packages without depending on the full linting engine. +export { + type FrontmatterFieldType, + type FrontmatterFieldSchema, + type FrontmatterSchema, + FRONTMATTER_SCHEMAS, + getFrontmatterSchema, +} from '@platformos/platformos-common'; diff --git a/packages/platformos-check-node/configs/all.yml b/packages/platformos-check-node/configs/all.yml index 0209dddd..9d284c60 100644 --- a/packages/platformos-check-node/configs/all.yml +++ b/packages/platformos-check-node/configs/all.yml @@ -91,6 +91,9 @@ UnusedDocParam: ValidDocParamTypes: enabled: true severity: 0 +ValidFrontmatter: + enabled: true + severity: 1 ValidHTMLTranslation: enabled: true severity: 1 diff --git a/packages/platformos-check-node/configs/recommended.yml b/packages/platformos-check-node/configs/recommended.yml index 0209dddd..9d284c60 100644 --- a/packages/platformos-check-node/configs/recommended.yml +++ b/packages/platformos-check-node/configs/recommended.yml @@ -91,6 +91,9 @@ UnusedDocParam: ValidDocParamTypes: enabled: true severity: 0 +ValidFrontmatter: + enabled: true + severity: 1 ValidHTMLTranslation: enabled: true severity: 1 diff --git a/packages/platformos-common/src/documents-locator/DocumentsLocator.ts b/packages/platformos-common/src/documents-locator/DocumentsLocator.ts index 331165ed..25cdc074 100644 --- a/packages/platformos-common/src/documents-locator/DocumentsLocator.ts +++ b/packages/platformos-common/src/documents-locator/DocumentsLocator.ts @@ -1,6 +1,6 @@ import yaml from 'js-yaml'; import { AbstractFileSystem, FileType } from '../AbstractFileSystem'; -import { getAppPaths, getModulePaths, PlatformOSFileType } from '../path-utils'; +import { getAppPaths, getModulePaths, parseModulePrefix, PlatformOSFileType } from '../path-utils'; import { URI, Utils } from 'vscode-uri'; export type DocumentType = @@ -34,9 +34,8 @@ export async function loadSearchPaths( } } -type ModulePathInfo = - | { isModule: false; key: string } - | { isModule: true; moduleName: string; key: string }; +/** Maximum number of concrete paths generated by a single dynamic search-path expansion. */ +const MAX_DYNAMIC_PATH_EXPANSIONS = 100; export class DocumentsLocator { constructor(private readonly fs: AbstractFileSystem) {} @@ -49,17 +48,6 @@ export class DocumentsLocator { } } - private parseModulePath(fileName: string): ModulePathInfo { - if (!fileName.startsWith('modules/')) { - return { isModule: false, key: fileName }; - } - - const [, moduleName, ...rest] = fileName.split('/'); - const key = rest.join('/'); - - return moduleName ? { isModule: true, moduleName, key } : { isModule: false, key: fileName }; - } - private getSearchPaths(type: 'partial' | 'graphql' | 'asset', moduleName?: string): string[] { const fileType: PlatformOSFileType = { partial: PlatformOSFileType.Partial, @@ -75,7 +63,7 @@ export class DocumentsLocator { fileName: string, type: 'partial' | 'graphql' | 'asset', ): Promise { - const parsed = this.parseModulePath(fileName); + const parsed = parseModulePrefix(fileName); const searchPaths = this.getSearchPaths(type, parsed.isModule ? parsed.moduleName : undefined); let targetFile = parsed.key; @@ -101,7 +89,7 @@ export class DocumentsLocator { filePrefix: string, type: 'partial' | 'graphql' | 'asset', ): Promise { - const parsed = this.parseModulePath(filePrefix); + const parsed = parseModulePrefix(filePrefix); const searchPaths = this.getSearchPaths(type, parsed.isModule ? parsed.moduleName : undefined); const results = new Set(); @@ -185,7 +173,8 @@ export class DocumentsLocator { * concrete directory prefixes by enumerating subdirectories at each dynamic * segment. Static segments pass through unchanged. * - * Results are cached per (rootUri, searchPath) and capped at 100 entries. + * Results are cached per (rootUri, searchPath) and capped at + * MAX_DYNAMIC_PATH_EXPANSIONS entries per dynamic segment. */ private async expandDynamicPath(rootUri: URI, searchPath: string): Promise { const segments = searchPath.split('/'); @@ -211,9 +200,9 @@ export class DocumentsLocator { } for (const sub of subdirs) { nextPrefixes.push(prefix ? `${prefix}/${sub}` : sub); - if (nextPrefixes.length >= 100) break; + if (nextPrefixes.length >= MAX_DYNAMIC_PATH_EXPANSIONS) break; } - if (nextPrefixes.length >= 100) break; + if (nextPrefixes.length >= MAX_DYNAMIC_PATH_EXPANSIONS) break; } prefixes = nextPrefixes; } @@ -269,7 +258,7 @@ export class DocumentsLocator { * Returns undefined for theme_render_rc (ambiguous search path) and asset. */ locateDefault(rootUri: URI, nodeName: DocumentType, fileName: string): string | undefined { - const parsed = this.parseModulePath(fileName); + const parsed = parseModulePrefix(fileName); let basePath: string; let ext: string; diff --git a/packages/platformos-common/src/frontmatter.ts b/packages/platformos-common/src/frontmatter.ts new file mode 100644 index 00000000..d0492ed4 --- /dev/null +++ b/packages/platformos-common/src/frontmatter.ts @@ -0,0 +1,533 @@ +/** + * Frontmatter schema definitions for platformOS Liquid file types. + * + * Each Liquid file type in platformOS has a YAML frontmatter section at the + * top of the file that configures server-side behaviour. The schema for each + * type is different — Pages have slug/layout, Emails have to/from/subject, etc. + * + * This module provides: + * - FrontmatterFieldSchema — type definition for a single field + * - FrontmatterSchema — type definition for a complete schema + * - FRONTMATTER_SCHEMAS — per-type schemas keyed by PlatformOSFileType + * - getFrontmatterSchema() — convenience lookup that returns undefined for + * types without a frontmatter schema + */ + +import { FILE_TYPE_DIRS, PlatformOSFileType } from './path-utils'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type FrontmatterFieldType = 'string' | 'boolean' | 'integer' | 'number' | 'array' | 'object'; + +export interface FrontmatterFieldSchema { + /** The expected YAML type(s) for this field's value. */ + type: FrontmatterFieldType | FrontmatterFieldType[]; + /** Whether this field must be present. */ + required?: boolean; + /** Human-readable description of this field. */ + description?: string; + /** Whether this field name is deprecated in favour of a newer one. */ + deprecated?: boolean; + /** Message shown when this deprecated field is used. */ + deprecatedMessage?: string; + /** + * Allowed values for this field. When set, a validator should warn if the + * field value is not one of these entries. + */ + enumValues?: (string | number)[]; +} + +export interface FrontmatterSchema { + /** Human-readable name of the file type, used in diagnostics. */ + name: string; + /** + * Known frontmatter fields. + * Checks can use this to surface unknown keys or missing required keys. + * Any key not listed here will produce an "unknown field" warning. + */ + fields: Record; +} + +// ─── Schemas ────────────────────────────────────────────────────────────────── + +/** + * Per-type frontmatter schemas. + * + * Only Liquid file types are present here — GraphQL, YAML, and Asset types + * do not use frontmatter. + * + * Field lists are derived from the server-side converter files in + * app/services/app_builder/converters/. Enum constraints mirror server validations. + * Unknown keys always produce a warning; file types with arbitrary frontmatter + * (e.g. Migration) are omitted so no validation runs for them. + */ +export const FRONTMATTER_SCHEMAS: Partial> = { + // ── Page ───────────────────────────────────────────────────────────────────── + [PlatformOSFileType.Page]: { + name: 'Page', + fields: { + slug: { + type: 'string', + description: 'URL slug for this page. Supports dynamic segments (e.g. users/:id).', + }, + layout: { + type: 'string', + description: 'Layout template to wrap this page (path relative to app root, no extension).', + }, + layout_name: { + type: 'string', + description: 'Alias for layout.', + deprecated: true, + deprecatedMessage: 'Use `layout` instead of `layout_name`.', + }, + method: { + type: 'string', + description: 'HTTP method this page responds to.', + enumValues: ['delete', 'get', 'patch', 'post', 'put', 'options'], + }, + redirect_to: { + type: 'string', + description: 'URL to redirect to.', + }, + redirect_url: { + type: 'string', + description: 'Alias for redirect_to.', + deprecated: true, + deprecatedMessage: 'Use `redirect_to` instead of `redirect_url`.', + }, + redirect_code: { + type: 'integer', + description: 'HTTP redirect status code.', + enumValues: [301, 302, 307], + }, + authorization_policies: { + type: 'array', + description: 'List of authorization policy names that must pass before rendering.', + }, + response_headers: { + type: 'string', + description: 'Liquid template that renders a JSON object of HTTP response headers.', + }, + metadata: { + type: 'object', + description: 'Arbitrary metadata object (e.g. SEO title/description, robots directives).', + }, + max_deep_level: { + type: 'integer', + description: 'Maximum number of dynamic URL segments to capture.', + }, + searchable: { + type: 'boolean', + description: 'Whether this page is included in platformOS search indexes.', + }, + format: { + type: 'string', + description: 'Response format (html, json, xml, csv, …). Often encoded in the filename.', + }, + default_layout: { + type: 'boolean', + description: 'Use the instance default layout.', + }, + handler: { + type: 'string', + description: 'Template handler type.', + }, + converter: { + type: 'string', + description: 'Content converter, e.g. `markdown`.', + }, + dynamic_cache: { + type: 'object', + description: 'Dynamic cache settings: { key, layout, expire }.', + }, + static_cache: { + type: 'object', + description: 'Static cache settings: { expire }.', + }, + cache_for: { + type: 'integer', + description: 'Static cache expiration in seconds.', + }, + subdomain: { + type: 'string', + description: 'Subdomain routing.', + }, + require_verified_user: { + type: 'boolean', + }, + admin_page: { + type: 'boolean', + }, + enable_profiler: { + type: 'boolean', + }, + metadata_title: { + type: 'string', + description: 'SEO shorthand (alias for metadata.title).', + }, + metadata_meta_description: { + type: 'string', + description: 'SEO meta description shorthand (alias for metadata.meta_description).', + }, + metadata_canonical_url: { + type: 'string', + description: 'Canonical URL shorthand (alias for metadata.canonical_url).', + }, + }, + }, + + // ── Layout ─────────────────────────────────────────────────────────────────── + // liquid_view_converter.rb: primary_key derived from physical_file_path; no `name` property. + [PlatformOSFileType.Layout]: { + name: 'Layout', + fields: { + converter: { + type: 'string', + description: 'Content converter, e.g. `markdown`.', + }, + metadata: { + type: 'object', + }, + }, + }, + + // ── Partial ────────────────────────────────────────────────────────────────── + [PlatformOSFileType.Partial]: { + name: 'Partial', + fields: { + metadata: { + type: 'object', + description: + 'Partial metadata. `metadata.params` declares accepted parameters; `metadata.name` is a human-readable label for the style guide.', + }, + converter: { + type: 'string', + description: 'Content converter, e.g. `markdown`.', + }, + }, + }, + + // ── AuthorizationPolicy ────────────────────────────────────────────────────── + // authorization_policy_converter.rb: name, content, flash_alert, redirect_to, http_status, metadata. + // Note: only flash_alert is supported — flash_notice is NOT a valid property here. + [PlatformOSFileType.Authorization]: { + name: 'AuthorizationPolicy', + fields: { + name: { + type: 'string', + description: 'Unique identifier for this authorization policy.', + }, + redirect_to: { + type: 'string', + description: 'URL to redirect the user to when the policy fails.', + }, + flash_alert: { + type: 'string', + description: 'Flash alert message shown after a failed authorization redirect.', + }, + http_status: { + type: 'integer', + description: 'HTTP status code returned on policy failure.', + enumValues: [403, 404], + }, + metadata: { + type: 'object', + }, + }, + }, + + // ── Email ──────────────────────────────────────────────────────────────────── + [PlatformOSFileType.Email]: { + name: 'Email', + fields: { + name: { + type: 'string', + description: 'Unique identifier for this email notification.', + }, + to: { + type: 'string', + description: 'Recipient email address (may use Liquid).', + }, + from: { + type: 'string', + description: 'Sender email address.', + }, + reply_to: { + type: 'string', + description: 'Reply-to email address.', + }, + cc: { + type: 'string', + description: 'Carbon-copy recipients.', + }, + bcc: { + type: 'string', + description: 'Blind carbon-copy recipients.', + }, + subject: { + type: 'string', + description: 'Email subject line (may use Liquid).', + }, + layout: { + type: 'string', + description: 'Layout partial to wrap the email body.', + }, + layout_path: { + type: 'string', + description: 'Alias for layout.', + deprecated: true, + deprecatedMessage: 'Use `layout` instead of `layout_path`.', + }, + delay: { + type: 'integer', + description: 'Seconds to delay delivery after being triggered.', + }, + enabled: { + type: 'boolean', + description: 'When false, this email is never sent. Defaults to true.', + }, + trigger_condition: { + type: ['boolean', 'string'], + description: + 'Liquid expression or boolean; email is only sent when this evaluates to true.', + }, + locale: { + type: 'string', + description: 'Locale for the email.', + }, + forced: { + type: 'boolean', + }, + attachments: { + type: 'string', + }, + unique_args: { + type: 'object', + description: 'Extra key/value pairs passed to the email delivery provider.', + }, + metadata: { + type: 'object', + }, + }, + }, + + // ── ApiCall ────────────────────────────────────────────────────────────────── + [PlatformOSFileType.ApiCall]: { + name: 'ApiCall', + fields: { + name: { + type: 'string', + description: 'Unique identifier for this API call notification.', + }, + to: { + type: 'string', + description: 'Target URL for the HTTP request (may use Liquid).', + }, + request_type: { + type: 'string', + description: 'HTTP method: GET, POST, PUT, PATCH, or DELETE.', + enumValues: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], + }, + request_headers: { + type: 'string', + description: 'Liquid template rendering a JSON object of request headers.', + }, + headers: { + type: 'string', + description: 'Alias for request_headers.', + deprecated: true, + deprecatedMessage: 'Use `request_headers` instead of `headers`.', + }, + callback: { + type: 'string', + description: 'Liquid template executed after the HTTP response is received.', + }, + delay: { + type: 'integer', + description: 'Seconds to delay the request after being triggered.', + }, + enabled: { + type: 'boolean', + description: 'When false, this API call is never executed. Defaults to true.', + }, + trigger_condition: { + type: ['boolean', 'string'], + description: 'Liquid expression or boolean; call is only made when this evaluates to true.', + }, + format: { + type: 'string', + description: 'Request body encoding format (http, json, …).', + }, + locale: { + type: 'string', + }, + metadata: { + type: 'object', + }, + }, + }, + + // ── Sms ────────────────────────────────────────────────────────────────────── + [PlatformOSFileType.Sms]: { + name: 'SMS', + fields: { + name: { + type: 'string', + description: 'Unique identifier for this SMS notification.', + }, + to: { + type: 'string', + description: 'Recipient phone number in E.164 format (may use Liquid).', + }, + content: { + type: 'string', + description: 'SMS body (may use Liquid).', + }, + delay: { + type: 'integer', + description: 'Seconds to delay sending after being triggered.', + }, + enabled: { + type: 'boolean', + description: 'When false, this SMS is never sent. Defaults to true.', + }, + trigger_condition: { + type: ['boolean', 'string'], + description: 'Liquid expression or boolean; SMS is only sent when this evaluates to true.', + }, + locale: { + type: 'string', + }, + metadata: { + type: 'object', + }, + }, + }, + + // ── FormConfiguration ──────────────────────────────────────────────────────── + [PlatformOSFileType.FormConfiguration]: { + name: 'FormConfiguration', + fields: { + name: { + type: 'string', + description: 'Unique identifier for this form, used in include_form / function calls.', + }, + resource: { + type: ['string', 'object'], + description: 'Model or resource type this form operates on.', + }, + resource_owner: { + type: 'string', + description: 'Who owns the resource being created/updated.', + }, + fields: { + type: 'object', + description: 'Field definitions — what data this form accepts and validates.', + }, + configuration: { + type: 'object', + description: 'Alias for fields.', + }, + redirect_to: { + type: 'string', + description: 'URL to redirect to after a successful form submission.', + }, + return_to: { + type: 'string', + description: 'Alias for redirect_to.', + deprecated: true, + deprecatedMessage: 'Use `redirect_to` instead of `return_to`.', + }, + flash_notice: { + type: 'string', + description: 'Flash notice message shown after a successful submission.', + }, + flash_alert: { + type: 'string', + description: 'Flash alert message shown after a failed submission.', + }, + spam_protection: { + type: 'string', + description: 'Spam protection mechanism to use.', + enumValues: ['recaptcha', 'recaptcha_v2', 'recaptcha_v3', 'hcaptcha'], + }, + request_allowed: { + type: 'boolean', + description: 'Whether the request is allowed. Default: true.', + }, + live_reindex: { + type: 'boolean', + }, + default_payload: { + type: ['string', 'object'], + }, + callback_actions: { + type: 'string', + description: 'Liquid template with GraphQL mutations to run after submission.', + }, + async_callback_actions: { + type: 'object', + description: 'Async callback settings: { content, delay, max_attempts, priority }.', + }, + email_notifications: { + type: 'array', + description: 'Email notification names to trigger after successful form submission.', + }, + sms_notifications: { + type: 'array', + description: 'SMS notification names to trigger after successful form submission.', + }, + api_call_notifications: { + type: 'array', + description: 'API call notification names to trigger after successful form submission.', + }, + response_headers: { + type: ['string', 'object'], + }, + metadata: { + type: 'object', + }, + }, + }, +}; + +// ─── Lookup helper ──────────────────────────────────────────────────────────── + +/** + * Returns the frontmatter schema for a given file type, or undefined if no + * schema is defined for that type (e.g. GraphQL, YAML, Asset types). + */ +export function getFrontmatterSchema( + fileType: PlatformOSFileType | undefined, +): FrontmatterSchema | undefined { + if (fileType === undefined) return undefined; + return FRONTMATTER_SCHEMAS[fileType]; +} + +// ─── Frontmatter association directories ────────────────────────────────────── + +/** + * Maps frontmatter list-field names to their canonical app-relative directory. + * + * Derived from FILE_TYPE_DIRS[type][0] so it always stays in sync with the + * single source of truth in path-utils.ts. + * + * Used by both the ValidFrontmatter check (file-existence validation) and the + * FrontmatterDefinitionProvider (go-to-definition) to resolve association paths. + */ +export const FRONTMATTER_ASSOCIATION_DIRS: Readonly<Record<string, string>> = { + authorization_policies: FILE_TYPE_DIRS[PlatformOSFileType.Authorization][0], + email_notifications: FILE_TYPE_DIRS[PlatformOSFileType.Email][0], + sms_notifications: FILE_TYPE_DIRS[PlatformOSFileType.Sms][0], + api_call_notifications: FILE_TYPE_DIRS[PlatformOSFileType.ApiCall][0], +}; + +// ─── Liquid expression detection ────────────────────────────────────────────── + +/** + * Returns true if the value contains a Liquid output tag (`{{`) or tag (`{%`). + * Used to skip static validation of dynamic frontmatter values. + */ +export function containsLiquid(value: string): boolean { + return value.includes('{{') || value.includes('{%'); +} diff --git a/packages/platformos-common/src/index.ts b/packages/platformos-common/src/index.ts index f17b5ae1..d47279a2 100644 --- a/packages/platformos-common/src/index.ts +++ b/packages/platformos-common/src/index.ts @@ -3,3 +3,4 @@ export * from './translation-provider/TranslationProvider'; export * from './route-table'; export * from './AbstractFileSystem'; export * from './path-utils'; +export * from './frontmatter'; diff --git a/packages/platformos-common/src/path-utils.ts b/packages/platformos-common/src/path-utils.ts index 6276ad4f..6ddf5270 100644 --- a/packages/platformos-common/src/path-utils.ts +++ b/packages/platformos-common/src/path-utils.ts @@ -220,6 +220,19 @@ export function isKnownLiquidFile(uri: UriString): boolean { return type !== undefined && LIQUID_FILE_TYPES.has(type); } +/** + * Returns true if the URI has a `.liquid` extension but does not match any + * recognized platformOS directory. Useful for detecting misplaced files that + * the server will silently ignore. + * + * @example + * isUnclassifiedLiquidFile('file:///project/scripts/helper.liquid') // → true + * isUnclassifiedLiquidFile('file:///project/app/views/pages/home.liquid') // → false (Page) + */ +export function isUnclassifiedLiquidFile(uri: UriString): boolean { + return uri.endsWith('.liquid') && getFileType(uri) === undefined; +} + /** * Returns true if the URI belongs to a recognized platformOS GraphQL directory * and should be linted. Files outside known directories (e.g. generator @@ -264,3 +277,45 @@ export function isMigration(uri: UriString): boolean { export function isFormConfiguration(uri: UriString): boolean { return getFileType(uri) === PlatformOSFileType.FormConfiguration; } + +// ─── Module prefix utilities ────────────────────────────────────────────────── + +/** + * Result of parsing a `modules/{name}/...` prefix from a path or key. + * Used by DocumentsLocator and TranslationProvider to route lookups to the + * correct module directory. + */ +export type ModulePrefix = + | { isModule: false; key: string } + | { isModule: true; moduleName: string; key: string }; + +/** + * Parse a `modules/{name}/{rest}` prefix from a path or translation key. + * Returns the module name and the remaining key, or marks it as non-module. + * + * @example + * parseModulePrefix('modules/community/components/card') // → { isModule: true, moduleName: 'community', key: 'components/card' } + * parseModulePrefix('modules/community/hello.world') // → { isModule: true, moduleName: 'community', key: 'hello.world' } + * parseModulePrefix('app/views/partials/card') // → { isModule: false, key: 'app/views/partials/card' } + * parseModulePrefix('modules/community') // → { isModule: false, key: 'modules/community' } (no key segment) + */ +export function parseModulePrefix(path: string): ModulePrefix { + if (!path.startsWith('modules/')) { + return { isModule: false, key: path }; + } + + const withoutPrefix = path.slice('modules/'.length); + const slashIdx = withoutPrefix.indexOf('/'); + + if (slashIdx === -1) { + // Just "modules/name" with no key segment + return { isModule: false, key: path }; + } + + const moduleName = withoutPrefix.slice(0, slashIdx); + const key = withoutPrefix.slice(slashIdx + 1); + + // moduleName must be non-empty to be a valid module prefix. + // key may be empty (e.g. 'modules/users/') — that means "all files in the module". + return moduleName ? { isModule: true, moduleName, key } : { isModule: false, key: path }; +} diff --git a/packages/platformos-common/src/route-table/RouteTable.ts b/packages/platformos-common/src/route-table/RouteTable.ts index 725c87dd..49b9fe3e 100644 --- a/packages/platformos-common/src/route-table/RouteTable.ts +++ b/packages/platformos-common/src/route-table/RouteTable.ts @@ -16,7 +16,9 @@ function extractFrontmatter(source: string): PageFrontmatter | null { const trimmed = source.trimStart(); if (!trimmed.startsWith('---')) return null; - const end = trimmed.indexOf('---', 3); + // Search for the closing delimiter as `\n---` so we don't accidentally match + // a `---` sequence that appears inside a YAML value (e.g. `slug: my---slug`). + const end = trimmed.indexOf('\n---', 3); if (end === -1) return null; const yamlBlock = trimmed.slice(3, end).trim(); @@ -48,7 +50,7 @@ function extractFrontmatter(source: string): PageFrontmatter | null { * file:///project/app/views/pages/about.html.liquid -> about.html.liquid * file:///project/modules/admin/public/views/pages/dashboard.html.liquid -> dashboard.html.liquid */ -function extractRelativePagePath(uri: string): string | null { +export function extractRelativePagePath(uri: string): string | null { const patterns = [ // App-level pages: app/views/pages/ or marketplace_builder/pages/ /\/(app|marketplace_builder)\/(views\/pages|pages)\//, @@ -187,6 +189,21 @@ export class RouteTable { return this._built; } + /** + * Populate the route table from an in-memory collection of URI → content + * pairs, bypassing the filesystem entirely. Useful for warming the table + * from a DocumentManager without a full disk scan on startup. + * + * Only page URIs are registered; non-page URIs are silently skipped. + */ + buildFromEntries(entries: Iterable<[uri: string, content: string]>): void { + this.routes.clear(); + for (const [uri, content] of entries) { + this.addPageFromContent(uri, content); + } + this._built = true; + } + async build(rootUri: URI): Promise<void> { this.routes.clear(); diff --git a/packages/platformos-common/src/route-table/index.ts b/packages/platformos-common/src/route-table/index.ts index c00df20a..f9d85c75 100644 --- a/packages/platformos-common/src/route-table/index.ts +++ b/packages/platformos-common/src/route-table/index.ts @@ -1,4 +1,4 @@ -export { RouteTable } from './RouteTable'; +export { RouteTable, extractRelativePagePath } from './RouteTable'; export { slugFromFilePath, formatFromFilePath, KNOWN_FORMATS } from './slugFromFilePath'; export { parseSlug, calculatePrecedence } from './parseSlug'; export type { RouteEntry, RouteSegment } from './types'; diff --git a/packages/platformos-common/src/translation-provider/TranslationProvider.ts b/packages/platformos-common/src/translation-provider/TranslationProvider.ts index 96165748..9715295b 100644 --- a/packages/platformos-common/src/translation-provider/TranslationProvider.ts +++ b/packages/platformos-common/src/translation-provider/TranslationProvider.ts @@ -1,14 +1,34 @@ import { AbstractFileSystem, FileType } from '../AbstractFileSystem'; +import { parseModulePrefix } from '../path-utils'; import { URI, Utils } from 'vscode-uri'; import yaml from 'js-yaml'; -type ModuleKeyInfo = - | { isModule: false; key: string } - | { isModule: true; moduleName: string; key: string }; - export class TranslationProvider { constructor(private readonly fs: AbstractFileSystem) {} + /** Cache for filesystem-only translation loads (bypassed when contentOverride is set). */ + private translationsCache = new Map<string, Record<string, any>>(); + + /** + * Invalidate cached translations. Call after any translation file is written + * to disk so subsequent calls re-read from the filesystem. + * + * Omitting `uri` clears the entire cache. + * Passing a `uri` removes only the entries whose base directory contains that file. + */ + clearTranslationsCache(uri?: string): void { + if (!uri) { + this.translationsCache.clear(); + return; + } + for (const key of this.translationsCache.keys()) { + const baseUri = key.slice(0, key.lastIndexOf(':')); + if (uri.startsWith(baseUri)) { + this.translationsCache.delete(key); + } + } + } + private async isFile(path: string): Promise<boolean> { try { return (await this.fs.stat(path)).type === FileType.File; @@ -42,16 +62,6 @@ export class TranslationProvider { return true; } - private parseModuleKey(translationKey: string): ModuleKeyInfo { - if (!translationKey.startsWith('modules/')) { - return { isModule: false, key: translationKey }; - } - - const [, moduleName, key] = translationKey.split('/', 3); - - return key ? { isModule: true, moduleName, key } : { isModule: false, key: translationKey }; - } - static getSearchPaths(moduleName?: string): string[] { if (!moduleName) { return ['app/translations']; @@ -70,7 +80,7 @@ export class TranslationProvider { translationKey: string, defaultLocale: string, ): Promise<[string | undefined, string | undefined]> { - const parsed = this.parseModuleKey(translationKey); + const parsed = parseModulePrefix(translationKey); if (!parsed.key) { return [undefined, undefined]; @@ -129,6 +139,14 @@ export class TranslationProvider { locale: string, contentOverride?: (uri: string) => string | undefined, ): Promise<Record<string, any>> { + const cacheKey = `${translationBaseUri.toString()}:${locale}`; + + // Return cached result when the caller has no editor overrides (e.g. linter/CI). + // Skip cache when contentOverride is set — unsaved buffer content may differ from disk. + if (!contentOverride && this.translationsCache.has(cacheKey)) { + return this.translationsCache.get(cacheKey)!; + } + const merged: Record<string, any> = {}; const read = async (uri: string): Promise<string | undefined> => { @@ -158,6 +176,10 @@ export class TranslationProvider { } } + if (!contentOverride) { + this.translationsCache.set(cacheKey, merged); + } + return merged; } diff --git a/packages/platformos-language-server-common/src/completions/CompletionsProvider.ts b/packages/platformos-language-server-common/src/completions/CompletionsProvider.ts index 402ab2e9..8bb34b48 100644 --- a/packages/platformos-language-server-common/src/completions/CompletionsProvider.ts +++ b/packages/platformos-language-server-common/src/completions/CompletionsProvider.ts @@ -3,8 +3,13 @@ import { SourceCodeType, PlatformOSDocset, } from '@platformos/platformos-check-common'; -import type { AbstractFileSystem, DocumentsLocator } from '@platformos/platformos-common'; +import { + type AbstractFileSystem, + type DocumentsLocator, + FileType, +} from '@platformos/platformos-common'; import { CompletionItem, CompletionParams } from 'vscode-languageserver'; +import { URI, Utils } from 'vscode-uri'; import { TypeSystem } from '../TypeSystem'; import { DocumentManager } from '../documents'; import { FindAppRootURI } from '../internal-types'; @@ -25,6 +30,9 @@ import { PartialCompletionProvider, RenderPartialParameterCompletionProvider, TranslationCompletionProvider, + FrontmatterKeyCompletionProvider, + GetLayoutNamesForURI, + GetAuthPolicyNamesForURI, } from './providers'; import { GetPartialNamesForURI } from './providers/PartialCompletionProvider'; import { GraphQLFieldCompletionProvider } from './providers/GraphQLFieldCompletionProvider'; @@ -35,7 +43,7 @@ export interface CompletionProviderDependencies { getTranslationsForURI?: GetTranslationsForURI; getPartialNamesForURI?: GetPartialNamesForURI; getDocDefinitionForURI?: GetDocDefinitionForURI; - /** File system for reading GraphQL files */ + /** File system for reading GraphQL files and listing frontmatter-referenced files */ fs?: AbstractFileSystem; /** Locator for finding documents by type */ documentsLocator?: DocumentsLocator; @@ -44,6 +52,10 @@ export interface CompletionProviderDependencies { log?: (message: string) => void; /** Callback to notify when unable to infer properties for a variable */ notifyUnableToInferProperties?: (variableName: string) => void; + /** Override for listing available layout names (used in frontmatter value completions) */ + getLayoutNamesForURI?: GetLayoutNamesForURI; + /** Override for listing available authorization policy names */ + getAuthPolicyNamesForURI?: GetAuthPolicyNamesForURI; } export class CompletionsProvider { @@ -63,6 +75,8 @@ export class CompletionsProvider { documentsLocator, findAppRootURI, log = () => {}, + getLayoutNamesForURI, + getAuthPolicyNamesForURI, }: CompletionProviderDependencies) { this.documentManager = documentManager; this.platformosDocset = platformosDocset; @@ -73,6 +87,27 @@ export class CompletionsProvider { documentManager, ); + // Build layout/policy name callbacks from fs+findAppRootURI when not explicitly provided + let layoutNames: GetLayoutNamesForURI | undefined = getLayoutNamesForURI; + let authPolicyNames: GetAuthPolicyNamesForURI | undefined = getAuthPolicyNamesForURI; + + if (fs && findAppRootURI) { + if (!layoutNames) { + layoutNames = async (uri: string) => { + const rootUri = await findAppRootURI(uri); + if (!rootUri) return []; + return listLayoutNames(fs, URI.parse(rootUri)); + }; + } + if (!authPolicyNames) { + authPolicyNames = async (uri: string) => { + const rootUri = await findAppRootURI(uri); + if (!rootUri) return []; + return listAuthPolicyNames(fs, URI.parse(rootUri)); + }; + } + } + this.providers = [ new HtmlTagCompletionProvider(), new HtmlAttributeCompletionProvider(documentManager), @@ -87,6 +122,7 @@ export class CompletionsProvider { new FilterNamedParameterCompletionProvider(platformosDocset), new LiquidDocTagCompletionProvider(), new LiquidDocParamTypeCompletionProvider(platformosDocset), + new FrontmatterKeyCompletionProvider(layoutNames, authPolicyNames), ]; } @@ -116,3 +152,85 @@ export class CompletionsProvider { } } } + +// ── File listing helpers ───────────────────────────────────────────────────── + +/** Recursively list .liquid files under a URI directory. Returns full URI strings. */ +async function listLiquidFilesRecursively(fs: AbstractFileSystem, dirUri: URI): Promise<string[]> { + let entries: [string, FileType][]; + try { + entries = await fs.readDirectory(dirUri.toString()); + } catch { + return []; + } + + const results: string[] = []; + for (const [entryUri, entryType] of entries) { + if (entryType === FileType.Directory) { + const sub = await listLiquidFilesRecursively(fs, URI.parse(entryUri)); + results.push(...sub); + } else if (entryType === FileType.File && entryUri.endsWith('.liquid')) { + results.push(entryUri); + } + } + return results; +} + +async function listLayoutNames(fs: AbstractFileSystem, root: URI): Promise<string[]> { + const names: string[] = []; + + // App layouts: app/views/layouts/**/*.liquid + const appLayoutsDir = Utils.joinPath(root, 'app', 'views', 'layouts'); + const appBase = appLayoutsDir.toString() + '/'; + for (const uri of await listLiquidFilesRecursively(fs, appLayoutsDir)) { + const rel = uri.startsWith(appBase) ? uri.slice(appBase.length) : uri; + names.push(rel.replace(/\.liquid$/, '')); + } + + // Module layouts from both modules/ and app/modules/ (overwrites). + // Both are reported as modules/{mod}/{rest} — the Set below deduplicates them. + for (const modulesRoot of ['modules', 'app/modules'] as const) { + let moduleEntries: [string, FileType][] = []; + try { + moduleEntries = await fs.readDirectory(Utils.joinPath(root, modulesRoot).toString()); + } catch { + /* directory does not exist */ + } + + for (const [modDirUri, modType] of moduleEntries) { + if (modType !== FileType.Directory) continue; + const modName = modDirUri.replace(/\/$/, '').split('/').at(-1)!; + for (const visibility of ['public', 'private'] as const) { + const layoutsDir = Utils.joinPath(URI.parse(modDirUri), visibility, 'views', 'layouts'); + const base = layoutsDir.toString() + '/'; + for (const uri of await listLiquidFilesRecursively(fs, layoutsDir)) { + const rest = uri.startsWith(base) ? uri.slice(base.length) : uri; + names.push(`modules/${modName}/${rest.replace(/\.liquid$/, '')}`); + } + } + } + } + + return [...new Set(names)].sort(); +} + +async function listAuthPolicyNames(fs: AbstractFileSystem, root: URI): Promise<string[]> { + const dir = Utils.joinPath(root, 'app', 'authorization_policies'); + let entries: [string, FileType][] = []; + try { + entries = await fs.readDirectory(dir.toString()); + } catch { + return []; + } + + return entries + .filter(([uri, type]) => type === FileType.File && uri.endsWith('.liquid')) + .map(([uri]) => + uri + .replace(/\/$/, '') + .split('/') + .at(-1)! + .replace(/\.liquid$/, ''), + ) + .sort(); +} diff --git a/packages/platformos-language-server-common/src/completions/providers/FrontmatterKeyCompletionProvider.spec.ts b/packages/platformos-language-server-common/src/completions/providers/FrontmatterKeyCompletionProvider.spec.ts new file mode 100644 index 00000000..946ff4f5 --- /dev/null +++ b/packages/platformos-language-server-common/src/completions/providers/FrontmatterKeyCompletionProvider.spec.ts @@ -0,0 +1,196 @@ +import { describe, beforeEach, it, expect } from 'vitest'; +import { CompletionsProvider } from '../CompletionsProvider'; +import { DocumentManager } from '../../documents'; + +const mockDocset = { + graphQL: async () => null, + filters: async () => [], + objects: async () => [], + liquidDrops: async () => [], + tags: async () => [], +}; + +describe('Module: FrontmatterKeyCompletionProvider', async () => { + let provider: CompletionsProvider; + + beforeEach(async () => { + provider = new CompletionsProvider({ + documentManager: new DocumentManager(), + platformosDocset: mockDocset, + }); + }); + + it('completes a key from a prefix inside page frontmatter', async () => { + // "slu" prefix matches only "slug" in the Page schema + await expect(provider).to.complete( + { + source: `---\nslu█\n---\n{{ content }}`, + relativePath: 'app/views/pages/test.html.liquid', + }, + ['slug'], + ); + }); + + it('completes a key from a prefix inside form_configurations frontmatter', async () => { + // "nam" prefix matches only "name" in the FormConfiguration schema + await expect(provider).to.complete( + { + source: `---\nnam█\n---\n`, + relativePath: 'app/form_configurations/test.liquid', + }, + ['name'], + ); + }); + + it('does not complete in value position for fields without enum values', async () => { + // "slug" has no enumValues — value position should return nothing + await expect(provider).to.complete( + { + source: `---\nslug: █\n---\n{{ content }}`, + relativePath: 'app/views/pages/test.html.liquid', + }, + [], + ); + }); + + it('completes enum values for the method field', async () => { + await expect(provider).to.complete( + { + source: `---\nmethod: █\n---\n{{ content }}`, + relativePath: 'app/views/pages/test.html.liquid', + }, + expect.arrayContaining([ + { label: 'get', kind: 12 }, + { label: 'post', kind: 12 }, + ]), + ); + }); + + it('filters enum completions by prefix', async () => { + await expect(provider).to.complete( + { + source: `---\nmethod: po█\n---\n{{ content }}`, + relativePath: 'app/views/pages/test.html.liquid', + }, + ['post'], + ); + }); + + it('completes layout names when getLayoutNamesForURI is provided', async () => { + const providerWithLayouts = new CompletionsProvider({ + documentManager: new DocumentManager(), + platformosDocset: mockDocset, + getLayoutNamesForURI: async () => ['application', 'auth', 'modules/community/base'], + }); + await expect(providerWithLayouts).to.complete( + { + source: `---\nlayout: app█\n---\n{{ content }}`, + relativePath: 'app/views/pages/test.html.liquid', + }, + ['application'], + ); + }); + + it('includes app/modules overwrite layouts alongside module layouts in completions', async () => { + // When both app/modules/community/public/views/layouts/base.liquid (overwrite) and + // the original modules/community/public/views/layouts/base.liquid are present, + // both appear as 'modules/community/base' and Set deduplication yields a single entry. + const providerWithLayouts = new CompletionsProvider({ + documentManager: new DocumentManager(), + platformosDocset: mockDocset, + getLayoutNamesForURI: async () => ['modules/community/base'], + }); + await expect(providerWithLayouts).to.complete( + { + source: `---\nlayout: modules/█\n---\n{{ content }}`, + relativePath: 'app/views/pages/test.html.liquid', + }, + ['modules/community/base'], + ); + }); + + it('filters module layout names by modules/ prefix', async () => { + const providerWithLayouts = new CompletionsProvider({ + documentManager: new DocumentManager(), + platformosDocset: mockDocset, + getLayoutNamesForURI: async () => ['application', 'auth', 'modules/community/base'], + }); + await expect(providerWithLayouts).to.complete( + { + source: `---\nlayout: modules/█\n---\n{{ content }}`, + relativePath: 'app/views/pages/test.html.liquid', + }, + ['modules/community/base'], + ); + }); + + it('returns no layout completions when getLayoutNamesForURI is not configured', async () => { + await expect(provider).to.complete( + { + source: `---\nlayout: █\n---\n{{ content }}`, + relativePath: 'app/views/pages/test.html.liquid', + }, + [], + ); + }); + + it('completes auth policy list items when getAuthPolicyNamesForURI is provided', async () => { + const providerWithPolicies = new CompletionsProvider({ + documentManager: new DocumentManager(), + platformosDocset: mockDocset, + getAuthPolicyNamesForURI: async () => ['is_authenticated', 'is_admin'], + }); + await expect(providerWithPolicies).to.complete( + { + source: `---\nauthorization_policies:\n - is_a█\n---\n{{ content }}`, + relativePath: 'app/views/pages/test.html.liquid', + }, + expect.arrayContaining([{ label: 'is_admin', kind: 12 }]), + ); + }); + + it('completes enum values for the request_type field on ApiCall', async () => { + await expect(provider).to.complete( + { + source: `---\nrequest_type: █\n---\n`, + relativePath: 'app/notifications/api_call_notifications/test.liquid', + }, + expect.arrayContaining([ + { label: 'GET', kind: 12 }, + { label: 'POST', kind: 12 }, + { label: 'DELETE', kind: 12 }, + ]), + ); + }); + + it('does not complete outside the frontmatter', async () => { + await expect(provider).to.complete( + { + source: `---\nslug: /home\n---\n{{ █ }}`, + relativePath: 'app/views/pages/test.html.liquid', + }, + [], + ); + }); + + it('does not complete for files with no known schema', async () => { + await expect(provider).to.complete( + { + source: `---\nslu█\n---\n{{ content }}`, + relativePath: 'some/random/path/file.liquid', + }, + [], + ); + }); + + it('excludes already-used keys from completions', async () => { + // slug is already present — "slu" prefix should return nothing + await expect(provider).to.complete( + { + source: `---\nslug: /home\nslu█\n---\n{{ content }}`, + relativePath: 'app/views/pages/test.html.liquid', + }, + [], + ); + }); +}); diff --git a/packages/platformos-language-server-common/src/completions/providers/FrontmatterKeyCompletionProvider.ts b/packages/platformos-language-server-common/src/completions/providers/FrontmatterKeyCompletionProvider.ts new file mode 100644 index 00000000..c6e7109e --- /dev/null +++ b/packages/platformos-language-server-common/src/completions/providers/FrontmatterKeyCompletionProvider.ts @@ -0,0 +1,182 @@ +import { NodeTypes, YAMLFrontmatter } from '@platformos/liquid-html-parser'; +import { getFrontmatterSchema, getFileType } from '@platformos/platformos-common'; +import { CompletionItem, CompletionItemKind } from 'vscode-languageserver'; +import { LiquidCompletionParams } from '../params'; +import { Provider } from './common'; + +export type GetLayoutNamesForURI = (uri: string) => Promise<string[]>; +export type GetAuthPolicyNamesForURI = (uri: string) => Promise<string[]>; + +export class FrontmatterKeyCompletionProvider implements Provider { + constructor( + private readonly getLayoutNamesForURI?: GetLayoutNamesForURI, + private readonly getAuthPolicyNamesForURI?: GetAuthPolicyNamesForURI, + ) {} + + async completions(params: LiquidCompletionParams): Promise<CompletionItem[]> { + const { document } = params; + + // Use the full document AST — the partial AST used for other completions is truncated + // at the cursor, so the frontmatter closing "---" is never present there. + const ast = document.ast; + if (ast instanceof Error || ast.type !== NodeTypes.Document) return []; + + const frontmatterNode = ast.children.find( + (child): child is YAMLFrontmatter => child.type === NodeTypes.YAMLFrontmatter, + ); + if (!frontmatterNode) return []; + + const schema = getFrontmatterSchema(getFileType(params.textDocument.uri)); + if (!schema) return []; + + // Locate the YAML body within the source: skip the opening "---\n" + const source = document.textDocument.getText(); + const bodyStart = source.indexOf('\n', frontmatterNode.position.start) + 1; + const bodyEnd = bodyStart + frontmatterNode.body.length; + + const cursor = document.textDocument.offsetAt(params.position); + if (cursor < bodyStart || cursor > bodyEnd) return []; + + const cursorInBody = cursor - bodyStart; + + // Determine what context the cursor is in based on the current line text. + const bodyUpToCursor = frontmatterNode.body.slice(0, cursorInBody); + const lastNewline = bodyUpToCursor.lastIndexOf('\n'); + const currentLineText = bodyUpToCursor.slice(lastNewline + 1); + + // ── List-item completion ───────────────────────────────────────────────── + // Must be checked before colonIndex since list items have no colon. + const listItemMatch = currentLineText.match(/^(\s*)-\s*(.*)/); + if (listItemMatch) { + const partial = listItemMatch[2]; + const parentKey = findParentKey(bodyUpToCursor); + return this.listItemCompletions(parentKey, partial, params.textDocument.uri); + } + + const colonIndex = currentLineText.indexOf(':'); + + if (colonIndex === -1) { + // ── Key completion ──────────────────────────────────────────────────── + return this.keyCompletions(frontmatterNode.body, currentLineText, schema); + } + + // ── Scalar value completion ─────────────────────────────────────────────── + const key = currentLineText.slice(0, colonIndex).trim(); + const afterColon = currentLineText.slice(colonIndex + 1); + const rawPartial = afterColon.trimStart(); + // Strip enclosing quotes for prefix matching, but keep raw text for filtering + const partial = rawPartial.replace(/^['"]/, '').replace(/['"]$/, ''); + + return this.valueCompletions(key, partial, schema, params.textDocument.uri); + } + + // ── Key completions ───────────────────────────────────────────────────────── + + private keyCompletions( + body: string, + currentLineText: string, + schema: ReturnType<typeof getFrontmatterSchema>, + ): CompletionItem[] { + if (!schema) return []; + const partial = currentLineText.trimStart(); + + // Collect keys already present so we can omit them. + const usedKeys = new Set<string>(); + const keyRegex = /^([a-zA-Z_][a-zA-Z0-9_]*):/gm; + let match: RegExpExecArray | null; + while ((match = keyRegex.exec(body)) !== null) { + usedKeys.add(match[1]); + } + + return Object.entries(schema.fields) + .filter(([key]) => !usedKeys.has(key) && key.startsWith(partial)) + .map(([key, fieldSchema]): CompletionItem => { + const typeStr = Array.isArray(fieldSchema.type) + ? fieldSchema.type.join(' | ') + : fieldSchema.type; + const tags = [ + fieldSchema.required ? 'required' : undefined, + fieldSchema.deprecated ? 'deprecated' : undefined, + ].filter(Boolean); + const detail = tags.length > 0 ? `${typeStr} (${tags.join(', ')})` : typeStr; + + return { + label: key, + kind: CompletionItemKind.Field, + detail, + documentation: fieldSchema.description + ? { kind: 'markdown', value: fieldSchema.description } + : undefined, + insertText: key + ': ', + }; + }); + } + + // ── Value completions for scalar fields ───────────────────────────────────── + + private async valueCompletions( + key: string, + partial: string, + schema: ReturnType<typeof getFrontmatterSchema>, + uri: string, + ): Promise<CompletionItem[]> { + if (!schema) return []; + + // Layout field — list available layout files + if (key === 'layout' || key === 'layout_name') { + const names = (await this.getLayoutNamesForURI?.(uri)) ?? []; + return names + .filter((n) => n.startsWith(partial)) + .map((n) => ({ label: n, kind: CompletionItemKind.Value })); + } + + // Fields with enum values + const fieldSchema = schema.fields[key]; + if (fieldSchema?.enumValues) { + return fieldSchema.enumValues + .map(String) + .filter((v) => v.startsWith(partial)) + .map((v) => ({ + label: v, + kind: CompletionItemKind.Value, + })); + } + + return []; + } + + // ── List-item completions (authorization_policies, etc.) ───────────────────── + + private async listItemCompletions( + parentKey: string | undefined, + partial: string, + uri: string, + ): Promise<CompletionItem[]> { + if (parentKey === 'authorization_policies') { + const names = (await this.getAuthPolicyNamesForURI?.(uri)) ?? []; + return names + .filter((n) => n.startsWith(partial)) + .map((n) => ({ label: n, kind: CompletionItemKind.Value })); + } + return []; + } +} + +/** Walk backwards through the YAML body up-to-cursor to find the key that + * owns the current list block (the first non-indented, non-list-item line). */ +function findParentKey(bodyUpToCursor: string): string | undefined { + const lines = bodyUpToCursor.split('\n'); + // Start from the second-to-last line (the current line is the last) + for (let i = lines.length - 2; i >= 0; i--) { + const line = lines[i]; + if (!line.trim()) continue; // skip blank lines + // List item line — keep walking up + if (/^\s+-/.test(line)) continue; + // Top-level key line + const match = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*):/); + if (match) return match[1]; + // Anything else — stop + break; + } + return undefined; +} diff --git a/packages/platformos-language-server-common/src/completions/providers/index.ts b/packages/platformos-language-server-common/src/completions/providers/index.ts index f98102a6..6b7ba2db 100644 --- a/packages/platformos-language-server-common/src/completions/providers/index.ts +++ b/packages/platformos-language-server-common/src/completions/providers/index.ts @@ -14,4 +14,9 @@ export { } from './RenderPartialParameterCompletionProvider'; export { LiquidDocTagCompletionProvider } from './LiquidDocTagCompletionProvider'; export { LiquidDocParamTypeCompletionProvider } from './LiquidDocParamTypeCompletionProvider'; +export { + FrontmatterKeyCompletionProvider, + GetLayoutNamesForURI, + GetAuthPolicyNamesForURI, +} from './FrontmatterKeyCompletionProvider'; export { Provider } from './common/Provider'; diff --git a/packages/platformos-language-server-common/src/definitions/DefinitionProvider.ts b/packages/platformos-language-server-common/src/definitions/DefinitionProvider.ts index a24b1187..fe24fef1 100644 --- a/packages/platformos-language-server-common/src/definitions/DefinitionProvider.ts +++ b/packages/platformos-language-server-common/src/definitions/DefinitionProvider.ts @@ -5,6 +5,7 @@ import { DefinitionLink, DefinitionParams } from 'vscode-languageserver'; import { AugmentedJsonSourceCode, DocumentManager } from '../documents'; import { SearchPathsLoader } from '../utils/searchPaths'; import { BaseDefinitionProvider } from './BaseDefinitionProvider'; +import { FrontmatterDefinitionProvider } from './providers/FrontmatterDefinitionProvider'; import { PageRouteDefinitionProvider } from './providers/PageRouteDefinitionProvider'; import { RenderPartialDefinitionProvider } from './providers/RenderPartialDefinitionProvider'; import { TranslationStringDefinitionProvider } from './providers/TranslationStringDefinitionProvider'; @@ -28,6 +29,7 @@ export class DefinitionProvider { if (fs && findAppRootURI) { this.pageRouteProvider = new PageRouteDefinitionProvider(documentManager, fs, findAppRootURI); this.providers.push(this.pageRouteProvider); + this.providers.push(new FrontmatterDefinitionProvider(documentManager, fs, findAppRootURI)); if (documentsLocator && searchPathsCache) { this.providers.push( diff --git a/packages/platformos-language-server-common/src/definitions/providers/FrontmatterDefinitionProvider.spec.ts b/packages/platformos-language-server-common/src/definitions/providers/FrontmatterDefinitionProvider.spec.ts new file mode 100644 index 00000000..e8fc8aca --- /dev/null +++ b/packages/platformos-language-server-common/src/definitions/providers/FrontmatterDefinitionProvider.spec.ts @@ -0,0 +1,463 @@ +import { describe, it, expect } from 'vitest'; +import { MockFileSystem } from '@platformos/platformos-check-common/src/test'; +import { DefinitionParams, Position } from 'vscode-languageserver-protocol'; +import { DocumentManager } from '../../documents'; +import { FrontmatterDefinitionProvider } from './FrontmatterDefinitionProvider'; + +const rootUri = 'file:///project'; +const pageUri = 'file:///project/app/views/pages/index.liquid'; +const emailUri = 'file:///project/app/emails/welcome.liquid'; +const formUri = 'file:///project/app/forms/signup.liquid'; + +function setup(files: Record<string, string>) { + const documentManager = new DocumentManager(); + const mockFs = new MockFileSystem(files); + const provider = new FrontmatterDefinitionProvider(documentManager, mockFs, async () => rootUri); + return { documentManager, provider }; +} + +function makeParams(uri: string, line: number, character: number): DefinitionParams { + return { + textDocument: { uri }, + position: Position.create(line, character), + }; +} + +// ── Layout field (Page) ────────────────────────────────────────────────────── + +describe('FrontmatterDefinitionProvider', () => { + describe('layout field on Page', () => { + it('resolves an app layout', async () => { + const source = `---\nlayout: application\n---\n{{ content }}`; + const { documentManager, provider } = setup({ + 'project/app/views/layouts/application.liquid': '{{ content }}', + }); + documentManager.open(pageUri, source, 1); + + const result = await provider.definitions(makeParams(pageUri, 1, 10), null as any, []); + + expect(result).toHaveLength(1); + expect(result[0].targetUri).toBe('file:///project/app/views/layouts/application.liquid'); + }); + + it('resolves a module layout (public visibility)', async () => { + const source = `---\nlayout: modules/community/base\n---\n`; + const { documentManager, provider } = setup({ + 'project/modules/community/public/views/layouts/base.liquid': '{{ content }}', + }); + documentManager.open(pageUri, source, 1); + + const result = await provider.definitions(makeParams(pageUri, 1, 10), null as any, []); + + expect(result).toHaveLength(1); + expect(result[0].targetUri).toBe( + 'file:///project/modules/community/public/views/layouts/base.liquid', + ); + }); + + it('resolves a module layout (private visibility)', async () => { + const source = `---\nlayout: modules/community/base\n---\n`; + const { documentManager, provider } = setup({ + 'project/modules/community/private/views/layouts/base.liquid': '{{ content }}', + }); + documentManager.open(pageUri, source, 1); + + const result = await provider.definitions(makeParams(pageUri, 1, 10), null as any, []); + + expect(result).toHaveLength(1); + expect(result[0].targetUri).toBe( + 'file:///project/modules/community/private/views/layouts/base.liquid', + ); + }); + + it('prefers public over private when both module visibilities exist', async () => { + const source = `---\nlayout: modules/community/base\n---\n`; + const { documentManager, provider } = setup({ + 'project/modules/community/public/views/layouts/base.liquid': '{{ content }}', + 'project/modules/community/private/views/layouts/base.liquid': '{{ content }}', + }); + documentManager.open(pageUri, source, 1); + + const result = await provider.definitions(makeParams(pageUri, 1, 10), null as any, []); + + expect(result).toHaveLength(1); + expect(result[0].targetUri).toBe( + 'file:///project/modules/community/public/views/layouts/base.liquid', + ); + }); + + it('resolves app/modules overwrite over the original module layout', async () => { + const source = `---\nlayout: modules/community/base\n---\n`; + const { documentManager, provider } = setup({ + 'project/app/modules/community/public/views/layouts/base.liquid': '{{ content }}', + 'project/modules/community/public/views/layouts/base.liquid': '{{ content }}', + }); + documentManager.open(pageUri, source, 1); + + const result = await provider.definitions(makeParams(pageUri, 1, 10), null as any, []); + + expect(result).toHaveLength(1); + expect(result[0].targetUri).toBe( + 'file:///project/app/modules/community/public/views/layouts/base.liquid', + ); + }); + + it('resolves a nested module layout path', async () => { + const source = `---\nlayout: modules/community/themes/dark\n---\n`; + const { documentManager, provider } = setup({ + 'project/modules/community/public/views/layouts/themes/dark.liquid': '{{ content }}', + }); + documentManager.open(pageUri, source, 1); + + const result = await provider.definitions(makeParams(pageUri, 1, 10), null as any, []); + + expect(result).toHaveLength(1); + expect(result[0].targetUri).toBe( + 'file:///project/modules/community/public/views/layouts/themes/dark.liquid', + ); + }); + + it('returns empty when layout file does not exist', async () => { + const source = `---\nlayout: nonexistent\n---\n{{ content }}`; + const { documentManager, provider } = setup({}); + documentManager.open(pageUri, source, 1); + + const result = await provider.definitions(makeParams(pageUri, 1, 10), null as any, []); + + expect(result).toHaveLength(0); + }); + + it('returns empty when layout value is a Liquid expression', async () => { + const source = `---\nlayout: {{ current_layout }}\n---\n{{ content }}`; + const { documentManager, provider } = setup({ + 'project/app/views/layouts/application.liquid': '{{ content }}', + }); + documentManager.open(pageUri, source, 1); + + const result = await provider.definitions(makeParams(pageUri, 1, 10), null as any, []); + + expect(result).toHaveLength(0); + }); + }); + + // ── Layout field (Email) ─────────────────────────────────────────────────── + + describe('layout field on Email', () => { + it('resolves an app layout from an email notification', async () => { + const source = `---\nlayout: email_base\n---\nHi`; + const { documentManager, provider } = setup({ + 'project/app/views/layouts/email_base.liquid': '{{ content }}', + }); + documentManager.open(emailUri, source, 1); + + // line 1: layout: email_base + const result = await provider.definitions(makeParams(emailUri, 1, 10), null as any, []); + + expect(result).toHaveLength(1); + expect(result[0].targetUri).toBe('file:///project/app/views/layouts/email_base.liquid'); + }); + + it('resolves a module layout from an email notification', async () => { + const source = `---\nlayout: modules/community/email_base\n---\nHi`; + const { documentManager, provider } = setup({ + 'project/modules/community/public/views/layouts/email_base.liquid': '{{ content }}', + }); + documentManager.open(emailUri, source, 1); + + const result = await provider.definitions(makeParams(emailUri, 1, 10), null as any, []); + + expect(result).toHaveLength(1); + expect(result[0].targetUri).toBe( + 'file:///project/modules/community/public/views/layouts/email_base.liquid', + ); + }); + + it('returns empty when email layout file does not exist', async () => { + const source = `---\nlayout: nonexistent\n---\nHi`; + const { documentManager, provider } = setup({}); + documentManager.open(emailUri, source, 1); + + const result = await provider.definitions(makeParams(emailUri, 1, 10), null as any, []); + + expect(result).toHaveLength(0); + }); + + it('does not resolve layout for Layout file types', async () => { + const layoutUri = 'file:///project/app/views/layouts/app.liquid'; + const source = `---\nconverter: markdown\n---\n{{ content }}`; + const { documentManager, provider } = setup({ + 'project/app/views/layouts/application.liquid': '{{ content }}', + }); + documentManager.open(layoutUri, source, 1); + + const result = await provider.definitions(makeParams(layoutUri, 1, 4), null as any, []); + + expect(result).toHaveLength(0); + }); + }); + + // ── authorization_policies (Page) ───────────────────────────────────────── + + describe('authorization_policies on Page', () => { + it('resolves an app-level authorization policy', async () => { + const source = `---\nauthorization_policies:\n - is_authenticated\n---\n{{ content }}`; + const { documentManager, provider } = setup({ + 'project/app/authorization_policies/is_authenticated.liquid': '{% return true %}', + }); + documentManager.open(pageUri, source, 1); + + // line 2 (0-indexed): " - is_authenticated" + const result = await provider.definitions(makeParams(pageUri, 2, 5), null as any, []); + + expect(result).toHaveLength(1); + expect(result[0].targetUri).toBe( + 'file:///project/app/authorization_policies/is_authenticated.liquid', + ); + }); + + it('resolves a module authorization policy (public visibility)', async () => { + const source = `---\nauthorization_policies:\n - modules/community/is_authenticated\n---\n{{ content }}`; + const { documentManager, provider } = setup({ + 'project/modules/community/public/authorization_policies/is_authenticated.liquid': + '{% return true %}', + }); + documentManager.open(pageUri, source, 1); + + const result = await provider.definitions(makeParams(pageUri, 2, 10), null as any, []); + + expect(result).toHaveLength(1); + expect(result[0].targetUri).toBe( + 'file:///project/modules/community/public/authorization_policies/is_authenticated.liquid', + ); + }); + + it('resolves a module authorization policy (private visibility)', async () => { + const source = `---\nauthorization_policies:\n - modules/community/is_authenticated\n---\n{{ content }}`; + const { documentManager, provider } = setup({ + 'project/modules/community/private/authorization_policies/is_authenticated.liquid': + '{% return true %}', + }); + documentManager.open(pageUri, source, 1); + + const result = await provider.definitions(makeParams(pageUri, 2, 10), null as any, []); + + expect(result).toHaveLength(1); + expect(result[0].targetUri).toBe( + 'file:///project/modules/community/private/authorization_policies/is_authenticated.liquid', + ); + }); + + it('resolves app/modules overwrite over original module policy', async () => { + const source = `---\nauthorization_policies:\n - modules/community/is_authenticated\n---\n{{ content }}`; + const { documentManager, provider } = setup({ + 'project/app/modules/community/public/authorization_policies/is_authenticated.liquid': + '{% return true %}', + 'project/modules/community/public/authorization_policies/is_authenticated.liquid': + '{% return true %}', + }); + documentManager.open(pageUri, source, 1); + + const result = await provider.definitions(makeParams(pageUri, 2, 10), null as any, []); + + expect(result).toHaveLength(1); + expect(result[0].targetUri).toBe( + 'file:///project/app/modules/community/public/authorization_policies/is_authenticated.liquid', + ); + }); + + it('returns empty when authorization policy file does not exist', async () => { + const source = `---\nauthorization_policies:\n - nonexistent_policy\n---\n{{ content }}`; + const { documentManager, provider } = setup({}); + documentManager.open(pageUri, source, 1); + + const result = await provider.definitions(makeParams(pageUri, 2, 5), null as any, []); + + expect(result).toHaveLength(0); + }); + }); + + // ── email_notifications (FormConfiguration) ─────────────────────────────── + + describe('email_notifications on FormConfiguration', () => { + it('resolves an app-level email notification', async () => { + const source = `---\nemail_notifications:\n - welcome\n---\n`; + const { documentManager, provider } = setup({ + 'project/app/emails/welcome.liquid': '---\nto: user@example.com\n---\n', + }); + documentManager.open(formUri, source, 1); + + // line 2: " - welcome" + const result = await provider.definitions(makeParams(formUri, 2, 5), null as any, []); + + expect(result).toHaveLength(1); + expect(result[0].targetUri).toBe('file:///project/app/emails/welcome.liquid'); + }); + + it('resolves a module email notification (public visibility)', async () => { + const source = `---\nemail_notifications:\n - modules/community/welcome\n---\n`; + const { documentManager, provider } = setup({ + 'project/modules/community/public/emails/welcome.liquid': + '---\nto: user@example.com\n---\n', + }); + documentManager.open(formUri, source, 1); + + const result = await provider.definitions(makeParams(formUri, 2, 10), null as any, []); + + expect(result).toHaveLength(1); + expect(result[0].targetUri).toBe( + 'file:///project/modules/community/public/emails/welcome.liquid', + ); + }); + + it('resolves app/modules overwrite over original module email notification', async () => { + const source = `---\nemail_notifications:\n - modules/community/welcome\n---\n`; + const { documentManager, provider } = setup({ + 'project/app/modules/community/public/emails/welcome.liquid': + '---\nto: user@example.com\n---\n', + 'project/modules/community/public/emails/welcome.liquid': + '---\nto: user@example.com\n---\n', + }); + documentManager.open(formUri, source, 1); + + const result = await provider.definitions(makeParams(formUri, 2, 10), null as any, []); + + expect(result).toHaveLength(1); + expect(result[0].targetUri).toBe( + 'file:///project/app/modules/community/public/emails/welcome.liquid', + ); + }); + + it('returns empty when email notification file does not exist', async () => { + const source = `---\nemail_notifications:\n - nonexistent\n---\n`; + const { documentManager, provider } = setup({}); + documentManager.open(formUri, source, 1); + + const result = await provider.definitions(makeParams(formUri, 2, 5), null as any, []); + + expect(result).toHaveLength(0); + }); + }); + + // ── sms_notifications (FormConfiguration) ───────────────────────────────── + + describe('sms_notifications on FormConfiguration', () => { + it('resolves an app-level SMS notification', async () => { + const source = `---\nsms_notifications:\n - sms_alert\n---\n`; + const { documentManager, provider } = setup({ + 'project/app/smses/sms_alert.liquid': '---\nto: "+15550001234"\n---\n', + }); + documentManager.open(formUri, source, 1); + + // line 2: " - sms_alert" + const result = await provider.definitions(makeParams(formUri, 2, 5), null as any, []); + + expect(result).toHaveLength(1); + expect(result[0].targetUri).toBe('file:///project/app/smses/sms_alert.liquid'); + }); + + it('resolves a module SMS notification (public visibility)', async () => { + const source = `---\nsms_notifications:\n - modules/community/sms_alert\n---\n`; + const { documentManager, provider } = setup({ + 'project/modules/community/public/smses/sms_alert.liquid': '---\nto: "+15550001234"\n---\n', + }); + documentManager.open(formUri, source, 1); + + const result = await provider.definitions(makeParams(formUri, 2, 10), null as any, []); + + expect(result).toHaveLength(1); + expect(result[0].targetUri).toBe( + 'file:///project/modules/community/public/smses/sms_alert.liquid', + ); + }); + + it('returns empty when SMS notification file does not exist', async () => { + const source = `---\nsms_notifications:\n - nonexistent\n---\n`; + const { documentManager, provider } = setup({}); + documentManager.open(formUri, source, 1); + + const result = await provider.definitions(makeParams(formUri, 2, 5), null as any, []); + + expect(result).toHaveLength(0); + }); + }); + + // ── api_call_notifications (FormConfiguration) ──────────────────────────── + + describe('api_call_notifications on FormConfiguration', () => { + it('resolves an app-level API call notification', async () => { + const source = `---\napi_call_notifications:\n - webhook\n---\n`; + const { documentManager, provider } = setup({ + 'project/app/api_calls/webhook.liquid': + '---\nto: https://example.com\nrequest_type: POST\n---\n', + }); + documentManager.open(formUri, source, 1); + + // line 2: " - webhook" + const result = await provider.definitions(makeParams(formUri, 2, 5), null as any, []); + + expect(result).toHaveLength(1); + expect(result[0].targetUri).toBe('file:///project/app/api_calls/webhook.liquid'); + }); + + it('resolves a module API call notification (public visibility)', async () => { + const source = `---\napi_call_notifications:\n - modules/community/webhook\n---\n`; + const { documentManager, provider } = setup({ + 'project/modules/community/public/api_calls/webhook.liquid': + '---\nto: https://example.com\nrequest_type: POST\n---\n', + }); + documentManager.open(formUri, source, 1); + + const result = await provider.definitions(makeParams(formUri, 2, 10), null as any, []); + + expect(result).toHaveLength(1); + expect(result[0].targetUri).toBe( + 'file:///project/modules/community/public/api_calls/webhook.liquid', + ); + }); + + it('resolves app/modules overwrite over original module API call notification', async () => { + const source = `---\napi_call_notifications:\n - modules/community/webhook\n---\n`; + const { documentManager, provider } = setup({ + 'project/app/modules/community/public/api_calls/webhook.liquid': + '---\nto: https://example.com\nrequest_type: POST\n---\n', + 'project/modules/community/public/api_calls/webhook.liquid': + '---\nto: https://example.com\nrequest_type: POST\n---\n', + }); + documentManager.open(formUri, source, 1); + + const result = await provider.definitions(makeParams(formUri, 2, 10), null as any, []); + + expect(result).toHaveLength(1); + expect(result[0].targetUri).toBe( + 'file:///project/app/modules/community/public/api_calls/webhook.liquid', + ); + }); + + it('returns empty when API call notification file does not exist', async () => { + const source = `---\napi_call_notifications:\n - nonexistent\n---\n`; + const { documentManager, provider } = setup({}); + documentManager.open(formUri, source, 1); + + const result = await provider.definitions(makeParams(formUri, 2, 5), null as any, []); + + expect(result).toHaveLength(0); + }); + }); + + // ── Outside frontmatter ─────────────────────────────────────────────────── + + describe('outside frontmatter', () => { + it('returns empty when cursor is in the Liquid body', async () => { + const source = `---\nlayout: application\n---\n{{ content }}`; + const { documentManager, provider } = setup({ + 'project/app/views/layouts/application.liquid': '{{ content }}', + }); + documentManager.open(pageUri, source, 1); + + // cursor on line 3 (the {{ content }} line) + const result = await provider.definitions(makeParams(pageUri, 3, 5), null as any, []); + + expect(result).toHaveLength(0); + }); + }); +}); diff --git a/packages/platformos-language-server-common/src/definitions/providers/FrontmatterDefinitionProvider.ts b/packages/platformos-language-server-common/src/definitions/providers/FrontmatterDefinitionProvider.ts new file mode 100644 index 00000000..91790b31 --- /dev/null +++ b/packages/platformos-language-server-common/src/definitions/providers/FrontmatterDefinitionProvider.ts @@ -0,0 +1,258 @@ +import { NodeTypes, YAMLFrontmatter } from '@platformos/liquid-html-parser'; +import { + FRONTMATTER_ASSOCIATION_DIRS, + getFileType, + type AbstractFileSystem, + PlatformOSFileType, +} from '@platformos/platformos-common'; +import { SourceCodeType } from '@platformos/platformos-check-common'; +import { + DefinitionParams, + DefinitionLink, + Range, + LocationLink, +} from 'vscode-languageserver-protocol'; +import { URI, Utils } from 'vscode-uri'; +import { LiquidHtmlNode } from '@platformos/liquid-html-parser'; +import { DocumentManager } from '../../documents'; +import { BaseDefinitionProvider } from '../BaseDefinitionProvider'; + +export class FrontmatterDefinitionProvider implements BaseDefinitionProvider { + constructor( + private documentManager: DocumentManager, + private fs: AbstractFileSystem, + private findAppRootURI: (uri: string) => Promise<string | null>, + ) {} + + async definitions( + params: DefinitionParams, + _node: LiquidHtmlNode, + _ancestors: LiquidHtmlNode[], + ): Promise<DefinitionLink[]> { + const uri = params.textDocument.uri; + const sourceCode = this.documentManager.get(uri); + if ( + !sourceCode || + sourceCode.type !== SourceCodeType.LiquidHtml || + sourceCode.ast instanceof Error + ) + return []; + + const ast = sourceCode.ast; + if (ast.type !== NodeTypes.Document) return []; + + const frontmatterNode = ast.children.find( + (child): child is YAMLFrontmatter => child.type === NodeTypes.YAMLFrontmatter, + ); + if (!frontmatterNode) return []; + + const doc = sourceCode.textDocument; + const source = doc.getText(); + + const bodyStart = source.indexOf('\n', frontmatterNode.position.start) + 1; + const bodyEnd = bodyStart + frontmatterNode.body.length; + const cursor = doc.offsetAt(params.position); + + if (cursor < bodyStart || cursor > bodyEnd) return []; + + const cursorInBody = cursor - bodyStart; + const bodyUpToCursor = frontmatterNode.body.slice(0, cursorInBody); + + // Determine the current line + const lastNewline = bodyUpToCursor.lastIndexOf('\n'); + const currentLineText = bodyUpToCursor.slice(lastNewline + 1); + + // Determine remaining text on current line + const bodyFromCursor = frontmatterNode.body.slice(cursorInBody); + const nextNewline = bodyFromCursor.indexOf('\n'); + const restOfLine = nextNewline === -1 ? bodyFromCursor : bodyFromCursor.slice(0, nextNewline); + + const fullCurrentLine = currentLineText + restOfLine; + + // List item: line starts with optional whitespace + "- " (no colon, check first) + const listItemMatch = fullCurrentLine.match(/^(\s*)-\s*(.*)/); + if (listItemMatch) { + const parentKey = findParentKey(bodyUpToCursor); + const appDir = parentKey ? FRONTMATTER_ASSOCIATION_DIRS[parentKey] : undefined; + if (!appDir) return []; + + const itemValue = listItemMatch[2].trim().replace(/^['"]/, '').replace(/['"]$/, ''); + if (!itemValue || itemValue.includes('{{') || itemValue.includes('{%')) return []; + + return this.resolveAssociationDefinition( + uri, + itemValue, + appDir, + lastNewline + 1 + bodyStart, + doc, + ); + } + + const colonIndex = fullCurrentLine.indexOf(':'); + if (colonIndex === -1) return []; + + const key = fullCurrentLine.slice(0, colonIndex).trim(); + + // Scalar value: cursor must be after the colon + if (cursor <= bodyStart + lastNewline + 1 + colonIndex) return []; + + // `layout` / `layout_name` are valid on Page; `layout` / `layout_path` on Email. + const fileType = getFileType(uri); + const isLayoutKey = + (fileType === PlatformOSFileType.Page && (key === 'layout' || key === 'layout_name')) || + (fileType === PlatformOSFileType.Email && (key === 'layout' || key === 'layout_path')); + if (!isLayoutKey) return []; + + const afterColon = fullCurrentLine.slice(colonIndex + 1).trimStart(); + const value = afterColon.replace(/^['"]/, '').replace(/['"]$/, '').trim(); + + if (!value || value.includes('{{') || value.includes('{%')) return []; + + // Compute origin range: from after colon+space to end of value + const lineStart = bodyStart + lastNewline + 1; + const valueStartInLine = + colonIndex + 1 + (fullCurrentLine.slice(colonIndex + 1).length - afterColon.length); + const originStart = lineStart + valueStartInLine; + const originEnd = originStart + afterColon.length; + + return this.resolveLayoutDefinition(uri, value, originStart, originEnd, doc); + } + + private async resolveLayoutDefinition( + fileUri: string, + layoutName: string, + originStart: number, + originEnd: number, + doc: NonNullable<ReturnType<DocumentManager['get']>>['textDocument'], + ): Promise<DefinitionLink[]> { + const rootUri = await this.findAppRootURI(fileUri); + if (!rootUri) return []; + const root = URI.parse(rootUri); + + let targetUri: string | undefined; + + if (layoutName.startsWith('modules/')) { + const match = layoutName.match(/^modules\/([^/]+)\/(.+)$/); + if (!match) return []; + const [, mod, rest] = match; + + // Check app overwrite first (app/modules/{mod}/{visibility}/views/layouts/{rest}.liquid), + // then fall back to the original module path (modules/{mod}/{visibility}/...). + // Both visibilities are checked for each root before moving to the next. + const roots: Array<(v: string) => URI> = [ + (v) => Utils.joinPath(root, 'app', 'modules', mod, v, 'views', 'layouts', `${rest}.liquid`), + (v) => Utils.joinPath(root, 'modules', mod, v, 'views', 'layouts', `${rest}.liquid`), + ]; + outer: for (const makeCandidate of roots) { + for (const visibility of ['public', 'private'] as const) { + const candidate = makeCandidate(visibility); + if (await this.fileExists(candidate.toString())) { + targetUri = candidate.toString(); + break outer; + } + } + } + } else { + const candidate = Utils.joinPath(root, 'app', 'views', 'layouts', `${layoutName}.liquid`); + if (await this.fileExists(candidate.toString())) { + targetUri = candidate.toString(); + } + } + + if (!targetUri) return []; + + const originRange = Range.create(doc.positionAt(originStart), doc.positionAt(originEnd)); + const targetRange = Range.create(0, 0, 0, 0); + return [LocationLink.create(targetUri, targetRange, targetRange, originRange)]; + } + + /** + * Resolves a frontmatter list-item value to a definition link. + * + * App-level items (e.g. `require_login`) resolve to: + * app/{appDir}/{name}.liquid + * + * Module-prefixed items (e.g. `modules/community/require_login`) resolve to the first + * existing path in priority order: + * app/modules/{mod}/public/{appDir}/{name}.liquid (app overwrite, public) + * app/modules/{mod}/private/{appDir}/{name}.liquid (app overwrite, private) + * modules/{mod}/public/{appDir}/{name}.liquid + * modules/{mod}/private/{appDir}/{name}.liquid + */ + private async resolveAssociationDefinition( + fileUri: string, + itemName: string, + appDir: string, + lineAbsStart: number, + doc: NonNullable<ReturnType<DocumentManager['get']>>['textDocument'], + ): Promise<DefinitionLink[]> { + const rootUri = await this.findAppRootURI(fileUri); + if (!rootUri) return []; + const root = URI.parse(rootUri); + + let targetUri: string | undefined; + + if (itemName.startsWith('modules/')) { + const match = itemName.match(/^modules\/([^/]+)\/(.+)$/); + if (!match) return []; + const [, mod, name] = match; + const candidates = [ + Utils.joinPath(root, 'app', 'modules', mod, 'public', appDir, `${name}.liquid`), + Utils.joinPath(root, 'app', 'modules', mod, 'private', appDir, `${name}.liquid`), + Utils.joinPath(root, 'modules', mod, 'public', appDir, `${name}.liquid`), + Utils.joinPath(root, 'modules', mod, 'private', appDir, `${name}.liquid`), + ]; + for (const candidate of candidates) { + if (await this.fileExists(candidate.toString())) { + targetUri = candidate.toString(); + break; + } + } + } else { + const candidate = Utils.joinPath(root, 'app', appDir, `${itemName}.liquid`); + if (await this.fileExists(candidate.toString())) { + targetUri = candidate.toString(); + } + } + + if (!targetUri) return []; + + // Compute origin range: from after the "- " to end of the item value on this line + const body = doc.getText(); + const lineText = body.slice(lineAbsStart, lineAbsStart + 200).split('\n')[0] ?? ''; + const dashIdx = lineText.indexOf('-'); + if (dashIdx === -1) return []; + const valueOffset = + lineAbsStart + + dashIdx + + 1 + + (lineText.slice(dashIdx + 1).length - lineText.slice(dashIdx + 1).trimStart().length); + const valueEnd = lineAbsStart + lineText.length; + + const originRange = Range.create(doc.positionAt(valueOffset), doc.positionAt(valueEnd)); + const targetRange = Range.create(0, 0, 0, 0); + return [LocationLink.create(targetUri, targetRange, targetRange, originRange)]; + } + + private async fileExists(uri: string): Promise<boolean> { + try { + await this.fs.stat(uri); + return true; + } catch { + return false; + } + } +} + +function findParentKey(bodyUpToCursor: string): string | undefined { + const lines = bodyUpToCursor.split('\n'); + for (let i = lines.length - 2; i >= 0; i--) { + const line = lines[i]; + if (!line.trim()) continue; + if (/^\s+-/.test(line)) continue; + const match = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*):/); + if (match) return match[1]; + break; + } + return undefined; +}