@platformos/platformos-check-common 0.0.11 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/CLAUDE.md +150 -0
  3. package/dist/AugmentedPlatformOSDocset.js +1 -0
  4. package/dist/AugmentedPlatformOSDocset.js.map +1 -1
  5. package/dist/checks/circular-render/index.d.ts +2 -0
  6. package/dist/checks/circular-render/index.js +164 -0
  7. package/dist/checks/circular-render/index.js.map +1 -0
  8. package/dist/checks/deprecated-filter/index.js +15 -0
  9. package/dist/checks/deprecated-filter/index.js.map +1 -1
  10. package/dist/checks/duplicate-content-for-arguments/index.js +1 -1
  11. package/dist/checks/duplicate-content-for-arguments/index.js.map +1 -1
  12. package/dist/checks/graphql/index.d.ts +1 -0
  13. package/dist/checks/graphql/index.js +20 -7
  14. package/dist/checks/graphql/index.js.map +1 -1
  15. package/dist/checks/index.d.ts +1 -1
  16. package/dist/checks/index.js +6 -0
  17. package/dist/checks/index.js.map +1 -1
  18. package/dist/checks/invalid-hash-assign-target/index.js +4 -3
  19. package/dist/checks/invalid-hash-assign-target/index.js.map +1 -1
  20. package/dist/checks/missing-content-for-arguments/index.js +1 -1
  21. package/dist/checks/missing-content-for-arguments/index.js.map +1 -1
  22. package/dist/checks/missing-page/index.d.ts +2 -0
  23. package/dist/checks/missing-page/index.js +73 -0
  24. package/dist/checks/missing-page/index.js.map +1 -0
  25. package/dist/checks/missing-partial/index.js +31 -31
  26. package/dist/checks/missing-partial/index.js.map +1 -1
  27. package/dist/checks/missing-render-partial-arguments/index.d.ts +2 -0
  28. package/dist/checks/missing-render-partial-arguments/index.js +37 -0
  29. package/dist/checks/missing-render-partial-arguments/index.js.map +1 -0
  30. package/dist/checks/nested-graphql-query/index.d.ts +2 -0
  31. package/dist/checks/nested-graphql-query/index.js +146 -0
  32. package/dist/checks/nested-graphql-query/index.js.map +1 -0
  33. package/dist/checks/pagination-size/index.js +1 -1
  34. package/dist/checks/pagination-size/index.js.map +1 -1
  35. package/dist/checks/translation-key-exists/index.js +16 -19
  36. package/dist/checks/translation-key-exists/index.js.map +1 -1
  37. package/dist/checks/translation-utils.d.ts +20 -0
  38. package/dist/checks/translation-utils.js +51 -0
  39. package/dist/checks/translation-utils.js.map +1 -0
  40. package/dist/checks/undefined-object/index.js +35 -13
  41. package/dist/checks/undefined-object/index.js.map +1 -1
  42. package/dist/checks/unknown-property/index.js +75 -10
  43. package/dist/checks/unknown-property/index.js.map +1 -1
  44. package/dist/checks/unknown-property/property-shape.js +14 -1
  45. package/dist/checks/unknown-property/property-shape.js.map +1 -1
  46. package/dist/checks/unrecognized-content-for-arguments/index.js +1 -1
  47. package/dist/checks/unrecognized-content-for-arguments/index.js.map +1 -1
  48. package/dist/checks/unused-assign/index.js +23 -1
  49. package/dist/checks/unused-assign/index.js.map +1 -1
  50. package/dist/checks/unused-translation-key/index.d.ts +4 -0
  51. package/dist/checks/unused-translation-key/index.js +85 -0
  52. package/dist/checks/unused-translation-key/index.js.map +1 -0
  53. package/dist/checks/valid-content-for-argument-types/index.js +1 -1
  54. package/dist/checks/valid-content-for-argument-types/index.js.map +1 -1
  55. package/dist/checks/valid-render-partial-argument-types/index.js +2 -1
  56. package/dist/checks/valid-render-partial-argument-types/index.js.map +1 -1
  57. package/dist/checks/variable-name/index.js +4 -0
  58. package/dist/checks/variable-name/index.js.map +1 -1
  59. package/dist/context-utils.d.ts +2 -1
  60. package/dist/context-utils.js +31 -1
  61. package/dist/context-utils.js.map +1 -1
  62. package/dist/doc-generator/DocBlockGenerator.d.ts +16 -0
  63. package/dist/doc-generator/DocBlockGenerator.js +464 -0
  64. package/dist/doc-generator/DocBlockGenerator.js.map +1 -0
  65. package/dist/doc-generator/index.d.ts +1 -0
  66. package/dist/doc-generator/index.js +6 -0
  67. package/dist/doc-generator/index.js.map +1 -0
  68. package/dist/frontmatter/index.d.ts +59 -0
  69. package/dist/frontmatter/index.js +301 -0
  70. package/dist/frontmatter/index.js.map +1 -0
  71. package/dist/index.d.ts +3 -1
  72. package/dist/index.js +6 -1
  73. package/dist/index.js.map +1 -1
  74. package/dist/liquid-doc/arguments.js +9 -0
  75. package/dist/liquid-doc/arguments.js.map +1 -1
  76. package/dist/liquid-doc/utils.d.ts +10 -2
  77. package/dist/liquid-doc/utils.js +26 -1
  78. package/dist/liquid-doc/utils.js.map +1 -1
  79. package/dist/path.d.ts +1 -1
  80. package/dist/path.js +3 -1
  81. package/dist/path.js.map +1 -1
  82. package/dist/to-schema.d.ts +1 -1
  83. package/dist/tsconfig.tsbuildinfo +1 -1
  84. package/dist/types.d.ts +8 -1
  85. package/dist/types.js.map +1 -1
  86. package/dist/url-helpers.d.ts +55 -0
  87. package/dist/url-helpers.js +334 -0
  88. package/dist/url-helpers.js.map +1 -0
  89. package/dist/utils/block.js.map +1 -1
  90. package/dist/utils/index.d.ts +1 -0
  91. package/dist/utils/index.js +1 -0
  92. package/dist/utils/index.js.map +1 -1
  93. package/dist/utils/levenshtein.d.ts +3 -0
  94. package/dist/utils/levenshtein.js +39 -0
  95. package/dist/utils/levenshtein.js.map +1 -0
  96. package/package.json +2 -2
  97. package/src/AugmentedPlatformOSDocset.ts +1 -0
  98. package/src/checks/deprecated-filter/index.spec.ts +41 -1
  99. package/src/checks/deprecated-filter/index.ts +17 -0
  100. package/src/checks/graphql/index.spec.ts +173 -0
  101. package/src/checks/graphql/index.ts +21 -10
  102. package/src/checks/index.ts +6 -0
  103. package/src/checks/invalid-hash-assign-target/index.spec.ts +26 -0
  104. package/src/checks/invalid-hash-assign-target/index.ts +6 -4
  105. package/src/checks/missing-page/index.spec.ts +755 -0
  106. package/src/checks/missing-page/index.ts +89 -0
  107. package/src/checks/missing-partial/index.spec.ts +361 -0
  108. package/src/checks/missing-partial/index.ts +39 -47
  109. package/src/checks/missing-render-partial-arguments/index.spec.ts +74 -0
  110. package/src/checks/missing-render-partial-arguments/index.ts +44 -0
  111. package/src/checks/nested-graphql-query/index.spec.ts +175 -0
  112. package/src/checks/nested-graphql-query/index.ts +203 -0
  113. package/src/checks/parser-blocking-script/index.spec.ts +7 -3
  114. package/src/checks/translation-key-exists/index.spec.ts +79 -2
  115. package/src/checks/translation-key-exists/index.ts +18 -27
  116. package/src/checks/translation-utils.ts +63 -0
  117. package/src/checks/undefined-object/index.spec.ts +153 -19
  118. package/src/checks/undefined-object/index.ts +43 -19
  119. package/src/checks/unknown-property/index.spec.ts +133 -0
  120. package/src/checks/unknown-property/index.ts +84 -10
  121. package/src/checks/unknown-property/property-shape.ts +15 -1
  122. package/src/checks/unused-assign/index.spec.ts +75 -1
  123. package/src/checks/unused-assign/index.ts +26 -1
  124. package/src/checks/unused-doc-param/index.spec.ts +4 -2
  125. package/src/checks/valid-doc-param-types/index.spec.ts +1 -1
  126. package/src/checks/valid-render-partial-argument-types/index.spec.ts +24 -1
  127. package/src/checks/valid-render-partial-argument-types/index.ts +3 -2
  128. package/src/checks/variable-name/index.spec.ts +10 -1
  129. package/src/checks/variable-name/index.ts +5 -0
  130. package/src/context-utils.ts +33 -1
  131. package/src/frontmatter/index.ts +344 -0
  132. package/src/index.ts +6 -0
  133. package/src/liquid-doc/arguments.ts +9 -0
  134. package/src/liquid-doc/utils.ts +26 -2
  135. package/src/path.ts +2 -0
  136. package/src/types.ts +9 -1
  137. package/src/url-helpers.spec.ts +241 -0
  138. package/src/url-helpers.ts +363 -0
  139. package/src/utils/index.ts +1 -0
  140. package/src/utils/levenshtein.ts +41 -0
