privon 0.1.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/LICENSE +21 -0
- package/README.md +171 -0
- package/bin/privon.js +80 -0
- package/package.json +46 -0
- package/src/ai/client.js +130 -0
- package/src/ai/mock.js +77 -0
- package/src/ai/prompt.js +32 -0
- package/src/colors.js +69 -0
- package/src/detector.js +64 -0
- package/src/env.js +29 -0
- package/src/errors.js +21 -0
- package/src/generator.js +69 -0
- package/src/index.js +8 -0
- package/src/model.js +32 -0
- package/src/scanner.js +97 -0
- package/src/templates.js +128 -0
- package/src/utils.js +25 -0
- package/src/writer.js +138 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tosmim
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# Privon
|
|
2
|
+
|
|
3
|
+
Privon is a dependency-free Node.js SDK and CLI that drafts a privacy policy and terms of use from a project's documentation, then creates pages appropriate for the detected frontend stack.
|
|
4
|
+
|
|
5
|
+
> Generated legal text is a draft, not legal advice. Before publishing, review it for your real data practices and add the operator's legal name, address, contact email, and any product-specific disclosures.
|
|
6
|
+
|
|
7
|
+
## What it supports
|
|
8
|
+
|
|
9
|
+
| Project | Output | Integration |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| Next.js App Router | `app/privacy-policy/page.*` and `app/terms-of-use/page.*` (also supports `src/app`) | File-system routing |
|
|
12
|
+
| Next.js Pages Router | `pages/privacy-policy.*` and `pages/terms-of-use.*` (also supports `src/pages`) | File-system routing |
|
|
13
|
+
| React + React Router | Two components under `src/pages` | Adds routes to a conventional `src/App.*`, `routes.*`, or `router.*` containing `<Routes>` |
|
|
14
|
+
| React without React Router | Two components under `src/pages` | Components only, with a warning |
|
|
15
|
+
| Angular | Two standalone components under `src/app` | Adds routes to `app.routes.ts` or `app-routing.module.ts` when conventional routing is found |
|
|
16
|
+
| HTML, backend, or generic project | `privacy-policy.html` and `terms-of-use.html` | Standalone HTML |
|
|
17
|
+
|
|
18
|
+
Pages use inline styling. Privon looks for colors in conventional home, header, footer, layout, global, and theme files and otherwise uses a neutral default palette.
|
|
19
|
+
|
|
20
|
+
## Requirements
|
|
21
|
+
|
|
22
|
+
- Node.js 18 or newer
|
|
23
|
+
- An AI endpoint compatible with OpenAI chat completions, Anthropic Messages, or Google Gemini generateContent
|
|
24
|
+
|
|
25
|
+
Set these variables in the shell or a `.env` file in the target project. Existing process environment values take precedence over `.env` values.
|
|
26
|
+
|
|
27
|
+
```dotenv
|
|
28
|
+
PRI_AI_URL=https://api.openai.com/v1
|
|
29
|
+
PRI_AI_MODEL=gpt-4.1-mini
|
|
30
|
+
PRI_AI_API_KEY=your-key
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Anthropic example:
|
|
34
|
+
|
|
35
|
+
```dotenv
|
|
36
|
+
PRI_AI_URL=https://api.anthropic.com
|
|
37
|
+
PRI_AI_MODEL=claude-sonnet-4-5
|
|
38
|
+
PRI_AI_API_KEY=your-key
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Gemini example:
|
|
42
|
+
|
|
43
|
+
```dotenv
|
|
44
|
+
PRI_AI_URL=https://generativelanguage.googleapis.com
|
|
45
|
+
PRI_AI_MODEL=gemini-2.5-flash
|
|
46
|
+
PRI_AI_API_KEY=your-key
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The URL can be either the provider base URL or the complete generation endpoint. Provider selection is automatic from the URL and can be overridden with `--provider` or the SDK's `provider` option.
|
|
50
|
+
|
|
51
|
+
## CLI
|
|
52
|
+
|
|
53
|
+
Run against the current project:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npx privon --country India
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Run against another directory and include source code in the AI context:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npx privon ../my-app --country "United Kingdom" --full-scan
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Use deterministic mock content while developing—no credentials or network request is required:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npx privon ./example --mock
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Preview detection and planned writes:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
npx privon --mock --dry-run --json
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Existing policy pages are never overwritten unless `--force` is supplied.
|
|
78
|
+
|
|
79
|
+
```text
|
|
80
|
+
Usage: privon [path] [options]
|
|
81
|
+
|
|
82
|
+
-c, --country <country> Target country (default: India)
|
|
83
|
+
--full-scan Include source files in AI context
|
|
84
|
+
--mock Use deterministic mock AI content
|
|
85
|
+
--force Overwrite existing generated pages
|
|
86
|
+
--dry-run Detect and generate without writing files
|
|
87
|
+
--provider <name> auto, openai, anthropic, or gemini
|
|
88
|
+
--date <YYYY-MM-DD> Override the effective date
|
|
89
|
+
--json Print a machine-readable result
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`PRI_AI_MOCK=1` is equivalent to `--mock`.
|
|
93
|
+
|
|
94
|
+
## SDK
|
|
95
|
+
|
|
96
|
+
```js
|
|
97
|
+
import { generateLegalPages } from 'privon';
|
|
98
|
+
|
|
99
|
+
const result = await generateLegalPages({
|
|
100
|
+
root: process.cwd(),
|
|
101
|
+
country: 'India',
|
|
102
|
+
fullScan: false
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
console.log(result.project);
|
|
106
|
+
console.log(result.output.files);
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Development/mock usage:
|
|
110
|
+
|
|
111
|
+
```js
|
|
112
|
+
const result = await generateLegalPages({
|
|
113
|
+
root: './fixtures/react-app',
|
|
114
|
+
country: 'India',
|
|
115
|
+
mock: true,
|
|
116
|
+
dryRun: false
|
|
117
|
+
});
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Custom credentials and transport can be passed without changing global environment variables:
|
|
121
|
+
|
|
122
|
+
```js
|
|
123
|
+
await generateLegalPages({
|
|
124
|
+
aiUrl: 'https://provider.example/v1',
|
|
125
|
+
aiModel: 'model-name',
|
|
126
|
+
aiApiKey: process.env.MY_PROVIDER_KEY,
|
|
127
|
+
provider: 'openai',
|
|
128
|
+
headers: { 'X-Organization': 'example' },
|
|
129
|
+
fetch: customFetch
|
|
130
|
+
});
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Important options:
|
|
134
|
+
|
|
135
|
+
- `root`: target project; defaults to `process.cwd()`.
|
|
136
|
+
- `country`: drafting jurisdiction; defaults to `India`.
|
|
137
|
+
- `fullScan`: scans bounded text/source files instead of product Markdown only.
|
|
138
|
+
- `force`: overwrites existing generated pages. Off by default.
|
|
139
|
+
- `dryRun`: returns detection, content, and intended writes without changing files.
|
|
140
|
+
- `mock`: uses a deterministic local response.
|
|
141
|
+
- `maxFiles`, `maxBytes`, `maxFileBytes`: cap AI context size.
|
|
142
|
+
- `client`: provide an object with an async `generate(prompt)` method for a completely custom provider.
|
|
143
|
+
- `loadEnv`: set to `false` to disable reading the target project's `.env`.
|
|
144
|
+
|
|
145
|
+
The resolved result includes framework detection, scanned filenames, selected theme, normalized policy content, created/skipped files, route integrations, and warnings.
|
|
146
|
+
|
|
147
|
+
## Scanning and privacy
|
|
148
|
+
|
|
149
|
+
By default Privon reads common product Markdown such as `README.md`, `PRD.md`, `REQUIREMENTS.md`, `PLAN.md`, architecture/specification files, and Markdown under `docs/`. It does not scan the complete codebase unless `fullScan`/`--full-scan` is enabled.
|
|
150
|
+
|
|
151
|
+
Even in full-scan mode, generated/build/vendor directories, hidden directories, binary files, and oversized inputs are excluded. The scanned text is sent to the configured AI provider, so review your provider's data terms before using full scan on sensitive repositories.
|
|
152
|
+
|
|
153
|
+
## Safe generation behavior
|
|
154
|
+
|
|
155
|
+
- Generated paths are constrained to the target project.
|
|
156
|
+
- Existing legal pages are preserved unless force mode is explicit.
|
|
157
|
+
- AI output is normalized into a strict document schema; the provider cannot choose arbitrary filesystem paths.
|
|
158
|
+
- Router files are changed only when a conventional, recognizable route container is found. Otherwise Privon creates the pages and reports a manual integration warning.
|
|
159
|
+
- The project is never executed by Privon.
|
|
160
|
+
|
|
161
|
+
## Publishing
|
|
162
|
+
|
|
163
|
+
The included GitHub Actions workflow publishes when a GitHub Release is published or when manually dispatched. Before using it:
|
|
164
|
+
|
|
165
|
+
1. Update the `repository.url` in `package.json`.
|
|
166
|
+
2. Add an npm automation token as the `NPM_TOKEN` repository secret (unless your npm trusted-publishing setup removes that requirement).
|
|
167
|
+
3. Commit the lockfile, run `npm test`, create a release, and let the workflow publish with npm provenance.
|
|
168
|
+
|
|
169
|
+
## License
|
|
170
|
+
|
|
171
|
+
MIT
|
package/bin/privon.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { generateLegalPages } from '../src/index.js';
|
|
4
|
+
|
|
5
|
+
const HELP = `privon - generate privacy and terms pages
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
privon [path] [options]
|
|
9
|
+
|
|
10
|
+
Options:
|
|
11
|
+
-c, --country <country> Target country (default: India)
|
|
12
|
+
--full-scan Include source files in AI context
|
|
13
|
+
--mock Use deterministic mock AI content
|
|
14
|
+
--force Overwrite existing generated pages
|
|
15
|
+
--dry-run Detect and generate without writing files
|
|
16
|
+
--provider <name> auto, openai, anthropic, or gemini
|
|
17
|
+
--date <YYYY-MM-DD> Effective date (default: today)
|
|
18
|
+
--json Print the result as JSON
|
|
19
|
+
-h, --help Show this help
|
|
20
|
+
-v, --version Show package version
|
|
21
|
+
|
|
22
|
+
Environment:
|
|
23
|
+
PRI_AI_URL, PRI_AI_MODEL, PRI_AI_API_KEY
|
|
24
|
+
PRI_AI_MOCK=1 can be used instead of --mock during development.
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
function takeValue(args, index, flag) {
|
|
28
|
+
const value = args[index + 1];
|
|
29
|
+
if (!value || value.startsWith('-')) throw new Error(`${flag} requires a value.`);
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseArgs(args) {
|
|
34
|
+
const options = {};
|
|
35
|
+
let root;
|
|
36
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
37
|
+
const arg = args[index];
|
|
38
|
+
if (arg === '-h' || arg === '--help') options.help = true;
|
|
39
|
+
else if (arg === '-v' || arg === '--version') options.version = true;
|
|
40
|
+
else if (arg === '--mock') options.mock = true;
|
|
41
|
+
else if (arg === '--full-scan') options.fullScan = true;
|
|
42
|
+
else if (arg === '--force') options.force = true;
|
|
43
|
+
else if (arg === '--dry-run') options.dryRun = true;
|
|
44
|
+
else if (arg === '--json') options.json = true;
|
|
45
|
+
else if (arg === '-c' || arg === '--country') { options.country = takeValue(args, index, arg); index += 1; }
|
|
46
|
+
else if (arg === '--provider') { options.provider = takeValue(args, index, arg); index += 1; }
|
|
47
|
+
else if (arg === '--date') { options.date = takeValue(args, index, arg); index += 1; }
|
|
48
|
+
else if (arg.startsWith('-')) throw new Error(`Unknown option: ${arg}`);
|
|
49
|
+
else if (!root) root = arg;
|
|
50
|
+
else throw new Error(`Unexpected argument: ${arg}`);
|
|
51
|
+
}
|
|
52
|
+
options.root = root;
|
|
53
|
+
return options;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function main() {
|
|
57
|
+
const options = parseArgs(process.argv.slice(2));
|
|
58
|
+
if (options.help) { console.log(HELP); return; }
|
|
59
|
+
if (options.version) {
|
|
60
|
+
const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
61
|
+
console.log(pkg.version);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const result = await generateLegalPages(options);
|
|
65
|
+
if (options.json) { console.log(JSON.stringify(result, null, 2)); return; }
|
|
66
|
+
const detected = `${result.project.framework}${result.project.router !== 'none' ? ` / ${result.project.router}` : ''}`;
|
|
67
|
+
console.log(`Privon generated legal pages for ${result.content.projectName}.`);
|
|
68
|
+
console.log(`Detected: ${detected} | Country: ${result.country} | AI: ${result.mock ? 'mock' : 'configured provider'}`);
|
|
69
|
+
for (const file of result.output.files) console.log(` created ${file}`);
|
|
70
|
+
for (const file of result.output.integrations) console.log(` updated ${file}`);
|
|
71
|
+
for (const file of result.output.skipped) console.log(` skipped ${file} (already exists; use --force)`);
|
|
72
|
+
for (const warning of result.output.warnings) console.warn(` warning ${warning}`);
|
|
73
|
+
if (options.dryRun) console.log('Dry run: no files were written.');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
main().catch((error) => {
|
|
77
|
+
console.error(`privon: ${error.message}`);
|
|
78
|
+
if (process.env.DEBUG) console.error(error.stack);
|
|
79
|
+
process.exitCode = 1;
|
|
80
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "privon",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Generate privacy policy and terms of use pages for web projects with AI.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/TosmimForidMehtab/Privon.git"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"main": "./src/index.js",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./src/index.js",
|
|
13
|
+
"./ai": "./src/ai/client.js"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"privon": "./bin/privon.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"bin",
|
|
20
|
+
"src",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "node --test",
|
|
26
|
+
"check": "node --check bin/privon.js && node --check src/index.js",
|
|
27
|
+
"prepublishOnly": "npm test"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"privacy-policy",
|
|
31
|
+
"terms-of-use",
|
|
32
|
+
"legal",
|
|
33
|
+
"ai",
|
|
34
|
+
"sdk",
|
|
35
|
+
"nextjs",
|
|
36
|
+
"react",
|
|
37
|
+
"angular"
|
|
38
|
+
],
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=18.0.0"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/ai/client.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { AIResponseError, ConfigurationError } from '../errors.js';
|
|
2
|
+
|
|
3
|
+
function providerFromUrl(url, explicit = 'auto') {
|
|
4
|
+
if (explicit && explicit !== 'auto') return explicit;
|
|
5
|
+
if (/anthropic\.com/i.test(url)) return 'anthropic';
|
|
6
|
+
if (/generativelanguage\.googleapis\.com/i.test(url)) return 'gemini';
|
|
7
|
+
return 'openai';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function endpointFor(url, provider, model, key) {
|
|
11
|
+
const trimmed = url.replace(/\/$/, '');
|
|
12
|
+
if (provider === 'anthropic' && !/\/messages(?:\?|$)/.test(trimmed)) {
|
|
13
|
+
return /\/v1$/.test(trimmed) ? `${trimmed}/messages` : `${trimmed}/v1/messages`;
|
|
14
|
+
}
|
|
15
|
+
if (provider === 'gemini') {
|
|
16
|
+
const base = /:generateContent(?:\?|$)/.test(trimmed)
|
|
17
|
+
? trimmed
|
|
18
|
+
: /\/v1(?:beta)?$/.test(trimmed)
|
|
19
|
+
? `${trimmed}/models/${encodeURIComponent(model)}:generateContent`
|
|
20
|
+
: `${trimmed}/v1beta/models/${encodeURIComponent(model)}:generateContent`;
|
|
21
|
+
const join = base.includes('?') ? '&' : '?';
|
|
22
|
+
return `${base}${join}key=${encodeURIComponent(key)}`;
|
|
23
|
+
}
|
|
24
|
+
if (!/(?:chat\/completions|responses|completions)(?:\?|$)/.test(trimmed)) return `${trimmed}/chat/completions`;
|
|
25
|
+
return trimmed;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function requestFor(provider, model, prompt, maxTokens, endpoint) {
|
|
29
|
+
if (provider === 'anthropic') {
|
|
30
|
+
return { model, max_tokens: maxTokens, messages: [{ role: 'user', content: prompt }] };
|
|
31
|
+
}
|
|
32
|
+
if (provider === 'gemini') {
|
|
33
|
+
return { contents: [{ role: 'user', parts: [{ text: prompt }] }], generationConfig: { maxOutputTokens: maxTokens, responseMimeType: 'application/json' } };
|
|
34
|
+
}
|
|
35
|
+
if (/\/responses(?:\?|$)/.test(endpoint)) {
|
|
36
|
+
return { model, input: prompt, max_output_tokens: maxTokens };
|
|
37
|
+
}
|
|
38
|
+
if (/\/completions(?:\?|$)/.test(endpoint) && !/\/chat\/completions(?:\?|$)/.test(endpoint)) {
|
|
39
|
+
return { model, prompt, temperature: 0.2, max_tokens: maxTokens };
|
|
40
|
+
}
|
|
41
|
+
return { model, messages: [{ role: 'user', content: prompt }], temperature: 0.2, max_tokens: maxTokens };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function contentToText(content) {
|
|
45
|
+
if (typeof content === 'string') return content;
|
|
46
|
+
if (Array.isArray(content)) return content.map((part) =>
|
|
47
|
+
typeof part === 'string' ? part : part?.text ?? part?.content ?? part?.value ?? ''
|
|
48
|
+
).join('');
|
|
49
|
+
if (content && typeof content === 'object') return content.text ?? content.value ?? '';
|
|
50
|
+
return '';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function extractResponseText(payload) {
|
|
54
|
+
if (typeof payload === 'string') return payload;
|
|
55
|
+
if (!payload || typeof payload !== 'object') return '';
|
|
56
|
+
if (typeof payload.output_text === 'string') return payload.output_text;
|
|
57
|
+
const choice = payload.choices?.[0];
|
|
58
|
+
if (choice) return contentToText(choice.message?.content ?? choice.text);
|
|
59
|
+
if (Array.isArray(payload.content)) return contentToText(payload.content);
|
|
60
|
+
const candidateParts = payload.candidates?.[0]?.content?.parts;
|
|
61
|
+
if (candidateParts) return contentToText(candidateParts);
|
|
62
|
+
if (Array.isArray(payload.output)) {
|
|
63
|
+
return payload.output.flatMap((item) => item.content || item).map(contentToText).join('');
|
|
64
|
+
}
|
|
65
|
+
if (payload.data) return extractResponseText(payload.data);
|
|
66
|
+
return '';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function parseJsonResponse(text) {
|
|
70
|
+
const clean = String(text).trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
|
|
71
|
+
try { return JSON.parse(clean); } catch (firstError) {
|
|
72
|
+
const start = clean.indexOf('{');
|
|
73
|
+
const end = clean.lastIndexOf('}');
|
|
74
|
+
if (start >= 0 && end > start) {
|
|
75
|
+
try { return JSON.parse(clean.slice(start, end + 1)); } catch { /* report original below */ }
|
|
76
|
+
}
|
|
77
|
+
throw new AIResponseError('The AI provider returned text that was not valid JSON.', firstError);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class AIClient {
|
|
82
|
+
constructor(options = {}) {
|
|
83
|
+
this.url = options.url ?? process.env.PRI_AI_URL;
|
|
84
|
+
this.model = options.model ?? process.env.PRI_AI_MODEL;
|
|
85
|
+
this.apiKey = options.apiKey ?? process.env.PRI_AI_API_KEY;
|
|
86
|
+
this.provider = providerFromUrl(this.url || '', options.provider);
|
|
87
|
+
this.fetch = options.fetch ?? globalThis.fetch;
|
|
88
|
+
this.timeout = options.timeout ?? 120_000;
|
|
89
|
+
this.maxTokens = options.maxTokens ?? 8_000;
|
|
90
|
+
this.headers = options.headers ?? {};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
validate() {
|
|
94
|
+
const missing = [!this.url && 'PRI_AI_URL', !this.model && 'PRI_AI_MODEL', !this.apiKey && 'PRI_AI_API_KEY'].filter(Boolean);
|
|
95
|
+
if (missing.length) throw new ConfigurationError(`Missing required AI configuration: ${missing.join(', ')}. Set the environment variables or use mock: true.`);
|
|
96
|
+
if (typeof this.fetch !== 'function') throw new ConfigurationError('No Fetch implementation is available. Privon requires Node.js 18 or newer.');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async generate(prompt) {
|
|
100
|
+
this.validate();
|
|
101
|
+
const controller = new AbortController();
|
|
102
|
+
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
103
|
+
const headers = { 'content-type': 'application/json', ...this.headers };
|
|
104
|
+
if (this.provider === 'anthropic') {
|
|
105
|
+
headers['x-api-key'] ??= this.apiKey;
|
|
106
|
+
headers['anthropic-version'] ??= '2023-06-01';
|
|
107
|
+
} else if (this.provider !== 'gemini') {
|
|
108
|
+
headers.authorization ??= `Bearer ${this.apiKey}`;
|
|
109
|
+
}
|
|
110
|
+
let response;
|
|
111
|
+
const endpoint = endpointFor(this.url, this.provider, this.model, this.apiKey);
|
|
112
|
+
try {
|
|
113
|
+
response = await this.fetch(endpoint, {
|
|
114
|
+
method: 'POST', headers, body: JSON.stringify(requestFor(this.provider, this.model, prompt, this.maxTokens, endpoint)), signal: controller.signal
|
|
115
|
+
});
|
|
116
|
+
} catch (error) {
|
|
117
|
+
const message = error?.name === 'AbortError' ? `AI request timed out after ${this.timeout}ms.` : `Could not reach the AI provider: ${error.message}`;
|
|
118
|
+
throw new AIResponseError(message, error);
|
|
119
|
+
} finally {
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
}
|
|
122
|
+
const raw = await response.text();
|
|
123
|
+
if (!response.ok) throw new AIResponseError(`AI provider returned HTTP ${response.status}: ${raw.slice(0, 500)}`);
|
|
124
|
+
let payload;
|
|
125
|
+
try { payload = JSON.parse(raw); } catch { payload = raw; }
|
|
126
|
+
const text = extractResponseText(payload);
|
|
127
|
+
if (!text) throw new AIResponseError('The AI provider response did not contain recognizable generated content.');
|
|
128
|
+
return parseJsonResponse(text);
|
|
129
|
+
}
|
|
130
|
+
}
|
package/src/ai/mock.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
function section(heading, paragraphs, bullets = []) {
|
|
2
|
+
return { heading, paragraphs, bullets };
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function inferName(project, scan) {
|
|
6
|
+
if (project.packageName) return project.packageName.replace(/^@[^/]+\//, '');
|
|
7
|
+
const readme = scan.documents.find((doc) => /readme\.md$/i.test(doc.path));
|
|
8
|
+
const match = readme?.content.match(/^#\s+(.+)$/m);
|
|
9
|
+
return match?.[1].trim() || 'This Service';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createMockResponse({ project, scan, country, date }) {
|
|
13
|
+
const name = inferName(project, scan);
|
|
14
|
+
const operator = `the operator of ${name}`;
|
|
15
|
+
return {
|
|
16
|
+
projectName: name,
|
|
17
|
+
summary: `${name} is the software service described in the project's supplied documentation.`,
|
|
18
|
+
privacyPolicy: {
|
|
19
|
+
title: 'Privacy Policy',
|
|
20
|
+
lastUpdated: date,
|
|
21
|
+
intro: [
|
|
22
|
+
`This Privacy Policy explains how ${operator} (\"we\", \"us\", or \"our\") handles information when you use ${name}. It is a draft generated from the available project documentation and should be reviewed by the operator before publication.`,
|
|
23
|
+
`This policy is intended for users in ${country}. If you use the service from another location, additional local rights may apply.`
|
|
24
|
+
],
|
|
25
|
+
sections: [
|
|
26
|
+
section('Information We Handle', [
|
|
27
|
+
'We may handle information you provide directly, such as account, profile, support, or form details, when those features are available. We may also receive limited technical information needed to operate and protect the service, such as device, browser, log, and diagnostic data.',
|
|
28
|
+
'The exact information collected depends on the features you choose to use. We do not intend to collect information that is not reasonably required for those features.'
|
|
29
|
+
]),
|
|
30
|
+
section('How We Use Information', ['We use information only for legitimate service purposes, including:'], [
|
|
31
|
+
'providing, maintaining, and improving the service;',
|
|
32
|
+
'responding to requests and communicating about the service;',
|
|
33
|
+
'protecting users, preventing abuse, and troubleshooting problems;',
|
|
34
|
+
'meeting applicable legal and regulatory obligations.'
|
|
35
|
+
]),
|
|
36
|
+
section('Consent and Lawful Handling', [`Where ${country} law requires consent, we will request clear consent before handling personal data and allow it to be withdrawn. In other cases, we handle data when necessary to provide the requested service, comply with law, or pursue a legitimate purpose permitted by law.`]),
|
|
37
|
+
section('Cookies and Similar Technologies', ['The service may use essential browser storage or similar technologies to maintain sessions, preferences, and security. Any non-essential analytics or advertising technology should be disclosed and, where required, enabled only after consent.']),
|
|
38
|
+
section('Service Providers and Disclosures', ['We may share information with vendors that host, secure, support, or otherwise process data for the service under appropriate instructions. We may also disclose information when legally required, to protect rights and safety, or as part of a business transfer. We do not authorize service providers to use personal data for unrelated purposes.']),
|
|
39
|
+
section('International Processing', [`Information may be processed outside your state or country where our infrastructure or service providers operate. When required by the laws of ${country}, we use appropriate safeguards for such transfers.`]),
|
|
40
|
+
section('Data Retention', ['We retain personal data only for as long as needed for the purposes described here, including providing the service, resolving disputes, maintaining security, and complying with law. Retention periods vary according to the type of information and the reason it is held.']),
|
|
41
|
+
section('Security', ['We use reasonable administrative, technical, and organizational safeguards appropriate to the service. No method of storage or transmission is completely secure, so absolute security cannot be guaranteed.']),
|
|
42
|
+
section('Your Choices and Rights', [`Depending on applicable law in ${country}, you may be able to access, correct, update, erase, or obtain information about your personal data; withdraw consent; object to certain handling; or raise a grievance. Requests can be made using the contact method published with the service. We may need to verify your identity.`]),
|
|
43
|
+
section("Children's Privacy", ['The service is not directed to children unless the product documentation expressly says otherwise. If we learn that personal data of a child was collected without authorization required by applicable law, we will take reasonable steps to remove it.']),
|
|
44
|
+
section('Changes to This Policy', ['We may update this policy as the service or applicable requirements change. The revised version will show a new effective date, and material changes may be communicated through the service or another appropriate channel.']),
|
|
45
|
+
section('Contact and Grievances', [`Questions, privacy requests, and grievances should be sent through the contact channel published by ${operator}. The operator should add a working email or postal address here before releasing this page.`])
|
|
46
|
+
]
|
|
47
|
+
},
|
|
48
|
+
termsOfUse: {
|
|
49
|
+
title: 'Terms of Use',
|
|
50
|
+
lastUpdated: date,
|
|
51
|
+
intro: [
|
|
52
|
+
`These Terms of Use govern access to and use of ${name}. By using the service, you agree to these terms. If you do not agree, do not use the service.`,
|
|
53
|
+
'These terms are a generated draft and should be reviewed and completed by the operator before publication.'
|
|
54
|
+
],
|
|
55
|
+
sections: [
|
|
56
|
+
section('Eligibility and Authority', ['You must be legally capable of entering into these terms. If you use the service for an organization, you confirm that you have authority to bind that organization. Additional age or guardian requirements under applicable law continue to apply.']),
|
|
57
|
+
section('The Service', [`${name} provides the functionality described through its product interface and documentation. Features may evolve, and we may change, suspend, or discontinue parts of the service where reasonably necessary.`]),
|
|
58
|
+
section('Accounts and Security', ['If an account is required, provide accurate information, protect your credentials, and promptly report suspected unauthorized use. You are responsible for activity under your account to the extent permitted by law.']),
|
|
59
|
+
section('Acceptable Use', ['You may not misuse the service. In particular, you must not:'], [
|
|
60
|
+
'violate law or the rights of another person;',
|
|
61
|
+
'upload malicious code or interfere with service security or availability;',
|
|
62
|
+
'attempt unauthorized access, scraping, reverse engineering, or circumvention of safeguards except where law expressly permits it;',
|
|
63
|
+
'use the service to distribute deceptive, abusive, or unlawful material.'
|
|
64
|
+
]),
|
|
65
|
+
section('Your Content', ['You retain ownership of content you submit. You grant us only the limited rights needed to host, process, display, and transmit that content to operate the service. You confirm that you have the rights needed to submit it and that it does not violate law or third-party rights.']),
|
|
66
|
+
section('Our Intellectual Property', [`The service, software, branding, and operator-provided content are owned by ${operator} or its licensors and are protected by applicable intellectual-property laws. These terms grant only a limited, revocable, non-transferable right to use the service as intended.`]),
|
|
67
|
+
section('Third-Party Services', ['The service may link to or depend on third-party products. Their terms and privacy practices apply to their services, and we are not responsible for third-party content or availability except where applicable law provides otherwise.']),
|
|
68
|
+
section('Disclaimers', ['To the extent permitted by law, the service is provided on an “as is” and “as available” basis. We do not promise uninterrupted or error-free operation. Nothing in these terms excludes warranties or consumer rights that cannot legally be excluded.']),
|
|
69
|
+
section('Limitation of Liability', ['To the maximum extent permitted by law, the operator will not be liable for indirect, incidental, special, consequential, or punitive losses arising from the service. Any enforceable limitation must be applied consistently with mandatory consumer and other applicable laws.']),
|
|
70
|
+
section('Suspension and Termination', ['We may restrict or end access for a material violation of these terms, risk to users or the service, legal requirements, or discontinuation. You may stop using the service at any time. Provisions that by their nature should survive termination will remain effective.']),
|
|
71
|
+
section('Governing Law and Disputes', [`These terms are governed by the applicable laws of ${country}, without overriding mandatory protections available to you. The operator should specify its state, courts, and any required dispute process before publication.`]),
|
|
72
|
+
section('Changes to These Terms', ['We may update these terms when the service or legal requirements change. We will post the revised terms with a new effective date and provide additional notice where required. Continued use after the effective date constitutes acceptance only to the extent permitted by law.']),
|
|
73
|
+
section('Contact', [`Questions about these terms should be sent through the contact channel published by ${operator}. The operator should add complete legal identity and contact details before releasing this page.`])
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
package/src/ai/prompt.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { formatScanForPrompt } from '../scanner.js';
|
|
2
|
+
|
|
3
|
+
export function buildPrompt({ scan, project, country, theme, date }) {
|
|
4
|
+
return `You are a careful legal-document drafting assistant and web content architect.
|
|
5
|
+
|
|
6
|
+
Create a privacy policy and terms of use for the software project described below. The documents must be tailored to the project's actual features and data practices, use plain language, and be localized for ${country}. Do not claim certifications, security controls, payment flows, data collection, or third-party services unless supported by the supplied project context. Where facts are unknown, phrase the text conservatively and tell users to contact the operator. Include the important topics normally expected for this project and jurisdiction. This is a draft for operator/legal review, not a claim of legal advice.
|
|
7
|
+
|
|
8
|
+
Project technology: ${project.framework}${project.router !== 'none' ? ` (${project.router})` : ''}
|
|
9
|
+
Page color theme: primary ${theme.primary}, background ${theme.background}, text ${theme.text}
|
|
10
|
+
Effective date: ${date}
|
|
11
|
+
|
|
12
|
+
Return ONLY valid JSON with exactly this shape (no Markdown fence):
|
|
13
|
+
{
|
|
14
|
+
"projectName": "string",
|
|
15
|
+
"summary": "one-sentence project description",
|
|
16
|
+
"privacyPolicy": {
|
|
17
|
+
"title": "Privacy Policy",
|
|
18
|
+
"lastUpdated": "${date}",
|
|
19
|
+
"intro": ["paragraph"],
|
|
20
|
+
"sections": [{"heading": "string", "paragraphs": ["string"], "bullets": ["optional bullet"]}]
|
|
21
|
+
},
|
|
22
|
+
"termsOfUse": {
|
|
23
|
+
"title": "Terms of Use",
|
|
24
|
+
"lastUpdated": "${date}",
|
|
25
|
+
"intro": ["paragraph"],
|
|
26
|
+
"sections": [{"heading": "string", "paragraphs": ["string"], "bullets": ["optional bullet"]}]
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
PROJECT CONTEXT:
|
|
31
|
+
${formatScanForPrompt(scan)}`;
|
|
32
|
+
}
|
package/src/colors.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
3
|
+
import { SKIP_DIRECTORIES } from './utils.js';
|
|
4
|
+
|
|
5
|
+
const COLOR_PATTERN = /#[0-9a-f]{3,8}\b|(?:rgb|hsl)a?\([^)]*\)/gi;
|
|
6
|
+
const RELEVANT_PATTERN = /(?:home|index|header|footer|navbar|nav|layout|app|global|theme).*(?:css|scss|sass|less|jsx?|tsx?|html?)$/i;
|
|
7
|
+
const DEFAULT_THEME = { primary: '#2563eb', background: '#f8fafc', surface: '#ffffff', text: '#172033', muted: '#526072', border: '#dce3ec' };
|
|
8
|
+
|
|
9
|
+
async function collect(directory, depth = 0, output = []) {
|
|
10
|
+
if (depth > 4 || output.length >= 25) return output;
|
|
11
|
+
let entries;
|
|
12
|
+
try { entries = await readdir(directory, { withFileTypes: true }); } catch { return output; }
|
|
13
|
+
for (const entry of entries) {
|
|
14
|
+
if (output.length >= 25) break;
|
|
15
|
+
if (entry.isDirectory() && !SKIP_DIRECTORIES.has(entry.name) && !entry.name.startsWith('.')) {
|
|
16
|
+
await collect(path.join(directory, entry.name), depth + 1, output);
|
|
17
|
+
} else if (entry.isFile() && RELEVANT_PATTERN.test(entry.name)) {
|
|
18
|
+
output.push(path.join(directory, entry.name));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return output;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalize(color) {
|
|
25
|
+
return color.toLowerCase();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function luminance(hex) {
|
|
29
|
+
if (!/^#[0-9a-f]{6}$/i.test(hex)) return 0.5;
|
|
30
|
+
const values = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
|
|
31
|
+
.map((v) => v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
|
|
32
|
+
return 0.2126 * values[0] + 0.7152 * values[1] + 0.0722 * values[2];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function expandHex(value) {
|
|
36
|
+
if (!/^#[0-9a-f]{3}$/i.test(value)) return value;
|
|
37
|
+
return `#${value.slice(1).split('').map((char) => char + char).join('')}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isUsableHex(value) {
|
|
41
|
+
return /^#[0-9a-f]{6}$/i.test(expandHex(value));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function detectTheme(root) {
|
|
45
|
+
const files = await collect(root);
|
|
46
|
+
const colors = [];
|
|
47
|
+
for (const file of files) {
|
|
48
|
+
try {
|
|
49
|
+
const source = (await readFile(file, 'utf8')).slice(0, 80_000);
|
|
50
|
+
for (const match of source.match(COLOR_PATTERN) || []) {
|
|
51
|
+
const color = normalize(match);
|
|
52
|
+
if (isUsableHex(color) && !colors.includes(expandHex(color))) colors.push(expandHex(color));
|
|
53
|
+
}
|
|
54
|
+
} catch { /* use the remaining files */ }
|
|
55
|
+
}
|
|
56
|
+
if (!colors.length) return { ...DEFAULT_THEME, detected: false, sourceFiles: [] };
|
|
57
|
+
const ranked = colors.filter((color) => {
|
|
58
|
+
const light = luminance(color);
|
|
59
|
+
return light > 0.08 && light < 0.82;
|
|
60
|
+
});
|
|
61
|
+
return {
|
|
62
|
+
...DEFAULT_THEME,
|
|
63
|
+
primary: ranked[0] || DEFAULT_THEME.primary,
|
|
64
|
+
detected: true,
|
|
65
|
+
sourceFiles: files.map((file) => path.relative(root, file))
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export { DEFAULT_THEME };
|
package/src/detector.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { access, readdir } from 'node:fs/promises';
|
|
3
|
+
import { readText, toPosix } from './utils.js';
|
|
4
|
+
|
|
5
|
+
async function exists(file) {
|
|
6
|
+
try { await access(file); return true; } catch { return false; }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async function hasPageFiles(directory) {
|
|
10
|
+
try {
|
|
11
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
12
|
+
return entries.some((entry) => entry.isDirectory() || /\.(jsx?|tsx?)$/.test(entry.name));
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function detectProject(root) {
|
|
19
|
+
let pkg = {};
|
|
20
|
+
try { pkg = JSON.parse(await readText(path.join(root, 'package.json'), '{}')); } catch { /* invalid package */ }
|
|
21
|
+
const dependencies = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
22
|
+
const has = (name) => Boolean(dependencies[name]);
|
|
23
|
+
const typescript = await exists(path.join(root, 'tsconfig.json'));
|
|
24
|
+
|
|
25
|
+
if (has('next')) {
|
|
26
|
+
const appCandidates = ['app', 'src/app'];
|
|
27
|
+
const pagesCandidates = ['pages', 'src/pages'];
|
|
28
|
+
for (const candidate of appCandidates) {
|
|
29
|
+
if (await hasPageFiles(path.join(root, candidate))) {
|
|
30
|
+
return { framework: 'next', router: 'app', sourceDirectory: candidate, typescript, packageName: pkg.name };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
for (const candidate of pagesCandidates) {
|
|
34
|
+
if (await hasPageFiles(path.join(root, candidate))) {
|
|
35
|
+
return { framework: 'next', router: 'pages', sourceDirectory: candidate, typescript, packageName: pkg.name };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { framework: 'next', router: 'app', sourceDirectory: 'app', typescript, packageName: pkg.name };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (has('@angular/core') || await exists(path.join(root, 'angular.json'))) {
|
|
42
|
+
return { framework: 'angular', router: 'angular', sourceDirectory: 'src/app', typescript: true, packageName: pkg.name };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (has('react') || has('react-dom')) {
|
|
46
|
+
return {
|
|
47
|
+
framework: 'react',
|
|
48
|
+
router: has('react-router-dom') ? 'react-router' : 'none',
|
|
49
|
+
sourceDirectory: 'src',
|
|
50
|
+
typescript,
|
|
51
|
+
packageName: pkg.name
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const htmlFiles = [];
|
|
56
|
+
try {
|
|
57
|
+
for (const entry of await readdir(root, { withFileTypes: true })) {
|
|
58
|
+
if (entry.isFile() && /\.html?$/i.test(entry.name)) htmlFiles.push(toPosix(entry.name));
|
|
59
|
+
}
|
|
60
|
+
} catch { /* inaccessible root is handled by caller */ }
|
|
61
|
+
return { framework: htmlFiles.length ? 'html' : 'generic', router: 'none', sourceDirectory: '.', typescript: false, packageName: pkg.name, htmlFiles };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export { exists };
|
package/src/env.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
function unquote(value) {
|
|
5
|
+
const trimmed = value.trim();
|
|
6
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
7
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
8
|
+
return trimmed.slice(1, -1);
|
|
9
|
+
}
|
|
10
|
+
return trimmed;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function loadEnv(root, target = process.env) {
|
|
14
|
+
const candidates = ['.env.local', '.env'];
|
|
15
|
+
for (const name of candidates) {
|
|
16
|
+
let source;
|
|
17
|
+
try {
|
|
18
|
+
source = await readFile(path.join(root, name), 'utf8');
|
|
19
|
+
} catch {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
for (const line of source.split(/\r?\n/)) {
|
|
23
|
+
const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][\w]*)\s*=\s*(.*)\s*$/);
|
|
24
|
+
if (!match || match[1] in target) continue;
|
|
25
|
+
target[match[1]] = unquote(match[2].replace(/\s+#.*$/, ''));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return target;
|
|
29
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export class PrivonError extends Error {
|
|
2
|
+
constructor(message, code = 'PRIVON_ERROR', cause) {
|
|
3
|
+
super(message, { cause });
|
|
4
|
+
this.name = 'PrivonError';
|
|
5
|
+
this.code = code;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class ConfigurationError extends PrivonError {
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super(message, 'CONFIGURATION_ERROR');
|
|
12
|
+
this.name = 'ConfigurationError';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class AIResponseError extends PrivonError {
|
|
17
|
+
constructor(message, cause) {
|
|
18
|
+
super(message, 'AI_RESPONSE_ERROR', cause);
|
|
19
|
+
this.name = 'AIResponseError';
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/generator.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { stat } from 'node:fs/promises';
|
|
3
|
+
import { AIClient } from './ai/client.js';
|
|
4
|
+
import { createMockResponse } from './ai/mock.js';
|
|
5
|
+
import { buildPrompt } from './ai/prompt.js';
|
|
6
|
+
import { detectTheme } from './colors.js';
|
|
7
|
+
import { detectProject } from './detector.js';
|
|
8
|
+
import { loadEnv } from './env.js';
|
|
9
|
+
import { ConfigurationError } from './errors.js';
|
|
10
|
+
import { normalizeGeneratedContent } from './model.js';
|
|
11
|
+
import { scanProject } from './scanner.js';
|
|
12
|
+
import { writePages } from './writer.js';
|
|
13
|
+
|
|
14
|
+
function isoDate(value = new Date()) {
|
|
15
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
16
|
+
if (Number.isNaN(date.getTime())) throw new ConfigurationError('The effective date is invalid. Use a date accepted by JavaScript, such as 2026-09-13.');
|
|
17
|
+
return date.toISOString().slice(0, 10);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function mockEnabled(value) {
|
|
21
|
+
if (typeof value === 'boolean') return value;
|
|
22
|
+
return /^(?:1|true|yes)$/i.test(process.env.PRI_AI_MOCK || '');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function generateLegalPages(options = {}) {
|
|
26
|
+
const root = path.resolve(options.root || process.cwd());
|
|
27
|
+
try {
|
|
28
|
+
if (!(await stat(root)).isDirectory()) throw new Error('not a directory');
|
|
29
|
+
} catch {
|
|
30
|
+
throw new ConfigurationError(`Project root does not exist or is not a directory: ${root}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (options.loadEnv !== false) await loadEnv(root);
|
|
34
|
+
const country = String(options.country || 'India').trim() || 'India';
|
|
35
|
+
const date = isoDate(options.date);
|
|
36
|
+
const [project, scan, theme] = await Promise.all([
|
|
37
|
+
detectProject(root),
|
|
38
|
+
scanProject(root, {
|
|
39
|
+
fullScan: options.fullScan,
|
|
40
|
+
maxFiles: options.maxFiles,
|
|
41
|
+
maxBytes: options.maxBytes,
|
|
42
|
+
maxFileBytes: options.maxFileBytes
|
|
43
|
+
}),
|
|
44
|
+
detectTheme(root)
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
const mock = mockEnabled(options.mock);
|
|
48
|
+
const rawContent = mock
|
|
49
|
+
? createMockResponse({ project, scan, country, date })
|
|
50
|
+
: await (options.client || new AIClient({
|
|
51
|
+
url: options.aiUrl,
|
|
52
|
+
model: options.aiModel,
|
|
53
|
+
apiKey: options.aiApiKey,
|
|
54
|
+
provider: options.provider,
|
|
55
|
+
fetch: options.fetch,
|
|
56
|
+
maxTokens: options.maxTokens,
|
|
57
|
+
headers: options.headers
|
|
58
|
+
})).generate(buildPrompt({ scan, project, country, theme, date }));
|
|
59
|
+
|
|
60
|
+
const fallbackName = project.packageName || path.basename(root);
|
|
61
|
+
const content = normalizeGeneratedContent(rawContent, fallbackName, date);
|
|
62
|
+
const output = await writePages({
|
|
63
|
+
root, project, content, theme,
|
|
64
|
+
force: Boolean(options.force),
|
|
65
|
+
dryRun: Boolean(options.dryRun)
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return { root, country, date, mock, project, scan: { files: scan.documents.map((item) => item.path), totalBytes: scan.totalBytes, truncated: scan.truncated, fullScan: scan.fullScan }, theme, content, output };
|
|
69
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { generateLegalPages } from './generator.js';
|
|
2
|
+
export { detectProject } from './detector.js';
|
|
3
|
+
export { detectTheme } from './colors.js';
|
|
4
|
+
export { scanProject } from './scanner.js';
|
|
5
|
+
export { AIClient, extractResponseText, parseJsonResponse } from './ai/client.js';
|
|
6
|
+
export { PrivonError, ConfigurationError, AIResponseError } from './errors.js';
|
|
7
|
+
|
|
8
|
+
export { generateLegalPages as default } from './generator.js';
|
package/src/model.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { AIResponseError } from './errors.js';
|
|
2
|
+
|
|
3
|
+
function strings(value) {
|
|
4
|
+
if (!Array.isArray(value)) return [];
|
|
5
|
+
return value.filter((item) => typeof item === 'string').map((item) => item.trim()).filter(Boolean);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function normalizeDocument(value, fallbackTitle, fallbackDate) {
|
|
9
|
+
if (!value || typeof value !== 'object') throw new AIResponseError(`AI response is missing ${fallbackTitle}.`);
|
|
10
|
+
const sections = Array.isArray(value.sections) ? value.sections.map((item) => ({
|
|
11
|
+
heading: typeof item?.heading === 'string' ? item.heading.trim() : '',
|
|
12
|
+
paragraphs: strings(item?.paragraphs),
|
|
13
|
+
bullets: strings(item?.bullets)
|
|
14
|
+
})).filter((item) => item.heading && (item.paragraphs.length || item.bullets.length)) : [];
|
|
15
|
+
if (!sections.length) throw new AIResponseError(`${fallbackTitle} did not contain any usable sections.`);
|
|
16
|
+
return {
|
|
17
|
+
title: typeof value.title === 'string' && value.title.trim() ? value.title.trim() : fallbackTitle,
|
|
18
|
+
lastUpdated: typeof value.lastUpdated === 'string' && value.lastUpdated.trim() ? value.lastUpdated.trim() : fallbackDate,
|
|
19
|
+
intro: strings(value.intro),
|
|
20
|
+
sections
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function normalizeGeneratedContent(value, fallbackName, date) {
|
|
25
|
+
if (!value || typeof value !== 'object') throw new AIResponseError('AI response was not an object.');
|
|
26
|
+
return {
|
|
27
|
+
projectName: typeof value.projectName === 'string' && value.projectName.trim() ? value.projectName.trim() : fallbackName,
|
|
28
|
+
summary: typeof value.summary === 'string' ? value.summary.trim() : '',
|
|
29
|
+
privacyPolicy: normalizeDocument(value.privacyPolicy, 'Privacy Policy', date),
|
|
30
|
+
termsOfUse: normalizeDocument(value.termsOfUse, 'Terms of Use', date)
|
|
31
|
+
};
|
|
32
|
+
}
|
package/src/scanner.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import { SKIP_DIRECTORIES, toPosix } from './utils.js';
|
|
4
|
+
|
|
5
|
+
const DOCUMENT_NAMES = new Set([
|
|
6
|
+
'readme.md', 'requirements.md', 'requirement.md', 'prd.md', 'plan.md',
|
|
7
|
+
'spec.md', 'specification.md', 'architecture.md', 'design.md', 'features.md',
|
|
8
|
+
'product.md', 'overview.md', 'docs.md', 'contributing.md', 'changelog.md'
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
const CODE_EXTENSIONS = new Set([
|
|
12
|
+
'.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.html', '.htm', '.css',
|
|
13
|
+
'.scss', '.sass', '.less', '.vue', '.svelte', '.json', '.yaml', '.yml',
|
|
14
|
+
'.py', '.rb', '.php', '.java', '.kt', '.go', '.rs', '.cs', '.swift',
|
|
15
|
+
'.md', '.mdx', '.txt'
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
async function walk(root, current, files, options, depth = 0) {
|
|
19
|
+
if (files.length >= options.maxFiles || depth > options.maxDepth) return;
|
|
20
|
+
let entries;
|
|
21
|
+
try {
|
|
22
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
23
|
+
} catch {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
if (files.length >= options.maxFiles) break;
|
|
29
|
+
if (entry.isSymbolicLink()) continue;
|
|
30
|
+
const absolute = path.join(current, entry.name);
|
|
31
|
+
if (entry.isDirectory()) {
|
|
32
|
+
if (!SKIP_DIRECTORIES.has(entry.name) && !entry.name.startsWith('.')) {
|
|
33
|
+
await walk(root, absolute, files, options, depth + 1);
|
|
34
|
+
}
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (!entry.isFile()) continue;
|
|
38
|
+
const lower = entry.name.toLowerCase();
|
|
39
|
+
const relative = toPosix(path.relative(root, absolute));
|
|
40
|
+
const extension = path.extname(lower);
|
|
41
|
+
const inDocsFolder = relative.toLowerCase().startsWith('docs/');
|
|
42
|
+
const include = options.fullScan
|
|
43
|
+
? CODE_EXTENSIONS.has(extension)
|
|
44
|
+
: DOCUMENT_NAMES.has(lower) || (inDocsFolder && ['.md', '.mdx'].includes(extension));
|
|
45
|
+
if (include) files.push(absolute);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function scanProject(root, options = {}) {
|
|
50
|
+
const settings = {
|
|
51
|
+
fullScan: Boolean(options.fullScan),
|
|
52
|
+
maxFiles: options.maxFiles ?? (options.fullScan ? 200 : 40),
|
|
53
|
+
maxBytes: options.maxBytes ?? 350_000,
|
|
54
|
+
maxFileBytes: options.maxFileBytes ?? 40_000,
|
|
55
|
+
maxDepth: options.maxDepth ?? (options.fullScan ? 12 : 4)
|
|
56
|
+
};
|
|
57
|
+
const candidates = [];
|
|
58
|
+
await walk(root, root, candidates, settings);
|
|
59
|
+
const documents = [];
|
|
60
|
+
let totalBytes = 0;
|
|
61
|
+
let truncated = false;
|
|
62
|
+
for (const file of candidates) {
|
|
63
|
+
let fileStat;
|
|
64
|
+
try {
|
|
65
|
+
fileStat = await stat(file);
|
|
66
|
+
} catch {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (totalBytes >= settings.maxBytes) {
|
|
70
|
+
truncated = true;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const remaining = settings.maxBytes - totalBytes;
|
|
74
|
+
let content;
|
|
75
|
+
try {
|
|
76
|
+
content = await readFile(file, 'utf8');
|
|
77
|
+
} catch {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (content.includes('\u0000')) continue;
|
|
81
|
+
const allowed = Math.min(settings.maxFileBytes, remaining);
|
|
82
|
+
if (Buffer.byteLength(content) > allowed) {
|
|
83
|
+
content = Buffer.from(content).subarray(0, allowed).toString('utf8');
|
|
84
|
+
truncated = true;
|
|
85
|
+
}
|
|
86
|
+
totalBytes += Buffer.byteLength(content);
|
|
87
|
+
documents.push({ path: toPosix(path.relative(root, file)), content });
|
|
88
|
+
}
|
|
89
|
+
return { documents, totalBytes, truncated, fullScan: settings.fullScan };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function formatScanForPrompt(scan) {
|
|
93
|
+
if (!scan.documents.length) return 'No project documentation was found.';
|
|
94
|
+
return scan.documents.map(({ path: file, content }) =>
|
|
95
|
+
`\n--- FILE: ${file} ---\n${content}`
|
|
96
|
+
).join('\n');
|
|
97
|
+
}
|
package/src/templates.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
function safeJson(value) {
|
|
2
|
+
return JSON.stringify(value, null, 2).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function reactStyles(theme, typescript) {
|
|
6
|
+
return `const styles = {
|
|
7
|
+
page: { minHeight: '100vh', background: '${theme.background}', color: '${theme.text}', padding: '48px 20px', fontFamily: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', lineHeight: 1.7 },
|
|
8
|
+
article: { maxWidth: 840, margin: '0 auto', background: '${theme.surface}', border: '1px solid ${theme.border}', borderRadius: 18, padding: 'clamp(24px, 5vw, 56px)', boxShadow: '0 18px 50px rgba(15, 23, 42, 0.08)' },
|
|
9
|
+
eyebrow: { color: '${theme.primary}', fontSize: 14, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', margin: 0 },
|
|
10
|
+
title: { fontSize: 'clamp(2rem, 5vw, 3.25rem)', letterSpacing: '-0.035em', lineHeight: 1.1, margin: '8px 0 12px' },
|
|
11
|
+
updated: { color: '${theme.muted}', fontSize: 14, marginBottom: 32 },
|
|
12
|
+
section: { marginTop: 32 },
|
|
13
|
+
heading: { fontSize: 22, lineHeight: 1.3, marginBottom: 10 },
|
|
14
|
+
paragraph: { margin: '10px 0' },
|
|
15
|
+
list: { paddingLeft: 24, margin: '10px 0' },
|
|
16
|
+
notice: { borderLeft: '4px solid ${theme.primary}', background: '${theme.background}', padding: '12px 16px', borderRadius: 6, marginBottom: 28 }
|
|
17
|
+
}${typescript ? ' as const' : ''};`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function reactBody() {
|
|
21
|
+
return ` return (
|
|
22
|
+
<main style={styles.page}>
|
|
23
|
+
<article style={styles.article}>
|
|
24
|
+
<p style={styles.eyebrow}>{projectName}</p>
|
|
25
|
+
<h1 style={styles.title}>{document.title}</h1>
|
|
26
|
+
<p style={styles.updated}>Effective date: {document.lastUpdated}</p>
|
|
27
|
+
<div style={styles.notice}>Please review this generated draft and add your legal identity and contact details before publishing.</div>
|
|
28
|
+
{document.intro.map((paragraph, index) => <p style={styles.paragraph} key={\`intro-\${index}\`}>{paragraph}</p>)}
|
|
29
|
+
{document.sections.map((section, index) => (
|
|
30
|
+
<section style={styles.section} key={\`section-\${index}\`}>
|
|
31
|
+
<h2 style={styles.heading}>{section.heading}</h2>
|
|
32
|
+
{section.paragraphs.map((paragraph, paragraphIndex) => <p style={styles.paragraph} key={\`p-\${paragraphIndex}\`}>{paragraph}</p>)}
|
|
33
|
+
{section.bullets.length > 0 && <ul style={styles.list}>{section.bullets.map((bullet, bulletIndex) => <li key={\`b-\${bulletIndex}\`}>{bullet}</li>)}</ul>}
|
|
34
|
+
</section>
|
|
35
|
+
))}
|
|
36
|
+
</article>
|
|
37
|
+
</main>
|
|
38
|
+
);`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function renderReactPage({ document, projectName, theme, componentName, nextApp = false, typescript = false }) {
|
|
42
|
+
return `${nextApp ? `export const metadata = { title: ${safeJson(document.title)} };\n\n` : ''}const projectName = ${safeJson(projectName)};
|
|
43
|
+
const document = ${safeJson(document)};
|
|
44
|
+
|
|
45
|
+
${reactStyles(theme, typescript)}
|
|
46
|
+
|
|
47
|
+
export default function ${componentName}() {
|
|
48
|
+
${reactBody()}
|
|
49
|
+
}
|
|
50
|
+
`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function escapeHtml(value) {
|
|
54
|
+
return String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function renderHtmlPage({ document, projectName, theme }) {
|
|
58
|
+
const intro = document.intro.map((item) => `<p>${escapeHtml(item)}</p>`).join('\n');
|
|
59
|
+
const sections = document.sections.map((section) => `<section>
|
|
60
|
+
<h2>${escapeHtml(section.heading)}</h2>
|
|
61
|
+
${section.paragraphs.map((item) => `<p>${escapeHtml(item)}</p>`).join('\n')}
|
|
62
|
+
${section.bullets.length ? `<ul>${section.bullets.map((item) => `<li>${escapeHtml(item)}</li>`).join('')}</ul>` : ''}
|
|
63
|
+
</section>`).join('\n');
|
|
64
|
+
return `<!doctype html>
|
|
65
|
+
<html lang="en">
|
|
66
|
+
<head>
|
|
67
|
+
<meta charset="utf-8">
|
|
68
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
69
|
+
<title>${escapeHtml(document.title)} | ${escapeHtml(projectName)}</title>
|
|
70
|
+
</head>
|
|
71
|
+
<body style="margin:0;background:${theme.background};color:${theme.text};font-family:Inter,system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;line-height:1.7">
|
|
72
|
+
<main style="min-height:100vh;padding:48px 20px;box-sizing:border-box">
|
|
73
|
+
<article style="max-width:840px;margin:0 auto;background:${theme.surface};border:1px solid ${theme.border};border-radius:18px;padding:clamp(24px,5vw,56px);box-sizing:border-box;box-shadow:0 18px 50px rgba(15,23,42,.08)">
|
|
74
|
+
<p style="color:${theme.primary};font-size:14px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;margin:0">${escapeHtml(projectName)}</p>
|
|
75
|
+
<h1 style="font-size:clamp(2rem,5vw,3.25rem);letter-spacing:-.035em;line-height:1.1;margin:8px 0 12px">${escapeHtml(document.title)}</h1>
|
|
76
|
+
<p style="color:${theme.muted};font-size:14px;margin-bottom:32px">Effective date: ${escapeHtml(document.lastUpdated)}</p>
|
|
77
|
+
<aside style="border-left:4px solid ${theme.primary};background:${theme.background};padding:12px 16px;border-radius:6px;margin-bottom:28px">Please review this generated draft and add your legal identity and contact details before publishing.</aside>
|
|
78
|
+
${intro}
|
|
79
|
+
${sections}
|
|
80
|
+
</article>
|
|
81
|
+
</main>
|
|
82
|
+
</body>
|
|
83
|
+
</html>
|
|
84
|
+
`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function renderAngularPage({ document, projectName, theme, componentName, selector }) {
|
|
88
|
+
const json = safeJson(document);
|
|
89
|
+
return `import { CommonModule } from '@angular/common';
|
|
90
|
+
import { Component } from '@angular/core';
|
|
91
|
+
|
|
92
|
+
@Component({
|
|
93
|
+
selector: '${selector}',
|
|
94
|
+
standalone: true,
|
|
95
|
+
imports: [CommonModule],
|
|
96
|
+
template: \`
|
|
97
|
+
<main [ngStyle]="styles.page">
|
|
98
|
+
<article [ngStyle]="styles.article">
|
|
99
|
+
<p [ngStyle]="styles.eyebrow">{{ projectName }}</p>
|
|
100
|
+
<h1 [ngStyle]="styles.title">{{ document.title }}</h1>
|
|
101
|
+
<p [ngStyle]="styles.updated">Effective date: {{ document.lastUpdated }}</p>
|
|
102
|
+
<aside [ngStyle]="styles.notice">Please review this generated draft and add your legal identity and contact details before publishing.</aside>
|
|
103
|
+
<p *ngFor="let paragraph of document.intro">{{ paragraph }}</p>
|
|
104
|
+
<section *ngFor="let section of document.sections" [ngStyle]="styles.section">
|
|
105
|
+
<h2 [ngStyle]="styles.heading">{{ section.heading }}</h2>
|
|
106
|
+
<p *ngFor="let paragraph of section.paragraphs">{{ paragraph }}</p>
|
|
107
|
+
<ul *ngIf="section.bullets.length"><li *ngFor="let bullet of section.bullets">{{ bullet }}</li></ul>
|
|
108
|
+
</section>
|
|
109
|
+
</article>
|
|
110
|
+
</main>
|
|
111
|
+
\`
|
|
112
|
+
})
|
|
113
|
+
export class ${componentName} {
|
|
114
|
+
readonly projectName = ${safeJson(projectName)};
|
|
115
|
+
readonly document = ${json};
|
|
116
|
+
readonly styles = {
|
|
117
|
+
page: { minHeight: '100vh', background: '${theme.background}', color: '${theme.text}', padding: '48px 20px', fontFamily: 'Inter, system-ui, sans-serif', lineHeight: '1.7' },
|
|
118
|
+
article: { maxWidth: '840px', margin: '0 auto', background: '${theme.surface}', border: '1px solid ${theme.border}', borderRadius: '18px', padding: 'clamp(24px, 5vw, 56px)', boxShadow: '0 18px 50px rgba(15,23,42,.08)' },
|
|
119
|
+
eyebrow: { color: '${theme.primary}', fontSize: '14px', fontWeight: '700', letterSpacing: '.08em', textTransform: 'uppercase' },
|
|
120
|
+
title: { fontSize: 'clamp(2rem, 5vw, 3.25rem)', lineHeight: '1.1' },
|
|
121
|
+
updated: { color: '${theme.muted}', fontSize: '14px', marginBottom: '32px' },
|
|
122
|
+
notice: { borderLeft: '4px solid ${theme.primary}', background: '${theme.background}', padding: '12px 16px', borderRadius: '6px' },
|
|
123
|
+
section: { marginTop: '32px' },
|
|
124
|
+
heading: { fontSize: '22px' }
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
`;
|
|
128
|
+
}
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
export const SKIP_DIRECTORIES = new Set([
|
|
5
|
+
'.git', '.next', '.nuxt', '.output', '.turbo', '.vercel',
|
|
6
|
+
'node_modules', 'dist', 'build', 'coverage', 'vendor', 'target'
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
export function toPosix(value) {
|
|
10
|
+
return value.split(path.sep).join('/');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function readText(file, fallback) {
|
|
14
|
+
try {
|
|
15
|
+
return await readFile(file, 'utf8');
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if (arguments.length > 1) return fallback;
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isInside(parent, child) {
|
|
23
|
+
const relative = path.relative(parent, child);
|
|
24
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
25
|
+
}
|
package/src/writer.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { exists } from './detector.js';
|
|
4
|
+
import { renderAngularPage, renderHtmlPage, renderReactPage } from './templates.js';
|
|
5
|
+
import { isInside, toPosix } from './utils.js';
|
|
6
|
+
|
|
7
|
+
async function writeGenerated(root, relative, content, options, manifest) {
|
|
8
|
+
const absolute = path.resolve(root, relative);
|
|
9
|
+
if (!isInside(root, absolute)) throw new Error(`Refusing to write outside the project: ${relative}`);
|
|
10
|
+
const alreadyExists = await exists(absolute);
|
|
11
|
+
if (alreadyExists && !options.force) {
|
|
12
|
+
manifest.skipped.push(toPosix(relative));
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
if (!options.dryRun) {
|
|
16
|
+
await mkdir(path.dirname(absolute), { recursive: true });
|
|
17
|
+
await writeFile(absolute, content, 'utf8');
|
|
18
|
+
}
|
|
19
|
+
manifest.files.push(toPosix(relative));
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function relativeImport(fromFile, targetWithoutExtension) {
|
|
24
|
+
let value = toPosix(path.relative(path.dirname(fromFile), targetWithoutExtension));
|
|
25
|
+
if (!value.startsWith('.')) value = `./${value}`;
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function findFirst(root, candidates) {
|
|
30
|
+
for (const relative of candidates) if (await exists(path.join(root, relative))) return relative;
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function integrateReactRouter(root, pages, options, manifest) {
|
|
35
|
+
const routeFile = await findFirst(root, [
|
|
36
|
+
'src/App.tsx', 'src/App.jsx', 'src/App.ts', 'src/App.js',
|
|
37
|
+
'src/routes.tsx', 'src/routes.jsx', 'src/router.tsx', 'src/router.jsx'
|
|
38
|
+
]);
|
|
39
|
+
if (!routeFile) {
|
|
40
|
+
manifest.warnings.push('React Router was detected, but no conventional route file was found. Import the generated components and add /privacy-policy and /terms-of-use routes manually.');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const absolute = path.join(root, routeFile);
|
|
44
|
+
let source = await readFile(absolute, 'utf8');
|
|
45
|
+
if (!/<Routes(?:\s|>)/.test(source)) {
|
|
46
|
+
manifest.warnings.push(`${toPosix(routeFile)} does not contain a <Routes> element; routes were not modified.`);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const privacyImport = `import PrivacyPolicy from '${relativeImport(routeFile, pages.privacy)}';`;
|
|
50
|
+
const termsImport = `import TermsOfUse from '${relativeImport(routeFile, pages.terms)}';`;
|
|
51
|
+
const routeImport = "import { Route as PrivonRoute } from 'react-router-dom';";
|
|
52
|
+
const addPrivacy = !/path=["']\/privacy-policy["']/.test(source);
|
|
53
|
+
const addTerms = !/path=["']\/terms-of-use["']/.test(source);
|
|
54
|
+
if (!addPrivacy && !addTerms) return;
|
|
55
|
+
if (addPrivacy && !source.includes(privacyImport)) source = `${privacyImport}\n${source}`;
|
|
56
|
+
if (addTerms && !source.includes(termsImport)) source = `${termsImport}\n${source}`;
|
|
57
|
+
if (!source.includes(routeImport)) source = `${routeImport}\n${source}`;
|
|
58
|
+
if (addPrivacy) {
|
|
59
|
+
source = source.replace(/<Routes([^>]*)>/, `<Routes$1>\n <PrivonRoute path="/privacy-policy" element={<PrivacyPolicy />} />`);
|
|
60
|
+
}
|
|
61
|
+
if (addTerms) {
|
|
62
|
+
source = source.replace(/<Routes([^>]*)>/, `<Routes$1>\n <PrivonRoute path="/terms-of-use" element={<TermsOfUse />} />`);
|
|
63
|
+
}
|
|
64
|
+
if (!options.dryRun) await writeFile(absolute, source, 'utf8');
|
|
65
|
+
manifest.integrations.push(toPosix(routeFile));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function integrateAngularRouter(root, pages, options, manifest) {
|
|
69
|
+
const routeFile = await findFirst(root, ['src/app/app.routes.ts', 'src/app/app-routing.module.ts']);
|
|
70
|
+
if (!routeFile) {
|
|
71
|
+
manifest.warnings.push('Angular was detected, but no app.routes.ts or app-routing.module.ts was found. Add the generated standalone components to your router manually.');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const absolute = path.join(root, routeFile);
|
|
75
|
+
let source = await readFile(absolute, 'utf8');
|
|
76
|
+
if (!/(?:const|export\s+const)\s+routes\s*(?::\s*Routes)?\s*=\s*\[/.test(source)) {
|
|
77
|
+
manifest.warnings.push(`${toPosix(routeFile)} has no conventional routes array; routes were not modified.`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const privacyImport = `import { PrivacyPolicyComponent } from '${relativeImport(routeFile, pages.privacy)}';`;
|
|
81
|
+
const termsImport = `import { TermsOfUseComponent } from '${relativeImport(routeFile, pages.terms)}';`;
|
|
82
|
+
const addPrivacy = !/path:\s*["']privacy-policy["']/.test(source);
|
|
83
|
+
const addTerms = !/path:\s*["']terms-of-use["']/.test(source);
|
|
84
|
+
if (!addPrivacy && !addTerms) return;
|
|
85
|
+
if (addPrivacy && !source.includes(privacyImport)) source = `${privacyImport}\n${source}`;
|
|
86
|
+
if (addTerms && !source.includes(termsImport)) source = `${termsImport}\n${source}`;
|
|
87
|
+
const routeArray = /((?:const|export\s+const)\s+routes\s*(?::\s*Routes)?\s*=\s*\[)/;
|
|
88
|
+
if (addPrivacy) {
|
|
89
|
+
source = source.replace(routeArray, `$1\n { path: 'privacy-policy', component: PrivacyPolicyComponent },`);
|
|
90
|
+
}
|
|
91
|
+
if (addTerms) {
|
|
92
|
+
source = source.replace(routeArray, `$1\n { path: 'terms-of-use', component: TermsOfUseComponent },`);
|
|
93
|
+
}
|
|
94
|
+
if (!options.dryRun) await writeFile(absolute, source, 'utf8');
|
|
95
|
+
manifest.integrations.push(toPosix(routeFile));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function writePages({ root, project, content, theme, force = false, dryRun = false }) {
|
|
99
|
+
const options = { force, dryRun };
|
|
100
|
+
const manifest = { files: [], skipped: [], integrations: [], warnings: [] };
|
|
101
|
+
const common = { projectName: content.projectName, theme };
|
|
102
|
+
|
|
103
|
+
if (project.framework === 'next') {
|
|
104
|
+
const extension = project.typescript ? 'tsx' : 'jsx';
|
|
105
|
+
if (project.router === 'app') {
|
|
106
|
+
await writeGenerated(root, path.join(project.sourceDirectory, 'privacy-policy', `page.${extension}`), renderReactPage({ ...common, document: content.privacyPolicy, componentName: 'PrivacyPolicyPage', nextApp: true, typescript: project.typescript }), options, manifest);
|
|
107
|
+
await writeGenerated(root, path.join(project.sourceDirectory, 'terms-of-use', `page.${extension}`), renderReactPage({ ...common, document: content.termsOfUse, componentName: 'TermsOfUsePage', nextApp: true, typescript: project.typescript }), options, manifest);
|
|
108
|
+
} else {
|
|
109
|
+
await writeGenerated(root, path.join(project.sourceDirectory, `privacy-policy.${extension}`), renderReactPage({ ...common, document: content.privacyPolicy, componentName: 'PrivacyPolicyPage', typescript: project.typescript }), options, manifest);
|
|
110
|
+
await writeGenerated(root, path.join(project.sourceDirectory, `terms-of-use.${extension}`), renderReactPage({ ...common, document: content.termsOfUse, componentName: 'TermsOfUsePage', typescript: project.typescript }), options, manifest);
|
|
111
|
+
}
|
|
112
|
+
return manifest;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (project.framework === 'react') {
|
|
116
|
+
const extension = project.typescript ? 'tsx' : 'jsx';
|
|
117
|
+
const privacy = path.join(project.sourceDirectory, 'pages', 'PrivacyPolicy');
|
|
118
|
+
const terms = path.join(project.sourceDirectory, 'pages', 'TermsOfUse');
|
|
119
|
+
await writeGenerated(root, `${privacy}.${extension}`, renderReactPage({ ...common, document: content.privacyPolicy, componentName: 'PrivacyPolicy', typescript: project.typescript }), options, manifest);
|
|
120
|
+
await writeGenerated(root, `${terms}.${extension}`, renderReactPage({ ...common, document: content.termsOfUse, componentName: 'TermsOfUse', typescript: project.typescript }), options, manifest);
|
|
121
|
+
if (project.router === 'react-router') await integrateReactRouter(root, { privacy, terms }, options, manifest);
|
|
122
|
+
else manifest.warnings.push('React Router was not detected. Components were generated without modifying application navigation.');
|
|
123
|
+
return manifest;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (project.framework === 'angular') {
|
|
127
|
+
const privacy = path.join(project.sourceDirectory, 'privacy-policy', 'privacy-policy.component');
|
|
128
|
+
const terms = path.join(project.sourceDirectory, 'terms-of-use', 'terms-of-use.component');
|
|
129
|
+
await writeGenerated(root, `${privacy}.ts`, renderAngularPage({ ...common, document: content.privacyPolicy, componentName: 'PrivacyPolicyComponent', selector: 'app-privacy-policy' }), options, manifest);
|
|
130
|
+
await writeGenerated(root, `${terms}.ts`, renderAngularPage({ ...common, document: content.termsOfUse, componentName: 'TermsOfUseComponent', selector: 'app-terms-of-use' }), options, manifest);
|
|
131
|
+
await integrateAngularRouter(root, { privacy, terms }, options, manifest);
|
|
132
|
+
return manifest;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
await writeGenerated(root, 'privacy-policy.html', renderHtmlPage({ ...common, document: content.privacyPolicy }), options, manifest);
|
|
136
|
+
await writeGenerated(root, 'terms-of-use.html', renderHtmlPage({ ...common, document: content.termsOfUse }), options, manifest);
|
|
137
|
+
return manifest;
|
|
138
|
+
}
|