@tinkerlandai/indiana-iam 0.2.0 → 0.4.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/README.md +127 -5
- package/assets/document-controller.js +18 -1
- package/assets/document-runtime.js +189 -1
- package/assets/viewer.js +74 -7
- package/package.json +6 -13
- package/src/analyze.mjs +897 -123
- package/src/anchors.mjs +5 -0
- package/src/build-options.d.ts +6 -1
- package/src/build-runtime.mjs +4 -1
- package/src/build-styles.mjs +49 -6
- package/src/build.mjs +13 -7
- package/src/cli.mjs +10 -4
- package/src/component-registry.mjs +229 -0
- package/src/dependencies.mjs +2 -2
- package/src/dom-payload-browser.mjs +28 -0
- package/src/dom-payload-json.mjs +12 -0
- package/src/dom-payload.mjs +50 -0
- package/src/framework-routes.mjs +44 -89
- package/src/generate.mjs +20 -7
- package/src/graph-sources.mjs +54 -0
- package/src/index.d.ts +13 -1
- package/src/index.mjs +1 -1
- package/src/integrity.mjs +4 -2
- package/src/library-entry.mjs +5 -0
- package/src/lucide-source.mjs +18 -0
- package/src/next-loader.cjs +7 -1
- package/src/next.cjs +5 -0
- package/src/next.mjs +4 -3
- package/src/node-hashes.mjs +13 -9
- package/src/render-index.mjs +6 -4
- package/src/route-lazy.mjs +80 -0
- package/src/route-tree.mjs +15 -3
- package/src/routing.mjs +1 -1
- package/src/schema-definition.mjs +8 -1
- package/src/source-attributes.mjs +24 -0
- package/src/source-charts.mjs +38 -0
- package/src/source-collections.mjs +241 -0
- package/src/source-entry.mjs +50 -0
- package/src/source-environment.mjs +26 -0
- package/src/source-media.mjs +21 -0
- package/src/source-query-evidence.mjs +64 -0
- package/src/source-redirects.mjs +388 -0
- package/src/source-resolution.mjs +268 -0
- package/src/static-primitives.mjs +48 -0
- package/src/static-query.mjs +39 -11
- package/src/tab-scenes.mjs +214 -0
- package/src/tanstack-routes.mjs +143 -0
- package/src/transform.mjs +2 -2
- package/src/ui-preview.mjs +225 -0
- package/src/ui-state-machine.mjs +76 -0
- package/src/ui-structure.mjs +645 -0
- package/src/upload.mjs +28 -6
- package/src/validate.mjs +19 -4
- package/src/vite.d.ts +4 -1
- package/src/vite.mjs +25 -10
package/src/anchors.mjs
CHANGED
|
@@ -9,6 +9,11 @@ export const anchorAttributes = [
|
|
|
9
9
|
"data-qa",
|
|
10
10
|
"data-indiana-id",
|
|
11
11
|
];
|
|
12
|
+
export function sourceScriptKind(file, ts) {
|
|
13
|
+
return /\.[cm]?ts$/.test(file)
|
|
14
|
+
? ts.ScriptKind.TS
|
|
15
|
+
: /\.tsx$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.JSX;
|
|
16
|
+
}
|
|
12
17
|
export function instrumentableSource(file, text) {
|
|
13
18
|
return (
|
|
14
19
|
typeof text === "string" &&
|
package/src/build-options.d.ts
CHANGED
|
@@ -6,17 +6,22 @@ export type IndianaIamBuildOptions = {
|
|
|
6
6
|
enabled?: boolean;
|
|
7
7
|
/** Reuse existing UI IDs and add build-only anchors for unambiguous native JSX (default: true). */
|
|
8
8
|
automaticAnchors?: boolean;
|
|
9
|
+
/** Browser entry relative to root for apps with multiple source roots. */
|
|
10
|
+
entry?: string;
|
|
9
11
|
/** App name recorded in the IAM; defaults to the root folder name. */
|
|
10
12
|
name?: string;
|
|
11
13
|
/** Fail the build when the IAM cannot be generated (default: warn and continue). */
|
|
12
14
|
strict?: boolean;
|
|
13
15
|
/** Font family declared by the compiled CSS that renders the root element. */
|
|
14
16
|
rootFont?: string;
|
|
15
|
-
/** Drop inline SVG resources
|
|
17
|
+
/** Drop inline SVG resources with a coverage gap instead of skipping the CSS (default: true). */
|
|
16
18
|
omitSvgResources?: boolean;
|
|
17
19
|
logger?: BuildLogger;
|
|
18
20
|
/** Key holding only app-manifests:write; defaults to `INDIANA_IAM_KEY`. Without one the IAM is not uploaded. */
|
|
19
21
|
apiKey?: string;
|
|
22
|
+
/** Client this build belongs to, such as `web` or `web-dashboard`; each client has its own current version.
|
|
23
|
+
* Defaults to `INDIANA_IAM_CLIENT` or `web`. */
|
|
24
|
+
client?: string;
|
|
20
25
|
/** Indiana origin; defaults to `INDIANA_IAM_BASE_URL` or https://tinker.cash. */
|
|
21
26
|
baseUrl?: string;
|
|
22
27
|
/** "production" (default) skips Vercel preview deployments; "always" uploads every build; false never uploads. */
|
package/src/build-runtime.mjs
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import { expandDomNodes } from './node-hashes.mjs';
|
|
3
|
+
import {applySourceAttributes} from './source-attributes.mjs';
|
|
4
|
+
import {applySourceCollections,markUnobservedData,checkedCollectionFragment} from './source-collections.mjs';
|
|
3
5
|
// Only these two reviewed local assets form the executable preview runtime.
|
|
4
6
|
const machine = await fs.readFile(new URL("../assets/state-machine.js", import.meta.url), "utf8");
|
|
7
|
+
await fs.writeFile(new URL('./ui-state-machine.mjs', import.meta.url), '/* Generated from assets/state-machine.js by build-runtime.mjs. */\n'+machine);
|
|
5
8
|
const controller = await fs.readFile(new URL("../assets/document-controller.js", import.meta.url), "utf8");
|
|
6
9
|
if (!machine.includes("export function createStateMachine(")) throw new Error("Unexpected state runtime export");
|
|
7
|
-
await fs.writeFile(new URL("../assets/document-runtime.js", import.meta.url), "/* Generated by scripts/build-runtime.mjs. */\n(() => {\n" + machine.replace("export function createStateMachine(", "function createStateMachine(") + "\n" + controller + "\n})();\n");
|
|
10
|
+
await fs.writeFile(new URL("../assets/document-runtime.js", import.meta.url), "/* Generated by scripts/build-runtime.mjs. */\n(() => {\n" + machine.replace("export function createStateMachine(", "function createStateMachine(") + "\n" + [checkedCollectionFragment,applySourceAttributes,applySourceCollections,markUnobservedData].map(fn=>fn.toString()).join('\n')+'\n'+ controller + "\n})();\n");
|
|
8
11
|
const viewerUrl=new URL('../assets/viewer.js',import.meta.url),viewer=await fs.readFile(viewerUrl,'utf8');
|
|
9
12
|
if(!viewer.includes('/* BEGIN HTML DOM DERIVATION */'))throw Error('Missing DOM derivation asset boundary');
|
|
10
13
|
await fs.writeFile(viewerUrl,viewer.replace(/\/\* BEGIN HTML DOM DERIVATION \*\/[\s\S]*?\/\* END HTML DOM DERIVATION \*\//,'/* BEGIN HTML DOM DERIVATION */\n'+expandDomNodes.toString()+'\n /* END HTML DOM DERIVATION */'));
|
package/src/build-styles.mjs
CHANGED
|
@@ -30,8 +30,8 @@ function checkedMounts(mounts) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/** Explicit public build inputs only. Never runs builds, reads env, downloads URLs or imports App code. */
|
|
33
|
-
export async function attachBuildStyles(manifest, { root, files = [], publicFiles = [], optimizePublicImages = false, rootFont = "", omitSvgResources = false, mounts = nextStaticMounts() }) {
|
|
34
|
-
if (!Array.isArray(files) || files.length > 16 || !Array.isArray(publicFiles) || publicFiles.length > 128 || (!files.length && !publicFiles.length)) throw new Error("Choose up to 16 CSS files and 128 public assets");
|
|
33
|
+
export async function attachBuildStyles(manifest, { root, files = [], publicFiles = [], referencedImages = false, optimizePublicImages = false, rootFont = "", omitSvgResources = false, mounts = nextStaticMounts() }) {
|
|
34
|
+
if (!Array.isArray(files) || files.length > 16 || !Array.isArray(publicFiles) || publicFiles.length > 128 || (!files.length && !publicFiles.length && !referencedImages)) throw new Error("Choose up to 16 CSS files and 128 public assets");
|
|
35
35
|
if (rootFont && !/^[A-Za-z][A-Za-z0-9 _-]{0,63}$/.test(rootFont)) throw new Error("Invalid root font family");
|
|
36
36
|
root = await fs.realpath(root);
|
|
37
37
|
const cssTree = dependency("css-tree").value;
|
|
@@ -45,9 +45,9 @@ export async function attachBuildStyles(manifest, { root, files = [], publicFile
|
|
|
45
45
|
const fileOf = pathname => { const mount = [...served].sort((a, b) => b.urlPath.length - a.urlPath.length).find(m => pathname.startsWith(m.urlPath)); if (!mount) throw new Error("CSS asset is outside the public build directories"); return mount.directory + "/" + pathname.slice(mount.urlPath.length); };
|
|
46
46
|
const inputs = new Map(), assets = new Map(manifest.assets.map(a => [a.assetKey, a]));
|
|
47
47
|
let total = 0, publicSourceBytes = 0;
|
|
48
|
-
async function read(file, publicImage = false) {
|
|
48
|
+
async function read(file, publicImage = false, sourceImage = false) {
|
|
49
49
|
const absolute = path.resolve(root, file), real = await fs.realpath(absolute);
|
|
50
|
-
if ((await fs.lstat(absolute)).isSymbolicLink() || !allowed.some(base => real.startsWith(base + path.sep))) throw new Error("Asset must be inside a real public build directory");
|
|
50
|
+
if ((await fs.lstat(absolute)).isSymbolicLink() || !(sourceImage ? real.startsWith(root+path.sep) : allowed.some(base => real.startsWith(base + path.sep)))) throw new Error("Asset must be inside a real public build directory");
|
|
51
51
|
const relative = path.relative(root, real).split(path.sep).join("/");
|
|
52
52
|
if (inputs.has(relative)) return inputs.get(relative);
|
|
53
53
|
const stat = await fs.stat(real);
|
|
@@ -113,14 +113,50 @@ export async function attachBuildStyles(manifest, { root, files = [], publicFile
|
|
|
113
113
|
assets.set(key, {assetKey:key, sourceUrl, mime, bytes:bytes.length, sha256:digest, dataUrl:'data:'+mime+';base64,'+bytes.toString('base64')});
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
|
+
if(referencedImages){
|
|
117
|
+
const urls=[...new Set(manifest.documents.flatMap(doc=>documentNodes(doc).map(n=>n.attributes?.src).filter(v=>typeof v==='string'&&/^\/(?!\/)[A-Za-z0-9_./-]+\.(?:png|jpe?g|webp|gif)$/i.test(v))))].sort();
|
|
118
|
+
if(urls.length>128)throw Error('Referenced image budget exceeded');
|
|
119
|
+
for(const url of urls){
|
|
120
|
+
if(assets.size>=128)throw Error('Referenced image budget exceeded');
|
|
121
|
+
if([...assets.values()].some(a=>a.sourceUrl===url))continue;
|
|
122
|
+
const source=url.startsWith('/__iam_source__/'),file=source?url.slice('/__iam_source__/'.length):fileOf(url);
|
|
123
|
+
if(file.split('/').some(p=>!p||p==='..'||source&&p.startsWith('.'))||/(?:^|\/)(?:api|server|secrets?|credentials)(?:\/|\.)/i.test(file))continue;
|
|
124
|
+
try{
|
|
125
|
+
const input=await read(file,false,source);let bytes=input.bytes,mime=types.get(path.extname(file).toLowerCase());
|
|
126
|
+
const valid=mime==='image/png'?bytes.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10])):mime==='image/jpeg'?bytes[0]===255&&bytes[1]===216&&bytes[2]===255:mime==='image/webp'?bytes.subarray(0,4).toString()==='RIFF'&&bytes.subarray(8,12).toString()==='WEBP':/^GIF8[79]a$/.test(bytes.subarray(0,6).toString());
|
|
127
|
+
if(!valid)throw Error('Referenced raster signature mismatch');
|
|
128
|
+
if(bytes.length>65536){
|
|
129
|
+
const sharp=dependency('sharp').value;if(sharp.versions.sharp!=='0.35.5')throw Error('Pinned image encoder required');
|
|
130
|
+
const image=sharp(bytes,{limitInputPixels:16777216,failOn:'warning'}),metadata=await image.metadata();if((metadata.pages||1)!==1)throw Error('Animated source images require an explicit adapter');
|
|
131
|
+
bytes=await image.rotate().resize({width:800,height:800,fit:'inside',withoutEnlargement:true}).webp({quality:90,effort:4}).toBuffer();mime='image/webp';
|
|
132
|
+
manifest.coverage.gaps.push({code:'REFERENCED_IMAGE_PREVIEW_OPTIMIZED',file,detail:'Build preview profile for rasters over 64 KiB: auto-orient, fit inside 800x800, WebP quality 90; source bytes remain hashed. Lossy preview, not original pixels.'});
|
|
133
|
+
}
|
|
134
|
+
const digest=sha256(bytes),assetKey=identity('asset',{sourceUrl:url,mime,sha256:digest});assets.set(assetKey,{assetKey,sourceUrl:url,mime,bytes:bytes.length,sha256:digest,dataUrl:'data:'+mime+';base64,'+bytes.toString('base64')});
|
|
135
|
+
}catch(error){
|
|
136
|
+
// A missing reference is a fidelity gap. Unsafe files/budgets fail the packaging step.
|
|
137
|
+
if(error.code!=='ENOENT')throw error;
|
|
138
|
+
manifest.coverage.gaps.push({code:'REFERENCED_IMAGE_MISSING',file,detail:'Referenced raster is absent from the local public build; no network fetch performed.'});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
116
142
|
if (optimizePublicImages && publicFiles.length) manifest.coverage.gaps.push({code:'PUBLIC_IMAGES_OPTIMIZED', file:publicFiles[0], detail:'Explicit sharp 0.35.5 profile: auto-orient, fit inside 800x800 without enlargement, WebP quality 90 effort 4; metadata stripped. Source bytes hashed independently; not pixel-identical originals.'});
|
|
117
143
|
const sheets = [], families = new Set();
|
|
118
144
|
for (const file of [...new Set(files)].sort()) {
|
|
119
145
|
if (!file.endsWith(".css")) throw new Error("Compiled CSS file required");
|
|
120
146
|
const input = await read(file), ast = cssTree.parse(new TextDecoder("utf-8", { fatal: true }).decode(input.bytes), { parseCustomProperty: true });
|
|
121
147
|
const urls = [];
|
|
148
|
+
// A build may retain a remote font import. Keep its local stylesheet, but
|
|
149
|
+
// never fetch or copy the external dependency into an offline projection.
|
|
150
|
+
cssTree.walk(ast, { visit: "Atrule", enter(node, item, list) {
|
|
151
|
+
if (node.name.toLowerCase() !== "import") return;
|
|
152
|
+
const target = node.prelude?.children?.first;
|
|
153
|
+
if (!["String", "Url"].includes(target?.type) || !/^(?:https?:)?\/\//i.test(target.value))
|
|
154
|
+
throw new Error("Precompile CSS imports before packaging");
|
|
155
|
+
list.remove(item);
|
|
156
|
+
if (!manifest.coverage.gaps.some(g => g.code === "BUILD_EXTERNAL_STYLESHEET_OMITTED" && g.file === input.relative))
|
|
157
|
+
manifest.coverage.gaps.push({ code: "BUILD_EXTERNAL_STYLESHEET_OMITTED", file: input.relative, detail: "External CSS import omitted without network access; local compiled styles retained. Remote fonts or rules remain unavailable." });
|
|
158
|
+
}});
|
|
122
159
|
cssTree.walk(ast, node => {
|
|
123
|
-
if (node.type === "Atrule" && node.name.toLowerCase() === "import") throw new Error("Precompile CSS imports before packaging");
|
|
124
160
|
if (node.type === "Atrule" && node.name.toLowerCase() === "font-face") node.block?.children.forEach(declaration => { if (declaration.type === "Declaration" && declaration.property === "font-family") families.add(cssTree.generate(declaration.value).replace(/^["']|["']$/g, "")); });
|
|
125
161
|
if (node.type === "Url") urls.push(node);
|
|
126
162
|
});
|
|
@@ -128,7 +164,7 @@ export async function attachBuildStyles(manifest, { root, files = [], publicFile
|
|
|
128
164
|
sheets.push(cssTree.generate(ast));
|
|
129
165
|
}
|
|
130
166
|
if (rootFont && !families.has(rootFont)) throw new Error("Root font must exist in compiled font faces");
|
|
131
|
-
const css = sheets.join("\n") + "\n.iam-page{min-height:100vh}.iam-icon{display:inline-flex;align-items:center;justify-content:center;width:1.1em;height:1.1em}.iam-placeholder{padding:1rem;border:1px dashed #d4d4d4}" + (rootFont ? "\nbody{font-family:" + JSON.stringify(rootFont) + "," + JSON.stringify(rootFont + " Fallback") + ",sans-serif}" : "");
|
|
167
|
+
const css = sheets.join("\n") + "\n[hidden]{display:none!important}.iam-state{display:contents}.iam-page{min-height:100vh}.iam-icon{display:inline-flex;align-items:center;justify-content:center;width:1.1em;height:1.1em}.iam-placeholder{padding:1rem;border:1px dashed #d4d4d4}" + (rootFont ? "\nbody{font-family:" + JSON.stringify(rootFont) + "," + JSON.stringify(rootFont + " Fallback") + ",sans-serif}" : "");
|
|
132
168
|
if (/<\/(?:style|script)/i.test(css)) throw new Error("HTML closing tag in compiled CSS");
|
|
133
169
|
if (files.length) for (const doc of manifest.documents) { doc.css = css; sealDocument(doc); }
|
|
134
170
|
// One content-addressed image in the envelope; DOM instances carry references.
|
|
@@ -142,6 +178,13 @@ export async function attachBuildStyles(manifest, { root, files = [], publicFile
|
|
|
142
178
|
doc.html = doc.html.replaceAll('src="' + asset.sourceUrl + '"', 'src="' + reference + '"');
|
|
143
179
|
}
|
|
144
180
|
storeDocumentNodes(doc, nodes, new Map(manifest.elements.map(element => [element.elementKey, element])));
|
|
181
|
+
// Resource replacement changes element and ancestor hashes. Keep target
|
|
182
|
+
// fingerprints tied to the final document, never the pre-packaging HTML.
|
|
183
|
+
const finalNodes=documentNodes(doc);
|
|
184
|
+
for(const target of manifest.routeTree?.targets??[]){
|
|
185
|
+
if(target.routeKey!==doc.pageKey)continue;const node=finalNodes.find(n=>n.nodeId===target.nodeId);if(!node)continue;
|
|
186
|
+
target.contentHash=node.contentHash;if(target.subtreeHash!==undefined)target.subtreeHash=node.subtreeHash;
|
|
187
|
+
}
|
|
145
188
|
}
|
|
146
189
|
manifest.coverage.gaps = manifest.coverage.gaps.filter(g => g.code !== 'PUBLIC_ASSET_NOT_PACKAGED' || ![...assets.values()].some(a => g.detail.startsWith(a.sourceUrl + ':')));
|
|
147
190
|
refreshRenderHashes(manifest);
|
package/src/build.mjs
CHANGED
|
@@ -27,10 +27,10 @@ async function guarded({ strict, logger }, action, what) {
|
|
|
27
27
|
* Before compilation: scans source once per build and returns its release ID.
|
|
28
28
|
* The same source and producer always get the same ID, so the page can embed its descriptor URL before the IAM exists.
|
|
29
29
|
*/
|
|
30
|
-
export async function prepareBuildRelease({ root, name, automaticAnchors = true, strict = false, logger = console }) {
|
|
30
|
+
export async function prepareBuildRelease({ root, name, entry, automaticAnchors = true, strict = false, logger = console }) {
|
|
31
31
|
return guarded({ strict, logger }, async () => {
|
|
32
32
|
root = await fs.realpath(root);
|
|
33
|
-
const analysis = await analyzeSource({ root, name, automaticAnchors });
|
|
33
|
+
const analysis = await analyzeSource({ root, name, entry, automaticAnchors });
|
|
34
34
|
const releaseId = deriveReleaseId(analysis);
|
|
35
35
|
process.env[STATE] = JSON.stringify({ root, releaseId, automaticAnchors });
|
|
36
36
|
return { releaseId };
|
|
@@ -59,22 +59,23 @@ export async function finishBuildRelease({
|
|
|
59
59
|
root,
|
|
60
60
|
releaseId,
|
|
61
61
|
name,
|
|
62
|
+
entry,
|
|
62
63
|
automaticAnchors = true,
|
|
63
64
|
cssDirectory,
|
|
64
65
|
mounts,
|
|
65
66
|
publicDirectory,
|
|
66
67
|
rootFont = "",
|
|
67
|
-
omitSvgResources =
|
|
68
|
+
omitSvgResources = true,
|
|
68
69
|
upload = {},
|
|
69
70
|
strict = false,
|
|
70
71
|
logger = console,
|
|
71
72
|
}) {
|
|
72
73
|
root = await fs.realpath(root);
|
|
73
74
|
return guarded({ strict, logger }, async () => {
|
|
74
|
-
const analysis = await analyzeSource({ root, name, automaticAnchors });
|
|
75
|
+
const analysis = await analyzeSource({ root, name, entry, automaticAnchors });
|
|
75
76
|
if (deriveReleaseId(analysis) !== releaseId) throw new Error("Source changed during the build; rebuild before publishing IAM");
|
|
76
77
|
const css = cssDirectory ? await compiledCss(root, cssDirectory) : [];
|
|
77
|
-
const plain = () => generateAppManifest({ root, analysis, releaseId });
|
|
78
|
+
const plain = () => generateAppManifest({ root, analysis, releaseId, referencedImages:true, mounts });
|
|
78
79
|
let result;
|
|
79
80
|
if (!css.length) result = await plain();
|
|
80
81
|
else if (css.length > MAX_BUILD_CSS) {
|
|
@@ -82,7 +83,7 @@ export async function finishBuildRelease({
|
|
|
82
83
|
result = await plain();
|
|
83
84
|
} else
|
|
84
85
|
try {
|
|
85
|
-
result = await generateAppManifest({ root, analysis, releaseId, buildCss: css, mounts, rootFont, omitSvgResources });
|
|
86
|
+
result = await generateAppManifest({ root, analysis, releaseId, buildCss: css, referencedImages:true, mounts, rootFont, omitSvgResources });
|
|
86
87
|
} catch (error) {
|
|
87
88
|
report(logger, "warn", "compiled CSS skipped: " + (error instanceof Error ? error.message : String(error)));
|
|
88
89
|
result = await plain();
|
|
@@ -104,11 +105,16 @@ export async function finishBuildRelease({
|
|
|
104
105
|
serialized: result.serialized,
|
|
105
106
|
digest: result.digest,
|
|
106
107
|
label,
|
|
108
|
+
client: settings.client,
|
|
107
109
|
apiKey: settings.apiKey,
|
|
108
110
|
baseUrl: settings.baseUrl,
|
|
109
111
|
fetch: upload.fetch,
|
|
110
112
|
});
|
|
111
|
-
report(
|
|
113
|
+
report(
|
|
114
|
+
logger,
|
|
115
|
+
"log",
|
|
116
|
+
`IAM ${result.digest.slice(0, 12)} ${outcome.uploaded ? "uploaded" : "already uploaded"} as the current version of client ${settings.client} (${label})`,
|
|
117
|
+
);
|
|
112
118
|
return outcome.version;
|
|
113
119
|
}, "IAM upload");
|
|
114
120
|
else if (settings.missingKey && upload.upload !== false)
|
package/src/cli.mjs
CHANGED
|
@@ -10,9 +10,9 @@ import { planAnchors, applyAnchors } from "./instrument.mjs";
|
|
|
10
10
|
import { buildLabel, uploadAppManifest, uploadSettings } from "./upload.mjs";
|
|
11
11
|
|
|
12
12
|
const usage =
|
|
13
|
-
"Usage: indiana-iam generate --root <app> --out <new directory> [--name <name> --repository <url> --commit <sha> --release-id <uuid> --write-public-release --build-css <file>...]" +
|
|
13
|
+
"Usage: indiana-iam generate --root <app> --out <new directory> [--entry <source module> --name <name> --repository <url> --commit <sha> --release-id <uuid> --write-public-release --build-css <file>...]" +
|
|
14
14
|
" | indiana-iam validate --file <manifest>" +
|
|
15
|
-
" | INDIANA_IAM_KEY=<key> indiana-iam upload --file <manifest> [--label <label> --base-url <url>]" +
|
|
15
|
+
" | INDIANA_IAM_KEY=<key> indiana-iam upload --file <manifest> [--client <key> --label <label> --base-url <url>]" +
|
|
16
16
|
" | indiana-iam prepare --root <app>" +
|
|
17
17
|
" | indiana-iam anchor --root <checkout> --out <new plan file> [--apply --primitive-map <file> --public-labels --forward-anchors]";
|
|
18
18
|
|
|
@@ -22,6 +22,7 @@ const { values, positionals } = parseArgs({
|
|
|
22
22
|
root: { type: "string" },
|
|
23
23
|
out: { type: "string" },
|
|
24
24
|
name: { type: "string" },
|
|
25
|
+
entry: { type: "string" },
|
|
25
26
|
repository: { type: "string" },
|
|
26
27
|
commit: { type: "string" },
|
|
27
28
|
file: { type: "string" },
|
|
@@ -39,6 +40,7 @@ const { values, positionals } = parseArgs({
|
|
|
39
40
|
"public-labels": { type: "boolean", default: false },
|
|
40
41
|
"forward-anchors": { type: "boolean", default: false },
|
|
41
42
|
label: { type: "string" },
|
|
43
|
+
client: { type: "string" },
|
|
42
44
|
"base-url": { type: "string" },
|
|
43
45
|
},
|
|
44
46
|
});
|
|
@@ -53,6 +55,7 @@ if (command === "generate") {
|
|
|
53
55
|
const result = await generateAppManifest({
|
|
54
56
|
root,
|
|
55
57
|
name: values.name,
|
|
58
|
+
entry: values.entry,
|
|
56
59
|
repository: values.repository || "",
|
|
57
60
|
commit: values.commit || "",
|
|
58
61
|
navigationPaths: (values["navigation-path"] || []).map((value) => JSON.parse(value)),
|
|
@@ -90,7 +93,7 @@ if (command === "generate") {
|
|
|
90
93
|
} else if (command === "upload") {
|
|
91
94
|
if (!values.file) throw new Error("upload requires --file");
|
|
92
95
|
// The key is read from INDIANA_IAM_KEY only, so it stays out of shell history.
|
|
93
|
-
const settings = uploadSettings({ baseUrl: values["base-url"], upload: "always" });
|
|
96
|
+
const settings = uploadSettings({ baseUrl: values["base-url"], upload: "always", client: values.client });
|
|
94
97
|
if (settings.missingKey) throw new Error("Set INDIANA_IAM_KEY to a key with only the app-manifests:write permission");
|
|
95
98
|
const bytes = await fs.readFile(values.file),
|
|
96
99
|
serialized = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
@@ -99,10 +102,13 @@ if (command === "generate") {
|
|
|
99
102
|
serialized,
|
|
100
103
|
digest: sha256(bytes),
|
|
101
104
|
label: values.label || buildLabel(path.dirname(path.resolve(values.file))),
|
|
105
|
+
client: settings.client,
|
|
102
106
|
apiKey: settings.apiKey,
|
|
103
107
|
baseUrl: settings.baseUrl,
|
|
104
108
|
});
|
|
105
|
-
console.log(
|
|
109
|
+
console.log(
|
|
110
|
+
JSON.stringify({ uploaded, version: { id: version.id, client: version.clientKey, state: version.state, isCurrent: version.isCurrent } }, null, 2),
|
|
111
|
+
);
|
|
106
112
|
} else if (command === "prepare") {
|
|
107
113
|
if (!values.root) throw new Error("prepare requires --root");
|
|
108
114
|
console.log(JSON.stringify(await prepareRelease(values.root)));
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/** Follow finite source selectors to their literal component registry, retaining alternatives as assumptions. */
|
|
2
|
+
export function componentRegistry({
|
|
3
|
+
ts,
|
|
4
|
+
node,
|
|
5
|
+
module,
|
|
6
|
+
locals,
|
|
7
|
+
binding,
|
|
8
|
+
functionFrom,
|
|
9
|
+
value,
|
|
10
|
+
component,
|
|
11
|
+
variable,
|
|
12
|
+
location,
|
|
13
|
+
unknown,
|
|
14
|
+
imported,
|
|
15
|
+
}) {
|
|
16
|
+
let steps = 0;
|
|
17
|
+
const choices = [],
|
|
18
|
+
variables = new Set(),
|
|
19
|
+
seen = new Set();
|
|
20
|
+
const unwrap = (n) => {
|
|
21
|
+
while (
|
|
22
|
+
n &&
|
|
23
|
+
(ts.isParenthesizedExpression(n) ||
|
|
24
|
+
ts.isAsExpression(n) ||
|
|
25
|
+
ts.isNonNullExpression(n) ||
|
|
26
|
+
ts.isSatisfiesExpression(n))
|
|
27
|
+
)
|
|
28
|
+
n = n.expression;
|
|
29
|
+
return n;
|
|
30
|
+
};
|
|
31
|
+
const localEnvironment = (fn, m, parent) => {
|
|
32
|
+
const env = new Map(parent);
|
|
33
|
+
if (ts.isBlock(fn.body))
|
|
34
|
+
for (const s of fn.body.statements)
|
|
35
|
+
if (ts.isVariableStatement(s))
|
|
36
|
+
for (const d of s.declarationList.declarations)
|
|
37
|
+
if (ts.isIdentifier(d.name))
|
|
38
|
+
env.set(d.name.text, { node: d.initializer, module: m, env });
|
|
39
|
+
return env;
|
|
40
|
+
};
|
|
41
|
+
const returns = (fn) =>
|
|
42
|
+
ts.isBlock(fn.body)
|
|
43
|
+
? fn.body.statements
|
|
44
|
+
.filter((s) => ts.isReturnStatement(s))
|
|
45
|
+
.map((s) => s.expression)
|
|
46
|
+
: [fn.body];
|
|
47
|
+
function walk(raw, m, env, path = [], record = null, depth = 0) {
|
|
48
|
+
const n = unwrap(raw);
|
|
49
|
+
if (!n || depth > 24 || ++steps > 3000 || choices.length > 32) return;
|
|
50
|
+
if (!path.length && !(ts.isIdentifier(n) && env.has(n.text))) {
|
|
51
|
+
const c = component(n, m);
|
|
52
|
+
if (c) {
|
|
53
|
+
const optionValue =
|
|
54
|
+
typeof record?.key === "string" ? record.key : c.symbol;
|
|
55
|
+
choices.push({
|
|
56
|
+
component: c,
|
|
57
|
+
value: optionValue,
|
|
58
|
+
label:
|
|
59
|
+
typeof record?.label === "string"
|
|
60
|
+
? record.label
|
|
61
|
+
: typeof record?.labelKey === "string"
|
|
62
|
+
? record.labelKey
|
|
63
|
+
: c.symbol,
|
|
64
|
+
source: location(m, n, c.symbol),
|
|
65
|
+
scope: typeof record?.scope === "string" ? record.scope : null,
|
|
66
|
+
});
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const identity = m.file + ":" + n.pos + ":" + n.end + ":" + path.join("/");
|
|
71
|
+
if (seen.has(identity)) return;
|
|
72
|
+
seen.add(identity);
|
|
73
|
+
if (ts.isIdentifier(n)) {
|
|
74
|
+
const local = env.get(n.text);
|
|
75
|
+
if (local) {
|
|
76
|
+
walk(local.node, local.module, local.env, path, record, depth + 1);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const ref = binding(m, n.text);
|
|
80
|
+
if (ref) walk(ref.node, ref.module, new Map(), path, record, depth + 1);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (ts.isPropertyAccessExpression(n)) {
|
|
84
|
+
walk(n.expression, m, env, [n.name.text, ...path], record, depth + 1);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (ts.isElementAccessExpression(n)) {
|
|
88
|
+
const key = value(n.argumentExpression, m);
|
|
89
|
+
walk(
|
|
90
|
+
n.expression,
|
|
91
|
+
m,
|
|
92
|
+
env,
|
|
93
|
+
[
|
|
94
|
+
typeof key === "number" || typeof key === "string"
|
|
95
|
+
? String(key)
|
|
96
|
+
: "[]",
|
|
97
|
+
...path,
|
|
98
|
+
],
|
|
99
|
+
record,
|
|
100
|
+
depth + 1,
|
|
101
|
+
);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (
|
|
105
|
+
ts.isBinaryExpression(n) &&
|
|
106
|
+
[ts.SyntaxKind.QuestionQuestionToken, ts.SyntaxKind.BarBarToken].includes(
|
|
107
|
+
n.operatorToken.kind,
|
|
108
|
+
)
|
|
109
|
+
) {
|
|
110
|
+
walk(n.left, m, env, path, record, depth + 1);
|
|
111
|
+
walk(n.right, m, env, path, record, depth + 1);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (ts.isConditionalExpression(n)) {
|
|
115
|
+
walk(n.whenTrue, m, env, path, record, depth + 1);
|
|
116
|
+
walk(n.whenFalse, m, env, path, record, depth + 1);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (ts.isArrayLiteralExpression(n)) {
|
|
120
|
+
const [key, ...rest] = path;
|
|
121
|
+
if (key === "[]")
|
|
122
|
+
for (const item of n.elements)
|
|
123
|
+
walk(item, m, env, rest, record, depth + 1);
|
|
124
|
+
else if (/^\d+$/.test(key ?? "") && n.elements[Number(key)])
|
|
125
|
+
walk(n.elements[Number(key)], m, env, rest, record, depth + 1);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (ts.isObjectLiteralExpression(n) && path.length) {
|
|
129
|
+
const [key, ...rest] = path,
|
|
130
|
+
fields = n.properties.filter(
|
|
131
|
+
(p) =>
|
|
132
|
+
ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p),
|
|
133
|
+
);
|
|
134
|
+
const field = fields.find((p) => p.name.text === key);
|
|
135
|
+
if (!field) return;
|
|
136
|
+
const meta = Object.fromEntries(
|
|
137
|
+
fields.flatMap((p) => {
|
|
138
|
+
if (!["key", "label", "labelKey", "scope"].includes(p.name.text))
|
|
139
|
+
return [];
|
|
140
|
+
const v = value(
|
|
141
|
+
ts.isPropertyAssignment(p) ? p.initializer : p.name,
|
|
142
|
+
m,
|
|
143
|
+
);
|
|
144
|
+
return typeof v === "string" ? [[p.name.text, v]] : [];
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
147
|
+
walk(
|
|
148
|
+
ts.isPropertyAssignment(field) ? field.initializer : field.name,
|
|
149
|
+
m,
|
|
150
|
+
env,
|
|
151
|
+
rest,
|
|
152
|
+
Object.keys(meta).length ? meta : record,
|
|
153
|
+
depth + 1,
|
|
154
|
+
);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (ts.isCallExpression(n)) {
|
|
158
|
+
if (
|
|
159
|
+
ts.isPropertyAccessExpression(n.expression) &&
|
|
160
|
+
["find", "filter"].includes(n.expression.name.text)
|
|
161
|
+
) {
|
|
162
|
+
const fn = unwrap(n.arguments[0]);
|
|
163
|
+
if (
|
|
164
|
+
n.expression.name.text === "find" &&
|
|
165
|
+
fn &&
|
|
166
|
+
ts.isArrowFunction(fn) &&
|
|
167
|
+
ts.isBinaryExpression(unwrap(fn.body))
|
|
168
|
+
) {
|
|
169
|
+
const comparison = unwrap(fn.body);
|
|
170
|
+
if (
|
|
171
|
+
comparison.operatorToken.kind ===
|
|
172
|
+
ts.SyntaxKind.EqualsEqualsEqualsToken
|
|
173
|
+
)
|
|
174
|
+
for (const side of [comparison.left, comparison.right])
|
|
175
|
+
if (ts.isIdentifier(side)) {
|
|
176
|
+
const key = variable(side.text);
|
|
177
|
+
if (key) variables.add(key);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
walk(
|
|
181
|
+
n.expression.expression,
|
|
182
|
+
m,
|
|
183
|
+
env,
|
|
184
|
+
n.expression.name.text === "find" ? ["[]", ...path] : path,
|
|
185
|
+
record,
|
|
186
|
+
depth + 1,
|
|
187
|
+
);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const callback = unwrap(n.arguments[0]);
|
|
191
|
+
if (
|
|
192
|
+
callback &&
|
|
193
|
+
(ts.isArrowFunction(callback) || ts.isFunctionExpression(callback)) &&
|
|
194
|
+
imported(m, n.expression.getText(m.source))?.specifier === "react" &&
|
|
195
|
+
imported(m, n.expression.getText(m.source))?.name === "useMemo"
|
|
196
|
+
) {
|
|
197
|
+
const local = localEnvironment(callback, m, env);
|
|
198
|
+
for (const returned of returns(callback))
|
|
199
|
+
walk(returned, m, local, path, record, depth + 1);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (ts.isIdentifier(n.expression)) {
|
|
203
|
+
const ref = binding(m, n.expression.text),
|
|
204
|
+
fn = ref && functionFrom(ref.node);
|
|
205
|
+
if (
|
|
206
|
+
fn &&
|
|
207
|
+
!fn.modifiers?.some((a) => a.kind === ts.SyntaxKind.AsyncKeyword)
|
|
208
|
+
) {
|
|
209
|
+
const local = localEnvironment(fn, ref.module, new Map());
|
|
210
|
+
for (const returned of returns(fn))
|
|
211
|
+
walk(returned, ref.module, local, path, record, depth + 1);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const env = new Map(
|
|
217
|
+
[...locals].map(([key, init]) => [key, { node: init, module, env: null }]),
|
|
218
|
+
);
|
|
219
|
+
for (const entry of env.values()) entry.env = env;
|
|
220
|
+
walk(node, module, env);
|
|
221
|
+
const options = [
|
|
222
|
+
...new Map(
|
|
223
|
+
choices.map((c) => [c.value + "\0" + c.component.key, c]),
|
|
224
|
+
).values(),
|
|
225
|
+
];
|
|
226
|
+
return options.length > 1 && options.length <= 32
|
|
227
|
+
? { options, variableKey: variables.size === 1 ? [...variables][0] : null }
|
|
228
|
+
: null;
|
|
229
|
+
}
|
package/src/dependencies.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { createRequire } from "node:module";
|
|
|
2
2
|
|
|
3
3
|
const require = createRequire(import.meta.url);
|
|
4
4
|
|
|
5
|
-
/** Loads one of this package's pinned dependencies
|
|
5
|
+
/** Loads one of this package's pinned dependencies including the image encoder. */
|
|
6
6
|
export function dependency(name) {
|
|
7
7
|
try {
|
|
8
8
|
return { value: require(name), resolve: (path) => require.resolve(path) };
|
|
@@ -13,7 +13,7 @@ export function dependency(name) {
|
|
|
13
13
|
)
|
|
14
14
|
throw error;
|
|
15
15
|
throw new Error(
|
|
16
|
-
`Missing ${name}. Reinstall @tinkerlandai/indiana-iam with its dependencies
|
|
16
|
+
`Missing ${name}. Reinstall @tinkerlandai/indiana-iam with its dependencies.`,
|
|
17
17
|
);
|
|
18
18
|
}
|
|
19
19
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { checkedDomPayload, parsedDomPayload } from "./dom-payload-json.mjs";
|
|
2
|
+
|
|
3
|
+
/** The viewer decodes locally; it never fetches content or executes merchant code. */
|
|
4
|
+
export async function documentDomBrowser(document) {
|
|
5
|
+
const payload = checkedDomPayload(document);
|
|
6
|
+
if (!payload) return { html: document.html, nodes: document.nodes };
|
|
7
|
+
if (typeof DecompressionStream !== "function") throw Error("Compressed IAM requires DecompressionStream support");
|
|
8
|
+
const binary = atob(payload.data);
|
|
9
|
+
if (btoa(binary) !== payload.data) throw Error("Noncanonical compressed DOM base64");
|
|
10
|
+
const compressed = Uint8Array.from(binary, character => character.charCodeAt(0));
|
|
11
|
+
const stream = new ReadableStream({ start(controller) { controller.enqueue(compressed); controller.close(); } }).pipeThrough(new DecompressionStream("gzip"));
|
|
12
|
+
const reader = stream.getReader(), chunks = [];
|
|
13
|
+
let total = 0;
|
|
14
|
+
try {
|
|
15
|
+
for (;;) {
|
|
16
|
+
const next = await reader.read();
|
|
17
|
+
if (next.done) break;
|
|
18
|
+
total += next.value.byteLength;
|
|
19
|
+
if (total > payload.bytes) throw Error("Expanded DOM byte budget exceeded");
|
|
20
|
+
chunks.push(next.value);
|
|
21
|
+
}
|
|
22
|
+
} finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
|
|
23
|
+
if (total !== payload.bytes) throw Error("Expanded DOM byte length mismatch");
|
|
24
|
+
const decoded = new Uint8Array(total);
|
|
25
|
+
let offset = 0;
|
|
26
|
+
for (const chunk of chunks) { decoded.set(chunk, offset); offset += chunk.byteLength; }
|
|
27
|
+
return parsedDomPayload(new TextDecoder("utf-8", { fatal: true }).decode(decoded));
|
|
28
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Shared with the offline viewer. Encodings describe storage, never executable code. */
|
|
2
|
+
export function checkedDomPayload(document) {
|
|
3
|
+
const payload = document.domPayload;
|
|
4
|
+
if (!payload) return null;
|
|
5
|
+
if (document.html !== "" || !Array.isArray(document.nodes) || document.nodes.length || Object.keys(payload).sort().join(",") !== "bytes,data,encoding" || payload.encoding !== "gzip-json-v1" || !Number.isSafeInteger(payload.bytes) || payload.bytes < 1 || payload.bytes > 8_388_608 || typeof payload.data !== "string" || !payload.data.length || payload.data.length > 11_184_812 || payload.data.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(payload.data)) throw Error("Invalid compressed DOM payload");
|
|
6
|
+
return payload;
|
|
7
|
+
}
|
|
8
|
+
export function parsedDomPayload(text) {
|
|
9
|
+
const value = JSON.parse(text);
|
|
10
|
+
if (!value || Object.keys(value).sort().join(",") !== "html,nodes" || typeof value.html !== "string" || value.html.length > 2_000_000 || !value.html.isWellFormed() || !Array.isArray(value.nodes) || value.nodes.length > 12000 || JSON.stringify(value) !== text) throw Error("Invalid expanded DOM payload");
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { gzip, Inflate } from "pako";
|
|
2
|
+
import { checkedDomPayload, parsedDomPayload } from "./dom-payload-json.mjs";
|
|
3
|
+
|
|
4
|
+
export const MAX_EXPANDED_DOM_BYTES = 33_554_432;
|
|
5
|
+
|
|
6
|
+
/** Lossless, local decoding with a declared and enforced output bound. */
|
|
7
|
+
export function documentDom(document) {
|
|
8
|
+
const payload = checkedDomPayload(document);
|
|
9
|
+
if (!payload) return { html: document.html, nodes: document.nodes };
|
|
10
|
+
const compressed = Buffer.from(payload.data, "base64");
|
|
11
|
+
if (compressed.toString("base64") !== payload.data) throw Error("Noncanonical compressed DOM base64");
|
|
12
|
+
const inflater = new Inflate({ windowBits: 31, chunkSize: 32_768 });
|
|
13
|
+
const chunks = [];
|
|
14
|
+
let total = 0;
|
|
15
|
+
inflater.onData = chunk => {
|
|
16
|
+
total += chunk.byteLength;
|
|
17
|
+
if (total > payload.bytes) throw Error("Expanded DOM byte budget exceeded");
|
|
18
|
+
chunks.push(chunk);
|
|
19
|
+
};
|
|
20
|
+
if (!inflater.push(compressed, true) || inflater.err || !inflater.ended) throw Error("Invalid compressed DOM stream: " + inflater.msg);
|
|
21
|
+
if (inflater.strm.avail_in !== 0) throw Error("Trailing compressed DOM data");
|
|
22
|
+
if (total !== payload.bytes) throw Error("Expanded DOM byte length mismatch");
|
|
23
|
+
const decoded = new Uint8Array(total);
|
|
24
|
+
let offset = 0;
|
|
25
|
+
for (const chunk of chunks) { decoded.set(chunk, offset); offset += chunk.byteLength; }
|
|
26
|
+
return parsedDomPayload(new TextDecoder("utf-8", { fatal: true }).decode(decoded));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function assertDomBudget(documents) {
|
|
30
|
+
let total = 0;
|
|
31
|
+
for (const document of documents) {
|
|
32
|
+
const payload = checkedDomPayload(document);
|
|
33
|
+
total += payload ? payload.bytes : Buffer.byteLength(JSON.stringify({ html: document.html, nodes: document.nodes }));
|
|
34
|
+
if (total > MAX_EXPANDED_DOM_BYTES) throw Error("Expanded DOM aggregate byte budget exceeded");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Only sizeable, beneficial payloads are compressed; the original IDs and values survive byte-for-byte. */
|
|
39
|
+
export function packDocumentDom(document) {
|
|
40
|
+
if (document.domPayload) return false;
|
|
41
|
+
const raw = Buffer.from(JSON.stringify({ html: document.html, nodes: document.nodes }));
|
|
42
|
+
if (raw.length < 16_384 || raw.length > 8_388_608) return false;
|
|
43
|
+
const zipped = gzip(raw, { level: 9, header: { time: 0, os: 255 } });
|
|
44
|
+
const payload = { encoding: "gzip-json-v1", bytes: raw.length, data: Buffer.from(zipped).toString("base64") };
|
|
45
|
+
if (Buffer.byteLength(JSON.stringify(payload)) + 1024 >= raw.length) return false;
|
|
46
|
+
document.html = "";
|
|
47
|
+
document.nodes = [];
|
|
48
|
+
document.domPayload = payload;
|
|
49
|
+
return true;
|
|
50
|
+
}
|