@@ -56,7 +56,7 @@ describe('Module: UnusedAssign', () => {
56
56
  {{ usedVar }}
57
57
  `;
58
58
 
59
- expect(suggestions).to.include(expectedFixedCode);
59
+ expect(suggestions).to.deep.equal([expectedFixedCode]);
60
60
  });
61
61
 
62
62
  it('should not report unused assigns for things used in a capture tag', async () => {
@@ -127,4 +127,78 @@ describe('Module: UnusedAssign', () => {
127
127
  const offenses = await runLiquidCheck(UnusedAssign, sourceCode);
128
128
  expect(offenses).to.have.lengthOf(1);
129
129
  });
130
+
131
+ it('should not report an offense for hash mutation via assign with lookup target', async () => {
132
+ const sourceCode = `
133
+ {% assign errors = {} %}
134
+ {% assign errors[field] = 'value' %}
135
+ {{ errors }}
136
+ `;
137
+
138
+ const offenses = await runLiquidCheck(UnusedAssign, sourceCode);
139
+ expect(offenses).to.have.lengthOf(0);
140
+ });
141
+
142
+ it('should not track assign with lookup as a new variable definition', async () => {
143
+ const sourceCode = `
144
+ {% assign errors[field_name] = field_errors %}
145
+ `;
146
+
147
+ const offenses = await runLiquidCheck(UnusedAssign, sourceCode);
148
+ expect(offenses).to.have.lengthOf(0);
149
+ });
150
+
151
+ it('should not report when a reference alias is mutated via subscript', async () => {
152
+ // assign errors = contract.errors creates a reference; mutations on errors
153
+ // have side effects on the original, so they count as "using" errors.
154
+ const sourceCode = `
155
+ {% assign errors = contract.errors %}
156
+ {% assign errors[field_name] = field_errors %}
157
+ `;
158
+
159
+ const offenses = await runLiquidCheck(UnusedAssign, sourceCode);
160
+ expect(offenses).to.have.lengthOf(0);
161
+ });
162
+
163
+ it('should not report when a reference alias is mutated via dot notation', async () => {
164
+ const sourceCode = `
165
+ {% assign data = response.data %}
166
+ {% assign data.status = 'ok' %}
167
+ `;
168
+
169
+ const offenses = await runLiquidCheck(UnusedAssign, sourceCode);
170
+ expect(offenses).to.have.lengthOf(0);
171
+ });
172
+
173
+ it('should not report when a reference alias is mutated via array push', async () => {
174
+ const sourceCode = `
175
+ {% assign items = cart.items %}
176
+ {% assign items << new_item %}
177
+ `;
178
+
179
+ const offenses = await runLiquidCheck(UnusedAssign, sourceCode);
180
+ expect(offenses).to.have.lengthOf(0);
181
+ });
182
+
183
+ it('should still report when a fresh literal hash is mutated but never used', async () => {
184
+ const sourceCode = `
185
+ {% assign x = {} %}
186
+ {% assign x['key'] = 'value' %}
187
+ `;
188
+
189
+ const offenses = await runLiquidCheck(UnusedAssign, sourceCode);
190
+ expect(offenses).to.have.lengthOf(1);
191
+ expect(offenses[0].message).to.equal("The variable 'x' is assigned but not used");
192
+ });
193
+
194
+ it('should still report when a fresh literal array is pushed to but never used', async () => {
195
+ const sourceCode = `
196
+ {% assign arr = [] %}
197
+ {% assign arr << item %}
198
+ `;
199
+
200
+ const offenses = await runLiquidCheck(UnusedAssign, sourceCode);
201
+ expect(offenses).to.have.lengthOf(1);
202
+ expect(offenses[0].message).to.equal("The variable 'arr' is assigned but not used");
203
+ });
130
204
  });
@@ -26,6 +26,10 @@ export const UnusedAssign: LiquidCheckDefinition = {
26
26
 
27
27
  create(context) {
28
28
  const assignedVariables: Map<string, LiquidTagAssign | LiquidTagCapture> = new Map();
29
+ // Variables assigned from a pure variable lookup (no filters, no literals).
30
+ // e.g. `assign errors = contract.errors` — mutations on `errors` have side
31
+ // effects on the original, so they count as "using" the variable.
32
+ const referenceAssignedVariables: Set<string> = new Set();
29
33
  const usedVariables: Set<string> = new Set();
30
34
 
31
35
  function checkVariableUsage(node: any) {
@@ -38,7 +42,28 @@ export const UnusedAssign: LiquidCheckDefinition = {
38
42
  async LiquidTag(node, ancestors) {
39
43
  if (isWithinRawTagThatDoesNotParseItsContents(ancestors)) return;
40
44
  if (isLiquidTagAssign(node)) {
41
- assignedVariables.set(node.markup.name, node);
45
+ if (node.markup.lookups.length === 0 && node.markup.operator === '=') {
46
+ // Simple assignment: register as a new variable
47
+ assignedVariables.set(node.markup.name, node);
48
+ // Track pure reference assignments (VariableLookup with no filters)
49
+ if (
50
+ node.markup.value.type === NodeTypes.LiquidVariable &&
51
+ node.markup.value.expression.type === NodeTypes.VariableLookup &&
52
+ node.markup.value.filters.length === 0
53
+ ) {
54
+ referenceAssignedVariables.add(node.markup.name);
55
+ }
56
+ } else {
57
+ // Hash/array mutation: assign x[key]=val, assign x.key=val, assign x<<val
58
+ // Counts as "using" x only when x is external (not locally assigned here)
59
+ // or was assigned as a reference alias to another variable.
60
+ if (
61
+ !assignedVariables.has(node.markup.name) ||
62
+ referenceAssignedVariables.has(node.markup.name)
63
+ ) {
64
+ usedVariables.add(node.markup.name);
65
+ }
66
+ }
42
67
  } else if (isLiquidTagCapture(node) && node.markup.name) {
43
68
  assignedVariables.set(node.markup.name, node);
44
69
  }
@@ -51,14 +51,16 @@ describe('Module: UnusedDocParam', () => {
51
51
  const offenses = await runLiquidCheck(UnusedDocParam, sourceCode);
52
52
  const suggestions = applySuggestions(sourceCode, offenses[0]);
53
53
 
54
- expect(suggestions).to.include(`
54
+ expect(suggestions).to.deep.equal([
55
+ `
55
56
  {% doc %}
56
57
  @param param1 - Example param
57
58
 
58
59
  {% enddoc %}
59
60
 
60
61
  {{ param1 }}
61
- `);
62
+ `,
63
+ ]);
62
64
  });
63
65
 
64
66
  LoopNamedTags.forEach((tag) => {
@@ -69,7 +69,7 @@ describe('Module: ValidDocParamTypes', () => {
69
69
  expect(offenses).to.have.length(1);
70
70
  const suggestions = applySuggestions(source, offenses[0]);
71
71
 
72
- expect(suggestions).to.include(`{% doc %} @param param1 - Example param {% enddoc %}`);
72
+ expect(suggestions).to.deep.equal([`{% doc %} @param param1 - Example param {% enddoc %}`]);
73
73
  }
74
74
  });
75
75
  });
@@ -34,7 +34,7 @@ describe('Module: ValidRenderPartialParamTypes', () => {
34
34
  { value: "'hello'", actualType: BasicParamTypes.String },
35
35
  { value: '123', actualType: BasicParamTypes.Number },
36
36
  { value: 'true', actualType: BasicParamTypes.Boolean },
37
- { value: 'empty', actualType: BasicParamTypes.Boolean },
37
+ { value: 'empty', actualType: BasicParamTypes.String },
38
38
  ],
39
39
  },
40
40
  ];
@@ -142,6 +142,29 @@ describe('Module: ValidRenderPartialParamTypes', () => {
142
142
  expect(offenses).toHaveLength(0);
143
143
  });
144
144
 
145
+ it('should not report null/nil as type mismatch for any type', async () => {
146
+ for (const type of ['string', 'number', 'object', 'boolean']) {
147
+ for (const literal of ['nil', 'null']) {
148
+ const sourceCode = `{% render 'card', param: ${literal} %}`;
149
+ const offenses = await runLiquidCheck(
150
+ ValidRenderPartialArgumentTypes,
151
+ sourceCode,
152
+ undefined,
153
+ {},
154
+ {
155
+ 'app/views/partials/card.liquid': `
156
+ {% doc %}
157
+ @param {${type}} param - Description
158
+ {% enddoc %}
159
+ <div>{{ param }}</div>
160
+ `,
161
+ },
162
+ );
163
+ expect(offenses, `${literal} should be valid for ${type}`).toHaveLength(0);
164
+ }
165
+ }
166
+ });
167
+
145
168
  it('should not enforce unsupported types', async () => {
146
169
  const sourceCode = `{% render 'card', title: 123 %}`;
147
170
  const offenses = await runLiquidCheck(
@@ -1,7 +1,7 @@
1
1
  import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types';
2
2
  import { NodeTypes, RenderMarkup } from '@platformos/liquid-html-parser';
3
3
  import { LiquidDocParameter } from '../../liquid-doc/liquidDoc';
4
- import { inferArgumentType, isTypeCompatible } from '../../liquid-doc/utils';
4
+ import { inferArgumentType, isNullLiteral, isTypeCompatible } from '../../liquid-doc/utils';
5
5
  import {
6
6
  findTypeMismatchParams,
7
7
  generateTypeMismatchSuggestions,
@@ -41,7 +41,8 @@ export const ValidRenderPartialArgumentTypes: LiquidCheckDefinition = {
41
41
  if (
42
42
  node.alias &&
43
43
  node.variable?.name &&
44
- node.variable.name.type !== NodeTypes.VariableLookup
44
+ node.variable.name.type !== NodeTypes.VariableLookup &&
45
+ !isNullLiteral(node.variable.name)
45
46
  ) {
46
47
  const paramIsDefinedWithType = liquidDocParameters
47
48
  .get(node.alias.value)
@@ -40,7 +40,16 @@ describe('Module: VariableName', () => {
40
40
 
41
41
  const expectedFixedCode = `{% assign variable_name = "value" %}`;
42
42
 
43
- expect(suggestions).to.include(expectedFixedCode);
43
+ expect(suggestions).to.deep.equal([expectedFixedCode]);
44
+ });
45
+
46
+ it('should not report an error for variables starting with underscore', async () => {
47
+ const varNames = [`_`, `_errors`, `_temp_var`, `_myPrivateVar`];
48
+ for (const varName of varNames) {
49
+ const sourceCode = `{% assign ${varName} = "value" %}`;
50
+ const offenses = await runLiquidCheck(VariableName, sourceCode);
51
+ expect(offenses).to.be.empty;
52
+ }
44
53
  });
45
54
 
46
55
  // It's impossible to make an idempotent rule that works for all cases. We
@@ -65,6 +65,11 @@ export const VariableName: LiquidCheckDefinition<typeof schema> = {
65
65
  };
66
66
  }
67
67
 
68
+ // Variables starting with _ are valid (used as throwaway/private variables)
69
+ if (node.markup.name.startsWith('_')) {
70
+ return { valid: true };
71
+ }
72
+
68
73
  const formatter = formatTypes[context.settings.format as FormatTypes];
69
74
  const suggestion = formatter(node.markup.name);
70
75
 
@@ -3,6 +3,7 @@ import {
3
3
  AbstractFileSystem,
4
4
  FileTuple,
5
5
  FileType,
6
+ RouteTable,
6
7
  TranslationProvider,
7
8
  UriString,
8
9
  } from '@platformos/platformos-common';
@@ -131,6 +132,31 @@ function getDefaultTranslationsFromBuffer(app: App): Translations | undefined {
131
132
  }
132
133
  }
133
134
 
135
+ export function makeGetRouteTable(
136
+ fs: AbstractFileSystem,
137
+ rootUri: string,
138
+ existingTable?: RouteTable,
139
+ ): () => Promise<RouteTable> {
140
+ const table = existingTable ?? new RouteTable(fs);
141
+ let buildPromise: Promise<RouteTable> | null = null;
142
+ return () => {
143
+ if (!buildPromise) {
144
+ if (table.isBuilt()) {
145
+ buildPromise = Promise.resolve(table);
146
+ } else {
147
+ buildPromise = table
148
+ .build(URI.parse(rootUri))
149
+ .then(() => table)
150
+ .catch((err) => {
151
+ buildPromise = null;
152
+ throw err;
153
+ });
154
+ }
155
+ }
156
+ return buildPromise;
157
+ };
158
+ }
159
+
134
160
  function cached<T>(fn: () => Promise<T>): () => Promise<T>;
135
161
  function cached<T>(fn: (...args: any[]) => Promise<T>): (...args: any[]) => Promise<T> {
136
162
  let cachedPromise: Promise<T>;
@@ -145,7 +171,13 @@ export async function recursiveReadDirectory(
145
171
  uri: string,
146
172
  filter: (fileTuple: FileTuple) => boolean,
147
173
  ): Promise<UriString[]> {
148
- const allFiles = await fs.readDirectory(uri);
174
+ let allFiles: FileTuple[];
175
+ try {
176
+ allFiles = await fs.readDirectory(uri);
177
+ } catch (err: any) {
178
+ if (err?.code === 'ENOENT') return [];
179
+ throw err;
180
+ }
149
181
  const files = allFiles.filter((ft) => !isIgnored(ft) && (isDirectory(ft) || filter(ft)));
150
182
 
151
183
  const results = await Promise.all(
@@ -0,0 +1,344 @@
1
+ /**
2
+ * Frontmatter schema definitions for platformOS Liquid file types.
3
+ *
4
+ * Each Liquid file type in platformOS has a YAML frontmatter section at the
5
+ * top of the file that configures server-side behaviour. The schema for each
6
+ * type is different — Pages have slug/layout, Emails have to/from/subject, etc.
7
+ *
8
+ * This module provides:
9
+ * - FrontmatterFieldSchema — type definition for a single field
10
+ * - FrontmatterSchema — type definition for a complete schema
11
+ * - FRONTMATTER_SCHEMAS — per-type schemas keyed by PlatformOSFileType
12
+ * - getFrontmatterSchema() — convenience lookup that returns undefined for
13
+ * types without a frontmatter schema
14
+ */
15
+
16
+ import { PlatformOSFileType } from '@platformos/platformos-common';
17
+
18
+ // ─── Types ────────────────────────────────────────────────────────────────────
19
+
20
+ export type FrontmatterFieldType = 'string' | 'boolean' | 'integer' | 'number' | 'array' | 'object';
21
+
22
+ export interface FrontmatterFieldSchema {
23
+ /** The expected YAML type(s) for this field's value. */
24
+ type: FrontmatterFieldType | FrontmatterFieldType[];
25
+ /** Whether this field must be present. */
26
+ required?: boolean;
27
+ /** Human-readable description of this field. */
28
+ description?: string;
29
+ /** Whether this field name is deprecated in favour of a newer one. */
30
+ deprecated?: boolean;
31
+ /** Message shown when this deprecated field is used. */
32
+ deprecatedMessage?: string;
33
+ }
34
+
35
+ export interface FrontmatterSchema {
36
+ /** Human-readable name of the file type, used in diagnostics. */
37
+ name: string;
38
+ /**
39
+ * Known frontmatter fields.
40
+ * Checks can use this to surface unknown keys or missing required keys.
41
+ */
42
+ fields: Record<string, FrontmatterFieldSchema>;
43
+ /**
44
+ * Whether fields not listed in `fields` are allowed without a warning.
45
+ * Defaults to true — schemas are additive and may not be exhaustive.
46
+ */
47
+ allowAdditionalFields?: boolean;
48
+ }
49
+
50
+ // ─── Schemas ──────────────────────────────────────────────────────────────────
51
+
52
+ /**
53
+ * Per-type frontmatter schemas.
54
+ *
55
+ * Only Liquid file types are present here — GraphQL, YAML, and Asset types
56
+ * do not use frontmatter.
57
+ *
58
+ * Field lists are based on real-world usage in platformOS apps. Set
59
+ * `allowAdditionalFields: true` (the default) everywhere so that apps using
60
+ * custom/undocumented keys don't get false-positive warnings until the schemas
61
+ * are finalised.
62
+ */
63
+ export const FRONTMATTER_SCHEMAS: Partial<Record<PlatformOSFileType, FrontmatterSchema>> = {
64
+ // ── Page ─────────────────────────────────────────────────────────────────────
65
+ [PlatformOSFileType.Page]: {
66
+ name: 'Page',
67
+ fields: {
68
+ slug: {
69
+ type: 'string',
70
+ description: 'URL slug for this page. Supports dynamic segments (e.g. users/:id).',
71
+ },
72
+ layout: {
73
+ type: 'string',
74
+ description: 'Layout template to wrap this page (path relative to app root, no extension).',
75
+ },
76
+ layout_name: {
77
+ type: 'string',
78
+ description: 'Alias for layout.',
79
+ deprecated: true,
80
+ deprecatedMessage: 'Use `layout` instead of `layout_name`.',
81
+ },
82
+ method: {
83
+ type: 'string',
84
+ description: 'HTTP method this page responds to (get, post, put, patch, delete).',
85
+ },
86
+ authorization_policies: {
87
+ type: 'array',
88
+ description: 'List of authorization policy names that must pass before rendering.',
89
+ },
90
+ response_headers: {
91
+ type: 'string',
92
+ description: 'Liquid template that renders a JSON object of HTTP response headers.',
93
+ },
94
+ metadata: {
95
+ type: 'object',
96
+ description: 'Arbitrary metadata object (e.g. SEO title/description, robots directives).',
97
+ },
98
+ max_deep_level: {
99
+ type: 'integer',
100
+ description: 'Maximum number of dynamic URL segments to capture.',
101
+ },
102
+ searchable: {
103
+ type: 'boolean',
104
+ description: 'Whether this page is included in platformOS search indexes.',
105
+ },
106
+ format: {
107
+ type: 'string',
108
+ description: 'Response format (html, json, xml, csv, …). Often encoded in the filename.',
109
+ },
110
+ },
111
+ allowAdditionalFields: true,
112
+ },
113
+
114
+ // ── Layout ───────────────────────────────────────────────────────────────────
115
+ [PlatformOSFileType.Layout]: {
116
+ name: 'Layout',
117
+ fields: {
118
+ name: {
119
+ type: 'string',
120
+ description: 'Identifier used to reference this layout from pages.',
121
+ },
122
+ },
123
+ allowAdditionalFields: true,
124
+ },
125
+
126
+ // ── Partial ──────────────────────────────────────────────────────────────────
127
+ [PlatformOSFileType.Partial]: {
128
+ name: 'Partial',
129
+ fields: {
130
+ metadata: {
131
+ type: 'object',
132
+ description:
133
+ 'Partial metadata. `metadata.params` declares accepted parameters; `metadata.name` is a human-readable label for the style guide.',
134
+ },
135
+ },
136
+ allowAdditionalFields: true,
137
+ },
138
+
139
+ // ── AuthorizationPolicy ──────────────────────────────────────────────────────
140
+ [PlatformOSFileType.Authorization]: {
141
+ name: 'AuthorizationPolicy',
142
+ fields: {
143
+ name: {
144
+ type: 'string',
145
+ required: true,
146
+ description: 'Unique identifier for this authorization policy.',
147
+ },
148
+ redirect_to: {
149
+ type: 'string',
150
+ description: 'URL to redirect the user to when the policy fails.',
151
+ },
152
+ flash_alert: {
153
+ type: 'string',
154
+ description: 'Flash alert message shown after a failed authorization redirect.',
155
+ },
156
+ flash_notice: {
157
+ type: 'string',
158
+ description: 'Flash notice message shown after a failed authorization redirect.',
159
+ },
160
+ },
161
+ allowAdditionalFields: true,
162
+ },
163
+
164
+ // ── Email ────────────────────────────────────────────────────────────────────
165
+ [PlatformOSFileType.Email]: {
166
+ name: 'Email',
167
+ fields: {
168
+ to: {
169
+ type: 'string',
170
+ required: true,
171
+ description: 'Recipient email address (may use Liquid).',
172
+ },
173
+ from: {
174
+ type: 'string',
175
+ description: 'Sender email address.',
176
+ },
177
+ reply_to: {
178
+ type: 'string',
179
+ description: 'Reply-to email address.',
180
+ },
181
+ cc: {
182
+ type: 'string',
183
+ description: 'Carbon-copy recipients.',
184
+ },
185
+ bcc: {
186
+ type: 'string',
187
+ description: 'Blind carbon-copy recipients.',
188
+ },
189
+ subject: {
190
+ type: 'string',
191
+ required: true,
192
+ description: 'Email subject line (may use Liquid).',
193
+ },
194
+ layout_path: {
195
+ type: 'string',
196
+ description: 'Layout partial to wrap the email body.',
197
+ },
198
+ delay: {
199
+ type: 'integer',
200
+ description: 'Seconds to delay delivery after being triggered.',
201
+ },
202
+ enabled: {
203
+ type: 'boolean',
204
+ description: 'When false, this email is never sent. Defaults to true.',
205
+ },
206
+ trigger_condition: {
207
+ type: ['boolean', 'string'],
208
+ description:
209
+ 'Liquid expression or boolean; email is only sent when this evaluates to true.',
210
+ },
211
+ },
212
+ allowAdditionalFields: true,
213
+ },
214
+
215
+ // ── ApiCall ──────────────────────────────────────────────────────────────────
216
+ [PlatformOSFileType.ApiCall]: {
217
+ name: 'ApiCall',
218
+ fields: {
219
+ to: {
220
+ type: 'string',
221
+ required: true,
222
+ description: 'Target URL for the HTTP request (may use Liquid).',
223
+ },
224
+ request_type: {
225
+ type: 'string',
226
+ required: true,
227
+ description: 'HTTP method: GET, POST, PUT, PATCH, or DELETE.',
228
+ },
229
+ request_headers: {
230
+ type: 'string',
231
+ description: 'Liquid template rendering a JSON object of request headers.',
232
+ },
233
+ headers: {
234
+ type: 'string',
235
+ description: 'Alias for request_headers.',
236
+ deprecated: true,
237
+ deprecatedMessage: 'Use `request_headers` instead of `headers`.',
238
+ },
239
+ callback: {
240
+ type: 'string',
241
+ description: 'Liquid template executed after the HTTP response is received.',
242
+ },
243
+ delay: {
244
+ type: 'integer',
245
+ description: 'Seconds to delay the request after being triggered.',
246
+ },
247
+ enabled: {
248
+ type: 'boolean',
249
+ description: 'When false, this API call is never executed. Defaults to true.',
250
+ },
251
+ trigger_condition: {
252
+ type: ['boolean', 'string'],
253
+ description: 'Liquid expression or boolean; call is only made when this evaluates to true.',
254
+ },
255
+ format: {
256
+ type: 'string',
257
+ description: 'Request body encoding format (http, json, …).',
258
+ },
259
+ },
260
+ allowAdditionalFields: true,
261
+ },
262
+
263
+ // ── Sms ──────────────────────────────────────────────────────────────────────
264
+ [PlatformOSFileType.Sms]: {
265
+ name: 'SMS',
266
+ fields: {
267
+ to: {
268
+ type: 'string',
269
+ required: true,
270
+ description: 'Recipient phone number in E.164 format (may use Liquid).',
271
+ },
272
+ delay: {
273
+ type: 'integer',
274
+ description: 'Seconds to delay sending after being triggered.',
275
+ },
276
+ enabled: {
277
+ type: 'boolean',
278
+ description: 'When false, this SMS is never sent. Defaults to true.',
279
+ },
280
+ trigger_condition: {
281
+ type: ['boolean', 'string'],
282
+ description: 'Liquid expression or boolean; SMS is only sent when this evaluates to true.',
283
+ },
284
+ },
285
+ allowAdditionalFields: true,
286
+ },
287
+
288
+ // ── Migration ────────────────────────────────────────────────────────────────
289
+ [PlatformOSFileType.Migration]: {
290
+ name: 'Migration',
291
+ fields: {},
292
+ allowAdditionalFields: true,
293
+ },
294
+
295
+ // ── FormConfiguration ────────────────────────────────────────────────────────
296
+ [PlatformOSFileType.FormConfiguration]: {
297
+ name: 'FormConfiguration',
298
+ fields: {
299
+ name: {
300
+ type: 'string',
301
+ required: true,
302
+ description: 'Unique identifier for this form, used in include_form / function calls.',
303
+ },
304
+ resource: {
305
+ type: ['string', 'object'],
306
+ description: 'Model or resource type this form operates on.',
307
+ },
308
+ resource_owner: {
309
+ type: 'string',
310
+ description: 'Who owns the resource being created/updated.',
311
+ },
312
+ fields: {
313
+ type: 'object',
314
+ description: 'Field definitions — what data this form accepts and validates.',
315
+ },
316
+ redirect_to: {
317
+ type: 'string',
318
+ description: 'URL to redirect to after a successful form submission.',
319
+ },
320
+ flash_notice: {
321
+ type: 'string',
322
+ description: 'Flash notice message shown after a successful submission.',
323
+ },
324
+ flash_alert: {
325
+ type: 'string',
326
+ description: 'Flash alert message shown after a failed submission.',
327
+ },
328
+ },
329
+ allowAdditionalFields: true,
330
+ },
331
+ };
332
+
333
+ // ─── Lookup helper ────────────────────────────────────────────────────────────
334
+
335
+ /**
336
+ * Returns the frontmatter schema for a given file type, or undefined if no
337
+ * schema is defined for that type (e.g. GraphQL, YAML, Asset types).
338
+ */
339
+ export function getFrontmatterSchema(
340
+ fileType: PlatformOSFileType | undefined,
341
+ ): FrontmatterSchema | undefined {
342
+ if (fileType === undefined) return undefined;
343
+ return FRONTMATTER_SCHEMAS[fileType];
344
+ }
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  makeFileSize,
6
6
  makeGetDefaultLocale,
7
7
  makeGetDefaultTranslations,
8
+ makeGetRouteTable,
8
9
  makeGetTranslationsForBase,
9
10
  } from './context-utils';
10
11
  import { createDisabledChecksModule } from './disabled-checks';
@@ -56,6 +57,8 @@ export {
56
57
  isApiCall,
57
58
  isAuthorization,
58
59
  isEmail,
60
+ isFormConfiguration,
61
+ isKnownGraphQLFile,
59
62
  isKnownLiquidFile,
60
63
  isLayout,
61
64
  isMigration,
@@ -64,6 +67,7 @@ export {
64
67
  isSms,
65
68
  PlatformOSFileType,
66
69
  } from '@platformos/platformos-common';
70
+ export * from './frontmatter';
67
71
  export * from './json';
68
72
  export * from './JSONValidator';
69
73
  export * as path from './path';
@@ -77,6 +81,7 @@ export * from './utils/object';
77
81
  export * from './visitor';
78
82
  export * from './liquid-doc/liquidDoc';
79
83
  export * from './liquid-doc/utils';
84
+ export * from './url-helpers';
80
85
 
81
86
  const defaultErrorHandler = (_error: Error): void => {
82
87
  // Silently ignores errors by default.
@@ -98,6 +103,7 @@ export async function check(
98
103
  getDefaultLocale: makeGetDefaultLocale(fs, rootUri),
99
104
  getDefaultTranslations: makeGetDefaultTranslations(fs, app, rootUri),
100
105
  getTranslationsForBase: makeGetTranslationsForBase(fs, app),
106
+ getRouteTable: makeGetRouteTable(fs, rootUri, injectedDependencies.routeTable),
101
107
  };
102
108
 
103
109
  const { DisabledChecksVisitor, isDisabled } = createDisabledChecksModule();