@bettercms-ai/convert 0.1.0 → 0.2.0

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.
package/dist/index.d.ts CHANGED
@@ -285,12 +285,31 @@ interface AttrNode {
285
285
  expression: boolean;
286
286
  /** The whole `name="value"` span, so an attribute can be replaced outright. */
287
287
  range: Range;
288
+ /**
289
+ * The braces of an expression-valued attribute, `{` and `}` included. Null when there are none.
290
+ *
291
+ * 🔴 `range` IS NOT THE EXPRESSION FOR A SHORTHAND. Astro reports `{title}` and `{...props}` at
292
+ * the offset of the IDENTIFIER, four characters into the spread — so a reader that looked for the
293
+ * first `{` inside `range` found none and concluded the attribute held no code. The section then
294
+ * travelled without whatever `title` was, into a component file that does not build. Every parser
295
+ * that has an expression says where it starts, rather than leaving each reader to scan for it.
296
+ */
297
+ expressionRange: Range | null;
288
298
  }
289
299
  type Node = {
290
300
  type: "element";
291
301
  tag: string;
292
302
  /** The `<` of the open tag. */
293
303
  start: number;
304
+ /**
305
+ * One past the element's LAST byte — the `>` of `</section>`, or of `<img … />`.
306
+ *
307
+ * Carried because the componentize lane lifts a whole element out of a page and into a file
308
+ * of its own: `start` and `inner` describe where a value goes, and neither says where the
309
+ * subtree stops. Recomputing it from `inner.end + "</tag>".length` would assume the closing
310
+ * tag holds no whitespace, and every parser here already reports the real end.
311
+ */
312
+ end: number;
294
313
  /** Where an attribute may be inserted: after the last attribute, before `/` and `>`. */
295
314
  attrInsertAt: number;
296
315
  /** Everything between the tags. Null for void and self-closing elements. */
@@ -634,7 +653,7 @@ declare function attrsFor(path: string, kind: string, props?: AttrBinding[], sco
634
653
  declare function propsAttribute(props: AttrBinding[]): string;
635
654
 
636
655
  /** Bumped whenever the emitted source changes. `isKnownHelper` upgrades anything older. */
637
- declare const HELPER_VERSION = 2;
656
+ declare const HELPER_VERSION = 3;
638
657
  /** Where each dialect's helper lives. Null = this dialect reads nothing at build time. */
639
658
  declare const HELPER_PATH: Record<Dialect, string | null>;
640
659
  /** The directory the snapshots live in, at the repository root. */
@@ -698,8 +717,17 @@ declare function resolveSpecifier(fromFile: string, specifier: string, files: Se
698
717
  /** What one local name was imported from, and WHICH export of it. */
699
718
  interface ImportedFrom {
700
719
  specifier: string;
701
- /** The exported name, or null for a default import. */
720
+ /** The exported name, or null for a default OR namespace import. @see kind */
702
721
  imported: string | null;
722
+ /**
723
+ * WHICH OF THE THREE, because they are three different statements to write back.
724
+ *
725
+ * 🔴 A NAMESPACE IMPORT IS NOT A NAMED ONE. `import * as Icons from "./icons"` was recorded with
726
+ * `imported: "Icons"` — the local name, because babel reports no `imported` for it — so a lane
727
+ * re-emitting the import wrote `import { Icons } from "./icons"`, which binds a named export
728
+ * nobody declared and fails at build. The distinction is on the specifier node; it is kept.
729
+ */
730
+ kind: "default" | "named" | "namespace";
703
731
  }
704
732
  /**
705
733
  * Local name → what it was imported from, for one file.
@@ -802,6 +830,268 @@ interface FileDeclaration {
802
830
  /** Every path one file declares, with the lane it declares it on. */
803
831
  declare function readDeclarations(file: string, content: string): Promise<FileDeclaration[]>;
804
832
 
833
+ /**
834
+ * A dynamic route reads ITS OWN entry, and the route parameter is how it says which.
835
+ *
836
+ * 🔴 WITHOUT THE PARAMETER THE PAGE IS WRONG, NOT INCOMPLETE. `bcms(page, "title", "…")` on
837
+ * `[slug].astro` resolves the same value for every URL the route serves, so a hundred posts render
838
+ * the first post's title — a conversion that builds, passes every attribute check, and is visibly
839
+ * broken. So a dynamic route either gets its parameter or reports `DYNAMIC_PARAMS_UNAVAILABLE`
840
+ * and stays as it was.
841
+ *
842
+ * Astro hands it over for free (`Astro.params`). Next does not: `params` is an argument to the
843
+ * page component, so the SIGNATURE has to be edited — and on Next 15 `params` is a promise, which
844
+ * is why an already-`async` component gets `(await params).slug` and a synchronous one does not.
845
+ */
846
+
847
+ /** `src/pages/[slug].astro`, `app/blog/[slug]/page.tsx` — a segment in brackets. */
848
+ declare const isDynamicRoute: (file: string) => boolean;
849
+ /**
850
+ * Which route does this FILE serve, or null when it is not a page at all.
851
+ *
852
+ * 🔴 A ROUTE IS NOT A FILE, AND IT IS NOT A SEARCH EITHER. Picking the page by "which file mentions
853
+ * this field group" reads whichever file holds the most matches — and a `hero` group is on every
854
+ * landing page, so `/about`'s sections were resolved against `/`'s file and one route's codemod
855
+ * rewrote another route's page. The router's own convention answers the question the route is
856
+ * actually asking.
857
+ *
858
+ * Every framework this package converts is here, including the two it cannot extract from
859
+ * (SvelteKit, Nuxt) — a page it can NAME is a page it can refuse by dialect, and a page it cannot
860
+ * name is `NOT_IN_SOURCE`, which are different facts.
861
+ */
862
+ declare function routeOfFile(file: string): string | null;
863
+ /**
864
+ * Every file that serves this route, in path order.
865
+ *
866
+ * More than one is not a tie to break: two files claiming one URL is a repository whose own router
867
+ * has to pick, and a codemod that picks differently edits a page nobody serves.
868
+ */
869
+ declare function pageFilesFor(route: string, files: Iterable<string>): string[];
870
+
871
+ type Element = Extract<Node, {
872
+ type: "element";
873
+ }>;
874
+ interface Carried {
875
+ /** `import Card from "../Card.astro";` — the page's own imports, re-based onto the new file. */
876
+ imports: string[];
877
+ /** Module-scope statements the markup depends on, in the page's own source order. */
878
+ declarations: string[];
879
+ /** Names nothing can account for. Non-empty means the section is refused, not written. */
880
+ free: string[];
881
+ }
882
+ /**
883
+ * Everything one section root needs, resolved against the page it is leaving.
884
+ *
885
+ * `provided` is what the component file declares for itself — its props, the snapshot local it
886
+ * redeclares, and the helper names it imports — so those are never carried and never free.
887
+ */
888
+ declare function carryFor(options: {
889
+ dialect: Dialect;
890
+ fromFile: string;
891
+ toFile: string;
892
+ content: string;
893
+ parsed: ParsedFile;
894
+ root: Element;
895
+ provided: Iterable<string>;
896
+ /** Repository paths that must never be carried as an import — the helper, the snapshots. */
897
+ skipSpecifier: (repoPath: string, specifier: string) => boolean;
898
+ }): Carried;
899
+
900
+ /**
901
+ * The plan, as `get_componentize_plan` returns it.
902
+ *
903
+ * READ DEFENSIVELY. A newer server may carry fields this version has never heard of, and the
904
+ * customer runs whichever `npx @bettercms-ai/convert` npm hands them — so every field this module
905
+ * does not need is `unknown`, every field it does need is optional with a stated fallback, and
906
+ * an unexpected extra key is simply carried past. The shape is frozen; the reader is not brittle.
907
+ */
908
+ interface ComponentizePlanField {
909
+ /** The leaf's key inside the group — `title`, `items[0].q`. */
910
+ key?: string;
911
+ /** The whole page path — `hero.title`. Derived from `groupKey` + `key` when absent. */
912
+ path?: string;
913
+ type?: string;
914
+ label?: string;
915
+ current?: unknown;
916
+ }
917
+ interface ComponentizePlanSection {
918
+ groupKey: string;
919
+ sectionType?: string;
920
+ label?: string;
921
+ order?: number;
922
+ shapeHash?: string;
923
+ fields?: ComponentizePlanField[];
924
+ /** Which component renders this placement — a proposed one, or one the project already has. */
925
+ reuse?: {
926
+ componentId?: string;
927
+ proposedSlug?: string;
928
+ };
929
+ /** The SERVER already decided it cannot place this one. Not this codemod's to extract. */
930
+ pending?: {
931
+ reason?: string;
932
+ };
933
+ }
934
+ interface ComponentizePlanPage {
935
+ pageId?: string;
936
+ route: string;
937
+ sections?: ComponentizePlanSection[];
938
+ }
939
+ interface ComponentizePlanComponent {
940
+ proposedSlug: string;
941
+ /** Present when the plan reuses a component the project already has. */
942
+ componentId?: string;
943
+ sectionType?: string;
944
+ name?: string;
945
+ category?: string;
946
+ placements?: number;
947
+ }
948
+ interface ComponentizePlan {
949
+ pages: ComponentizePlanPage[];
950
+ components?: ComponentizePlanComponent[];
951
+ digest?: string;
952
+ }
953
+ /**
954
+ * Why a section is not a component in this repository.
955
+ *
956
+ * Every one of them is a fact about the SOURCE rather than an apology:
957
+ * `SECTION_ROOT_AMBIGUOUS` its fields do not sit under one element, or two sections nest.
958
+ * `SECTION_NOT_CONTIGUOUS` the page's section roots do not share a container at all. The
959
+ * component IS written; the page is left exactly as it was.
960
+ * `SECTION_FREE_IDENTIFIERS` the markup uses a name the component file cannot be given — a
961
+ * local of the page's own component body, or an expression this
962
+ * version cannot read.
963
+ * `COMPONENT_CONFLICT` a file already sits at the component's path and this package did
964
+ * not write it.
965
+ * `REGISTRY_CONFLICT` the same, for the registry or the sections library.
966
+ * `ROUTE_FILE_AMBIGUOUS` two files serve one route; the repository's own router has to
967
+ * pick, and a codemod that picks differently edits a dead page.
968
+ * `DIALECT_UNSUPPORTED` the page is svelte / vue / html; nothing is written for it.
969
+ * `HELPER_CONFLICT` `src/bcms-content.ts` is a module this package did not write.
970
+ * `SNAPSHOT_INVALID` `bcms-content/<page>.json` is not JSON this can add `blocks` to.
971
+ * `NOT_IN_SOURCE` no file we were given renders the group, or the route names no
972
+ * page file this package can find.
973
+ */
974
+ type SectionPendingReason = "SECTION_ROOT_AMBIGUOUS" | "SECTION_NOT_CONTIGUOUS" | "SECTION_FREE_IDENTIFIERS" | "COMPONENT_CONFLICT" | "REGISTRY_CONFLICT" | "ROUTE_FILE_AMBIGUOUS" | "DIALECT_UNSUPPORTED" | "HELPER_CONFLICT" | "SNAPSHOT_INVALID" | "NOT_IN_SOURCE";
975
+ interface PendingSection {
976
+ route: string;
977
+ groupKey: string;
978
+ reason: SectionPendingReason;
979
+ /** What exactly could not be done. Informative; the reason is the contract. */
980
+ message?: string;
981
+ }
982
+ interface SectionsReceipt {
983
+ /** Component written AND the page's run replaced by `<Sections page="…" />`. */
984
+ extracted: number;
985
+ /**
986
+ * Component written, but the page keeps CALLING it in place.
987
+ *
988
+ * The run was not contiguous — a sibling that belongs to no section sits between two roots — so
989
+ * a loop over `pageSections()` would silently reorder the page. Each root becomes its own
990
+ * `<Hero …/>` instead: the markup is componentised, the ORDER stays the repository's.
991
+ */
992
+ inlineOnly: number;
993
+ /**
994
+ * Already ours, from a previous run — its fields now live in a file carrying our marker.
995
+ *
996
+ * 🔴 ITS OWN BUCKET, NOT `extracted`. The round trip has to be observable: a second run over an
997
+ * already-componentised tree reports `extracted: 0, pending: []`, and it can only do that AND
998
+ * account for every section of the plan if "done earlier" is a bucket of its own.
999
+ */
1000
+ alreadyExtracted: number;
1001
+ pending: PendingSection[];
1002
+ }
1003
+ interface ComponentizeReceipt {
1004
+ /** The plan this run was made against, carried so a receipt can be matched to one. */
1005
+ planDigest: string;
1006
+ sections: SectionsReceipt;
1007
+ /** The component slugs this run wrote a file for, sorted. */
1008
+ components: string[];
1009
+ /**
1010
+ * Sections the SERVER already marked pending (`NO_GROUP_ROOT`, `ALREADY_COMPONENTIZED`, …).
1011
+ *
1012
+ * Outside the arithmetic below on purpose: the plan says they are not to be placed, so a repo
1013
+ * that does not place them has done exactly what it was told.
1014
+ */
1015
+ planPending: number;
1016
+ }
1017
+ /** Raised when the receipt does not account for every section. Never caught inside this package. */
1018
+ declare class SectionInvariantError extends Error {
1019
+ readonly code = "SECTION_INVARIANT";
1020
+ constructor(message: string);
1021
+ }
1022
+ /**
1023
+ * The invariant, checked where the receipt is built.
1024
+ *
1025
+ * A section in none of the buckets is a section the codemod forgot, and forgetting is the failure
1026
+ * this whole lane exists to make impossible — the same reasoning, and the same shape, as P2's
1027
+ * `assertReceipt`.
1028
+ */
1029
+ declare function assertSections(total: number, sections: SectionsReceipt): SectionsReceipt;
1030
+ interface ComponentizeOptions {
1031
+ /** Replace a helper module this package did not write, instead of refusing. The CLI's flag. */
1032
+ overwriteHelper?: boolean;
1033
+ }
1034
+ /** The marker that says a file is OURS. Idempotence is decided on it, never on a path. */
1035
+ declare const SECTION_MARKER = "// @bettercms-ai/convert section v1";
1036
+ /** The same, for the two modules that hold the registry rather than a section. */
1037
+ declare const REGISTRY_MARKER = "// @bettercms-ai/convert sections v1";
1038
+ /** Where the generated components live. Fixed, so a second run knows its own output on sight. */
1039
+ declare const COMPONENT_DIR = "src/components/bcms";
1040
+ /** The module that reads `blocks` out of the committed snapshots. */
1041
+ declare const SECTIONS_LIB = "src/lib/bcms-sections.ts";
1042
+ declare function componentizeSources(briefIn: Brief, planIn: ComponentizePlan, sources: SourceFile[], options?: ComponentizeOptions): Promise<{
1043
+ files: PlanFile[];
1044
+ receipt: ComponentizeReceipt;
1045
+ }>;
1046
+ /**
1047
+ * The plan, checked at INGRESS.
1048
+ *
1049
+ * Every value the codemod turns into a path or writes into generated source is validated before it
1050
+ * can reach either — `../package` as a page slug is a `package.json` this would have overwritten.
1051
+ * @see validate.ts
1052
+ */
1053
+ declare function readComponentizePlan(value: unknown): ComponentizePlan;
1054
+
1055
+ /**
1056
+ * The brief and the plan, checked at INGRESS — before a single byte is written.
1057
+ *
1058
+ * 🔴 A SLUG FROM THE WIRE BECOMES A PATH ON DISK. `bcms-content/<page.slug>.json` is composed from
1059
+ * a string this package did not choose: a page slugged `../package` names the repository's own
1060
+ * `package.json`, and the codemod would have written `{"blocks": []}` over it. The CLI's `--root`
1061
+ * check catches an escape out of the tree; it cannot catch an escape WITHIN it, because the target
1062
+ * is still under `--root`. So the value is refused where it enters, not where it is used.
1063
+ *
1064
+ * The same reasoning covers `groupKey` and `proposedSlug`: both are written into generated source
1065
+ * as object keys and one of them decides a component's file name.
1066
+ *
1067
+ * NO ZOD. This package ships to `npx` in somebody else's repository and its dependency list is the
1068
+ * five parsers; a validator for two known shapes is forty lines, and forty lines is cheaper than a
1069
+ * dependency in every install. The checks are the ones zod would express: a type per field, a
1070
+ * pattern per string that becomes a path, and a refusal that names the field it refused.
1071
+ */
1072
+
1073
+ type ValidationCode = "BRIEF_INVALID" | "PLAN_INVALID";
1074
+ /** Raised at ingress. The run is refused whole: nothing is written on a value this cannot trust. */
1075
+ declare class ValidationError extends Error {
1076
+ readonly code: ValidationCode;
1077
+ constructor(code: ValidationCode, message: string);
1078
+ }
1079
+ /**
1080
+ * The brief, checked for everything the codemod reads off it.
1081
+ *
1082
+ * `slug` is the one that becomes a path. `route` is matched against the plan's and against the
1083
+ * router's own conventions, so it has to be a URL path rather than an arbitrary string.
1084
+ */
1085
+ declare function readBrief(value: unknown): Brief;
1086
+ /**
1087
+ * The plan, checked for everything the codemod reads off it.
1088
+ *
1089
+ * PERMISSIVE ABOUT SHAPE, STRICT ABOUT VALUES: a newer server may add fields, and an unknown key
1090
+ * is carried past untouched — but every field this version actually consumes is typed, and the two
1091
+ * that become file names or source keys are `groupKey` and `proposedSlug`.
1092
+ */
1093
+ declare function readPlan(value: unknown): ComponentizePlan;
1094
+
805
1095
  /** One file operation, exactly as `src/lib/github/plan-digest.ts` spells it. */
806
1096
  interface PlanFile {
807
1097
  path: string;
@@ -831,9 +1121,9 @@ interface Splice {
831
1121
  end: number;
832
1122
  text: string;
833
1123
  }
834
- declare function convertSources(brief: Brief, sources: SourceFile[], options?: ConvertOptions): Promise<{
1124
+ declare function convertSources(briefIn: Brief, sources: SourceFile[], options?: ConvertOptions): Promise<{
835
1125
  files: PlanFile[];
836
1126
  receipt: ConversionReceipt;
837
1127
  }>;
838
1128
 
839
- export { type AstNode, type AttrBinding, type Brief, type BriefPage, type BriefPath, CONTENT_DIR, type ConversionReceipt, ConvertError, type ConvertErrorCode, type ConvertOptions, DIALECT_RULES, type Dialect, type DynamicBinding, type FileDeclaration, type FindSitesResult, HELPER_PATH, HELPER_VERSION, type ImportedFrom, type LlmFallback, type LocatedPath, type Node, PARSE_FILE, type ParsedFile, type ParserError, type PathLocator, type PendingPath, type PendingReason, type PlanFile, type ReceiptFile, ReceiptInvariantError, type Rewrite, SKIP_DIRS, SKIP_FILES, SOURCE_EXTENSIONS, STUB_CONTENT, type Site, type SiteWhere, type SourceFile, type Splice, type TargetIdentity, type UnlocatedPath, aliasesFrom, assertReceipt, attrsFor, briefDigest, convertSources, convertedHere, coverageOf, declaringElements, dialectOf, exportedFunction, findSites, findSitesTolerant, flat, helperSource, importsIn, isKnownHelper, isSourceCandidate, locate, overlapping, parseFile, propsAttribute, readDeclarations, readExpr, relativeImport, resolveSpecifier, rewriteFile, stripTags, walkAst };
1129
+ export { type AstNode, type AttrBinding, type Brief, type BriefPage, type BriefPath, COMPONENT_DIR, CONTENT_DIR, type Carried, type ComponentizeOptions, type ComponentizePlan, type ComponentizePlanComponent, type ComponentizePlanField, type ComponentizePlanPage, type ComponentizePlanSection, type ComponentizeReceipt, type ConversionReceipt, ConvertError, type ConvertErrorCode, type ConvertOptions, DIALECT_RULES, type Dialect, type DynamicBinding, type FileDeclaration, type FindSitesResult, HELPER_PATH, HELPER_VERSION, type ImportedFrom, type LlmFallback, type LocatedPath, type Node, PARSE_FILE, type ParsedFile, type ParserError, type PathLocator, type PendingPath, type PendingReason, type PendingSection, type PlanFile, REGISTRY_MARKER, type ReceiptFile, ReceiptInvariantError, type Rewrite, SECTIONS_LIB, SECTION_MARKER, SKIP_DIRS, SKIP_FILES, SOURCE_EXTENSIONS, STUB_CONTENT, SectionInvariantError, type SectionPendingReason, type SectionsReceipt, type Site, type SiteWhere, type SourceFile, type Splice, type TargetIdentity, type UnlocatedPath, type ValidationCode, ValidationError, aliasesFrom, assertReceipt, assertSections, attrsFor, briefDigest, carryFor, componentizeSources, convertSources, convertedHere, coverageOf, declaringElements, dialectOf, exportedFunction, findSites, findSitesTolerant, flat, helperSource, importsIn, isDynamicRoute, isKnownHelper, isSourceCandidate, locate, overlapping, pageFilesFor, parseFile, propsAttribute, readBrief, readComponentizePlan, readDeclarations, readExpr, readPlan, relativeImport, resolveSpecifier, rewriteFile, routeOfFile, stripTags, walkAst };